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>
This commit is contained in:
DongHyeonka
2026-09-17 11:02:02 +09:00
co-authored by Claude Opus 5
parent 2109f726fe
commit ab59130196
1524 changed files with 3160026 additions and 8369 deletions
+483
View File
@@ -0,0 +1,483 @@
#!/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 <rev>` 의 경로 중 등록 leaf 의 `source_path` 로 시작하는 것
(submodule 없음 · 제외 경로 없음 · 디렉터리는 ls-tree -r 이 애초에 안 낸다)
main Java : 그중 `/src/main/java/` 를 포함하고 `.java` 로 끝나는 것
main LOC : 그 파일들의 줄 수 합 (`git show <rev>:<path>` 의 `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<path>[A-Za-z0-9_.\-/]+/[A-Za-z0-9_.\-/]+"
r"\.(?:java|gradle|json|ya?ml|properties|sql|groovy|kts|toml|xml|sh))"
r"(?::(?P<line>\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())