chore: 이전 세션이 남긴 변경을 커밋한다

이번 파이프라인 작업과 무관하게 작업 트리에 남아 있던 것을 그대로 올린다.
사용자가 「전부 커밋」으로 정했고, 이번 작업과 섞이지 않게 커밋만 나눴다.

대부분은 clean-architecture-backend-template 의 그림 정본 재배치다 —
final/assets/diagrams/<이름>/ 에 있던 것이 CLAUDE.md 가 적은 배치인
final/assets/<이름>/ 로 옮겨졌고 .techviz/<이름>/ 이 함께 들어왔다.
삽입 줄의 대부분(3.15M)이 그 .techviz context.json 이다.

그 밖에 ca-tmpl·document-haness 의 정리, .claude/agents/ 열한 개,
writing-practitioner-guides 스킬, .playwright-mcp 세션 산출물,
scripts/check-ssot-facts.py 와 그 시험이 들어 있다.

이 커밋의 내용은 내가 만든 것이 아니라 이전 세션이 남긴 것이고 검증하지 않았다.

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