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