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:
co-authored by
Claude Opus 5
parent
2109f726fe
commit
ab59130196
@@ -11,9 +11,21 @@ import os, re, sys, glob, json, collections
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "scripts"))
|
||||
import techlog # noqa: E402
|
||||
KINDS = {"case": "CASE", "concept": "CONCEPT", "reference": "REFERENCE",
|
||||
"question": "QUESTION", "decision": "PROJECT_DECISION"}
|
||||
BODY_KINDS = {"case", "concept"}
|
||||
# 폴더 이름 ↔ 종류. 손으로 다시 적지 않는다 — 여섯 번째 종류(SETUP)가 빠졌던 자리다
|
||||
KINDS = techlog.KIND_OF_DIR
|
||||
BODY_KINDS = techlog.BODY_KINDS
|
||||
# 마크다운 **블록** 파서를 안 거치는 칸. 그렇다고 전부 글자로 나오지는 않는다 —
|
||||
# 렌더러가 이 칸들을 `<ProseText>` 로 그리고(`tech-log-frontend` 의
|
||||
# `presentation/shared/public-render/prose-text.tsx`), 거기서 백틱 쌍은 인라인 `<code>` 로,
|
||||
# 빈 줄은 문단으로, 한 줄 바꿈은 `<br>` 로 산다. **살아나지 않는 것만 잡는다** —
|
||||
# 코드펜스·별표·파이프다.
|
||||
#
|
||||
# 여기서 백틱도 잡던 때가 있었다. 그때는 맞았고(칸이 진짜 평문이었다) 지금은 틀렸다.
|
||||
# 지금 이 검사가 0건인 것은 깨끗해서가 아니라 **그 규칙을 따라 이미 다 떼어 놨기**
|
||||
# 때문이다. 되돌리는 것은 기록 수백 편을 건드리는 일이라 따로 다룬다.
|
||||
#
|
||||
# **`concept` 과 `setup` 은 여기 없는 것이 맞다** — 본문 밖에 절이 없다. 그 둘의 본문 밖 칸
|
||||
# (`basisVersion` · `pinnedVersions`)은 frontmatter 에 있다
|
||||
PLAIN_FIELDS = {"case": ("문제", "결론", "검증 환경", "재현 조건"),
|
||||
"reference": ("목적", "규칙", "적용 조건", "예외", "예시"),
|
||||
"question": ("사실", "가정", "미지수", "제약", "선택지", "다음 검증"),
|
||||
@@ -79,13 +91,31 @@ def audit_project(project: str) -> dict:
|
||||
if not os.path.exists(os.path.normpath(os.path.join(d, m.group(1)))):
|
||||
flag("evidence 링크 깨짐", f"{rel} — {m.group(1)}")
|
||||
|
||||
# 평문 칸은 백틱·코드펜스가 글자 그대로 보인다
|
||||
# 이 칸에서 살아나지 않는 마크업. 백틱은 `<code>` 로 사니까 잡지 않는다
|
||||
head = text if "<!-- body:start -->" not in text \
|
||||
else text[:text.index("<!-- body:start -->")]
|
||||
for field in PLAIN_FIELDS.get(kind_dir, ()):
|
||||
fm2 = re.search(rf"^## {re.escape(field)}\n(.*?)(?=\n## |\Z)", head, re.M | re.S)
|
||||
if fm2 and ("`" in fm2.group(1) or "```" in fm2.group(1)):
|
||||
flag("평문 칸에 마크업", f"{rel} — {field}")
|
||||
if not fm2:
|
||||
continue
|
||||
chunk = fm2.group(1)
|
||||
dead = []
|
||||
if "```" in chunk:
|
||||
dead.append("코드펜스")
|
||||
# 목록 표지(`- **제목**`)는 관계 절의 표기이고 이 칸들에는 안 온다.
|
||||
# 줄 첫머리가 아닌 자리의 `**…**` 만 본다
|
||||
if re.search(r"(?<!^)(?<!- )\*\*[^*\n]+\*\*", chunk, re.M):
|
||||
dead.append("별표")
|
||||
if re.search(r"^\s*\|", chunk, re.M):
|
||||
dead.append("표")
|
||||
# 인용 표지도 안 산다. `studio-save.py` 의 `_sections()` 가 `>` 를 그대로
|
||||
# 실어 보내고 `ProseText` 는 백틱·빈 줄·줄바꿈 셋만 해석한다 — 화면에
|
||||
# 홑화살괄호가 글자로 나온다. SSOT 를 그대로 옮긴 인용이라도 마찬가지다
|
||||
if re.search(r"^\s*>", chunk, re.M):
|
||||
dead.append("인용 표지")
|
||||
if dead:
|
||||
flag("평문 칸에 살아나지 않는 마크업",
|
||||
f"{rel} — {field} · {'·'.join(dead)}. 백틱은 괜찮다")
|
||||
|
||||
# 본문이 부르는 자산이 frontmatter 에 선언돼 있나
|
||||
declared = set(re.findall(r"^ - key: (\S+)$", text, re.M))
|
||||
|
||||
@@ -15,6 +15,22 @@ lint 는 의미(관계가 이어져 있는가)를 보고 `check-figure-text.py`
|
||||
|
||||
renderer 가 배경 사각형을 정확한 좌표로 남기므로 글자 폭을 어림하지 않는다 —
|
||||
`edge-label-bg` 와 `group-label-bg` 가 그 라벨이 실제로 차지하는 자리다.
|
||||
|
||||
## 그 방법이 성립하는 범위 — 「안 겹친다」와 「못 봤다」는 다른 출력이다
|
||||
|
||||
상자를 알아보는 근거는 **techviz 렌더러가 붙인 `class`** 다(`node-shape`·`group-box`·
|
||||
`edge-label-bg`·`group-label-bg`). 손으로 그린 SVG 에는 그 class 가 없으므로 `boxes()` 가
|
||||
**빈 목록**을 돌려주고, 겹칠 짝이 없으니 겹침도 0 이 된다.
|
||||
|
||||
그래서 고치기 전의 이 검사기는 `그림 182 · 겹친 그림 0` 을 찍었지만 **실제로 본 것은 18장**
|
||||
이었다. 나머지 164장은 안 겹친 것이 아니라 **볼 수 없었던 것**이다. CLAUDE.md 가 그 상태를
|
||||
이렇게 적는다 — 「이 검사기는 techviz 가 만든 SVG 에서만 유효하다. 손으로 고친 SVG …는
|
||||
겹침이 있어도 없다고 답한다」.
|
||||
|
||||
**그러면 출력이 그 사실을 말해야 한다.** 못 본 그림을 따로 세어 요약 줄에 싣는다. 결함으로
|
||||
세지는 않는다 — 손그림이 있는 것 자체는 잘못이 아니다. 다만 초록으로 보이면 안 된다.
|
||||
숫자를 되찾으려면 그 그림을 techviz 로 다시 만들어야 하고, `verify-project-layout.py` 의
|
||||
「techviz 정본이 없는 그림」이 같은 모집단을 센다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -114,6 +130,19 @@ def _near(lbls, box, limit=2) -> str:
|
||||
return " · ".join(hit[:limit])
|
||||
|
||||
|
||||
def is_measurable(path: str) -> bool:
|
||||
"""이 그림의 좌표를 읽을 수 있나. 읽을 수 없으면 판정 자체가 성립하지 않는다.
|
||||
|
||||
근거는 techviz 렌더러가 붙인 `class` 하나뿐이다(모듈 독스트링). 그것이 없으면
|
||||
`boxes()` 가 비고, 겹칠 짝이 없어 **겹침 0** 이 나온다 — 「안 겹친다」가 아니라
|
||||
「못 봤다」다. 부르는 쪽이 그 둘을 갈라 세라고 따로 낸다.
|
||||
"""
|
||||
try:
|
||||
return bool(boxes(open(path, encoding="utf-8").read()))
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def check(path: str) -> list[str]:
|
||||
try:
|
||||
svg = open(path, encoding="utf-8").read()
|
||||
@@ -169,7 +198,12 @@ def main() -> int:
|
||||
return 0
|
||||
|
||||
bad = 0
|
||||
unseen: list[str] = []
|
||||
for path in files:
|
||||
if not is_measurable(path):
|
||||
# 좌표를 읽을 수 없다. 「안 겹친다」로 세면 안 본 것을 봤다고 하는 것이다
|
||||
unseen.append(os.path.relpath(path, ROOT))
|
||||
continue
|
||||
hits = check(path)
|
||||
if not hits:
|
||||
continue
|
||||
@@ -180,7 +214,19 @@ def main() -> int:
|
||||
print(f" · {line}")
|
||||
if len(hits) > args.samples:
|
||||
print(f" … 외 {len(hits) - args.samples}건")
|
||||
print(f"FIGURE OVERLAP: {'FAIL' if bad else 'PASS'} — 그림 {len(files)} · 겹친 그림 {bad}")
|
||||
|
||||
if unseen:
|
||||
print(f"! 못 본 그림 {len(unseen)}장 — techviz 가 만든 것이 아니라 배경 사각형이 없다. "
|
||||
"겹침이 있어도 이 검사기는 답하지 못한다")
|
||||
for rel in unseen[:args.samples]:
|
||||
print(f" · {rel}")
|
||||
if len(unseen) > args.samples:
|
||||
print(f" … 외 {len(unseen) - args.samples}장")
|
||||
|
||||
seen = len(files) - len(unseen)
|
||||
tail = f" · 못 본 그림 {len(unseen)}" if unseen else ""
|
||||
print(f"FIGURE OVERLAP: {'FAIL' if bad else 'PASS'} — 그림 {len(files)} · "
|
||||
f"본 그림 {seen} · 겹친 그림 {bad}{tail}")
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
|
||||
@@ -45,13 +45,18 @@ SECTIONS: dict[str, dict[str, tuple[str, ...]]] = {
|
||||
# Decision 의 틀에는 `관계` 가 없다 (templates/decision.md). 있으면 받되 요구하지 않는다
|
||||
"decision": {"required": ("근거", "결정문", "판단 이유", "영향"),
|
||||
"optional": ("관계",)},
|
||||
# 환경 구성의 절 구성은 Concept 과 같다 — 본문 밖의 칸이 없다. 「실행 절차·구성 값·확인
|
||||
# 방법을 `##` 절로 적는다. **절 이름을 강제하지 않는다** — 프로젝트마다 셋업의 모양이
|
||||
# 다르다」(`SetupInput.bodyMarkdown`)라 그 셋은 본문 구간 안에 있고 여기서 세지 않는다.
|
||||
# 본문 밖의 칸 `pinnedVersions` 는 절이 아니라 frontmatter 에 있다 (templates/setup.md)
|
||||
"setup": {"required": ("관계", "본문"), "optional": ()},
|
||||
}
|
||||
BODY_KINDS = {"case", "concept"}
|
||||
# 본문이 있는 종류는 셋이다. 목록은 techlog 가 정한다
|
||||
BODY_KINDS = techlog.BODY_KINDS
|
||||
|
||||
# frontmatter 의 `kind` 는 Studio 가 쓰는 값이다. 폴더 이름과 하나가 다르다 —
|
||||
# decision/ 폴더의 기록은 `kind: PROJECT_DECISION` 이다 (templates/decision.md:3)
|
||||
KIND_ALIASES = {"CASE": "case", "CONCEPT": "concept", "REFERENCE": "reference",
|
||||
"QUESTION": "question", "PROJECT_DECISION": "decision"}
|
||||
KIND_ALIASES = techlog.DIR_OF_KIND
|
||||
|
||||
# 종류가 요구하는 근거의 자리. 값이 옳은지가 아니라 **자리가 채워졌는지**만 본다
|
||||
FRONTMATTER: dict[str, tuple[str, ...]] = {
|
||||
@@ -60,6 +65,10 @@ FRONTMATTER: dict[str, tuple[str, ...]] = {
|
||||
"reference": ("sourceRevision",),
|
||||
"question": ("questionStatus",),
|
||||
"decision": ("decisionStatus",),
|
||||
# 환경 구성에는 검증일 칸이 없다 — `lastVerifiedOn` 도 `verifiedOn` 도 계약에 없다.
|
||||
# 낡음을 말하는 것은 `pinnedVersions` 뿐이라(「어느 버전 위에서 이 절차가 성립했는지가
|
||||
# 유효 범위다」 · `SetupDetailResponse`) Concept 의 `basisVersion` 과 같은 자리다
|
||||
"setup": ("pinnedVersions",),
|
||||
}
|
||||
|
||||
BODY_START, BODY_END = "<!-- body:start -->", "<!-- body:end -->"
|
||||
@@ -113,10 +122,35 @@ def _summary(text: str, fm_end: int) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _lead_paragraphs(text: str, fm_end: int) -> list[str]:
|
||||
"""제목과 첫 `##` 사이의 문단 **전부**.
|
||||
|
||||
`_summary()` 는 그중 첫 문단만 돌려준다. `scripts/studio-save.py` 의 같은 이름 함수도
|
||||
그렇다 — **둘째 문단부터는 Studio 저장에서 통째로 사라진다.** 저장은 성공하고 화면에도
|
||||
빈 곳이 없어서, 저장소의 `.md` 와 공개본이 갈린 것을 아무도 모른다.
|
||||
|
||||
실제로 두 프로젝트에서 15편이 그 상태였고 그중 13편은 이미 그렇게 게시돼 있었다.
|
||||
버려지던 글자가 2,305자다. 한 편은 REFERENCE 인데 **지침이 통째로 둘째 문단에 있어**
|
||||
공개본에 무엇을 하라는 말이 한 줄도 없었다.
|
||||
"""
|
||||
rest = text.split("\n", fm_end)[-1] if fm_end else text
|
||||
m = re.search(r"^#\s+.+$", rest, re.M)
|
||||
if not m:
|
||||
return []
|
||||
after = re.split(r"^##\s", rest[m.end():], maxsplit=1, flags=re.M)[0]
|
||||
return [p for p in (x.strip() for x in after.split("\n\n"))
|
||||
if p and not p.startswith("<!--")]
|
||||
|
||||
|
||||
def check_record(path: str, rep: techlog.Report) -> None:
|
||||
rel = os.path.relpath(path, ROOT)
|
||||
text = open(path, encoding="utf-8").read()
|
||||
fm, fm_end = _front_matter(text)
|
||||
# `pinnedVersions:` 처럼 값이 아래 줄에 있는 칸은 한 줄 정규식이 빈 값으로 읽는다.
|
||||
# 채워진 목록을 「없다」로 세지 않는다 — 스칼라 칸에서는 블록이 없으므로 그대로다
|
||||
for _key, _value in list(fm.items()):
|
||||
if not _value:
|
||||
fm[_key] = techlog.front_matter_block(text, _key)
|
||||
kind = KIND_ALIASES.get((fm.get("kind") or "").upper(),
|
||||
(fm.get("kind") or "").lower())
|
||||
if kind not in SECTIONS:
|
||||
@@ -142,6 +176,24 @@ def check_record(path: str, rep: techlog.Report) -> None:
|
||||
if not _summary(text, fm_end):
|
||||
rep.error("요약이 없다", f"{rel} — 제목 바로 아래 첫 문단이 `요약` 칸이다")
|
||||
|
||||
# 제목 아래 문단이 둘 이상이면 둘째부터는 Studio 저장에서 버려진다. 저장은 성공하고
|
||||
# 화면에도 빈 곳이 없어 아무도 모른다 — 그래서 검사기가 없으면 같은 일이 되풀이된다
|
||||
lead = _lead_paragraphs(text, fm_end)
|
||||
if len(lead) > 1:
|
||||
dropped = sum(len(p) for p in lead[1:])
|
||||
rep.error("제목 아래 문단이 둘 이상이다 — 둘째부터 Studio 저장에서 사라진다",
|
||||
f"{rel} — 문단 {len(lead)}개 · 버려지는 글자 {dropped}자. "
|
||||
f"요약에 합치거나(200자 아래) 다른 칸으로 옮긴다")
|
||||
|
||||
# 여기에 「요약에 백틱이 있으면 error」를 한 번 넣었다가 뺐다. **틀린 조항이었다.**
|
||||
# 근거로 삼은 것이 `record-kinds.md` 의 「본문을 뺀 모든 칸은 평문이라 백틱이 글자 그대로
|
||||
# 보인다」였는데, 그 문장이 낡았다. 렌더러
|
||||
# (`tech-log-frontend` 의 `public-render/prose-text.tsx`)가 요약을 `<ProseText>` 로
|
||||
# 그리고, 그것이 백틱 쌍을 인라인 `<code>` 로 바꾼다. 백틱이 글자로 나오던 것은
|
||||
# **고쳐진 옛 버그**이고 그 파일 주석에 그렇게 적혀 있다.
|
||||
#
|
||||
# 스킬 문서를 근거로 검사기를 만들면 이렇게 된다. 칸이 어떻게 보이는지는 렌더러가
|
||||
# 정본이다. 백틱을 빼는 쪽이 오히려 계약과 어긋난다.
|
||||
for key in FRONTMATTER.get(kind, ()):
|
||||
if not fm.get(key):
|
||||
rep.error(f"{kind.upper()} 에 `{key}` 가 없다", rel)
|
||||
|
||||
@@ -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())
|
||||
@@ -33,18 +33,27 @@ from techlog import KINDS # noqa: E402
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# 사람이 쓴 트리의 `## <종류> — <제목>` 머리를 폴더 이름으로. 한 종류에 별칭이 둘일 수 있다
|
||||
KIND_LABELS = {"CASE": "case", "CONCEPT": "concept", "REFERENCE": "reference",
|
||||
"OPEN QUESTION": "question", "QUESTION": "question", "DECISION": "decision"}
|
||||
"OPEN QUESTION": "question", "QUESTION": "question", "DECISION": "decision",
|
||||
"SETUP": "setup"}
|
||||
# 아래 두 정규식이 같은 목록을 **또** 적고 있었다. 종류를 더할 때 KIND_LABELS 만 고치고
|
||||
# 정규식을 안 고치면 그 종류의 가지는 파서에 안 잡힌다 — 표에서 만든다.
|
||||
# 긴 이름부터 잇는다. `QUESTION` 이 먼저 오면 `OPEN QUESTION` 의 뒷부분만 물린다
|
||||
_LABELS = "|".join(sorted((re.escape(k) for k in KIND_LABELS), key=len, reverse=True))
|
||||
FIELD_NAMES = ("slug", "readiness", "disposition", "source", "code", "evidence",
|
||||
"classification", "missing-verification", "relations", "scope", "exceptions",
|
||||
"known", "unknown", "next-verification", "decision-criterion",
|
||||
"decision-status", "decision-evidence", "grounds", "basis-version")
|
||||
"decision-status", "decision-evidence", "grounds", "basis-version",
|
||||
"pinned-versions")
|
||||
# 값이 여럿일 수 있는 칸. 사람이 쓴 트리에서 ` · ` 로 나눠 적는다.
|
||||
# `pinned-versions` 는 버전이 하나가 아니라 여럿인 것이 이 종류의 요지다 (`PinnedVersion` 배열)
|
||||
MULTI = {"source", "code", "evidence", "relations", "grounds", "decision-evidence",
|
||||
"known", "unknown", "scope", "exceptions"}
|
||||
"known", "unknown", "scope", "exceptions", "pinned-versions"}
|
||||
|
||||
_TOPIC_HEAD = re.compile(r"^##\s+TOPIC(?:\s+\d+)?\s+[—-]\s+(.+?)\s*$")
|
||||
_SPEC_HEAD = re.compile(r"^###\s+(CASE|CONCEPT|REFERENCE|OPEN QUESTION|QUESTION|DECISION)\s+[—-]\s+(.+?)\s*$")
|
||||
_BRANCH = re.compile(r"^[├└]──\s+(CASE|CONCEPT|REFERENCE|OPEN QUESTION|QUESTION|DECISION)\s*$")
|
||||
_SPEC_HEAD = re.compile(r"^###\s+(" + _LABELS + r")\s+[—-]\s+(.+?)\s*$")
|
||||
_BRANCH = re.compile(r"^[├└]──\s+(" + _LABELS + r")\s*$")
|
||||
_ITEM = re.compile(r"^(?:│|\s)\s{2,}[├└]──\s+(.+?)\s*$")
|
||||
_FIELD = re.compile(r"^-\s+([a-z][a-z-]*):\s*(.*)$")
|
||||
_CONT = re.compile(r"^\s{2,}-\s+(.+?)\s*$")
|
||||
|
||||
+44
-4
@@ -15,7 +15,7 @@
|
||||
**새로 만드는 것이 아니라 같은 규약을 옮기는 것이다.**
|
||||
|
||||
python3 scripts/run-ledger.py open <원장> --project P --record R
|
||||
python3 scripts/run-ledger.py begin <원장> --stage S3 --runby subagent
|
||||
python3 scripts/run-ledger.py begin <원장> --stage S3 # runBy 는 계약값을 쓴다
|
||||
python3 scripts/run-ledger.py gate <원장> --stage S3 --cmd "..." --exit 0
|
||||
python3 scripts/run-ledger.py end <원장> --stage S3 --status DONE --echo "..." --output ...
|
||||
python3 scripts/run-ledger.py rider <원장> --id R11 --why "..." --seconds 1070
|
||||
@@ -28,6 +28,7 @@ import datetime
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
@@ -37,6 +38,7 @@ TEMPLATE = os.path.join(
|
||||
STAGES = ["S1", "S2", "S3", "S4", "S5", "S6", "S7"]
|
||||
UNSKIPPABLE = {"S3", "S5", "S6"}
|
||||
OPEN_STATES = {"RUNNING"}
|
||||
REVISION_FIELD = "skillRevision"
|
||||
|
||||
|
||||
class Contract(Exception):
|
||||
@@ -47,6 +49,36 @@ def _now() -> str:
|
||||
return datetime.datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _git(*args: str) -> str | None:
|
||||
"""저장소에 묻는다. 실패는 빈 문자열이 아니라 `None` 이다."""
|
||||
try:
|
||||
p = subprocess.run(["git", "-C", ROOT, *args], capture_output=True,
|
||||
encoding="utf-8", errors="replace", timeout=30)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return p.stdout if p.returncode == 0 else None
|
||||
|
||||
|
||||
def _skill_revision(skill: str) -> str | None:
|
||||
"""지금 이 스킬의 글자를 담고 있는 커밋.
|
||||
|
||||
영수증(`skillEcho`)은 그때 SKILL.md 에 있던 문장인데, 스킬은 나중에 고쳐진다. 그러면
|
||||
검사기가 「위조」와 「그 뒤에 고쳐졌다」를 가르려고 이력을 훑어야 한다. 그 시점 커밋을
|
||||
여기서 적어 두면 훑지 않고 그 커밋 하나만 본다.
|
||||
|
||||
작업 트리가 그 커밋과 다르면 `None` 이다 — 모르는 리비전을 지어내지 않는다.
|
||||
그런 원장은 검사기에서 이력 훑기로 떨어지고, 그것이 맞는 결과다.
|
||||
"""
|
||||
if not skill:
|
||||
return None
|
||||
rel = f".agents/skills/{skill}"
|
||||
dirty = _git("status", "--porcelain", "--", rel)
|
||||
if dirty is None or dirty.strip():
|
||||
return None
|
||||
out = _git("log", "-n", "1", "--format=%H", "--", rel)
|
||||
return (out or "").strip() or None
|
||||
|
||||
|
||||
class Ledger:
|
||||
"""flock 을 잡고 읽어서, 임시 파일에 완성한 뒤 원자적으로 바꾼다.
|
||||
|
||||
@@ -146,7 +178,8 @@ def cmd_open(args) -> int:
|
||||
"sessions": [{"session": args.session, "openedAt": _now()}],
|
||||
})
|
||||
for st in led.data["stages"]:
|
||||
st.update({"startedAt": None, "finishedAt": None, "elapsedSeconds": 0})
|
||||
st.update({"startedAt": None, "finishedAt": None, "elapsedSeconds": 0,
|
||||
REVISION_FIELD: _skill_revision(str(st.get("skill") or ""))})
|
||||
led.save()
|
||||
print(f"런을 열었다: {args.ledger} (runId={led.data['runId']})")
|
||||
return 0
|
||||
@@ -175,7 +208,11 @@ def cmd_begin(args) -> int:
|
||||
})
|
||||
print(f"{args.stage} 이(가) 열린 채였다 — 그 구간을 닫고 잇는다")
|
||||
st["status"] = "RUNNING"
|
||||
st["runBy"] = args.runby
|
||||
# `--runby` 를 안 주면 **틀이 적어 둔 계약값을 그대로 둔다.** 여기서 기본값으로
|
||||
# 덮어쓰면 단계마다 배정된 관리 에이전트 이름이 매번 지워지고, 원장은 다시
|
||||
# 「서브에이전트가 돌렸다」까지만 말하게 된다 — 누가 돌렸는지는 안 남는다
|
||||
if args.runby:
|
||||
st["runBy"] = args.runby
|
||||
st["startedAt"] = _now()
|
||||
# 이어받을 때마다 세대를 올리고 주인을 적는다. `bin/task.py` 의 attempt 와 같은 자리다 —
|
||||
# 앞 세션이 아직 살아 있어도 낮은 세대의 쓰기는 이 단계에 못 들어온다.
|
||||
@@ -223,6 +260,9 @@ def cmd_end(args) -> int:
|
||||
st["finishedBy"] = args.session or None
|
||||
if args.echo:
|
||||
st["skillEcho"] = args.echo
|
||||
# 영수증을 적는 자리에서 그 영수증이 어느 판의 스킬에서 나왔는지도 적는다.
|
||||
# 여는 시각이 아니라 읽은 시각이라 이쪽이 더 맞다
|
||||
st[REVISION_FIELD] = _skill_revision(str(st.get("skill") or ""))
|
||||
if args.why:
|
||||
st["skipReason"] = args.why
|
||||
for key, values in (("inputs", args.input), ("outputs", args.output)):
|
||||
@@ -293,7 +333,7 @@ def main() -> int:
|
||||
p.add_argument("--force", action="store_true"); p.set_defaults(fn=cmd_open)
|
||||
|
||||
p = sub.add_parser("begin"); p.add_argument("ledger")
|
||||
p.add_argument("--stage", required=True); p.add_argument("--runby", default="subagent")
|
||||
p.add_argument("--stage", required=True); p.add_argument("--runby", default=None)
|
||||
p.add_argument("--session", default=""); p.set_defaults(fn=cmd_begin)
|
||||
|
||||
p = sub.add_parser("gate"); p.add_argument("ledger")
|
||||
|
||||
@@ -21,6 +21,10 @@ import os
|
||||
import re
|
||||
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
|
||||
|
||||
BODY = re.compile(r"(<!-- body:start -->)(.*?)(<!-- body:end -->)", re.S)
|
||||
IMAGE = re.compile(r"^!\[([^\]]*)\]\(([^)]+)\)$", re.M)
|
||||
|
||||
@@ -33,8 +37,9 @@ def asset_keys(text: str) -> dict[str, str]:
|
||||
return {f: k for k, f in zip(keys, files)}
|
||||
|
||||
|
||||
# 본문이 있는 종류는 둘뿐이다. 나머지 셋은 본문 구간이 없는 것이 정상이다
|
||||
BODY_KINDS = {"CASE", "CONCEPT"}
|
||||
# 본문이 있는 종류는 **셋**이다 — CASE·CONCEPT·SETUP. 나머지 셋은 본문 구간이 없는 것이
|
||||
# 정상이다. 목록을 여기 다시 적지 않는다 (techlog.BODY_KIND_CODES 가 정본)
|
||||
BODY_KINDS = techlog.BODY_KIND_CODES
|
||||
|
||||
|
||||
def record_kind(text: str) -> str:
|
||||
|
||||
+193
-13
@@ -36,10 +36,13 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.join(ROOT, "scripts"))
|
||||
import techlog # noqa: E402
|
||||
|
||||
# 서버에 studio:publish 가 갈라져 들어오기 전에는 무인 저장을 켜지 않는다.
|
||||
# 켜는 조건은 사람의 판단이지 이 파일의 기본값이 아니다 (A-studio-change-requests.md CR-001).
|
||||
@@ -84,6 +87,12 @@ FIELD_MAP = {
|
||||
# `decisionStatus`·`decidedOn` 은 frontmatter 에 있다 (`_decision_shape`)
|
||||
"PROJECT_DECISION": {"결정문": "statement", "판단 이유": "rationale",
|
||||
"영향": "consequences"},
|
||||
# **SETUP 은 절 이름을 강제하지 않는다.** 「실행 절차·구성 값·확인 방법을 `##` 절로 적는다.
|
||||
# 절 이름을 강제하지 않는다 — 프로젝트마다 셋업의 모양이 다르다」(`SetupInput.bodyMarkdown`).
|
||||
# 그 절들은 전부 본문 안이라 `_sections` 가 세지 않는다. 본문 밖의 칸 `pinnedVersions` 는
|
||||
# Concept 의 `basisVersion` 과 같이 **절이 아니라 frontmatter 에 있다** — `_setup_shape`
|
||||
# 가 가져온다. 절만 보면 빠지고, required 라 빠지면 422 다
|
||||
"SETUP": {"본문": "bodyMarkdown"},
|
||||
}
|
||||
BODY_START, BODY_END = "<!-- body:start -->", "<!-- body:end -->"
|
||||
|
||||
@@ -423,6 +432,79 @@ def _titled(slug: str, field: str, raw) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
# `PinnedVersion` 은 `{name, version}` 둘뿐이다. `OrderedText` 와 달리 `id` 도 `order` 도
|
||||
# 없고 `additionalProperties: false` 라 더 넣으면 거절된다 (`studio-api.openapi.yaml:964-973`).
|
||||
PINNED_MAX_ITEMS = 30 # `SetupInput.pinnedVersions.maxItems`
|
||||
PINNED_NAME_MAX, PINNED_VERSION_MAX = 60, 40
|
||||
# 이름·버전을 따옴표나 백틱으로 감싸 적는 것이 자연스럽다. 값에는 넣지 않는다 —
|
||||
# 화면이 표의 칸으로 그리므로 따옴표가 글자 그대로 남는다
|
||||
_PINNED_TRIM = " \t`\"'\u201c\u201d\u2018\u2019"
|
||||
|
||||
|
||||
def _pinned_versions(block) -> list[dict]:
|
||||
"""frontmatter 의 `pinnedVersions:` 블록을 `PinnedVersion` 배열로.
|
||||
|
||||
pinnedVersions:
|
||||
- name: Keycloak
|
||||
version: 26.7.0
|
||||
|
||||
비어 있는 것은 정상이다 — 「비우면 화면에 표를 그리지 않습니다」. 그러나 **글이 있는데
|
||||
항목이 0 개면 거절한다.** `_ordered` 와 같은 자리다: 비어 있는 것과 못 읽은 것은 다른 일이다.
|
||||
"""
|
||||
if not isinstance(block, str) or not block.strip():
|
||||
return []
|
||||
out: list[dict] = []
|
||||
for line in block.splitlines():
|
||||
item = line.strip()
|
||||
if not item or item.startswith("#"):
|
||||
continue
|
||||
m = re.match(r"^-\s*name:\s*(.+)$", item)
|
||||
if m:
|
||||
out.append({"name": m.group(1).strip().strip(_PINNED_TRIM)})
|
||||
continue
|
||||
m = re.match(r"^version:\s*(.+)$", item)
|
||||
if m and out and "version" not in out[-1]:
|
||||
out[-1]["version"] = m.group(1).strip().strip(_PINNED_TRIM)
|
||||
continue
|
||||
raise Refused(f"고정한 버전을 읽지 못했다: {item!r} — frontmatter 에 "
|
||||
"`- name: <이름>` 과 ` version: <버전>` 두 줄씩 적는다")
|
||||
if not out:
|
||||
raise Refused("pinnedVersions 에 글이 있는데 항목을 하나도 못 읽었다. "
|
||||
"빈 칸으로 보내지 않는다 — 무엇이 사라졌는지 보고 고친다")
|
||||
for item in out:
|
||||
name, version = item.get("name", ""), item.get("version", "")
|
||||
# **조용히 자르거나 채우지 않는다.** `minLength: 1` 이라 빈 쪽이 있으면 서버가 거절한다
|
||||
if not name or not version:
|
||||
raise Refused(f"고정한 버전의 한쪽이 비었다: {item} — "
|
||||
"이름과 버전 둘 다 있어야 한다 (minLength 1)")
|
||||
if len(name) > PINNED_NAME_MAX or len(version) > PINNED_VERSION_MAX:
|
||||
raise Refused(f"고정한 버전이 상한을 넘는다: 이름 {len(name)}자 "
|
||||
f"(상한 {PINNED_NAME_MAX}) · 버전 {len(version)}자 "
|
||||
f"(상한 {PINNED_VERSION_MAX}) — 기록에서 줄인다")
|
||||
if len(out) > PINNED_MAX_ITEMS:
|
||||
raise Refused(f"고정한 버전이 {len(out)}개다. 상한 {PINNED_MAX_ITEMS}개 "
|
||||
"(studio-api.openapi.yaml:1011) — 기록에서 줄인다")
|
||||
return out
|
||||
|
||||
|
||||
def _setup_shape(doc: dict, fm: dict) -> dict:
|
||||
"""SETUP 의 본문 밖 칸은 `pinnedVersions` 하나다.
|
||||
|
||||
**이 종류에는 검증일 칸이 없다.** `lastVerifiedOn` 도 `verifiedOn` 도 계약에 없고,
|
||||
「낡음은 검증일이 아니라 `pinnedVersions` 가 말한다 — 어느 버전 위에서 이 절차가
|
||||
성립했는지가 유효 범위다」(`SetupDetailResponse`). 그러니 다른 종류를 보고 검증일을
|
||||
넣지 않는다 — `unevaluatedProperties: false` 라 보내면 거절된다.
|
||||
"""
|
||||
doc["pinnedVersions"] = _pinned_versions(fm.get("pinnedVersions"))
|
||||
return doc
|
||||
|
||||
|
||||
def _case_shape(doc: dict, fm: dict) -> dict:
|
||||
"""CASE 의 `lastVerifiedOn` 은 절이 아니라 frontmatter 에 있다. 없으면 null 이다."""
|
||||
doc["lastVerifiedOn"] = fm.get("lastVerifiedOn") or None
|
||||
return doc
|
||||
|
||||
|
||||
def _concept_shape(doc: dict, fm: dict) -> dict:
|
||||
"""`basisVersion` 은 frontmatter 에 있다. 절만 보면 빠진다."""
|
||||
basis = (fm.get("basisVersion") or "").strip()
|
||||
@@ -512,10 +594,23 @@ def _summary(text: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
# 종류 → 그 종류의 칸을 서버 스키마의 **모양**으로 바꾸는 함수.
|
||||
# 여섯 종류 전부에 자리가 있다 — 모양을 바꿀 것이 없는 종류도 함수를 둔다. 빈 자리를
|
||||
# 두면 「없는 것」과 「빠뜨린 것」이 같아 보인다
|
||||
SHAPES = {"CASE": _case_shape, "CONCEPT": _concept_shape, "REFERENCE": _reference_shape,
|
||||
"QUESTION": _question_shape, "PROJECT_DECISION": _decision_shape,
|
||||
"SETUP": _setup_shape}
|
||||
|
||||
|
||||
def build_input(record_path: str) -> dict:
|
||||
"""기록 `.md` 를 `WorkingCopyInput` 으로. 없는 칸을 지어내지 않는다."""
|
||||
text = open(record_path, encoding="utf-8").read()
|
||||
fm = _front_matter(text)
|
||||
# `pinnedVersions:` 처럼 값이 아래 줄에 있는 칸은 한 줄 정규식이 빈 값으로 읽는다.
|
||||
# 스칼라 칸에는 블록이 없으므로 그대로다
|
||||
for key, value in list(fm.items()):
|
||||
if not value:
|
||||
fm[key] = techlog.front_matter_block(text, key)
|
||||
kind = (fm.get("kind") or "").upper()
|
||||
if kind not in FIELD_MAP:
|
||||
raise Refused(f"모르는 kind: {fm.get('kind')!r}")
|
||||
@@ -535,17 +630,9 @@ def build_input(record_path: str) -> dict:
|
||||
for section, field in FIELD_MAP[kind].items():
|
||||
if section in found:
|
||||
doc[field] = found[section]
|
||||
if kind == "CASE":
|
||||
doc["lastVerifiedOn"] = fm.get("lastVerifiedOn") or None
|
||||
if kind == "QUESTION":
|
||||
doc = _question_shape(doc, fm)
|
||||
if kind == "CONCEPT":
|
||||
doc = _concept_shape(doc, fm)
|
||||
if kind == "REFERENCE":
|
||||
doc = _reference_shape(doc, fm)
|
||||
if kind == "PROJECT_DECISION":
|
||||
doc = _decision_shape(doc, fm)
|
||||
return doc
|
||||
# **손으로 이은 if 사슬이 아니라 표다.** 사슬이었을 때 여섯 번째 종류가 붙을 자리가
|
||||
# 보이지 않았다 — 종류를 더하면 `_kinds_line_up()` 이 import 할 때 걸린다
|
||||
return SHAPES[kind](doc, fm)
|
||||
|
||||
|
||||
VERDICTS = ("PASS", "FAIL", "UNKNOWN")
|
||||
@@ -599,6 +686,33 @@ DELETE_PATHS = {
|
||||
}
|
||||
|
||||
|
||||
def _kinds_line_up() -> None:
|
||||
"""세 표가 같은 여섯 종류를 말하는지 **import 할 때** 본다.
|
||||
|
||||
**손으로 나열한 목록에 새 종류를 빠뜨리는 일이 이 저장소에서 반복됐다.** 프론트엔드가
|
||||
같은 실패를 먼저 적어 두었다 — 「여기 손으로 적어 두었던 동안 개념과 환경 구성이 빠져
|
||||
있었고, 작업본 목록의 종류 필터는 그 둘을 아예 고를 수 없었다」
|
||||
(`application/ports/studio-gateway.ts:8-12`).
|
||||
|
||||
빠뜨렸을 때 나는 일이 표마다 다르다. `FIELD_MAP` 이면 「모르는 kind」로 거절되고,
|
||||
`SHAPES` 면 `KeyError` 로 죽고, `DELETE_PATHS` 면 **지울 수 없는 초안이 운영에 남는다.**
|
||||
셋째는 되돌릴 수 없으므로 여기서 막는다.
|
||||
"""
|
||||
for other, name in ((SHAPES, "SHAPES"), (DELETE_PATHS, "DELETE_PATHS")):
|
||||
diff = set(FIELD_MAP) ^ set(other)
|
||||
if diff:
|
||||
raise RuntimeError(
|
||||
f"FIELD_MAP 과 {name} 이 다른 종류를 말한다: {sorted(diff)} — "
|
||||
"종류를 더할 때 세 표를 함께 채운다 (techlog.KIND_OF_DIR 가 종류의 정본)")
|
||||
known = set(techlog.KIND_OF_DIR.values())
|
||||
if set(FIELD_MAP) != known:
|
||||
raise RuntimeError(
|
||||
f"FIELD_MAP 이 techlog 의 종류와 다르다: {sorted(set(FIELD_MAP) ^ known)}")
|
||||
|
||||
|
||||
_kinds_line_up()
|
||||
|
||||
|
||||
def published_marks(record_path: str) -> list[str]:
|
||||
"""이 기록이 게시된 것으로 보이는 표시. 비면 저장 대상이다.
|
||||
|
||||
@@ -750,11 +864,68 @@ def _review_gate(warnings: list[dict], verdict_path: str | None,
|
||||
for w, v in passed]
|
||||
|
||||
|
||||
# 고정한 해시가 지금 파일과 어긋날 때, 그것이 **이 파일의 과거 판**인지 **남의 묶음**인지.
|
||||
# 양쪽 다 거절이지만 사람이 할 일이 다르다 — 앞은 다시 검토하는 것이고, 뒤는 묶음을
|
||||
# 바로잡는 것이다. 하나로 뭉쳐 놓으면 낡은 검토를 「잘못 물린 묶음」으로 읽고 묶음을 고친다
|
||||
PIN_PAST, PIN_ALIEN, PIN_UNSEEN = "PAST", "ALIEN", "UNSEEN"
|
||||
PIN_HISTORY_LIMIT = 200
|
||||
|
||||
|
||||
def _git_out(args: list[str]) -> str | None:
|
||||
try:
|
||||
r = subprocess.run(["git", *args], cwd=ROOT, capture_output=True, text=True)
|
||||
except OSError:
|
||||
return None
|
||||
return r.stdout if r.returncode == 0 else None
|
||||
|
||||
|
||||
def _pin_verdict(record_path: str, want: str) -> tuple[str, str]:
|
||||
"""고정값이 이 파일의 과거 상태였나, 어느 판에도 없나, 아니면 못 봤나.
|
||||
|
||||
`_echo_verdict` 와 같은 판정이다. 찾지 못한 것과 볼 수 없었던 것을 가른다 — git 이
|
||||
없거나 이력 상한에 걸렸으면 `ALIEN` 이 아니라 `UNSEEN` 이다. **못 봤는데 남의
|
||||
묶음이라고 쓰면 멀쩡한 검토를 버리게 된다.**
|
||||
"""
|
||||
rel = os.path.relpath(os.path.abspath(record_path), ROOT)
|
||||
log = _git_out(["log", f"-{PIN_HISTORY_LIMIT + 1}", "--format=%H", "--", rel])
|
||||
if log is None:
|
||||
return PIN_UNSEEN, "git 이 없거나 이 저장소의 이력을 읽지 못했다"
|
||||
commits = log.split()
|
||||
if not commits:
|
||||
return PIN_UNSEEN, "이 기록이 아직 커밋되지 않아 견줄 과거 판이 없다"
|
||||
truncated = len(commits) > PIN_HISTORY_LIMIT
|
||||
blind = False
|
||||
for commit in commits[:PIN_HISTORY_LIMIT]:
|
||||
blob = _git_out(["show", f"{commit}:{rel}"])
|
||||
if blob is None:
|
||||
blind = True
|
||||
elif _sha256_text(blob) == want:
|
||||
when = (_git_out(["log", "-1", "--format=%ad", "--date=short", commit]) or "").strip()
|
||||
return PIN_PAST, f"{commit[:12]}{(' · ' + when) if when else ''}"
|
||||
if truncated:
|
||||
return PIN_UNSEEN, f"이력 상한 {PIN_HISTORY_LIMIT} 커밋까지 보고 못 찾았다"
|
||||
if blind:
|
||||
return PIN_UNSEEN, "이력의 일부를 읽지 못했다"
|
||||
return PIN_ALIEN, f"커밋 {len(commits)}개를 다 봤다"
|
||||
|
||||
|
||||
def approved(record_path: str, package_path: str) -> dict:
|
||||
"""검토를 지난 최종본만 통과시킨다.
|
||||
|
||||
묶음의 `target.sha256` 은 검토가 본 파일의 해시다. 지금 디스크의 파일이 그것과 다르면
|
||||
**검토 뒤에 바뀐 것**이라 그 판정을 이 파일에 붙일 수 없다.
|
||||
|
||||
어긋났을 때 무엇이 낡았는지는 `_pin_verdict` 가 git 으로 가른다. 셋 다 거절이지만
|
||||
사람이 할 일이 다르다.
|
||||
|
||||
| 고정값이 | 뜻 | 할 일 |
|
||||
|---|---|---|
|
||||
| 이 파일의 과거 판이다 (`PAST`) | 검토는 진짜고 그 뒤에 파일이 바뀌었다 | 다시 검토해 묶음을 새로 뜬다 |
|
||||
| 어느 판도 아니다 (`ALIEN`) | 다른 파일의 묶음을 물렸다 | 묶음을 바로잡는다 |
|
||||
| 못 봤다 (`UNSEEN`) | git 이 없거나 이력 상한에 걸렸다 | 판정하지 않는다 |
|
||||
|
||||
**기록을 되돌리거나 묶음의 해시를 고쳐 쓰지 않는다.** 앞은 검사기에 답하는 것이고
|
||||
뒤는 영수증 위조다. 런 원장의 `skillEcho` 와 같은 자리다.
|
||||
"""
|
||||
pkg = json.load(open(package_path, encoding="utf-8"))
|
||||
now = _sha256_file(record_path)
|
||||
@@ -762,9 +933,16 @@ def approved(record_path: str, package_path: str) -> dict:
|
||||
if not want:
|
||||
raise Refused(f"묶음에 target.sha256 이 없다: {package_path}")
|
||||
if want != now:
|
||||
kind, detail = _pin_verdict(record_path, want)
|
||||
why = {
|
||||
PIN_PAST: f"그 뒤에 이 파일이 바뀌었다 — 고정값은 {detail} 의 판이다.\n"
|
||||
f" 검토는 진짜다. 바뀐 내용을 다시 검토해 묶음을 새로 뜬다",
|
||||
PIN_ALIEN: f"고정값이 이 파일의 어느 판도 아니다 — 다른 파일의 묶음을 물렸다 ({detail})",
|
||||
PIN_UNSEEN: f"고정값이 이 파일의 과거 판인지 못 봤다 — {detail}",
|
||||
}[kind]
|
||||
raise Refused(
|
||||
"검토가 본 파일과 지금 파일이 다르다 — 그 판정을 이 파일에 붙일 수 없다\n"
|
||||
f" 검토 시점 {want}\n 지금 {now}")
|
||||
f" 검토 시점 {want}\n 지금 {now}\n {why}")
|
||||
failed = [g["cmd"] for g in pkg.get("gates", []) if g.get("exit") not in (0, "0")]
|
||||
if failed:
|
||||
raise Refused("관문이 통과하지 못한 묶음이다:\n " + "\n ".join(failed))
|
||||
@@ -1135,7 +1313,9 @@ def main() -> int:
|
||||
ap.add_argument("--project-id",
|
||||
help="PROJECT_DECISION 이 걸리는 프로젝트의 uuid. 사람이 Studio 목록에서 "
|
||||
"읽은 값이어야 한다 — 이 어댑터는 이름을 uuid 로 바꾸지 않는다. "
|
||||
"삭제 경로가 이 값을 쓴다")
|
||||
"삭제 경로가 이 값을 쓴다. **SETUP 도 프로젝트가 필수다** "
|
||||
"(「환경 구성은 프로젝트에 속합니다」 · project-public-render-model.ts:245) "
|
||||
"— 다만 삭제 경로는 /setups/{id} 라 지우는 데는 안 쓴다")
|
||||
ap.add_argument("--harness-test", action="store_true",
|
||||
help=f"시험 초안이다. 제목 앞에 {HARNESS_PREFIX}, slug 앞에 {HARNESS_SLUG_PREFIX} 를 붙인다")
|
||||
ap.add_argument("--send", action="store_true", help="실제로 보낸다 (지금은 막혀 있다)")
|
||||
|
||||
+46
-1
@@ -11,6 +11,7 @@ import collections
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
|
||||
@@ -64,11 +65,55 @@ def check_targets(projects, root: str, needs: str = "") -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
KINDS = ["case", "concept", "reference", "question", "decision"]
|
||||
# ── 종류 ─────────────────────────────────────────────────────────────────────
|
||||
# **한 곳에서 정하고 나머지가 여기를 쓴다.** 손으로 나열한 목록에 새 종류를 빠뜨리는 일이
|
||||
# 반복됐다 — 프론트엔드가 같은 실패를 먼저 적어 두었다(`application/ports/studio-gateway.ts:8-12`):
|
||||
# 「여기 손으로 적어 두었던 동안 개념과 환경 구성이 빠져 있었고, 작업본 목록의 종류 필터는
|
||||
# 그 둘을 아예 고를 수 없었다」. 계약의 `RecordKind` 는 여섯이다
|
||||
# (`studio-api.openapi.yaml:838-840` · tech-log-frontend @ 9e5642c).
|
||||
KINDS = ["case", "concept", "reference", "question", "decision", "setup"]
|
||||
|
||||
# 폴더 이름 → frontmatter 의 `kind`. 하나만 다르다 — `decision/` 의 kind 는 PROJECT_DECISION 이다
|
||||
KIND_OF_DIR = {"case": "CASE", "concept": "CONCEPT", "reference": "REFERENCE",
|
||||
"question": "QUESTION", "decision": "PROJECT_DECISION", "setup": "SETUP"}
|
||||
DIR_OF_KIND = {v: k for k, v in KIND_OF_DIR.items()}
|
||||
|
||||
# 본문(`bodyMarkdown`)을 갖는 종류. **셋이다** — 환경 구성의 본문도 Case 와 같은 파서를 탄다
|
||||
# (`SetupInput.required` 에 `bodyMarkdown` 이 있다 · `setup-document-page.tsx:11`).
|
||||
# 나머지 셋(Reference·Question·Decision)의 칸은 평문으로 렌더링된다
|
||||
BODY_KINDS = {"case", "concept", "setup"}
|
||||
BODY_KIND_CODES = {KIND_OF_DIR[k] for k in BODY_KINDS}
|
||||
|
||||
READINESS = ["READY", "OPEN", "NEEDS_EVIDENCE", "NEEDS_DECISION", "BLOCKED"]
|
||||
DISPOSITIONS = ["PROMOTE", "MERGE_INTO", "KEEP_IN_SSOT",
|
||||
"NEEDS_EVIDENCE", "NEEDS_DECISION", "BLOCKED"]
|
||||
|
||||
def front_matter_block(text: str, key: str) -> str:
|
||||
"""frontmatter 의 `key:` 아래 들여쓴 블록. 없으면 빈 문자열.
|
||||
|
||||
이 저장소의 frontmatter 파서들은 `^키: 값$` 한 줄만 읽는다. 그래서 값이 아래 줄에 있는
|
||||
칸(`source:` · `assets:` · `pinnedVersions:`)은 **빈 값으로 읽힌다.** 스칼라 칸만
|
||||
요구하는 동안에는 드러나지 않았는데, 환경 구성의 `pinnedVersions` 는 목록이면서
|
||||
그 기록의 유효 범위라 「비었다」와 「채웠다」를 갈라야 한다 — 그 칸만 따로 읽는다.
|
||||
"""
|
||||
if not text.startswith("---"):
|
||||
return ""
|
||||
end = text.find("\n---", 3)
|
||||
if end < 0:
|
||||
return ""
|
||||
lines = text[3:end].splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
if not re.match(rf"^{re.escape(key)}:\s*$", line):
|
||||
continue
|
||||
block = []
|
||||
for nxt in lines[i + 1:]:
|
||||
if nxt.strip() and not nxt[:1].isspace():
|
||||
break # 들여쓰기가 끝났다 — 다음 칸이다
|
||||
block.append(nxt)
|
||||
return "\n".join(block).strip("\n")
|
||||
return ""
|
||||
|
||||
|
||||
def sha256_of(path: str) -> str | None:
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
kind: SETUP
|
||||
slug: fixture-setup
|
||||
title: 고정 사례 환경 구성
|
||||
topic: fixture-topic
|
||||
project: fixture
|
||||
status: 게시 전
|
||||
sourceRevision: 0000000000000000000000000000000000000000
|
||||
---
|
||||
|
||||
# 고정 사례 환경 구성
|
||||
|
||||
이 절차를 따라 하면 무엇이 서는지 한 문단으로 적는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **고정 사례 케이스**
|
||||
그 사건을 이 환경 위에서 재현한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 실행 절차
|
||||
|
||||
1. 예시 명령을 그대로 실행한다.
|
||||
|
||||
## 구성 값
|
||||
|
||||
어떤 값을 어디에 두는지 적는다.
|
||||
|
||||
## 확인 방법
|
||||
|
||||
제대로 섰는지 확인하는 명령과 그때 보이는 출력을 적는다.
|
||||
|
||||
<!-- body:end -->
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
kind: SETUP
|
||||
slug: fixture-setup
|
||||
title: 고정 사례 환경 구성
|
||||
topic: fixture-topic
|
||||
project: fixture
|
||||
status: 게시 전
|
||||
sourceRevision: 0000000000000000000000000000000000000000
|
||||
pinnedVersions:
|
||||
- name: 예시 런타임
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# 고정 사례 환경 구성
|
||||
|
||||
이 절차를 따라 하면 무엇이 서는지 한 문단으로 적는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **고정 사례 케이스**
|
||||
그 사건을 이 환경 위에서 재현한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 실행 절차
|
||||
|
||||
1. 예시 명령을 그대로 실행한다.
|
||||
|
||||
## 구성 값
|
||||
|
||||
어떤 값을 어디에 두는지 적는다.
|
||||
|
||||
## 확인 방법
|
||||
|
||||
제대로 섰는지 확인하는 명령과 그때 보이는 출력을 적는다.
|
||||
|
||||
<!-- body:end -->
|
||||
@@ -89,3 +89,34 @@ class Project(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class UnseenIsNotClean(unittest.TestCase):
|
||||
"""「안 겹친다」와 「못 봤다」는 다른 출력이다.
|
||||
|
||||
이 검사기는 techviz 렌더러가 붙인 `class` 로만 상자를 알아본다. 손으로 그린 SVG 에는
|
||||
그 class 가 없어 `boxes()` 가 비고, 겹칠 짝이 없으니 겹침 0 이 나온다. 고치기 전에는
|
||||
그것을 `그림 182 · 겹친 그림 0` 으로 찍었는데 **실제로 본 것은 18장**이었다.
|
||||
나머지 164장은 안 겹친 것이 아니라 볼 수 없었던 것이다.
|
||||
"""
|
||||
|
||||
HAND_DRAWN = ('<svg xmlns="http://www.w3.org/2000/svg">'
|
||||
'<rect x="0" y="0" width="400" height="300" fill="#fff"/>'
|
||||
'<rect x="10" y="10" width="100" height="40"/>'
|
||||
'<rect x="20" y="20" width="100" height="40"/>'
|
||||
'<text x="30" y="30">겹친 상자 둘</text></svg>')
|
||||
|
||||
def test_a_hand_drawn_svg_is_counted_as_unseen(self):
|
||||
_, out = run(self.HAND_DRAWN)
|
||||
self.assertIn("못 본 그림 1", out, out)
|
||||
self.assertIn("본 그림 0", out, out)
|
||||
|
||||
def test_a_hand_drawn_svg_does_not_read_as_checked(self):
|
||||
"""상자가 실제로 겹쳐 있는데도 이 검사기는 답하지 못한다. 그 사실이 보여야 한다."""
|
||||
_, out = run(self.HAND_DRAWN)
|
||||
self.assertIn("답하지 못한다", out, out)
|
||||
|
||||
def test_a_techviz_svg_is_counted_as_seen(self):
|
||||
_, out = run(svg(rect("node-shape", 10, 10, 100, 40)))
|
||||
self.assertIn("본 그림 1", out, out)
|
||||
self.assertNotIn("못 본 그림", out, out)
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
한다. **정본을 거쳤는지만 보면 렌더러를 몰라도 된다** — 그리고 손으로 고친 그림이
|
||||
화살표 뒤집기의 모양이다.
|
||||
"""
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
@@ -26,6 +29,26 @@ def _cli(*args):
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _a_figure_without_a_spec():
|
||||
"""`.techviz/<이름>/spec.json` 이 없는 그림 한 장짜리 프로젝트를 만들어 준다.
|
||||
|
||||
이 상태를 실재 프로젝트 이름으로 가리키면 그 프로젝트의 그림이 전부 정본을 갖는
|
||||
순간 시험이 깨지고, 프로젝트가 빠지면 `verify()` 가 그림 0장을 보고 「못 본 그림」도
|
||||
0 이 되어 시험이 엉뚱한 이유로 깨진다. 이름이 아니라 상태가 필요한 시험이다.
|
||||
"""
|
||||
docs = os.path.join(ROOT, "docs")
|
||||
path = tempfile.mkdtemp(prefix="zz-no-spec-", dir=docs)
|
||||
try:
|
||||
assets = os.path.join(path, "final", "assets")
|
||||
os.makedirs(assets)
|
||||
with open(os.path.join(assets, "handmade.svg"), "w", encoding="utf-8") as fh:
|
||||
fh.write('<svg xmlns="http://www.w3.org/2000/svg"><title>손그림</title></svg>\n')
|
||||
yield os.path.basename(path)
|
||||
finally:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
|
||||
class ProvenanceTest(unittest.TestCase):
|
||||
def test_every_figure_in_this_repository_passes(self):
|
||||
"""대조군. 지금 저장소의 그림에 하나라도 걸리면 정책이 과하다."""
|
||||
@@ -53,7 +76,8 @@ class ProvenanceTest(unittest.TestCase):
|
||||
|
||||
def test_a_figure_without_a_spec_is_counted_not_flagged(self):
|
||||
"""정본이 없는 그림은 이 검사기가 볼 것이 아니다. 세기만 한다."""
|
||||
rep = fp.verify("ca-tmpl")
|
||||
with _a_figure_without_a_spec() as project:
|
||||
rep = fp.verify(project)
|
||||
self.assertEqual(0, rep.error_count)
|
||||
self.assertGreater(rep.facts.get("정본이 없어 못 본 그림", 0), 0)
|
||||
|
||||
|
||||
@@ -10,13 +10,34 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# `docs/` 안에 만드는 픽스처의 접두사. 실재 프로젝트와 섞이지 않게 `zz-` 로 시작한다
|
||||
FIXTURE_PREFIX = "zz-no-contract-"
|
||||
|
||||
|
||||
def _sweep_stale_fixtures(docs: str) -> None:
|
||||
"""지난 실행이 남긴 픽스처를 치운다.
|
||||
|
||||
`finally` 는 SIGKILL 을 못 막는다. 한 번 남으면 그 폴더가 **진짜 프로젝트로
|
||||
세어져서** `verify-pipeline.py` 가 프로젝트 수를 하나 더 세고 계약이 없다고
|
||||
error 를 낸다 — 시험이 저장소를 고장 낸 것처럼 보인다. 지우는 것은 이 접두사로
|
||||
시작하는 것뿐이라 실재 프로젝트를 건드리지 않는다.
|
||||
"""
|
||||
for name in os.listdir(docs):
|
||||
if name.startswith(FIXTURE_PREFIX):
|
||||
shutil.rmtree(os.path.join(docs, name), ignore_errors=True)
|
||||
|
||||
|
||||
PY_CHECKERS = (
|
||||
"check-figure-text.py",
|
||||
"check-figure-overlap.py",
|
||||
@@ -28,6 +49,49 @@ MJS_CHECKER = os.path.join(".agents", "skills", "writing-tech-log-records",
|
||||
"scripts", "check_evidence.mjs")
|
||||
ABSENT = "no-such-project-r4-regression"
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _a_contract_with_no_records():
|
||||
"""계약은 있고 기록이 0건인 프로젝트를 만들어 준다.
|
||||
|
||||
이 상태를 살아 있는 프로젝트 이름으로 가리키면, 누가 그 프로젝트에 기록 한 편을
|
||||
쓰는 순간 시험이 깨진다. 실제로 그렇게 깨졌다. 이름이 아니라 상태가 필요한
|
||||
시험이므로 여기서 그 상태를 만든다.
|
||||
"""
|
||||
docs = os.path.join(ROOT, "docs")
|
||||
path = tempfile.mkdtemp(prefix="zz-no-records-", dir=docs)
|
||||
try:
|
||||
studio = os.path.join(path, "tech-log-studio")
|
||||
os.makedirs(studio)
|
||||
name = os.path.basename(path)
|
||||
with open(os.path.join(studio, "tech-log-tree.json"), "w", encoding="utf-8") as fh:
|
||||
json.dump({"schemaVersion": 4, "project": name, "ssot": "final/document.md",
|
||||
"topics": {}, "candidates": []}, fh)
|
||||
yield name
|
||||
finally:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _a_project_without_a_contract():
|
||||
"""SSOT 는 있고 분해 계약이 없는 프로젝트를 만들어 준다.
|
||||
|
||||
이 상태를 실재 프로젝트 이름으로 가리키면 그 프로젝트가 저장소에서 빠지는 순간
|
||||
시험이 조용히 `skip` 으로 넘어간다. 실제로 그렇게 됐다 — `ca-tmpl` 을 지웠다.
|
||||
이름이 아니라 상태가 필요한 시험이므로 여기서 그 상태를 만든다.
|
||||
"""
|
||||
docs = os.path.join(ROOT, "docs")
|
||||
_sweep_stale_fixtures(docs)
|
||||
path = tempfile.mkdtemp(prefix=FIXTURE_PREFIX, dir=docs)
|
||||
try:
|
||||
final = os.path.join(path, "final")
|
||||
os.makedirs(final)
|
||||
with open(os.path.join(final, "document.md"), "w", encoding="utf-8") as fh:
|
||||
fh.write("# 계약 없는 프로젝트\n\n## §1 아무것도 아니다\n\n본문.\n")
|
||||
yield os.path.basename(path)
|
||||
finally:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
|
||||
|
||||
def _run(cmd: list[str]) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=300)
|
||||
@@ -52,24 +116,20 @@ class NoTargetExitCode(unittest.TestCase):
|
||||
|
||||
def test_project_without_contract_is_not_a_target(self) -> None:
|
||||
"""계약이 없는 실재 프로젝트도 「대상이 성립하지 않는다」다."""
|
||||
base = os.path.join(ROOT, "docs", "ca-tmpl")
|
||||
if not os.path.isdir(base) or os.path.exists(os.path.join(base, "tech-log-studio")):
|
||||
self.skipTest("계약 없는 프로젝트가 이 저장소에 없다")
|
||||
for name in ("audit-records.py", "verify-tech-log-tree.py"):
|
||||
with self.subTest(checker=name):
|
||||
run = _run([sys.executable, os.path.join("scripts", name), "ca-tmpl"])
|
||||
self.assertEqual(run.returncode, 2, run.stdout + run.stderr)
|
||||
with _a_project_without_a_contract() as project:
|
||||
for name in ("audit-records.py", "verify-tech-log-tree.py"):
|
||||
with self.subTest(checker=name):
|
||||
run = _run([sys.executable, os.path.join("scripts", name), project])
|
||||
self.assertEqual(run.returncode, 2, run.stdout + run.stderr)
|
||||
|
||||
|
||||
class EmptyTargetIsNotGreen(unittest.TestCase):
|
||||
"""계약은 있고 기록이 0건인 것은 결함이 아니다. 다만 「문제 없음」이라고 쓰지 않는다."""
|
||||
|
||||
def test_zero_records_says_so(self) -> None:
|
||||
base = os.path.join(ROOT, "docs", "keycloak-session-store")
|
||||
if not os.path.isdir(base):
|
||||
self.skipTest("기록 0건 프로젝트가 이 저장소에 없다")
|
||||
run = _run([sys.executable, os.path.join("scripts", "audit-records.py"),
|
||||
"keycloak-session-store"])
|
||||
with _a_contract_with_no_records() as project:
|
||||
run = _run([sys.executable, os.path.join("scripts", "audit-records.py"),
|
||||
project])
|
||||
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
|
||||
self.assertIn("기록 0건", run.stdout)
|
||||
self.assertNotIn("문제 없음", run.stdout)
|
||||
@@ -108,16 +168,14 @@ class FileTargets(unittest.TestCase):
|
||||
class ContractlessProjectIsCounted(unittest.TestCase):
|
||||
"""계약이 없는 프로젝트가 전체 훑기의 목록에서 사라지면 안 된다 (R6).
|
||||
|
||||
`verify_projects()` 가 `docs/*/tech-log-studio` 만 훑던 동안 `ca-tmpl` 은
|
||||
`verify_projects()` 가 `docs/*/tech-log-studio` 만 훑던 동안 계약 없는 프로젝트는
|
||||
`TECH LOG TREES` 블록에 아예 안 나왔다. CLAUDE.md 는 「계약 미채택도 error」라고
|
||||
적었는데 그 error 를 셀 자리가 없었다.
|
||||
"""
|
||||
|
||||
def test_missing_contract_is_an_error(self) -> None:
|
||||
base = os.path.join(ROOT, "docs", "ca-tmpl")
|
||||
if not os.path.isdir(base) or os.path.exists(os.path.join(base, "tech-log-studio")):
|
||||
self.skipTest("계약 없는 프로젝트가 이 저장소에 없다")
|
||||
run = _run([sys.executable, os.path.join("scripts", "verify-pipeline.py")])
|
||||
with _a_project_without_a_contract():
|
||||
run = _run([sys.executable, os.path.join("scripts", "verify-pipeline.py")])
|
||||
self.assertIn("분해 계약 없음", run.stdout)
|
||||
block = run.stdout.split("TECH LOG TREES:", 1)
|
||||
self.assertEqual(len(block), 2, "TECH LOG TREES 블록이 없다")
|
||||
|
||||
@@ -1,21 +1,93 @@
|
||||
"""종류가 요구하는 내용이 채워졌는지 보는 검사기.
|
||||
|
||||
고정 사례는 다섯 종류마다 둘이다 — 채운 것과 하나를 뺀 것.
|
||||
고정 사례는 **여섯** 종류마다 둘이다 — 채운 것과 하나를 뺀 것.
|
||||
「무조건 통과」도 「무조건 거절」도 아닌 것을 이 짝이 확인한다.
|
||||
"""
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
FIXTURES = os.path.join(ROOT, "scripts", "tests", "fixtures", "required-content")
|
||||
|
||||
# `docs/` 안에 만드는 픽스처의 접두사. 실재 프로젝트와 섞이지 않게 `zz-` 로 시작한다
|
||||
FIXTURE_PREFIX = "zz-no-contract-"
|
||||
|
||||
|
||||
def _sweep_stale_fixtures(docs: str) -> None:
|
||||
"""지난 실행이 남긴 픽스처를 치운다.
|
||||
|
||||
`finally` 는 SIGKILL 을 못 막는다. 한 번 남으면 그 폴더가 **진짜 프로젝트로
|
||||
세어져서** `verify-pipeline.py` 가 프로젝트 수를 하나 더 세고 계약이 없다고
|
||||
error 를 낸다 — 시험이 저장소를 고장 낸 것처럼 보인다. 지우는 것은 이 접두사로
|
||||
시작하는 것뿐이라 실재 프로젝트를 건드리지 않는다.
|
||||
"""
|
||||
for name in os.listdir(docs):
|
||||
if name.startswith(FIXTURE_PREFIX):
|
||||
shutil.rmtree(os.path.join(docs, name), ignore_errors=True)
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"check_required_content", os.path.join(ROOT, "scripts", "check-required-content.py"))
|
||||
crc = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(crc)
|
||||
|
||||
KINDS = ("case", "concept", "reference", "question", "decision")
|
||||
# 계약의 `RecordKind` 와 같은 여섯이다. 손으로 세지 않는다 — 이 목록에 종류를 빠뜨리면
|
||||
# 그 종류의 고정 사례가 있어도 **아무도 열어 보지 않는다**
|
||||
KINDS = tuple(crc.techlog.KINDS)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _a_contract_with_no_records():
|
||||
"""계약은 있고 기록이 0건인 프로젝트를 만들어 준다.
|
||||
|
||||
이 상태를 살아 있는 프로젝트 이름으로 가리키면, 누가 그 프로젝트에 기록 한 편을
|
||||
쓰는 순간 시험이 깨진다. 실제로 그렇게 깨졌다. 이름이 아니라 상태가 필요한
|
||||
시험이므로 여기서 그 상태를 만든다.
|
||||
"""
|
||||
docs = os.path.join(ROOT, "docs")
|
||||
path = tempfile.mkdtemp(prefix="zz-no-records-", dir=docs)
|
||||
try:
|
||||
studio = os.path.join(path, "tech-log-studio")
|
||||
os.makedirs(studio)
|
||||
name = os.path.basename(path)
|
||||
with open(os.path.join(studio, "tech-log-tree.json"), "w", encoding="utf-8") as fh:
|
||||
json.dump({"schemaVersion": 4, "project": name, "ssot": "final/document.md",
|
||||
"topics": {}, "candidates": []}, fh)
|
||||
yield name
|
||||
finally:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _a_project_without_a_contract():
|
||||
"""SSOT 는 있고 분해 계약이 없는 프로젝트를 만들어 준다.
|
||||
|
||||
`_sweep_stale_fixtures` 를 먼저 부르는 이유는 `finally` 가 못 도는 경우가 있어서다.
|
||||
이 시험이 SIGKILL 을 맞으면(메모리 부족 등) 픽스처가 `docs/` 에 남고, 그러면
|
||||
**그것이 진짜 프로젝트로 세어진다** — `verify-pipeline.py` 가 「프로젝트 7」로
|
||||
찍고 계약이 없으니 error 를 낸다. 실제로 그렇게 됐다. 저장소를 어지럽히지 않는
|
||||
자리에 두는 것이 낫지만, `check_evidence.mjs <프로젝트>` 가 `docs/` 아래에서만
|
||||
프로젝트를 찾으므로 여기 두어야 한다. 그래서 다음 실행이 스스로 치운다.
|
||||
|
||||
이 상태를 실재 프로젝트 이름으로 가리키면 그 프로젝트가 저장소에서 빠지는 순간
|
||||
시험이 엉뚱한 이유로 통과한다 — 「계약이 없다」가 「그런 프로젝트가 없다」로 바뀐다.
|
||||
둘 다 exit 2 라 종료 코드만 보면 구분되지 않는다. 그래서 상태를 여기서 만든다.
|
||||
"""
|
||||
docs = os.path.join(ROOT, "docs")
|
||||
_sweep_stale_fixtures(docs)
|
||||
path = tempfile.mkdtemp(prefix=FIXTURE_PREFIX, dir=docs)
|
||||
try:
|
||||
final = os.path.join(path, "final")
|
||||
os.makedirs(final)
|
||||
with open(os.path.join(final, "document.md"), "w", encoding="utf-8") as fh:
|
||||
fh.write("# 계약 없는 프로젝트\n\n## §1 아무것도 아니다\n\n본문.\n")
|
||||
yield os.path.basename(path)
|
||||
finally:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
|
||||
|
||||
def _report(sub, name):
|
||||
@@ -47,6 +119,7 @@ class RequiredContentTest(unittest.TestCase):
|
||||
"reference": "적용 조건",
|
||||
"question": "다음 검증",
|
||||
"decision": "판단 이유",
|
||||
"setup": "pinnedVersions",
|
||||
}
|
||||
for kind, part in expected.items():
|
||||
with self.subTest(kind=kind):
|
||||
@@ -64,8 +137,22 @@ class RequiredContentTest(unittest.TestCase):
|
||||
rep = _report("ok", "question")
|
||||
self.assertEqual(0, rep.error_count)
|
||||
|
||||
def test_body_markers_belong_only_to_case_and_concept(self):
|
||||
self.assertEqual({"case", "concept"}, crc.BODY_KINDS)
|
||||
def test_body_markers_belong_to_case_concept_and_setup(self):
|
||||
"""**본문이 있는 종류는 셋이다.** 환경 구성의 본문도 Case 와 같은 파서를 탄다."""
|
||||
self.assertEqual({"case", "concept", "setup"}, crc.BODY_KINDS)
|
||||
|
||||
def test_every_kind_has_a_fixture_pair(self):
|
||||
"""종류를 더하면서 고정 사례를 안 만들면 위 시험들이 조용히 그 종류를 건너뛴다.
|
||||
|
||||
`_report` 가 없는 파일을 열면 터지지만, 그 전에 **어느 종류가 비었는지**를 말해 준다.
|
||||
「볼 것이 없어서 통과」를 「문제 없음」으로 쓰지 않는 것과 같은 자리다.
|
||||
"""
|
||||
for sub in ("ok", "missing"):
|
||||
for kind in KINDS:
|
||||
with self.subTest(sub=sub, kind=kind):
|
||||
self.assertTrue(
|
||||
os.path.isfile(os.path.join(FIXTURES, sub, f"{kind}.md")),
|
||||
f"{sub}/{kind}.md 이 없다 — 종류를 더했으면 고정 사례도 둘 만든다")
|
||||
|
||||
def _cli(self, *args):
|
||||
import subprocess
|
||||
@@ -81,13 +168,15 @@ class RequiredContentTest(unittest.TestCase):
|
||||
|
||||
def test_a_project_without_a_contract_does_not_come_back_green(self):
|
||||
"""계약이 없으면 「볼 것이 없어서 통과」다. 그것을 초록으로 내지 않는다."""
|
||||
p = self._cli("ca-tmpl")
|
||||
with _a_project_without_a_contract() as project:
|
||||
p = self._cli(project)
|
||||
self.assertEqual(2, p.returncode)
|
||||
self.assertIn("tech-log-tree.json 이 없다", p.stderr)
|
||||
|
||||
def test_a_contract_with_no_records_yet_is_not_an_error(self):
|
||||
"""아직 안 쓴 것은 결함이 아니다. 다만 초록으로 보이면 안 된다."""
|
||||
p = self._cli("keycloak-session-store")
|
||||
with _a_contract_with_no_records() as project:
|
||||
p = self._cli(project)
|
||||
self.assertEqual(0, p.returncode)
|
||||
self.assertIn("아직 안 쓰였다", p.stdout)
|
||||
|
||||
@@ -98,7 +187,8 @@ class RequiredContentTest(unittest.TestCase):
|
||||
저장소 체크아웃 이름을 적는 것을 금지해서(`FORBIDDEN_LITERAL`), 그 이름과 같은
|
||||
프로젝트를 테스트에 적으면 계약 검사가 깨진다.
|
||||
"""
|
||||
p = self._cli("keycloak", "ca-tmpl")
|
||||
with _a_project_without_a_contract() as project:
|
||||
p = self._cli("keycloak", project)
|
||||
self.assertEqual(2, p.returncode)
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import glob
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
@@ -18,6 +19,14 @@ _spec = importlib.util.spec_from_file_location("run_ledger", TOOL)
|
||||
rl = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(rl)
|
||||
|
||||
VERIFIER = os.path.join(ROOT, "scripts", "verify-pipeline-run.py")
|
||||
_vspec = importlib.util.spec_from_file_location("verify_pipeline_run", VERIFIER)
|
||||
vpr = importlib.util.module_from_spec(_vspec)
|
||||
_vspec.loader.exec_module(vpr)
|
||||
|
||||
# 지금 `writing-tech-log-records/SKILL.md` 에 있는 문장. 영수증으로 적으면 통과해야 한다.
|
||||
CURRENT_SENTENCE = "본문이 있는 종류는 Case·Concept·Setup 셋이다."
|
||||
|
||||
|
||||
def _cli(*args, **kw):
|
||||
return subprocess.run(["python3", TOOL, *args], cwd=ROOT,
|
||||
@@ -69,7 +78,7 @@ class LedgerTest(unittest.TestCase):
|
||||
self.assertEqual(2, _cli("end", self.led, "--stage", "S3",
|
||||
"--status", "DONE").returncode)
|
||||
self.assertEqual(0, _cli("end", self.led, "--stage", "S3", "--status", "DONE",
|
||||
"--echo", "본문이 있는 종류는 Case 와 Concept 둘뿐이다.").returncode)
|
||||
"--echo", CURRENT_SENTENCE).returncode)
|
||||
|
||||
def test_a_gate_must_carry_an_exit_code(self):
|
||||
"""종료 코드 없는 관문을 못 적는다. 돌리지 않고 적는 경로를 막는다."""
|
||||
@@ -189,10 +198,23 @@ class LedgerTest(unittest.TestCase):
|
||||
def test_the_added_fields_do_not_break_the_verifier(self):
|
||||
"""이 도구가 더한 칸이 있어도 검사기가 그대로 읽어야 한다."""
|
||||
# 프로젝트 이름을 적지 않는다 — verify-pipeline.py 의 FORBIDDEN_LITERAL 가드가
|
||||
# scripts/ 안에서 저장소 체크아웃 이름을 금지한다. 아무 실제 원장이나 하나 고른다
|
||||
found = sorted(glob.glob(os.path.join(ROOT, "runs", "*", "*", "run.json")))
|
||||
# scripts/ 안에서 저장소 체크아웃 이름을 금지한다. 실제 원장 하나를 고른다.
|
||||
#
|
||||
# **아무거나 고르면 안 된다.** 이 시험이 묻는 것은 「더한 칸이 검사기를 깨뜨리는가」
|
||||
# 이므로 밑바탕은 **원래 통과하는 원장**이어야 한다. 파이프라인을 돌리는 중에는
|
||||
# 단계가 RUNNING·PENDING 인 원장이 `runs/` 에 있고, 그걸 고르면 더한 칸과 아무
|
||||
# 상관없이 「끝나지 않은 단계가 있다」로 실패한다 — 실제로 그렇게 깨졌다.
|
||||
# 단계가 전부 닫힌 것만 고른다.
|
||||
found = []
|
||||
for cand in sorted(glob.glob(os.path.join(ROOT, "runs", "*", "*", "run.json"))):
|
||||
try:
|
||||
stages = json.load(open(cand, encoding="utf-8")).get("stages") or []
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if stages and all(s.get("status") in ("DONE", "SKIPPED") for s in stages):
|
||||
found.append(cand)
|
||||
if not found:
|
||||
self.skipTest("견줄 실제 원장이 없다")
|
||||
self.skipTest("단계가 전부 닫힌 실제 원장이 없다")
|
||||
real = found[-1]
|
||||
d = json.load(open(real, encoding="utf-8"))
|
||||
d.update({"revision": 1, "riders": [], "sessions": [], "updatedAt": "x"})
|
||||
@@ -208,5 +230,268 @@ class LedgerTest(unittest.TestCase):
|
||||
self.assertEqual(0, p.returncode, p.stdout + p.stderr)
|
||||
|
||||
|
||||
def _git(*args):
|
||||
"""시험이 저장소에 직접 묻는다. 검사기와 **다른 방법**으로 물어야 대조가 된다."""
|
||||
p = subprocess.run(["git", "-C", ROOT, *args], capture_output=True,
|
||||
encoding="utf-8", errors="replace")
|
||||
return p.stdout if p.returncode == 0 else None
|
||||
|
||||
|
||||
def _commit_that_still_had(skill, sentence):
|
||||
"""그 문장을 아직 담고 있던 가장 최근 커밋. 없으면 None.
|
||||
|
||||
검사기는 `ls-tree` + `cat-file` 로 본문을 모아 부분 문자열을 찾는다. 여기서는
|
||||
`git grep` 으로 묻는다 — 같은 코드로 확인하면 시험이 아무것도 안 보는 것이 된다.
|
||||
"""
|
||||
rel = f".agents/skills/{skill}"
|
||||
out = _git("log", "--max-count=200", "--format=%H", "--", rel) or ""
|
||||
for commit in out.split():
|
||||
got = subprocess.run(["git", "-C", ROOT, "grep", "-F", "-q", sentence,
|
||||
commit, "--", rel], capture_output=True)
|
||||
if got.returncode == 0:
|
||||
return commit
|
||||
return None
|
||||
|
||||
|
||||
def _a_sentence_from(skill):
|
||||
"""그 스킬의 SKILL.md 에서 지금 실재하는 한 줄. 문구를 시험에 박아 두지 않는다."""
|
||||
path = os.path.join(ROOT, ".agents", "skills", skill, "SKILL.md")
|
||||
for line in open(path, encoding="utf-8"):
|
||||
line = line.strip()
|
||||
if len(line) >= 30 and not line.startswith(("#", "|", "-", ">", "`")):
|
||||
return line
|
||||
raise AssertionError(f"{skill}/SKILL.md 에서 쓸 만한 줄을 못 찾았다")
|
||||
|
||||
|
||||
@unittest.skipUnless(_git("rev-parse", "--git-dir"), "저장소가 아니라 과거를 볼 수 없다")
|
||||
class EchoAgainstSkillHistory(unittest.TestCase):
|
||||
"""영수증이 지금 스킬에 없을 때, 위조와 「그 뒤에 스킬이 고쳐졌다」를 가르는지 본다.
|
||||
|
||||
스킬은 고쳐진다. 2026-09-12 에 `writing-tech-log-records` 의 「본문이 있는 종류」
|
||||
문장을 고쳤고, 그 문장을 인용한 과거 원장 4건이 한꺼번에 error 가 됐다. **그 영수증은
|
||||
사실이다** — 그때 그 문장이 거기 있었다. 원장을 고쳐 쓰는 것은 위조이고 틀린 문장을
|
||||
스킬에 되살리는 것은 검사기에 답하는 것이라, 둘 다 하지 않고 검사기가 가른다.
|
||||
"""
|
||||
|
||||
SKILL = "writing-tech-log-records"
|
||||
# 2026-09-12 에 물러난 문장. Studio 의 여섯 번째 종류 SETUP 이 빠져 있던 것을 메우며
|
||||
# 바뀌었다. **이것을 현재 SKILL.md 에 되살리지 않는다** — 과거 커밋에만 있어야 한다
|
||||
RETIRED = "본문이 있는 종류는 Case 와 Concept 둘뿐이다."
|
||||
FABRICATED = "이 문장은 그 스킬의 어느 판에도 없다 한 글자도 없다 정말로"
|
||||
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
|
||||
def _ledger(self, echo, revision="", carry_field=True):
|
||||
"""S3 의 영수증만 갈아 끼운, 그 밖에는 흠이 없는 원장."""
|
||||
run = json.load(open(vpr.TEMPLATE, encoding="utf-8"))
|
||||
run.update({"runId": "2026-01-01-0000", "project": "demo",
|
||||
"record": "CLAUDE.md", "startedAt": "2026-01-01T00:00:00+09:00"})
|
||||
for st in run["stages"]:
|
||||
spec = vpr.STAGES[st["id"]]
|
||||
if not carry_field:
|
||||
st.pop(vpr.REVISION_FIELD, None)
|
||||
elif st["id"] == "S3":
|
||||
st[vpr.REVISION_FIELD] = revision or None
|
||||
if spec["skippable"]:
|
||||
st.update({"status": "SKIPPED", "skipReason": "이 시험은 영수증만 본다"})
|
||||
continue
|
||||
st.update({
|
||||
"status": "DONE",
|
||||
"skillEcho": echo if st["id"] == "S3" else _a_sentence_from(st["skill"]),
|
||||
"gates": [{"cmd": tok, "exit": 0} for tok in spec["gates"]],
|
||||
})
|
||||
path = os.path.join(self.dir, f"run-{len(os.listdir(self.dir))}.json")
|
||||
json.dump(run, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
return path
|
||||
|
||||
def _run(self, path, *args):
|
||||
p = subprocess.run([sys.executable, VERIFIER, path, *args], cwd=ROOT,
|
||||
capture_output=True, text=True)
|
||||
return p.returncode, p.stdout + p.stderr
|
||||
|
||||
def test_현재_스킬에_있는_영수증은_통과한다(self):
|
||||
"""대조군. 이 자리가 통과하지 않으면 나머지 둘은 아무것도 말하지 않는다."""
|
||||
code, out = self._run(self._ledger(CURRENT_SENTENCE))
|
||||
self.assertEqual(0, code, out)
|
||||
self.assertIn("대조 못 한 영수증 0", out)
|
||||
self.assertNotIn("그 뒤에 스킬이 고쳐져", out)
|
||||
|
||||
def test_과거_판에만_있는_영수증은_error_가_아니라_warn_이다(self):
|
||||
code, out = self._run(self._ledger(self.RETIRED))
|
||||
self.assertEqual(0, code, out)
|
||||
self.assertIn("그 뒤에 스킬이 고쳐져 영수증을 대조할 수 없다", out)
|
||||
self.assertNotIn("스킬 영수증이 그 스킬의 문장이 아니다", out)
|
||||
self.assertIn("대조 못 한 영수증 1", out)
|
||||
# 어느 커밋에 있었는지 함께 적는다. 「과거 어딘가」로는 다시 찾아갈 수 없다
|
||||
commit = _commit_that_still_had(self.SKILL, self.RETIRED)
|
||||
self.assertIsNotNone(commit, "그 문장을 담은 커밋이 이력에 없다")
|
||||
self.assertIn(commit[:12], out)
|
||||
|
||||
def test_warn_은_통과가_아니다(self):
|
||||
"""초록으로 보이면 안 된다. `--strict` 에서는 이것이 실패다."""
|
||||
code, out = self._run(self._ledger(self.RETIRED), "--strict")
|
||||
self.assertEqual(1, code, out)
|
||||
|
||||
def test_어느_판에도_없는_영수증은_error_다(self):
|
||||
code, out = self._run(self._ledger(self.FABRICATED))
|
||||
self.assertEqual(1, code, out)
|
||||
self.assertIn("스킬 영수증이 그 스킬의 문장이 아니다", out)
|
||||
self.assertNotIn("그 뒤에 스킬이 고쳐져", out)
|
||||
|
||||
def test_원장이_적은_리비전이_있으면_그_커밋을_본다(self):
|
||||
"""새 원장은 대조를 싸게 만든다 — 이력을 훑지 않고 적힌 커밋만 본다."""
|
||||
commit = _commit_that_still_had(self.SKILL, self.RETIRED)
|
||||
self.assertIsNotNone(commit)
|
||||
code, out = self._run(self._ledger(self.RETIRED, revision=commit))
|
||||
self.assertEqual(0, code, out)
|
||||
self.assertIn("원장이 적은 리비전", out)
|
||||
|
||||
def test_그_칸이_없는_옛_원장도_같은_답을_낸다(self):
|
||||
"""`skillRevision` 을 모르는 원장은 이력 훑기로 떨어진다. 칸이 없다고 잡지 않는다."""
|
||||
path = self._ledger(self.RETIRED, carry_field=False)
|
||||
self.assertNotIn(vpr.REVISION_FIELD,
|
||||
json.load(open(path, encoding="utf-8"))["stages"][2])
|
||||
code, out = self._run(path)
|
||||
self.assertEqual(0, code, out)
|
||||
self.assertIn("그 뒤에 스킬이 고쳐져 영수증을 대조할 수 없다", out)
|
||||
|
||||
def test_git_이_없으면_못_봤다고_한다(self):
|
||||
"""과거를 볼 수 없는 것은 「없다」가 아니다. error 로 올리지 않는다."""
|
||||
empty = os.path.join(self.dir, "bin")
|
||||
os.makedirs(empty, exist_ok=True)
|
||||
env = dict(os.environ, PATH=empty)
|
||||
# PATH 를 비우면 python3 도 같이 사라진다. 해석기는 절대 경로로 부른다
|
||||
p = subprocess.run([sys.executable, VERIFIER, self._ledger(self.RETIRED)],
|
||||
cwd=ROOT, capture_output=True, text=True, env=env)
|
||||
out = p.stdout + p.stderr
|
||||
self.assertEqual(0, p.returncode, out)
|
||||
self.assertIn("스킬의 과거 본문을 못 봐서 영수증을 대조하지 못했다", out)
|
||||
self.assertIn("대조 못 한 영수증 1", out)
|
||||
|
||||
|
||||
class RunByNamesAManagedAgent(unittest.TestCase):
|
||||
"""단계를 **누가** 돌렸는지가 원장에 남는가.
|
||||
|
||||
`skillEcho` 는 「스킬을 열었다」를 증명하지만 누가 열었는지는 증명하지 않는다 — 매번
|
||||
새로 띄운 일반 에이전트도 SKILL.md 를 읽고 한 줄을 옮겨 적을 수 있다. 그동안 `runBy`
|
||||
는 `"subagent"` 라는 상수였고, 그래서 원장 5건이 전부 통과하는 동안에도 어느 에이전트가
|
||||
돌았는지는 아무 데도 없었다.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
|
||||
def _ledger(self, schema=2, run_by=None):
|
||||
"""runBy 만 갈아 끼운, 그 밖에는 흠이 없는 원장."""
|
||||
run = json.load(open(vpr.TEMPLATE, encoding="utf-8"))
|
||||
run.update({"runId": "2026-01-01-0000", "project": "demo",
|
||||
"record": "CLAUDE.md", "startedAt": "2026-01-01T00:00:00+09:00",
|
||||
"finishedAt": "2026-01-01T01:00:00+09:00", "schemaVersion": schema})
|
||||
for st in run["stages"]:
|
||||
spec = vpr.STAGES[st["id"]]
|
||||
if run_by is not None:
|
||||
st["runBy"] = run_by
|
||||
if spec["skippable"]:
|
||||
st.update({"status": "SKIPPED", "skipReason": "이 시험은 runBy 만 본다"})
|
||||
continue
|
||||
st.update({"status": "DONE", "skillEcho": _a_sentence_from(st["skill"]),
|
||||
"gates": [{"cmd": tok, "exit": 0} for tok in spec["gates"]]})
|
||||
path = os.path.join(self.dir, f"run-{len(os.listdir(self.dir))}.json")
|
||||
json.dump(run, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
return path
|
||||
|
||||
def _run(self, path, *args):
|
||||
p = subprocess.run([sys.executable, VERIFIER, path, *args], cwd=ROOT,
|
||||
capture_output=True, text=True)
|
||||
return p.returncode, p.stdout + p.stderr
|
||||
|
||||
# ── 계약이 스스로 맞는가 ─────────────────────────────────────────
|
||||
def test_모든_단계에_에이전트가_배정돼_있다(self):
|
||||
"""빠진 단계가 있으면 그 단계만 조용히 일반 에이전트로 돌아간다."""
|
||||
for sid in vpr.ORDER:
|
||||
with self.subTest(stage=sid):
|
||||
self.assertTrue(vpr.STAGES[sid].get("agent"),
|
||||
f"{sid} 에 agent 가 없다")
|
||||
|
||||
def test_배정된_에이전트가_실재한다(self):
|
||||
"""`.claude/agents/<이름>.md` 가 없으면 그 이름은 약속일 뿐이다."""
|
||||
for sid in vpr.ORDER:
|
||||
agent = vpr.STAGES[sid]["agent"]
|
||||
with self.subTest(stage=sid, agent=agent):
|
||||
self.assertTrue(
|
||||
os.path.exists(os.path.join(ROOT, ".claude", "agents", f"{agent}.md")),
|
||||
f"{sid} 이 가리키는 .claude/agents/{agent}.md 가 없다")
|
||||
|
||||
def test_틀과_검사기가_같은_에이전트를_말한다(self):
|
||||
"""틀에만 적어 두면 STAGES 와 갈린다. 갈린 채로는 둘 다 「계약」이라고 말한다."""
|
||||
run = json.load(open(vpr.TEMPLATE, encoding="utf-8"))
|
||||
for st in run["stages"]:
|
||||
with self.subTest(stage=st["id"]):
|
||||
self.assertEqual(vpr.STAGES[st["id"]]["agent"], st["runBy"])
|
||||
|
||||
# ── 판정 ────────────────────────────────────────────────────────
|
||||
def test_계약대로_적은_원장은_통과한다(self):
|
||||
"""대조군. 이 자리가 통과하지 않으면 나머지는 아무것도 말하지 않는다."""
|
||||
code, out = self._run(self._ledger())
|
||||
self.assertEqual(0, code, out)
|
||||
self.assertIn("누가 돌렸는지 모르는 단계 0", out)
|
||||
|
||||
def test_옛_판의_원장은_error_가_아니라_warn_이다(self):
|
||||
"""`schemaVersion` 1 에는 그 칸이 없었다. 위조가 아니라 그때의 계약이다."""
|
||||
code, out = self._run(self._ledger(schema=1, run_by=vpr.LEGACY_RUNBY))
|
||||
self.assertEqual(0, code, out)
|
||||
self.assertIn("옛 판의 원장이라 누가 돌렸는지 적혀 있지 않다", out)
|
||||
self.assertNotIn("단계를 맡은 에이전트가 계약과 다르다", out)
|
||||
|
||||
def test_옛_판이어도_초록으로_보이지_않는다(self):
|
||||
"""warn 은 통과가 아니다. 요약 줄이 그 수를 따로 센다."""
|
||||
code, out = self._run(self._ledger(schema=1, run_by=vpr.LEGACY_RUNBY))
|
||||
self.assertIn("누가 돌렸는지 모르는 단계 7", out)
|
||||
code, _ = self._run(self._ledger(schema=1, run_by=vpr.LEGACY_RUNBY), "--strict")
|
||||
self.assertEqual(1, code)
|
||||
|
||||
def test_새_판에서_상수를_적으면_error_다(self):
|
||||
"""유예는 옛 원장의 것이다. 지금 판으로 열고 상수를 적는 것은 다른 일이다."""
|
||||
code, out = self._run(self._ledger(schema=2, run_by=vpr.LEGACY_RUNBY))
|
||||
self.assertEqual(1, code, out)
|
||||
self.assertIn("단계를 맡은 에이전트가 계약과 다르다", out)
|
||||
|
||||
def test_다른_관리_에이전트를_적어도_error_다(self):
|
||||
"""실재하는 이름이라고 맞는 것은 아니다. 단계마다 맡은 역할이 다르다."""
|
||||
code, out = self._run(self._ledger(schema=2, run_by="fact-reviewer"))
|
||||
self.assertEqual(1, code, out)
|
||||
self.assertIn("단계를 맡은 에이전트가 계약과 다르다", out)
|
||||
|
||||
# ── 원장을 여는 도구가 계약값을 지우지 않는가 ───────────────────
|
||||
def test_런을_열면_계약이_적힌다(self):
|
||||
led = os.path.join(self.dir, "opened", "run.json")
|
||||
p = _cli("open", led, "--project", "demo", "--record", "docs/demo/x.md")
|
||||
self.assertEqual(0, p.returncode, p.stderr)
|
||||
run = json.load(open(led, encoding="utf-8"))
|
||||
self.assertEqual(vpr.AGENT_RUNBY_SCHEMA, run["schemaVersion"])
|
||||
for st in run["stages"]:
|
||||
self.assertEqual(vpr.STAGES[st["id"]]["agent"], st["runBy"])
|
||||
|
||||
def test_단계를_열어도_계약값이_남는다(self):
|
||||
"""`begin` 의 기본값이 계약값을 덮어쓰면 원장은 다시 누가 돌렸는지 잃는다."""
|
||||
led = os.path.join(self.dir, "begun", "run.json")
|
||||
_cli("open", led, "--project", "demo", "--record", "docs/demo/x.md")
|
||||
p = _cli("begin", led, "--stage", "S3")
|
||||
self.assertEqual(0, p.returncode, p.stderr)
|
||||
st = next(s for s in json.load(open(led, encoding="utf-8"))["stages"]
|
||||
if s["id"] == "S3")
|
||||
self.assertEqual(vpr.STAGES["S3"]["agent"], st["runBy"])
|
||||
|
||||
def test_사람이_지목하면_그것을_적는다(self):
|
||||
"""계약과 다르면 검사기가 잡는다. 도구가 값을 막지는 않는다 — 거짓말은 원장에 남아야 한다."""
|
||||
led = os.path.join(self.dir, "named", "run.json")
|
||||
_cli("open", led, "--project", "demo", "--record", "docs/demo/x.md")
|
||||
_cli("begin", led, "--stage", "S3", "--runby", "fact-reviewer")
|
||||
st = next(s for s in json.load(open(led, encoding="utf-8"))["stages"]
|
||||
if s["id"] == "S3")
|
||||
self.assertEqual("fact-reviewer", st["runBy"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""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()
|
||||
@@ -757,6 +757,43 @@ class HarnessTestPlanTest(unittest.TestCase):
|
||||
for gone in ("basis", "decision", "impact"):
|
||||
self.assertNotIn(gone, doc)
|
||||
|
||||
def test_every_kind_in_the_contract_has_a_field_map_shape_and_delete_path(self):
|
||||
"""**세 표가 같은 여섯 종류를 말해야 한다.**
|
||||
|
||||
빠뜨렸을 때 나는 일이 표마다 다르다 — `FIELD_MAP` 이면 「모르는 kind」로 거절되고,
|
||||
`SHAPES` 면 `KeyError` 로 죽고, `DELETE_PATHS` 면 **지울 수 없는 초안이 운영에 남는다.**
|
||||
`_kinds_line_up()` 이 import 할 때 보지만, 그 관문이 지워지면 여기서 걸린다.
|
||||
"""
|
||||
six = {"CASE", "CONCEPT", "REFERENCE", "QUESTION", "PROJECT_DECISION", "SETUP"}
|
||||
self.assertEqual(six, set(ss.FIELD_MAP))
|
||||
self.assertEqual(six, set(ss.SHAPES))
|
||||
self.assertEqual(six, set(ss.DELETE_PATHS))
|
||||
|
||||
def test_setup_takes_its_pinned_versions_from_the_frontmatter(self):
|
||||
"""`pinnedVersions` 는 절이 아니라 frontmatter 에 있다. 절만 보면 빠지고,
|
||||
`SetupInput.required` 에 있어 빠지면 422 다 — Concept 의 `basisVersion` 과 같은 자리다."""
|
||||
self.assertEqual({"본문": "bodyMarkdown"}, ss.FIELD_MAP["SETUP"])
|
||||
doc = ss._setup_shape(
|
||||
{"slug": "s"},
|
||||
{"pinnedVersions": "- name: Keycloak\n version: 26.7.0"})
|
||||
self.assertEqual([{"name": "Keycloak", "version": "26.7.0"}], doc["pinnedVersions"])
|
||||
# 이 종류에는 검증일 칸이 없다. 다른 종류를 보고 넣지 않는다 —
|
||||
# `unevaluatedProperties: false` 라 보내면 거절된다
|
||||
for gone in ("lastVerifiedOn", "verifiedOn", "basisVersion"):
|
||||
self.assertNotIn(gone, doc)
|
||||
|
||||
def test_an_empty_pinned_version_list_is_not_the_same_as_an_unreadable_one(self):
|
||||
"""「비우면 화면에 표를 그리지 않습니다」 — 비운 것은 정상이다.
|
||||
글이 있는데 못 읽은 것은 거절한다. 빈 칸으로 보내지 않는다."""
|
||||
self.assertEqual([], ss._pinned_versions(""))
|
||||
self.assertEqual([], ss._pinned_versions(None))
|
||||
with self.assertRaises(ss.Refused):
|
||||
ss._pinned_versions("- Keycloak / 26.7.0") # 모양이 다르다
|
||||
with self.assertRaises(ss.Refused):
|
||||
ss._pinned_versions("- name: Keycloak") # 버전이 없다
|
||||
with self.assertRaises(ss.Refused):
|
||||
ss._pinned_versions("- name: K\n version: " + "9" * 41) # 상한 40자
|
||||
|
||||
def test_decision_status_outside_the_enum_is_refused(self):
|
||||
"""enum 이 셋뿐이다. 지어내지 않고 여기서 막는다 —
|
||||
운영에 초안을 만들어 놓고 422 를 받는 것보다 낫다."""
|
||||
@@ -968,6 +1005,17 @@ class HarnessTestPlanTest(unittest.TestCase):
|
||||
# 묶음은 worktree 밖에 있다. 없으면 **안 돌았다고 적고 넘어간다** —
|
||||
# 조용히 통과시키면 이 회귀가 초록인 채로 아무것도 안 재게 된다
|
||||
self.skipTest(f"묶음이 없어 계획을 못 낸다: {PACKAGE}")
|
||||
# 이 시험이 재는 것은 **런 식별자를 계획이 적는가** 이고, 고정 해시는 그저 입력이다.
|
||||
# 작업 트리에서 이 기록을 고치면 어댑터가 (맞게) 거절해서 계획 자체가 안 나온다.
|
||||
# 그때 **고치는 것은 기록도 묶음도 아니다** — 재지 못했다고 적고 넘어간다.
|
||||
# 고정값이 이 파일의 과거 판일 때만 그렇다. 남의 묶음이면 그건 진짜 결함이라 실패한다
|
||||
want = (json.load(open(PACKAGE, encoding="utf-8")).get("target") or {}).get("sha256")
|
||||
record = os.path.join(ROOT, DECISION_RECORD)
|
||||
if want and want != ss._sha256_file(record):
|
||||
kind, detail = ss._pin_verdict(record, want)
|
||||
if kind != ss.PIN_ALIEN:
|
||||
self.skipTest(
|
||||
f"검토가 본 판과 작업 트리가 다르다 — 계획을 못 낸다 ({kind} · {detail})")
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
out = os.path.join(d, "plan.json")
|
||||
r = subprocess.run(
|
||||
|
||||
+281
-28
@@ -13,6 +13,13 @@
|
||||
옮겨 오게 하고, 그 문자열이 실제로 그 파일 안에 있는지 대조한다. 스킬을 안 읽고 결과만
|
||||
그럴듯하게 낸 단계는 여기서 걸린다.
|
||||
|
||||
**스킬은 고쳐진다.** 그러면 그 전에 돈 런의 영수증이 현재 SKILL.md 에서 사라진다. 그
|
||||
영수증은 사실이다 — 그때 그 문장이 거기 있었다. 원장을 고쳐 쓰는 것은 위조이고 틀린
|
||||
문장을 스킬에 되살리는 것은 검사기에 답하는 것이라, 둘 다 하지 않고 **검사기가 가른다.**
|
||||
git 이 그 스킬의 과거 본문을 갖고 있으므로 그것으로 본다 — 현재에 있으면 통과, 과거
|
||||
판에만 있으면 warn, 어느 판에도 없으면 error. 볼 수 없었던 것(git 이 없다 · 이력 상한에
|
||||
걸렸다)은 또 따로 warn 이다. 못 본 것을 「없다」로 세면 위조와 같은 칸에 들어간다.
|
||||
|
||||
계약은 `.agents/skills/running-tech-log-pipeline/references/stage-contracts.md` 다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -22,6 +29,7 @@ import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -33,34 +41,52 @@ TEMPLATE = os.path.join(SKILLS, "running-tech-log-pipeline", "templates", "run.j
|
||||
|
||||
STATUSES = ("PENDING", "RUNNING", "DONE", "SKIPPED", "FAILED")
|
||||
|
||||
# 단계마다 어떤 스킬이 맡고, 관문에 어떤 명령이 있어야 하는가.
|
||||
# 단계마다 어떤 스킬이 맡고, **어느 에이전트가 돌리고**, 관문에 어떤 명령이 있어야 하는가.
|
||||
# 관문은 명령 문자열에 이 토큰이 들어 있는지로 본다 — 호출형이 조금씩 달라도 같은 검사다.
|
||||
#
|
||||
# `agent` 는 `.claude/agents/<이름>.md` 다. 단계마다 새 에이전트를 띄우면 어떤 규칙으로
|
||||
# 일했는지가 어디에도 안 남는다 — 원장의 `runBy` 가 `"subagent"` 라는 상수였던 동안이
|
||||
# 그 상태였고, 그때는 스킬을 열었는지(`skillEcho`)만 남고 **누가 열었는지는 안 남았다.**
|
||||
STAGES = {
|
||||
"S1": {"skill": "analyzing-codebase-for-tech-log",
|
||||
"S1": {"skill": "analyzing-codebase-for-tech-log", "agent": "ssot-analyst",
|
||||
"gates": ["verify-project-layout.py"], "skippable": True},
|
||||
"S2": {"skill": "deriving-tech-log-root-tree",
|
||||
"S2": {"skill": "deriving-tech-log-root-tree", "agent": "tree-deriver",
|
||||
"gates": ["build-tech-log-tree.py", "verify-tech-log-tree.py"], "skippable": True},
|
||||
"S3": {"skill": "writing-tech-log-records",
|
||||
"S3": {"skill": "writing-tech-log-records", "agent": "record-writer",
|
||||
"gates": ["check_body.mjs", "check_prose.mjs", "check_evidence.mjs"],
|
||||
"skippable": False},
|
||||
"S4": {"skill": "technical-visualizer",
|
||||
"S4": {"skill": "technical-visualizer", "agent": "diagram-maker",
|
||||
"gates": ["lint", "check-figure-text.py", "check-figure-overlap.py",
|
||||
"preview-figure.py"],
|
||||
"skippable": True},
|
||||
# S5·S6 은 문장을 고친 뒤라 S3 관문을 다시 돈다 (stage-contracts.md 「관문 요약」)
|
||||
"S5": {"skill": "rewriting-technical-prose-naturally",
|
||||
"S5": {"skill": "rewriting-technical-prose-naturally", "agent": "prose-rewriter",
|
||||
"gates": ["check_prose.mjs", "style_profile.mjs", "check_body.mjs",
|
||||
"check_evidence.mjs"],
|
||||
"skippable": False},
|
||||
"S6": {"skill": "writing-as-the-person-who-did-it",
|
||||
"S6": {"skill": "writing-as-the-person-who-did-it", "agent": "voice-writer",
|
||||
"gates": ["check_voice.mjs", "check_prose.mjs", "check_body.mjs",
|
||||
"check_evidence.mjs"],
|
||||
"skippable": False},
|
||||
"S7": {"skill": "publishing-tech-log-to-studio",
|
||||
"S7": {"skill": "publishing-tech-log-to-studio", "agent": "studio-validator",
|
||||
"gates": ["저장됨", "build-tech-log-tree.py", "verify-tech-log-tree.py"],
|
||||
"skippable": True},
|
||||
}
|
||||
|
||||
AGENTS_DIR = os.path.join(ROOT, ".claude", "agents")
|
||||
|
||||
# 에이전트 이름을 적기 전의 원장이 쓰던 값. 위조가 아니라 **그때의 계약**이다.
|
||||
LEGACY_RUNBY = "subagent"
|
||||
|
||||
# `runBy` 가 에이전트 이름을 담기 시작한 원장 판. 이보다 낮은 판은 그 칸이 상수였다.
|
||||
#
|
||||
# 관문 쪽은 이 유예를 git 으로 가르지만(`_gate_required_since`), 여기서는 **원장이 스스로
|
||||
# 밝힌 판**으로 가른다. 까닭은 git 이 작업 트리를 못 보기 때문이다 — 요구를 더한 커밋이
|
||||
# 아직 안 들어갔으면 `git log -S` 가 빈손으로 돌아오고, 그러면 지난 런이 전부 error 로
|
||||
# 뒤집힌다. 「요구가 언제 생겼나」를 커밋 시각으로 재는 대신 **이 원장이 어느 계약으로
|
||||
# 쓰였나**를 읽으면 커밋 전후로 판정이 흔들리지 않는다.
|
||||
AGENT_RUNBY_SCHEMA = 2
|
||||
|
||||
# 측정 관문 — 돌았다는 것은 요구하지만 종료 코드 0 은 요구하지 않는다.
|
||||
# 문서 계약이 「error 0」을 붙인 것은 check_prose 뿐이고 style_profile 은 문체 수치를 보여 주는
|
||||
# 측정이다 (stage-contracts.md:178·:252). 여기에 0 을 요구하면 정직하게 적은 원장이 실패하고,
|
||||
@@ -69,6 +95,17 @@ MEASUREMENT_GATES = ("style_profile.mjs",)
|
||||
|
||||
ORDER = ["S1", "S2", "S3", "S4", "S5", "S6", "S7"]
|
||||
|
||||
# 영수증을 못 찾았을 때 과거 본문을 몇 커밋까지 거슬러 보는가.
|
||||
# 상한에 걸려 못 찾은 것은 「없다」가 아니라 「못 봤다」로 센다.
|
||||
HISTORY_LIMIT = 200
|
||||
|
||||
# 원장이 단계마다 적는, 그 시점 스킬의 커밋. 이 칸이 있으면 이력을 훑지 않고 그것부터 본다.
|
||||
# 없는 옛 원장은 이력 훑기로 떨어진다 — 칸이 없다고 error 를 내지 않는다.
|
||||
REVISION_FIELD = "skillRevision"
|
||||
|
||||
# 영수증의 판정. 셋이 아니라 넷이다 — 「대조하지 못했다」가 따로 있다.
|
||||
CURRENT, PAST, UNSEEN, ABSENT = "CURRENT", "PAST", "UNSEEN", "ABSENT"
|
||||
|
||||
|
||||
def _known_projects() -> set[str]:
|
||||
docs = os.path.join(ROOT, "docs")
|
||||
@@ -123,6 +160,192 @@ def _skill_text(skill: str) -> str | None:
|
||||
return _norm("\n".join(out))
|
||||
|
||||
|
||||
def _git(*args: str) -> str | None:
|
||||
"""저장소에 묻는다.
|
||||
|
||||
**실패는 빈 문자열이 아니라 `None` 이다.** git 이 없어서 못 본 것과 정말 비어 있는
|
||||
것은 다른 답이고, 여기서 둘을 섞으면 위에서 「없다」와 「못 봤다」를 못 가른다.
|
||||
"""
|
||||
try:
|
||||
p = subprocess.run(["git", "-C", ROOT, *args], capture_output=True,
|
||||
encoding="utf-8", errors="replace", timeout=30)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return p.stdout if p.returncode == 0 else None
|
||||
|
||||
|
||||
_TEXT_AT: dict[tuple[str, str], str | None] = {}
|
||||
|
||||
|
||||
def _skill_text_at(skill: str, commit: str) -> str | None:
|
||||
"""그 커밋에서의 스킬 폴더 글자. 지금 본문을 읽는 `_skill_text` 와 같은 모양으로 잇는다.
|
||||
|
||||
한 런에서 같은 커밋을 여러 번 보게 되므로 기억해 둔다. `-z` 를 쓰는 것은 경로에
|
||||
한글이 있으면 git 이 따옴표로 감싸 내놓기 때문이다.
|
||||
"""
|
||||
key = (skill, commit)
|
||||
if key in _TEXT_AT:
|
||||
return _TEXT_AT[key]
|
||||
listing = _git("ls-tree", "-r", "-z", commit, "--", f".agents/skills/{skill}")
|
||||
if listing is None:
|
||||
_TEXT_AT[key] = None
|
||||
return None
|
||||
chunks = []
|
||||
for entry in listing.split("\0"):
|
||||
meta, _, path = entry.partition("\t")
|
||||
fields = meta.split()
|
||||
if len(fields) < 3 or fields[1] != "blob" or not path.endswith(".md"):
|
||||
continue
|
||||
blob = _git("cat-file", "blob", fields[2])
|
||||
if blob is not None:
|
||||
chunks.append(blob)
|
||||
_TEXT_AT[key] = _norm("\n".join(chunks))
|
||||
return _TEXT_AT[key]
|
||||
|
||||
|
||||
def _skill_commits(skill: str, limit: int = HISTORY_LIMIT):
|
||||
"""그 스킬을 건드린 커밋을 최근 것부터. 두 번째 값이 상한에 걸렸는가다.
|
||||
|
||||
git 이 답하지 못하면 `None` — 「이력이 없다」가 아니라 「이력을 못 봤다」다.
|
||||
"""
|
||||
out = _git("log", f"--max-count={limit + 1}", "--format=%H",
|
||||
"--", f".agents/skills/{skill}")
|
||||
if out is None:
|
||||
return None
|
||||
commits = out.split()
|
||||
return commits[:limit], len(commits) > limit
|
||||
|
||||
|
||||
def _skill_revision(skill: str) -> str | None:
|
||||
"""지금 이 스킬의 글자를 담고 있는 커밋.
|
||||
|
||||
작업 트리가 그 커밋과 다르면 `None` 이다 — 모르는 리비전을 지어내지 않는다.
|
||||
그런 원장은 나중에 이력 훑기로 떨어지고, 그것이 맞는 결과다.
|
||||
"""
|
||||
rel = f".agents/skills/{skill}"
|
||||
dirty = _git("status", "--porcelain", "--", rel)
|
||||
if dirty is None or dirty.strip():
|
||||
return None
|
||||
out = _git("log", "-n", "1", "--format=%H", "--", rel)
|
||||
return (out or "").strip() or None
|
||||
|
||||
|
||||
def _echo_verdict(skill: str, echo: str, revision: str | None = None) -> tuple[str, str]:
|
||||
"""영수증이 지금 그 스킬에 있나, 과거 판에만 있나, 어디에도 없나, 아니면 못 봤나.
|
||||
|
||||
스킬을 고치면 그 전에 쓴 원장의 영수증이 현재 본문에서 사라진다. **고친 쪽이 맞아도
|
||||
그 영수증은 위조가 아니다.** 그래서 현재 본문에 없으면 과거 본문을 본다.
|
||||
|
||||
찾지 못한 것과 볼 수 없었던 것을 또 가른다 — git 이 없거나, 이력 상한에 걸렸거나,
|
||||
스킬이 아직 커밋되지 않았으면 `ABSENT` 가 아니라 `UNSEEN` 이다.
|
||||
"""
|
||||
text = _skill_text(skill)
|
||||
if text is not None and echo in text:
|
||||
return CURRENT, ""
|
||||
|
||||
# 원장이 그 시점 리비전을 적어 두었으면 이력을 훑지 않고 그 커밋만 본다.
|
||||
# 못 찾으면 이력으로 넘어간다 — 그 커밋 뒤에 고친 작업 트리를 읽은 원장도 있다
|
||||
if revision:
|
||||
past = _skill_text_at(skill, str(revision))
|
||||
if past and echo in past:
|
||||
return PAST, f"{str(revision)[:12]} (원장이 적은 리비전)"
|
||||
|
||||
history = _skill_commits(skill)
|
||||
if history is None:
|
||||
return UNSEEN, "git 이 없거나 이 저장소의 이력을 읽지 못했다"
|
||||
commits, truncated = history
|
||||
if not commits:
|
||||
return UNSEEN, "이 스킬이 아직 커밋되지 않아 견줄 과거 본문이 없다"
|
||||
blind = False
|
||||
for commit in commits:
|
||||
past = _skill_text_at(skill, commit)
|
||||
if past is None:
|
||||
blind = True
|
||||
elif echo in past:
|
||||
return PAST, commit[:12]
|
||||
if truncated:
|
||||
return UNSEEN, f"이력 상한 {HISTORY_LIMIT} 커밋까지 보고 못 찾았다"
|
||||
if blind:
|
||||
return UNSEEN, "이력의 일부를 읽지 못했다"
|
||||
return ABSENT, f"커밋 {len(commits)}개를 다 봤다"
|
||||
|
||||
|
||||
def _gate_required_since(token: str) -> tuple[str, str] | None:
|
||||
"""이 관문을 요구하기 시작한 커밋과 날짜. 못 보면 None.
|
||||
|
||||
검사기에 관문을 더하면 **그 전에 돈 런이 전부 error 가 된다.** 그 런은 그때 요구되지
|
||||
않은 것을 안 돌렸을 뿐이다. 원장에 없던 관문을 적어 넣는 것은 영수증 위조이고, 요구를
|
||||
빼는 것은 검사기에 답하는 것이라, `skillEcho` 와 같은 자리를 git 으로 가른다.
|
||||
"""
|
||||
out = _git("log", "--reverse", "--format=%H %ad", "--date=short",
|
||||
"-S", token, "--", "scripts/verify-pipeline-run.py")
|
||||
if not out:
|
||||
return None
|
||||
first = out.splitlines()[0].split()
|
||||
return (first[0], first[1]) if len(first) >= 2 else None
|
||||
|
||||
|
||||
def _run_finished_before(run: dict, date: str) -> bool:
|
||||
"""런이 그 날짜보다 먼저 끝났나. 시각을 못 읽으면 False — 모르면 봐주지 않는다."""
|
||||
stamp = str(run.get("finishedAt") or run.get("startedAt") or "")[:10]
|
||||
return bool(stamp) and stamp < date
|
||||
|
||||
|
||||
def _judge_echo(rep: Report, skill: str, echo: str, revision, where: str,
|
||||
prefix: str = "") -> int:
|
||||
"""영수증을 판정해 보고에 적는다. 대조하지 **못한** 것이면 1 을 돌려준다.
|
||||
|
||||
warn 이지 통과가 아니다. 요약 줄이 그 수를 따로 세는 것은 그래서다 — 초록으로
|
||||
보이면 안 된다.
|
||||
"""
|
||||
subject = "곁증명의 영수증" if prefix else "영수증"
|
||||
kind, detail = _echo_verdict(skill, echo, revision)
|
||||
if kind == CURRENT:
|
||||
if not prefix and len(echo) < 20:
|
||||
rep.warn("스킬 영수증이 너무 짧다", f"{where} — {echo}")
|
||||
return 0
|
||||
if kind == PAST:
|
||||
rep.warn(f"그 뒤에 스킬이 고쳐져 {subject}을 대조할 수 없다",
|
||||
f"{where} — {detail} 에는 있었다 · {echo[:40]}…")
|
||||
return 1
|
||||
if kind == UNSEEN:
|
||||
rep.warn(f"스킬의 과거 본문을 못 봐서 {subject}을 대조하지 못했다",
|
||||
f"{where} — {detail} · {echo[:40]}…")
|
||||
return 1
|
||||
rep.error(f"{prefix}스킬 영수증이 그 스킬의 문장이 아니다", f"{where} — {echo[:60]}…")
|
||||
return 0
|
||||
|
||||
|
||||
def _judge_run_by(rep: Report, run: dict, run_by, agent: str, where: str) -> int:
|
||||
"""이 단계를 **누가** 돌렸는가. 계약이 배정한 관리 에이전트여야 한다.
|
||||
|
||||
`skillEcho` 는 「스킬을 열었다」를 증명하지만 **누가 열었는지는 증명하지 않는다.**
|
||||
매번 새로 띄운 일반 에이전트도 SKILL.md 를 읽고 한 줄을 옮겨 적을 수 있다. 그래서
|
||||
이름을 적게 하고 그 이름이 `.claude/agents/` 에 실재하는지까지 본다 — 안 그러면
|
||||
「관리 에이전트를 쓴다」가 원장에서 확인되지 않는 약속으로만 남는다.
|
||||
|
||||
옛 판(`schemaVersion` < 2)의 원장은 이 칸이 `"subagent"` 라는 상수였다. **위조가 아니라
|
||||
그때의 계약이다.** 고쳐 쓰지 않고 warn 으로 세고, 요약 줄이 그 수를 따로 적는다 —
|
||||
「누가 돌렸는지 안 적혀 있다」가 「맞는 에이전트가 돌렸다」로 읽히면 안 된다.
|
||||
|
||||
대조하지 **못한** 것이면 1 을 돌려준다.
|
||||
"""
|
||||
if run_by == agent:
|
||||
if not os.path.exists(os.path.join(AGENTS_DIR, f"{agent}.md")):
|
||||
rep.error("그 에이전트의 정의가 없다",
|
||||
f"{where} — .claude/agents/{agent}.md 가 없다")
|
||||
return 0
|
||||
|
||||
schema = run.get("schemaVersion")
|
||||
if run_by == LEGACY_RUNBY and isinstance(schema, int) and schema < AGENT_RUNBY_SCHEMA:
|
||||
rep.warn("옛 판의 원장이라 누가 돌렸는지 적혀 있지 않다",
|
||||
f"{where} — schemaVersion={schema} · runBy={run_by!r} 는 그때의 상수다")
|
||||
return 1
|
||||
rep.error("단계를 맡은 에이전트가 계약과 다르다",
|
||||
f"{where} — runBy={run_by!r} · 계약은 {agent!r}")
|
||||
return 0
|
||||
|
||||
|
||||
def init(path: str, project: str, record: str, run_id: str | None) -> int:
|
||||
if os.path.exists(path):
|
||||
print(f"이미 있다: {path}", file=sys.stderr)
|
||||
@@ -133,6 +356,15 @@ def init(path: str, project: str, record: str, run_id: str | None) -> int:
|
||||
run["project"] = project
|
||||
run["record"] = record
|
||||
run["startedAt"] = now.astimezone().isoformat(timespec="seconds")
|
||||
# 그 시점 스킬의 커밋을 단계마다 적어 둔다. 나중에 스킬이 고쳐져도 이 런의 영수증은
|
||||
# 이력을 훑지 않고 이 커밋 하나로 대조된다. 작업 트리가 커밋과 다르면 null 이다
|
||||
for st in run.get("stages") or []:
|
||||
st[REVISION_FIELD] = _skill_revision(str(st.get("skill") or ""))
|
||||
# `runBy` 도 여기서 계약에서 박는다. 틀에만 적어 두면 STAGES 와 갈리고, 갈린 뒤에는
|
||||
# 「검사기가 요구하니까」 틀을 맞추게 된다 — 계약이 둘이 되는 자리다
|
||||
spec = STAGES.get(str(st.get("id") or ""))
|
||||
if spec:
|
||||
st["runBy"] = spec["agent"]
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(run, fh, ensure_ascii=False, indent=2)
|
||||
@@ -141,12 +373,15 @@ def init(path: str, project: str, record: str, run_id: str | None) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _side_proof(rep: Report, st: dict, sid: str, spec: dict, where: str) -> None:
|
||||
def _side_proof(rep: Report, st: dict, sid: str, spec: dict, where: str) -> int:
|
||||
"""건너뛴 단계의 곁증명(`sideProof`)을 본 단계와 같은 잣대로 검사한다.
|
||||
|
||||
`outputs` 가 가리키는 `*stage-report.json` 중 `"stage"` 가 이 단계인 것을 곁증명으로
|
||||
본다. 곁증명이 없는 것은 정상이다 — 있는데 엉터리인 것만 잡는다.
|
||||
|
||||
돌려주는 값은 **대조하지 못한 영수증의 수**다. 본 단계와 같은 잣대로 센다.
|
||||
"""
|
||||
unverifiable = 0
|
||||
for out in st.get("outputs") or []:
|
||||
if not out.endswith(".json"):
|
||||
continue
|
||||
@@ -165,12 +400,12 @@ def _side_proof(rep: Report, st: dict, sid: str, spec: dict, where: str) -> None
|
||||
rep.error("곁증명이 다른 스킬을 썼다",
|
||||
f"{where} — {proof.get('skill')!r} · 계약은 {spec['skill']!r}")
|
||||
echo = _norm(proof.get("skillEcho") or "")
|
||||
text = _skill_text(spec["skill"])
|
||||
if not echo:
|
||||
rep.error("곁증명에 스킬 영수증이 없다", f"{where} — {out}")
|
||||
elif text is not None and echo not in text:
|
||||
rep.error("곁증명의 스킬 영수증이 그 스킬의 문장이 아니다",
|
||||
f"{where} — {echo[:60]}…")
|
||||
elif _skill_text(spec["skill"]) is not None:
|
||||
unverifiable += _judge_echo(rep, spec["skill"], echo,
|
||||
proof.get(REVISION_FIELD) or st.get(REVISION_FIELD),
|
||||
where, "곁증명의 ")
|
||||
gates = proof.get("gates") or []
|
||||
if not gates:
|
||||
rep.error("곁증명에 관문이 없다", f"{where} — {out}")
|
||||
@@ -184,6 +419,7 @@ def _side_proof(rep: Report, st: dict, sid: str, spec: dict, where: str) -> None
|
||||
rep.error("곁증명의 관문이 통과하지 못했다",
|
||||
f"{where} — {cmd[:70]} → exit {g.get('exit')}")
|
||||
rep.facts.setdefault("곁증명", []).append(f"{sid}:{os.path.basename(out)}")
|
||||
return unverifiable
|
||||
|
||||
|
||||
def verify(path: str) -> Report:
|
||||
@@ -208,6 +444,8 @@ def verify(path: str) -> Report:
|
||||
rep.error("단계가 원장에 없다", " · ".join(missing))
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
unverifiable = 0 # 위조는 아니고 대조를 못 한 영수증. 0 으로 뭉개지 않는다
|
||||
unattributed = 0 # 누가 돌렸는지 원장에 없는 단계. 영수증과 다른 것이라 따로 센다
|
||||
for sid in ORDER:
|
||||
st = stages.get(sid)
|
||||
if st is None:
|
||||
@@ -223,9 +461,7 @@ def verify(path: str) -> Report:
|
||||
if st.get("skill") != spec["skill"]:
|
||||
rep.error("단계가 다른 스킬을 썼다",
|
||||
f"{where} — {st.get('skill')!r} · 계약은 {spec['skill']!r}")
|
||||
if st.get("runBy") != "subagent":
|
||||
rep.warn("서브에이전트가 아닌 것으로 적혀 있다",
|
||||
f"{where} — runBy={st.get('runBy')!r}")
|
||||
unattributed += _judge_run_by(rep, run, st.get("runBy"), spec["agent"], where)
|
||||
|
||||
if status in ("PENDING", "RUNNING"):
|
||||
rep.error("끝나지 않은 단계가 있다", f"{where} — {status}")
|
||||
@@ -241,7 +477,7 @@ def verify(path: str) -> Report:
|
||||
f"{where} — 판단해서 건너뛴 것과 빠뜨린 것을 구분해야 한다")
|
||||
# 이 기록에서는 건너뛰었지만 그 단계가 도는지 따로 증명했으면 그것도 검사한다.
|
||||
# 안 그러면 곁증명은 아무도 읽지 않는 파일이 된다
|
||||
_side_proof(rep, st, sid, spec, where)
|
||||
unverifiable += _side_proof(rep, st, sid, spec, where)
|
||||
continue
|
||||
|
||||
# ── 여기부터 DONE ────────────────────────────────────────────
|
||||
@@ -249,15 +485,11 @@ def verify(path: str) -> Report:
|
||||
if not echo:
|
||||
rep.error("스킬 영수증이 없다",
|
||||
f"{where} — SKILL.md 를 열었다는 증거가 원장에 없다")
|
||||
elif _skill_text(spec["skill"]) is None:
|
||||
rep.error("스킬 폴더가 없다", f"{where} — {spec['skill']}")
|
||||
else:
|
||||
text = _skill_text(spec["skill"])
|
||||
if text is None:
|
||||
rep.error("스킬 폴더가 없다", f"{where} — {spec['skill']}")
|
||||
elif echo not in text:
|
||||
rep.error("스킬 영수증이 그 스킬의 문장이 아니다",
|
||||
f"{where} — {echo[:60]}…")
|
||||
elif len(echo) < 20:
|
||||
rep.warn("스킬 영수증이 너무 짧다", f"{where} — {echo}")
|
||||
unverifiable += _judge_echo(rep, spec["skill"], echo,
|
||||
st.get(REVISION_FIELD), where)
|
||||
|
||||
gates = st.get("gates") or []
|
||||
cmds = " ; ".join(str(g.get("cmd") or "") for g in gates)
|
||||
@@ -267,7 +499,16 @@ def verify(path: str) -> Report:
|
||||
rep.error("관문이 다른 대상에 돌았다",
|
||||
f"{where} — {str(g.get('cmd'))[:60]} → {other} (원장은 {project})")
|
||||
for token in spec["gates"]:
|
||||
if token not in cmds:
|
||||
if token in cmds:
|
||||
continue
|
||||
since = _gate_required_since(token)
|
||||
if since and _run_finished_before(run, since[1]):
|
||||
# 그때는 요구되지 않은 관문이다. warn 이지 통과가 아니다 —
|
||||
# 요약 줄이 따로 세서 초록으로 보이지 않게 한다
|
||||
rep.warn("그 뒤에 관문이 늘어 이 런에는 요구되지 않았다",
|
||||
f"{where} — {token} · {since[0][:12]} ({since[1]}) 부터 요구한다")
|
||||
unverifiable += 1
|
||||
else:
|
||||
rep.error("관문이 빠졌다", f"{where} — {token}")
|
||||
for g in gates:
|
||||
cmd = str(g.get("cmd") or "")
|
||||
@@ -285,9 +526,15 @@ def verify(path: str) -> Report:
|
||||
if not os.path.exists(os.path.join(ROOT, out)):
|
||||
rep.error("적어 낸 산출물이 디스크에 없다", f"{where} — {out}")
|
||||
# 한 단계를 두 번 돌렸으면 두 번째 것도 같은 잣대로 본다
|
||||
_side_proof(rep, st, sid, spec, where)
|
||||
unverifiable += _side_proof(rep, st, sid, spec, where)
|
||||
|
||||
rep.facts["stages"] = counts
|
||||
if unverifiable:
|
||||
# 「위조가 아니다」와 「맞다」는 다른 말이다. 대조를 못 한 것은 수로 남긴다
|
||||
rep.facts["대조 못 한 영수증"] = unverifiable
|
||||
if unattributed:
|
||||
# 스킬은 대조됐는데 **누가 열었는지**는 안 적힌 단계. 초록으로 보이면 안 된다
|
||||
rep.facts["누가 돌렸는지 모르는 단계"] = unattributed
|
||||
record = run.get("record")
|
||||
if record and not os.path.exists(os.path.join(ROOT, record)):
|
||||
rep.error("런이 만든다는 기록이 없다", record)
|
||||
@@ -333,8 +580,14 @@ def main() -> int:
|
||||
reports = [verify(p) for p in args.ledgers]
|
||||
e = sum(r.error_count for r in reports)
|
||||
w = sum(r.warn_count for r in reports)
|
||||
# 대조를 못 한 영수증은 error 도 아니고 「봤고 괜찮다」도 아니다. 따로 센다.
|
||||
# 「누가 돌렸는지 모른다」도 같은 자리인데 **다른 것**이라 칸을 나눈다 — 스킬을 열었다는
|
||||
# 증거가 없는 것과, 증거는 있는데 연 사람이 안 적힌 것은 고치는 방법이 다르다
|
||||
u = sum(int(r.facts.get("대조 못 한 영수증") or 0) for r in reports)
|
||||
a = sum(int(r.facts.get("누가 돌렸는지 모르는 단계") or 0) for r in reports)
|
||||
print(f"PIPELINE RUN: {'FAIL' if e or (args.strict and w) else 'PASS'}"
|
||||
f" — 런 {len(reports)} · error {e} · warn {w}")
|
||||
f" — 런 {len(reports)} · error {e} · warn {w}"
|
||||
f" · 대조 못 한 영수증 {u} · 누가 돌렸는지 모르는 단계 {a}")
|
||||
for r in reports:
|
||||
render(r, args.samples)
|
||||
return 1 if e or (args.strict and w) else 0
|
||||
|
||||
@@ -43,6 +43,31 @@ REQUIRED_PATHS = (
|
||||
".agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs",
|
||||
".agents/skills/technical-visualizer/SKILL.md",
|
||||
".agents/skills/refactoring-from-analysis/SKILL.md",
|
||||
# 환경 구성(SETUP) 본문의 명령 형태를 정한다. writing-tech-log-records 가 아홉 곳에서
|
||||
# 이 스킬을 열라고 시키므로 저장소 안에 있어야 한다 — 전역에만 두면 클론한 사람에게는 없다
|
||||
".agents/skills/writing-practitioner-guides/SKILL.md",
|
||||
".agents/skills/writing-practitioner-guides/references/kubernetes.md",
|
||||
".agents/skills/writing-practitioner-guides/references/linux-systemd.md",
|
||||
".agents/skills/writing-practitioner-guides/references/networking-tls.md",
|
||||
".agents/skills/writing-practitioner-guides/references/datastores.md",
|
||||
# 역할이 나뉜 서브에이전트. 한 세션이 쓰기와 검증을 겸하면 자동 검사가 전부 통과한
|
||||
# 상태로 사실 오류가 새어 나간다 — 실제로 그렇게 새어 나간 것이 이 저장소에 있었다.
|
||||
#
|
||||
# 앞의 다섯은 파이프라인 S1·S2·S4·S5·S6 을 맡는다. 없으면 그 단계가 매번 새로 띄운
|
||||
# 일반 에이전트로 돌고, 그러면 어떤 규칙으로 일했는지가 어디에도 안 남는다 —
|
||||
# 원장의 `runBy` 가 `"subagent"` 라는 상수였던 것이 그 상태였다.
|
||||
".claude/agents/ssot-analyst.md",
|
||||
".claude/agents/tree-deriver.md",
|
||||
".claude/agents/diagram-maker.md",
|
||||
".claude/agents/prose-rewriter.md",
|
||||
".claude/agents/voice-writer.md",
|
||||
# 뒤의 여섯은 기록 한 편을 쓰고 검증하는 역할이다. S3·S7 이 여기에 걸린다
|
||||
".claude/agents/source-auditor.md",
|
||||
".claude/agents/record-writer.md",
|
||||
".claude/agents/fact-reviewer.md",
|
||||
".claude/agents/reader-reviewer.md",
|
||||
".claude/agents/setup-runner.md",
|
||||
".claude/agents/studio-validator.md",
|
||||
# 프로젝트 폴더 틀 — 끝난 프로젝트의 모양. 작업 재료는 여기 없다
|
||||
"docs/_templates/README.md",
|
||||
"docs/_templates/final/document.md",
|
||||
@@ -58,6 +83,8 @@ REQUIRED_PATHS = (
|
||||
".agents/skills/writing-tech-log-records/templates/reference.md",
|
||||
".agents/skills/writing-tech-log-records/templates/question.md",
|
||||
".agents/skills/writing-tech-log-records/templates/decision.md",
|
||||
# 여섯 번째 종류. 이 목록도 손으로 나열하는 자리라 종류를 더할 때 함께 채운다
|
||||
".agents/skills/writing-tech-log-records/templates/setup.md",
|
||||
# 도구
|
||||
"scripts/techviz",
|
||||
"scripts/build-tech-log-tree.py",
|
||||
@@ -282,6 +309,9 @@ class _OutputReport:
|
||||
def error(self, rule: str, detail: str = "") -> None:
|
||||
self.errors.setdefault(rule, []).append(detail)
|
||||
|
||||
def warn(self, rule: str, detail: str = "") -> None:
|
||||
self.warns.setdefault(rule, []).append(detail)
|
||||
|
||||
@property
|
||||
def error_count(self) -> int:
|
||||
return sum(len(v) for v in self.errors.values())
|
||||
@@ -307,6 +337,11 @@ OUTPUT_CHECKS = (
|
||||
("check-required-content",
|
||||
lambda root, proj: [sys.executable,
|
||||
str(root / "scripts" / "check-required-content.py"), proj]),
|
||||
# 넷째 `check-ssot-facts.py`. 앞의 셋은 **기록**이 SSOT 와 어긋나지 않는지를 보고,
|
||||
# 이것은 그 위 — SSOT 자신이 코드베이스와 맞는지를 본다 (계획서 §6).
|
||||
("check-ssot-facts",
|
||||
lambda root, proj: [sys.executable,
|
||||
str(root / "scripts" / "check-ssot-facts.py"), proj]),
|
||||
)
|
||||
|
||||
|
||||
@@ -320,7 +355,7 @@ def _last_meaningful_line(text: str) -> str:
|
||||
def verify_outputs(shared_root: Path) -> list:
|
||||
"""산출물 검사 셋을 프로젝트마다 돌린다.
|
||||
|
||||
종료 코드를 그대로 읽는다. **0 이 아니면 error 다** — `ca-tmpl` 처럼 계약 파일이 없어
|
||||
종료 코드를 그대로 읽는다. **0 이 아니면 error 다** — 계약 파일(`tech-log-tree.json`)이 없어
|
||||
나는 exit 2 도 포함한다. 「대상 없음」으로 넘기면 계약을 채택하지 않은 프로젝트가
|
||||
검사를 피한다. `verify-tech-log-tree.py` 는 `tech-log-studio/` 가 없는 프로젝트를
|
||||
아예 목록에 넣지 않으므로 지금은 그 상태를 아무도 세지 않는다.
|
||||
@@ -359,6 +394,47 @@ def verify_runs(shared_root: Path):
|
||||
return [module.verify(str(p)) for p in ledgers]
|
||||
|
||||
|
||||
def verify_run_coverage(shared_root: Path):
|
||||
"""기록 한 편에 런 원장 하나가 붙어 있나.
|
||||
|
||||
원장의 `record` 칸이 그 런이 만든 기록을 가리킨다. 기록은 47편인데 원장이 1개면
|
||||
**나머지 46편은 어느 절차로 나왔는지 이 저장소가 모른다.** 검사기가 그것을 세지
|
||||
않으면 「원장이 전부 통과」가 「전부 원장을 지났다」로 읽힌다.
|
||||
|
||||
**없는 것은 error 가 아니다.** 파이프라인을 거치지 않고 손으로 쓴 기록이 있는 것
|
||||
자체는 잘못이 아니고, 지나간 일에 원장을 소급해 만드는 것은 영수증 위조다. 다만
|
||||
**초록으로 보이면 안 된다** — warn 으로 세고 요약 줄이 덮인 편수를 적는다.
|
||||
"""
|
||||
reports = []
|
||||
for project_dir in sorted((shared_root / "docs").glob("*/tech-log-studio")):
|
||||
project = project_dir.parent.name
|
||||
records = {
|
||||
str(p.relative_to(shared_root))
|
||||
for p in project_dir.rglob("*.md")
|
||||
}
|
||||
if not records:
|
||||
continue
|
||||
covered: set[str] = set()
|
||||
for ledger in sorted((shared_root / "runs" / project).glob("*/run.json")):
|
||||
try:
|
||||
rec = json.loads(ledger.read_text(encoding="utf-8")).get("record")
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if not rec:
|
||||
continue
|
||||
rec = str(rec).strip()
|
||||
if rec in records:
|
||||
covered.add(rec)
|
||||
rep = _OutputReport(project)
|
||||
rep.facts["기록"] = len(records)
|
||||
rep.facts["원장이 덮은 기록"] = len(covered)
|
||||
rep.facts["원장 없는 기록"] = len(records) - len(covered)
|
||||
for rec in sorted(records - covered):
|
||||
rep.warn("이 기록을 만든 런 원장이 없다", rec)
|
||||
reports.append(rep)
|
||||
return reports
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Verify the Tech Log documentation pipeline workspace.")
|
||||
parser.add_argument("shared_root", nargs="?", type=Path,
|
||||
@@ -373,6 +449,7 @@ def main() -> int:
|
||||
layouts = [] if args.skip_projects else verify_layouts(args.shared_root)
|
||||
runs = [] if args.skip_projects else verify_runs(args.shared_root)
|
||||
outputs = [] if args.skip_projects else verify_outputs(args.shared_root)
|
||||
coverage = [] if args.skip_projects else verify_run_coverage(args.shared_root)
|
||||
project_errors = (sum(r.error_count for r in reports)
|
||||
+ sum(r.error_count for r in layouts)
|
||||
+ sum(r.error_count for r in runs)
|
||||
@@ -400,10 +477,18 @@ def main() -> int:
|
||||
|
||||
if runs:
|
||||
run_errors = sum(r.error_count for r in runs)
|
||||
# 대조를 못 한 영수증(그 뒤에 스킬이 고쳐졌다 · git 을 못 봤다)은 error 도 아니고
|
||||
# 「봤고 괜찮다」도 아니다. 따로 센다 — 0 으로 뭉개면 초록으로 보인다.
|
||||
# 「누가 돌렸는지 모르는 단계」도 같은 자리인데 고치는 방법이 달라 칸을 나눈다 —
|
||||
# 스킬을 열었다는 증거가 없는 것과, 증거는 있는데 연 사람이 안 적힌 것은 다른 일이다
|
||||
unverified = sum(int(r.facts.get("대조 못 한 영수증") or 0) for r in runs)
|
||||
unattributed = sum(int(r.facts.get("누가 돌렸는지 모르는 단계") or 0) for r in runs)
|
||||
print()
|
||||
print(f"PIPELINE RUNS: {'FAIL' if run_errors else 'PASS'}"
|
||||
f" — 런 {len(runs)} · error {run_errors} ·"
|
||||
f" warn {sum(r.warn_count for r in runs)}")
|
||||
f" warn {sum(r.warn_count for r in runs)}"
|
||||
f" · 대조 못 한 영수증 {unverified}"
|
||||
f" · 누가 돌렸는지 모르는 단계 {unattributed}")
|
||||
for report in runs:
|
||||
verifier_render(report, args.samples)
|
||||
|
||||
@@ -425,6 +510,16 @@ def main() -> int:
|
||||
for report in outputs:
|
||||
verifier_render(report, args.samples)
|
||||
|
||||
if coverage:
|
||||
total = sum(int(r.facts.get("기록") or 0) for r in coverage)
|
||||
cov = sum(int(r.facts.get("원장이 덮은 기록") or 0) for r in coverage)
|
||||
print()
|
||||
# error 를 내지 않는다. 덮이지 않은 것은 결함이 아니라 **모르는 것**이다
|
||||
print(f"RUN COVERAGE: 기록 {total} · 원장이 덮은 기록 {cov}"
|
||||
f" · 원장 없는 기록 {total - cov}")
|
||||
for report in coverage:
|
||||
verifier_render(report, args.samples)
|
||||
|
||||
return 1 if errors or project_errors else 0
|
||||
|
||||
|
||||
|
||||
@@ -48,10 +48,20 @@ REQUIRED_FIELDS = {
|
||||
"next-verification", "decision-criterion", "relations"),
|
||||
"decision": ("slug", "readiness", "source", "decision-status", "decision-evidence",
|
||||
"grounds", "classification", "relations"),
|
||||
# 환경 구성. `pinned-versions` 는 Concept 의 `basis-version` 과 같은 자리다 —
|
||||
# 이 종류에는 검증일이 없고 「낡음은 `pinnedVersions` 가 말한다」(`SetupDetailResponse`).
|
||||
# 다른 점은 버전이 하나가 아니라 여럿이라는 것뿐이다
|
||||
"setup": ("slug", "readiness", "source", "pinned-versions", "classification", "relations"),
|
||||
}
|
||||
# 글을 써도 되는 readiness. 나머지는 글감으로만 남는다
|
||||
GENERATABLE = {"case": {"READY"}, "concept": {"READY"}, "reference": {"READY"},
|
||||
"question": {"OPEN"}, "decision": {"READY"}}
|
||||
"question": {"OPEN"}, "decision": {"READY"}, "setup": {"READY"}}
|
||||
|
||||
# 표를 손으로 채우다 종류를 빠뜨리면 그 종류의 글감은 **아무 칸도 요구받지 않는다** —
|
||||
# 조용히 통과한다. 빠진 것이 있으면 import 할 때 걸리게 둔다
|
||||
_gap = set(KINDS) ^ set(REQUIRED_FIELDS) | set(KINDS) ^ set(GENERATABLE)
|
||||
if _gap:
|
||||
raise RuntimeError(f"종류별 표가 techlog.KINDS 와 다르다: {sorted(_gap)}")
|
||||
DECISION_STATUS = {"PROPOSED", "ADOPTED", "SUPERSEDED", "NOT_DECIDED"}
|
||||
|
||||
# 분석 문서의 절 제목을 그대로 옮겨 온 자리
|
||||
|
||||
Reference in New Issue
Block a user