#!/usr/bin/env python3 """SSOT 가 코드베이스를 옳게 읽었는지 본다. `check-code-anchors.py` 는 **기록**이 인용한 코드가 그 리비전에 실재하는지 본다. 그 위쪽은 아무도 안 봤다 — 기록의 근거인 `final/document.md` 자신이 코드베이스와 맞는지다. 계획서 §6 이 그 자리를 이렇게 적는다. 「SSOT 는 근거를 찾아갈 수 있는 정리 문서다. SSOT 에 적혔다는 이유만으로 사실이 되지는 않는다.」 이 검사기가 그 대조 중 **기계로 결정적으로 판정 가능한 부분**을 맡는다. D-005 가 그 경계를 그었다 — 구조·숫자·파일·버전은 코드로 검사하고, 근거의 의미와 문맥은 원자료를 보는 별도 검토가 담당한다. 그래서 아래는 **보지 않는다**. · 서술이 맞는가 · 인과 주장이 성립하는가 · finding 이 실제로 결함인가 · 그 적용 범위가 어디까지인가 · SSOT 가 **빠뜨린** finding. 없는 것을 찾는 검사기는 만들 수 없다 마지막 줄이 이 검사기의 가장 큰 한계다. 전부 통과해도 그것은 「적힌 것이 맞다」이지 「적을 것을 다 적었다」가 아니다. 출력의 맨 위에 그 문장을 찍는다. ## 세는 방법 — 517 차이를 낳은 자리 「추적 파일」을 저장소 전체로 세면 7,264 이고 SSOT 는 6,747 을 적는다. 차이 517 은 SSOT 가 틀린 것이 아니라 **분모가 다른 것**이다. SSOT §1.2 의 표가 가족별 leaf 를 더해 6,747 을 만들므로, 분모는 **등록 leaf 62개의 `source_path` 아래**이지 저장소 전체가 아니다. 이 정의를 코드에 적어 둔다. 적지 않으면 다음 사람이 또 `git ls-tree -r | wc -l` 을 쳐 보고 「SSOT 가 517 틀렸다」고 적는다. 실제로 이 배치 직전에 그렇게 적힌 표가 있었다. 추적 파일 : `git ls-tree -r ` 의 경로 중 등록 leaf 의 `source_path` 로 시작하는 것 (submodule 없음 · 제외 경로 없음 · 디렉터리는 ls-tree -r 이 애초에 안 낸다) main Java : 그중 `/src/main/java/` 를 포함하고 `.java` 로 끝나는 것 main LOC : 그 파일들의 줄 수 합 (`git show :` 의 `splitlines()` 길이) 「등록 leaf」의 정본은 `src/config/architecture/modules.json` 이고, SSOT 자신이 머리말에서 그 파일을 SSOT 로 지목한다. ## 머리표에는 성격이 다른 두 종류가 섞여 있다 위 셋은 **리비전에 고정된 값**이다. 소스가 `21234e38` 에 묶여 있으니 지금 다시 세도 같아야 하고, 다르면 error 다. **증거 파일 수는 그렇지 않다.** 증거는 원 저장소가 아니라 이 저장소 안에 살고, 뒤이은 배치가 계속 늘린다. 그것을 지금의 파일 수와 견주는 것은 서로 다른 것을 비교하는 일이다 — 첫 판이 그 범주 오류로 error 2건을 냈다. 그래서 증거 수치는 크고 작음이 아니라 **기준 시점을 선언했는가** 를 본다. `check_evidence_counts` 의 독스트링에 그 근거가 있다. python3 scripts/check-ssot-facts.py <프로젝트> python3 scripts/check-ssot-facts.py <프로젝트> --samples 10 """ from __future__ import annotations import argparse import glob import json import os import re import subprocess import sys ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.join(ROOT, "scripts")) import techlog # noqa: E402 MODULES_JSON = "src/config/architecture/modules.json" # SSOT 본문이 코드 경로를 적는 모양. 백틱 안에 슬래시가 있고 아는 확장자로 끝난다. # 줄 번호는 `:123` 또는 `:123-140` 이다. CITATION = re.compile( r"`(?P[A-Za-z0-9_.\-/]+/[A-Za-z0-9_.\-/]+" r"\.(?:java|gradle|json|ya?ml|properties|sql|groovy|kts|toml|xml|sh))" r"(?::(?P\d+)(?:-\d+)?)?`") # SSOT 는 저장소 루트가 아니라 `src/` 안에서 본 경로를 그대로 적는 자리가 있다 # (`app-bootstrap/build.gradle` = `src/app-bootstrap/build.gradle`). 두 형태를 다 시도하고 # **어느 쪽으로 풀렸는지 세어서 낸다** — 조용히 보정하면 경로 규약이 섞인 것을 못 본다. PREFIXES = ("", "src/") # 경로가 안 풀렸을 때 **없는 파일**과 **줄여 쓴 경로**를 가르는 방법. # # SSOT 는 경로를 자주 줄인다 — `app-bootstrap/application.yml` 의 실제 자리는 # `src/app-bootstrap/src/main/resources/application.yml` 이고, 중간 세 마디가 빠져 있다. # 이것을 「그 리비전에 없다」로 세면 **있는 파일을 없다고 하는 것**이고, 그것이 # `check-code-anchors.py` 가 `_undecidable` 로 막아 둔 실패와 같은 모양이다. # # 그래서 풀리지 않은 경로는 **파일 이름(basename)이 그 리비전 어디엔가 있는지**로 가른다. # 결정적이고, 어느 쪽으로 갈리든 근거를 댈 수 있다. # # 이름이 그 리비전에 있다 → 경로를 줄여 쓴 것이다. 못 대조로 세고 warn # 이름이 어디에도 없다 → 그 리비전에 없는 파일을 인용한 것이다. error # # 「이름은 있는데 SSOT 가 가리킨 자리가 틀렸다」는 이 방법으로 못 가른다. 그것을 보려면 # 줄여 쓴 경로를 사람이 펴야 한다 — 이 검사기의 범위 밖이다. def _git(repo: str, *args: str) -> tuple[int, str]: try: p = subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True, timeout=60) except (OSError, subprocess.SubprocessError) as e: return 127, str(e) return p.returncode, p.stdout def _num(text: str) -> int | None: """`6,747` · `**462**` 에서 정수를 꺼낸다. 없으면 None.""" m = re.search(r"(\d[\d,]*)", text.replace("*", "")) return int(m.group(1).replace(",", "")) if m else None def header_claims(doc: str) -> dict[str, str]: """머리표(`| 이름 | 값 |`)를 읽는다. 첫 표만 본다 — 그 아래는 본문 표다.""" out: dict[str, str] = {} for line in doc.splitlines(): if line.startswith("## "): break m = re.match(r"^\|\s*([^|]+?)\s*\|\s*(.+?)\s*\|\s*$", line) if m and m.group(1) and not set(m.group(1)) <= set("- :"): out[m.group(1).strip()] = m.group(2).strip() return out def leaf_paths(repo: str, rev: str) -> list[str] | None: code, blob = _git(repo, "show", f"{rev}:{MODULES_JSON}") if code != 0: return None try: mods = json.loads(blob)["modules"] except (ValueError, KeyError, TypeError): return None return [m["source_path"].rstrip("/") + "/" for m in mods if m.get("source_path")] def recount(repo: str, rev: str) -> dict | None: """코드베이스를 직접 세어 SSOT 와 견줄 값을 만든다. 세는 방법은 모듈 독스트링에 있다.""" paths = leaf_paths(repo, rev) if paths is None: return None code, out = _git(repo, "ls-tree", "-r", "--name-only", rev) if code != 0: return None tracked_all = out.splitlines() in_leaf = [f for f in tracked_all if any(f.startswith(p) for p in paths)] main_java = [f for f in in_leaf if f.endswith(".java") and "/src/main/java/" in f] loc = 0 for f in main_java: c, text = _git(repo, "show", f"{rev}:{f}") if c == 0: loc += len(text.splitlines()) return {"등록 leaf": len(paths), "추적 파일": len(in_leaf), "main Java 파일": len(main_java), "main Java LOC": loc, "저장소 전체 추적 파일": len(tracked_all), "_tracked": set(tracked_all)} def check_header(rep: techlog.Report, claims: dict, actual: dict) -> None: """머리표의 수치를 다시 센 값과 대조한다.""" def cmp(label: str, claimed: int | None, real: int) -> None: if claimed is None: rep.warn("머리표에서 수치를 못 읽었다", label) elif claimed != real: rep.error("머리표의 수치가 코드베이스와 다르다", f"{label} — SSOT {claimed:,} · 다시 센 값 {real:,}") cmp("등록 leaf", _num(claims.get("등록 leaf", "")), actual["등록 leaf"]) cmp("추적 파일", _num(claims.get("추적 파일", "")), actual["추적 파일"]) mj = claims.get("main Java", "") m = re.search(r"([\d,]+)\s*파일\s*/\s*([\d,]+)\s*LOC", mj) if not m: rep.warn("머리표에서 수치를 못 읽었다", f"main Java — {mj[:40]!r}") else: cmp("main Java 파일", int(m.group(1).replace(",", "")), actual["main Java 파일"]) cmp("main Java LOC", int(m.group(2).replace(",", "")), actual["main Java LOC"]) # 증거 수치가 기준 시점을 선언했다고 볼 표시. 날짜이거나 스냅샷이라고 적었으면 선언한 것이다 EVIDENCE_BASIS = re.compile(r"\d{4}-\d{2}-\d{2}|스냅샷|기준 시점") def check_evidence_counts(rep: techlog.Report, claims: dict, base: str) -> dict[str, str]: """`evidence/raw` · `evidence/meta` 수치가 **기준 시점을 선언했는지** 본다. 첫 판은 이 수치를 지금의 파일 수와 견주어 error 를 냈다. **그것이 범주 오류였다.** 머리표에는 성격이 다른 두 종류가 섞여 있다. 리비전에 고정된 값 : 등록 leaf · 추적 파일 · main Java · LOC. 소스가 `21234e38` 에 고정돼 있으니 지금 다시 세도 같아야 한다 분석 시점의 스냅샷 : 증거 파일 수. 증거는 **이 저장소** 안에 살고 이후 배치가 계속 늘린다 뒤쪽을 지금의 파일 수와 견주는 것은 서로 다른 것을 비교하는 일이다. 실제로 `evidence/meta` 의 `executedAt` 을 날짜로 묶으면 2026-08-31 이하가 정확히 14건 — 머리표의 값 그대로다. 수치가 틀린 것이 아니라 **어느 시점의 값인지 적혀 있지 않았던 것**이다. 그래서 판정을 바꾼다. 수치의 크고 작음이 아니라 **기준 시점을 선언했는가**를 본다. 선언하지 않은 수치는 읽는 사람에게 지금 값으로 읽히고, 그것이 고칠 거리다. 선언했으면 지금 값과의 차이는 결함이 아니라 사실이므로 facts 로 낸다. """ text = claims.get("증거", "") facts: dict[str, str] = {} declared = bool(EVIDENCE_BASIS.search(text)) for name in ("raw", "meta"): m = re.search(rf"`?evidence/{name}`?\s*([\d,]+)", text) d = os.path.join(base, "final", "evidence", name) if not os.path.isdir(d): rep.warn("증거 폴더가 없다", f"final/evidence/{name}") continue real = sum(len(fs) for _, _, fs in os.walk(d)) if not m: rep.warn("머리표에서 증거 수치를 못 읽었다", f"evidence/{name}") continue said = int(m.group(1).replace(",", "")) facts[f"증거 {name}"] = f"머리표 {said:,} · 지금 {real:,}" if said != real and not declared: rep.error("증거 수치에 기준 시점이 없다 — 지금 값으로 읽힌다", f"evidence/{name} — 머리표 {said:,} · 지금 {real:,}. " "증거는 이 저장소 안에서 늘어나므로 어느 시점의 수인지 적어야 한다") return facts def check_arithmetic(rep: techlog.Report, claims: dict) -> None: """합계 = 부분의 합. 「462 — P1 30 · P2 147 · P3 285」 꼴을 본다. **값이 옳은지가 아니라 자기 안에서 맞는지만 본다.** 462 는 SSOT 자신이 델타 조정값이라고 밝힌 수이고(§14.2), 그 델타가 옳은지는 코드로 못 본다. """ for label, text in claims.items(): if "P1" not in text or "P2" not in text: continue total = _num(text) parts = [int(x) for x in re.findall(r"P[123]\s*(\d+)", text)] if total is None or len(parts) < 2: continue if sum(parts) != total: rep.error("합계가 부분의 합과 다르다", f"{label} — 합계 {total} · 부분의 합 {sum(parts)} ({'+'.join(map(str, parts))})") def check_citations(rep: techlog.Report, doc: str, repo: str, rev: str, tracked: set[str], base: str) -> dict[str, int]: """SSOT 가 인용한 파일 경로와 줄이 그 리비전에 실재하는가. 판정할 수 있는 것만 판정한다 — `.../` 로 줄인 경로나 백틱 밖의 산문은 못 본다. 못 본 것은 세어서 낸다. 조용히 건너뛰면 「전부 맞다」가 「본 것만 맞다」를 가린다. """ names = {p.rsplit("/", 1)[-1] for p in tracked} seen: dict[str, set] = {} for m in CITATION.finditer(doc): seen.setdefault(m.group("path"), set()).add(m.group("line")) stats = {"대조한 인용": 0, "못 대조한 인용": 0, "src/ 접두사로 풀린 인용": 0} for path, lines in sorted(seen.items()): if path.startswith("/") or path.startswith("~"): stats["못 대조한 인용"] += 1 rep.warn("대조하지 못한 인용 — 저장소 밖의 절대 경로다", path[:70]) continue if "..." in path or "…" in path: stats["못 대조한 인용"] += 1 rep.warn("대조하지 못한 인용 — 축약된 경로다", path[:70]) continue if path.startswith("build/"): # `build/` 는 gitignore 된 빌드 산출물이다. 그 리비전에 없는 것이 정상이고, # 있는지 없는지는 빌드를 돌려야 정해진다 — 이 검사기가 판정할 자리가 아니다 stats["못 대조한 인용"] += 1 rep.warn("대조하지 못한 인용 — 빌드 산출물 경로다", path[:70]) continue if path.startswith("evidence/"): # 증거는 원 저장소가 아니라 **이 저장소**의 `final/evidence/` 에 있다. # 머리표가 「끊긴 참조 0」을 주장하는 자리라 실재를 본다 stats["대조한 인용"] += 1 if not os.path.isfile(os.path.join(base, "final", path)): rep.error("SSOT 가 인용한 증거 파일이 없다", f"final/{path}") continue resolved = next((pre + path for pre in PREFIXES if pre + path in tracked), None) if resolved is None: # 없는 파일인가, 줄여 쓴 경로인가. 파일 이름으로 가른다 (위 주석) if path.rsplit("/", 1)[-1] in names: stats["못 대조한 인용"] += 1 rep.warn("대조하지 못한 인용 — 경로가 줄어 있다 " "(같은 이름의 파일은 그 리비전에 있다)", path[:70]) else: stats["대조한 인용"] += 1 rep.error("SSOT 가 인용한 파일이 그 리비전에 없다", f"{path} @ {rev[:8]}") continue if resolved != path: stats["src/ 접두사로 풀린 인용"] += 1 stats["대조한 인용"] += 1 nums = [int(x) for x in lines if x] if not nums: continue c, text = _git(repo, "show", f"{rev}:{resolved}") if c != 0: continue length = len(text.splitlines()) for n in sorted(set(nums)): if n > length: rep.error("SSOT 가 인용한 줄이 그 리비전의 파일 길이를 넘는다", f"{resolved}:{n} @ {rev[:8]} (그 커밋에서 {length}줄)") return stats # SSOT 가 적은 의존성 버전 → 원 저장소에서 그 값을 확인하는 방법. # # **lockfile·빌드 파일이 정본이다.** 버전 카탈로그(`libs.versions.toml`)는 BOM 이 덮으면 # 해석되는 값과 달라진다 — `junitJupiter = "5.11.3"` 이 실제로는 6.0.x 로 풀리는 것이 # 이 저장소에 있다. 카탈로그를 정본으로 삼으면 틀린 버전을 맞다고 한다. # # **주장 정규식을 느슨하게 쓰면 안 된다.** 이 문서에서 `Java 27` 은 버전이 아니라 # **파일 수**다 — 「main Java 27 · test Java 18」 처럼 쓴다. `Java\s+([\d.]+)` 로 훑으면 # 그 27 을 버전으로 읽고 「SSOT 가 Java 27 이라고 적었다」는 거짓 error 를 낸다. # 이 검사기의 첫 판에서 실제로 그렇게 나왔다. # # 그래서 **버전을 말하는 문장에서만** 찾는다. 이 SSOT 는 스택을 한 줄에 모아 적으므로 # (`Java 21 · Spring Boot 4.0.8 · Gradle 9.0.0 멀티모듈`), 아래 이름 중 둘 이상이 함께 # 있는 줄만 버전 주장으로 본다. 그런 줄이 없으면 「SSOT 가 이 버전을 적지 않았다」다. DEPENDENCIES = [ # (이름, SSOT 가 주장하는 모양, 빌드 파일에서 진짜 값을 읽는 모양, 볼 파일) ("Spring Boot", r"Spring Boot (\d+\.\d+\.\d+)", r"id 'org\.springframework\.boot' version '([\d.]+)'", ["src/build.gradle"]), ("Java", r"\bJava (\d{2})\b", r"JavaLanguageVersion\.of\((\d+)\)", ["src/build.gradle"]), ("Gradle", r"Gradle (\d+\.\d+(?:\.\d+)?)", r"gradle-([\d.]+?)-(?:bin|all)\.zip", ["src/gradle/wrapper/gradle-wrapper.properties"]), ] def stack_lines(doc: str) -> str: """스택을 선언하는 줄만 모은다. 이름 둘 이상이 한 줄에 있으면 버전 문장으로 본다.""" names = [d[0] for d in DEPENDENCIES] return "\n".join(ln for ln in doc.splitlines() if sum(n in ln for n in names) >= 2) def check_dependencies(rep: techlog.Report, doc: str, repo: str, rev: str) -> int: """SSOT 가 적은 의존성 버전이 빌드 파일과 같은가.""" doc = stack_lines(doc) checked = 0 for name, claim_re, build_re, files in DEPENDENCIES: real = None for f in files: c, text = _git(repo, "show", f"{rev}:{f}") if c != 0: continue m = re.search(build_re, text) if m: real = m.group(1) break if real is None: rep.warn("빌드 파일에서 버전을 못 찾았다", name) continue claimed = set(re.findall(claim_re, doc)) if not claimed: rep.warn("SSOT 가 이 버전을 적지 않았다", name) continue checked += 1 wrong = sorted(v for v in claimed if v != real) if wrong: rep.error("SSOT 가 적은 의존성 버전이 빌드 파일과 다르다", f"{name} — SSOT {', '.join(wrong)} · 빌드 파일 {real}") return checked def _codebase(index: dict, rep: techlog.Report) -> tuple[str, str] | str: """(저장소, 리비전) 또는 **코드베이스와 대조할 수 없는 사유**. 사유가 돌아오는 것은 결함이 아니다. CLAUDE.md 「검사」 절이 「대상이 성립하지 않는다」를 **프로젝트 없음 · 계약 없음** 으로 한정하고, 리비전을 모르는 것은 따로 **warn 으로 센다**고 적는다. 이 저장소에는 그럴 이유가 있는 프로젝트가 실제로 있다. keycloak 작업이 브랜치 넷으로 갈려 `revision` 이 null 이고 `revisions` 에 tip 넷이 있다 virtualization 코드 저장소를 분석해 쓴 문서가 아니다. 근거를 고정하는 것은 반입한 파일이다 이 문서 저장소 진짜 저장소이고 리비전도 있지만 Gradle 레지스트리가 아니라 modules.json 이 없다 셋을 exit 2 로 세면 「이 검사기의 대상이 아니다」가 「이 프로젝트가 깨졌다」로 보인다. 그래서 사유를 facts 로 내고 warn 한 번을 남긴다 — 초록으로도 보이지 않게. """ src = index.get("sourceRepository") or {} src = src[0] if isinstance(src, list) and src else src repo, rev = src.get("path"), src.get("revision") if not repo or not os.path.isdir(repo): return f"분석한 저장소가 이 기계에 없다 — {repo}" if not rev: return "sourceRepository.revision 이 비었다 — 무엇과 대조할지 정해지지 않았다" if _git(repo, "cat-file", "-e", f"{rev}^{{commit}}")[0] != 0: rep.error("sourceRevision 이 그 저장소에 없는 커밋이다", f"{rev} — {repo}") return "sourceRevision 이 그 저장소에 없다" if leaf_paths(repo, rev) is None: return f"{MODULES_JSON} 이 그 리비전에 없다 — leaf 분모를 정할 수 없다" return repo, rev def verify(project: str) -> tuple[techlog.Report, str | None]: """(보고, 대상이 성립하지 않는 사유). 「봤고 괜찮다」와 「볼 것이 없어서 통과」를 가른다 — CLAUDE.md 「검사」 절의 표 그대로다. **exit 2 는 프로젝트가 없거나 계약이 없을 때뿐이다.** 계약은 있는데 대조할 코드베이스가 정해지지 않은 것은 「볼 것이 아직 없다」이고, 사유를 facts 에 적고 warn 으로 센다. 코드베이스가 없어도 볼 수 있는 것이 있다 — 내부 산술과 증거 수치의 기준 시점이다. 그 둘은 SSOT 안에서 닫히므로 언제나 돌린다. """ rep = techlog.Report(project) base = os.path.join(ROOT, "docs", project) doc_path = os.path.join(base, "final", "document.md") if not os.path.isfile(doc_path): return rep, "final/document.md 가 없다" index = techlog.load_index(os.path.join(base, "tech-log-studio", "tech-log-tree.json")) if index is None: return rep, "tech-log-tree.json 이 없다 — 어느 저장소를 볼지 알 수 없다" doc = open(doc_path, encoding="utf-8").read() claims = header_claims(doc) rep.facts["머리표 항목"] = len(claims) if not claims: rep.facts["미측정"] = "머리표가 없다 — 대조할 수치가 아직 적히지 않았다" return rep, None # SSOT 안에서 닫히는 것 — 코드베이스가 없어도 본다 check_arithmetic(rep, claims) rep.facts.update(check_evidence_counts(rep, claims, base)) grounded = _codebase(index, rep) if isinstance(grounded, str): rep.facts["코드베이스 미대조"] = grounded rep.warn("코드베이스와 대조하지 못했다", f"{project} — {grounded}") return rep, None repo, rev = grounded actual = recount(repo, rev) rep.facts["다시 센 추적 파일"] = actual["추적 파일"] rep.facts["저장소 전체"] = actual["저장소 전체 추적 파일"] check_header(rep, claims, actual) rep.facts["대조한 의존성"] = check_dependencies(rep, doc, repo, rev) rep.facts.update(check_citations(rep, doc, repo, rev, actual["_tracked"], base)) return rep, None LIMIT = ("이 검사기는 **적힌 것이 맞는지**만 본다. SSOT 가 빠뜨린 finding 은 찾지 못한다 — " "없는 것을 찾는 검사기는 만들 수 없다. 전부 통과해도 「SSOT 가 맞다」가 아니다.") def main() -> int: ap = argparse.ArgumentParser(description="SSOT 가 코드베이스를 옳게 읽었는지 본다.") ap.add_argument("projects", nargs="*") ap.add_argument("--samples", type=int, default=3) args = ap.parse_args() projects = args.projects or sorted( os.path.basename(os.path.dirname(p)) for p in glob.glob(os.path.join(ROOT, "docs/*/final/document.md")) if not os.path.basename(os.path.dirname(os.path.dirname(p))).startswith("_")) if not projects: print("볼 프로젝트가 없다", file=sys.stderr) return techlog.NO_TARGET_EXIT bad = techlog.check_targets(projects, ROOT) if bad is not None: return bad print(f"\n{LIMIT}") reports = [] for p in projects: rep, why = verify(p) if why: return techlog.missing_target(p, why) reports.append(rep) for rep in reports: facts = " · ".join(f"{k}={v}" for k, v in rep.facts.items()) or "볼 것이 없다" print(f"\n[{rep.project}] {facts}") for label, bucket, mark in (("error", rep.errors, "✗"), ("warn", rep.warns, "!")): for rule, details in sorted(bucket.items(), key=lambda kv: -len(kv[1])): print(f" {mark} {label} {len(details):>4} {rule}") for d in details[:args.samples]: print(f" · {d}") if args.samples and len(details) > args.samples: print(f" … 외 {len(details) - args.samples}건") e = sum(r.error_count for r in reports) for r in reports: if "미측정" in r.facts: print(f" · {r.project}: {r.facts['미측정']}") print(f"\nSSOT FACTS: {'FAIL' if e else 'PASS'} — 프로젝트 {len(reports)} · error {e}") return 1 if e else 0 if __name__ == "__main__": raise SystemExit(main())