pipeline: make tech-log-tree.json the one decomposition contract and enforce it

리뷰 두 건을 반영했다.

계약
- tech-log-tree.json 하나가 분해 계약이자 색인이다. 사람이 읽는 트리·Node Specification·
  후보 대장은 없어졌고, 문서에 남아 있던 그 개념을 걷어냈다
- candidateScope — 후보를 찾는 SSOT 범위. 접어 넣은 제2부·제3부는 근거이지 후보가 아니다
- sourceRepository — 분석한 저장소의 경로·리비전·판단 근거. 리비전을 모르면 null 로 두고
  지어내지 않는다. 갈래가 여럿이면 revisions
- 검사기: 계약 미채택·PENDING·PROMOTE↔글감 양방향·candidateScope·sourceRepository 를
  error/warn 으로 센다. 옛 스키마도 검사를 피하지 못한다. 테스트 22 → 31

기록 쓰기
- 템플릿 5종에 source·sourceRevision·topicName, Question 에 닫는 조건, 본문 없는 종류에서
  assets 제거. 고정 절 개수 삭제
- check_evidence.mjs — 인용한 코드가 SSOT 에 있는지, 앵커가 SSOT 를 가리키는지, 제목이
  계약과 같은지, 리비전이 저장소에 있는지. 게시된 기록에서 SSOT 와 다른 URL 을 잡았다

문체
- 문체 규칙의 정본을 ai-tells.md 로. explaining.md 의 질문체 제목·절 끝 대조 반복·그림 예고
  규칙을 삭제해 충돌을 없앴다. 첫 절 「설명 뒤에 평가를 붙이지 않는다」에 지우는 사례 네 유형
- voice 스킬의 「독자 쪽을 본다」를 자료에 오독 기록이 있을 때로 좁히고, 평가만 더한 예시를 교체
- check_prose: 안내 문장을 요구하던 경고 제거, 문장이 끝나지 않은 채 문단이 끝나는 조각 검사 추가

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-07 12:39:20 +09:00
co-authored by Claude Fable 5.1
parent 73026cada6
commit 9d2a3725c5
54 changed files with 3583 additions and 871 deletions
+101 -72
View File
@@ -1,113 +1,142 @@
#!/usr/bin/env python3
"""tech-log-tree.json 을 다시 만든다.
"""`tech-log-tree.json` 의 파생 칸을 다시 채운다.
노드 필드는 document-detail 의 root-tree 계약을 따른다 — readiness, source, code,
evidence, classification, missing-verification, relations. 제목만 보고 기록을 만들지
못하게 하려는 것이다.
**이 파일이 정본이다.** 주제·글감·readiness·source·classification·relations 는 사람이
적고, 이 스크립트는 손대지 않는다. 기록 파일을 읽어 채우는 것은 넷뿐이다.
SSOT(final/document.md)에서 뽑은 글감과 이미 쓴 기록을 한 파일에 모은다. 기록 파일이
정본이므로 이 스크립트는 그것을 읽어 채우고, 아직 글이 없는 글감은 사람이 적은 항목을
그대로 둔다.
file 그 글감의 기록이 디스크에 있으면 상대 경로
publication 게시됨 | 초안 | 미작성
status 기록 frontmatter 의 status
studioId · assets · evidenceFiles
**readiness 와 publication 은 다른 것이다.** 증거가 갖춰진 정도와 Studio 에 올렸는지를
섞지 않는다. 계약에 없는 기록이 디스크에 있으면 `unlisted` 에 적는다 — 지우지도, 몰래
주제로 만들지도 않는다.
python3 scripts/build-tech-log-tree.py [프로젝트 ...]
"""
from __future__ import annotations
import json, re, sys, glob, os, datetime, hashlib
KINDS = ["case", "concept", "reference", "question", "decision"]
import datetime
import glob
import json
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import techlog # noqa: E402
from techlog import KINDS # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DERIVED = ("file", "publication", "status", "studioId", "assets", "evidenceFiles")
def front_matter(path: str) -> dict:
def front_matter(path: str) -> tuple[dict, str]:
text = open(path, encoding="utf-8").read()
if not text.startswith("---"):
return {}
return {}, text
end = text.find("\n---", 3)
out = {}
for line in text[3:end].splitlines():
m = re.match(r"^([a-zA-Z_]+):\s*(.*)$", line)
if m:
out[m.group(1)] = m.group(2).strip().strip('"')
return out
return out, text
def build(project: str) -> dict:
def records_on_disk(studio: str) -> dict[tuple[str, str], str]:
found = {}
for path in sorted(glob.glob(os.path.join(studio, "*", "*", "*.md"))):
parts = path.split(os.sep)
topic_dir, kind_dir = parts[-3], parts[-2]
if topic_dir.startswith("_") or kind_dir not in KINDS:
continue
fm, _ = front_matter(path)
slug = fm.get("slug") or os.path.basename(path)[:-3]
found[(kind_dir, slug)] = path
return found
def build(project: str) -> tuple[dict | None, list[str]]:
base = os.path.join(ROOT, "docs", project)
studio = os.path.join(base, "tech-log-studio")
tree_path = os.path.join(studio, "tech-log-tree.json")
previous = {}
if os.path.exists(tree_path):
previous = json.load(open(tree_path, encoding="utf-8"))
index_path = os.path.join(studio, "tech-log-tree.json")
index = techlog.load_index(index_path)
if index is None:
return None, [f"{project}: tech-log-tree.json 이 없다. 글감을 먼저 적는다"]
ssot = "final/document.md" if os.path.exists(os.path.join(base, "final/document.md")) else None
topics = {}
for topic_dir in sorted(d for d in glob.glob(os.path.join(studio, "*"))
if os.path.isdir(d) and not os.path.basename(d).startswith("_")):
topic = os.path.basename(topic_dir)
entry = {"topic": topic, "kinds": {}}
for kind in KINDS:
items = []
for f in sorted(glob.glob(os.path.join(topic_dir, kind, "*.md"))):
fm = front_matter(f)
text = open(f, encoding="utf-8").read()
node = {
"title": fm.get("title", os.path.basename(f)),
"slug": fm.get("slug", ""),
"file": os.path.relpath(f, studio),
"readiness": "READY" if fm.get("id") else "NEEDS_EVIDENCE",
"status": fm.get("status", "미작성"),
"studioId": fm.get("id", ""),
"assets": re.findall(r"^ - key: (\S+)", text, re.M),
"evidence": re.findall(r"^ - (\.\./\S+)", text, re.M),
"relations": re.findall(r"^- \*\*(.+?)\*\*$", text, re.M),
}
# 이미 쓴 글감은 지난 트리의 사람이 적은 칸을 잃지 않는다
for old in previous.get("topics", {}).get(topic, {}).get("kinds", {}).get(kind, []):
if old.get("slug") == node["slug"]:
for key in ("classification", "missing-verification", "source", "code"):
if old.get(key):
node[key] = old[key]
items.append(node)
# 아직 글이 없는 글감은 지난 tree 에서 가져와 유지한다
written = {i["slug"] for i in items if i["slug"]}
for old in previous.get("topics", {}).get(topic, {}).get("kinds", {}).get(kind, []):
if not old.get("file") and old.get("slug") not in written:
items.append(old)
entry["kinds"][kind] = items
topics[topic] = entry
disk = records_on_disk(studio)
used: set[tuple[str, str]] = set()
for _, kind, node in techlog.nodes(index):
for key in DERIVED:
node.pop(key, None)
node["publication"] = "미작성"
key = (kind, node.get("slug", ""))
if not node.get("slug") or key not in disk:
continue
path = disk[key]
fm, text = front_matter(path)
studio_id = fm.get("id", "")
node.update({
"file": os.path.relpath(path, studio),
"status": fm.get("status", ""),
"studioId": studio_id,
"publication": "게시됨" if studio_id else "초안",
"assets": re.findall(r"^ - key: (\S+)", text, re.M),
"evidenceFiles": re.findall(r"^ - (\.\./\S+)", text, re.M),
})
used.add(key)
ssot_path = os.path.join(base, ssot) if ssot else None
digest = None
if ssot_path and os.path.exists(ssot_path):
digest = hashlib.sha256(open(ssot_path, "rb").read()).hexdigest()
unlisted = [os.path.relpath(p, studio) for k, p in sorted(disk.items()) if k not in used]
total = sum(1 for _ in techlog.nodes(index))
written = sum(1 for _, _, n in techlog.nodes(index) if n.get("file"))
ssot = index.get("ssot", "final/document.md")
return {
"schemaVersion": 2,
"project": project,
"ssot": ssot,
"ssotSha256": digest,
"generatedAt": datetime.date.today().isoformat(),
"note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다. "
"ssotSha256 이 지금 final/document.md 와 다르면 SSOT 가 바뀐 뒤 트리를 다시 보지 않은 것이다.",
"readinessValues": ["READY", "NEEDS_EVIDENCE", "BLOCKED", "REJECTED"],
"topics": topics,
index["ssotSha256"] = techlog.sha256_of(os.path.join(base, ssot))
index["generatedAt"] = datetime.date.today().isoformat()
index["counts"] = {
"topics": len(index.get("topics", {})),
"nodes": total,
"written": written,
"unwritten": total - written,
"unlisted": len(unlisted),
"candidates": len(index.get("candidates", [])),
}
index["unlisted"] = unlisted
warnings = []
if unlisted:
warnings.append(f"계약에 없는 기록 {len(unlisted)}건이 디스크에 있다 — "
"글감으로 올리거나 지운다")
return index, warnings
def main(argv: list[str]) -> int:
projects = argv[1:] or [
os.path.basename(os.path.dirname(p))
for p in glob.glob(os.path.join(ROOT, "docs/*/tech-log-studio"))
if not os.path.basename(os.path.dirname(p)).startswith("_")
]
failed = 0
for project in sorted(projects):
tree = build(project)
index, messages = build(project)
if index is None:
failed += 1
for line in messages:
print(line)
continue
out = os.path.join(ROOT, "docs", project, "tech-log-studio", "tech-log-tree.json")
with open(out, "w", encoding="utf-8") as fh:
json.dump(tree, fh, ensure_ascii=False, indent=2)
json.dump(index, fh, ensure_ascii=False, indent=2)
fh.write("\n")
n = sum(len(v) for t in tree["topics"].values() for v in t["kinds"].values())
print(f"{os.path.relpath(out, ROOT)} — 주제 {len(tree['topics'])} · 글감 {n}")
return 0
c = index["counts"]
extra = f" · 계약 밖 기록 {c['unlisted']}" if c.get("unlisted") else ""
print(f"{os.path.relpath(out, ROOT)} — 주제 {c['topics']} · 글감 {c['nodes']} "
f"(쓴 것 {c['written']}){extra}")
for line in messages:
print(f" ! {line}")
return 1 if failed else 0
if __name__ == "__main__":
+273
View File
@@ -0,0 +1,273 @@
#!/usr/bin/env python3
"""분석 작업 재료를 `final/document.md` 로 옮기고 폴더에서 지운다.
`analyzing-codebase-for-tech-log` 의 11·12 단계다. 분석하는 동안 쌓은 것 —
`analysis/` · `source-index.md` · `notes/` · `state.json` 의 커버리지 — 은 작업 재료이고,
분석이 끝나면 그 내용이 SSOT 안에 있어야 한다. 요약만 하고 근거를 원래 자리에 두면
기록의 `source` 가 `analysis/` 를 가리켜 SSOT 가 둘이 된다.
**옮기는 것이지 요약하는 것이 아니다.** 본문을 그대로 싣고 제목 수준만 내려 붙인다.
그다음 `analysis/NN` 을 가리키던 앵커를 `final/document.md#aNN` 으로 고치고, 작업 재료를
지운다.
python3 scripts/fold-analysis-into-final.py <프로젝트> [--dry-run] [--keep]
`--keep` 은 옮기기만 하고 지우지 않는다. 되돌리려면 git 으로 돌린다.
"""
from __future__ import annotations
import argparse
import collections
import glob
import json
import os
import re
import shutil
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import techlog
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 분석 스킬의 틀 파일. 내용이 아니라 빈 서식이라 옮기지 않는다
TEMPLATE_FILES = {"module.md"}
# 가족 문서가 대표하는 하위 폴더 → 그 가족의 번호
FAMILY = {"messaging": "19", "grpc": "20"}
FAMILY_DIR = {v: k for k, v in FAMILY.items()}
# 분석 과정 기록. 글감 선별(_meta)이 아니라 분석 자체의 기록만 문서로 옮긴다
STUDIO_NOTES = ("tech-log-candidate-recall-audit", "tech-log-concept-recall-audit",
"tech-log-reselection-plan")
FENCE = re.compile(r"^\s*(```|~~~)")
def shift_headings(text: str, by: int = 2) -> str:
"""코드펜스 밖의 제목만 수준을 내린다. 펜스 안의 `#` 은 주석이다."""
out = []
fence = None
for line in text.splitlines():
m = FENCE.match(line)
if m:
token = m.group(1)
fence = None if fence == token else (fence or token)
out.append(line)
continue
if fence is None and line.startswith("#"):
level = len(line) - len(line.lstrip("#"))
out.append("#" * min(level + by, 6) + line[level:])
else:
out.append(line)
return "\n".join(out)
def collect(base: str) -> list[dict]:
"""옮길 모듈 문서를 순서대로 모은다."""
analysis = os.path.join(base, "analysis")
items = []
for path in sorted(glob.glob(os.path.join(analysis, "*.md"))):
name = os.path.basename(path)
if name in TEMPLATE_FILES:
continue
m = re.match(r"^(\d+)-?(.*)\.md$", name)
if not m:
continue
number, rest = m.group(1), m.group(2)
items.append({"id": f"a{number}", "title": rest or "project-overview",
"origin": f"analysis/{name}", "path": path})
for family, number in FAMILY.items():
for path in sorted(glob.glob(os.path.join(analysis, family, "*.md"))):
stem = os.path.basename(path)[:-3]
items.append({"id": f"a{number}-{stem}", "title": stem,
"origin": f"analysis/{family}/{stem}.md", "path": path})
return items
def anchor_rules(items: list[dict]) -> list[tuple[re.Pattern, str]]:
"""긴 형태부터 고친다 — `analysis/05-…md` 를 `analysis/05` 보다 먼저."""
# 줄 앵커는 옮기면 뜻을 잃는다. 절 앵커로 낮춘다 — 후보 대장은 `sourceHeading` 을 갖고 있다
rules = [(re.compile(r"(analysis/[\w/.-]+\.md)#L\d+"), r"\1")]
for it in sorted(items, key=lambda x: -len(x["origin"])):
rules.append((re.compile(re.escape(it["origin"])), f"final/document.md#{it['id']}"))
for it in items:
m = re.match(r"^a(\d+)$", it["id"])
if m:
rules.append((re.compile(rf"analysis/{m.group(1)}(?![\w./-])"),
f"final/document.md#{it['id']}"))
rules.append((re.compile(r"`?analysis/module\.md`?"), "분석 틀"))
return rules
def anchors_from_document(document: str) -> list[dict]:
"""이미 옮긴 문서에서 절 목록을 되읽는다. 앵커만 마저 고칠 때 쓴다."""
items = []
for line in open(document, encoding="utf-8"):
m = re.match(r"^## (A\d+(?:-[\w-]+)?)\. (.+)$", line)
if m:
ident, title = m.group(1).lower(), m.group(2).strip()
number = ident[1:3]
origin = (f"analysis/{FAMILY_DIR[number]}/{ident[4:]}.md"
if "-" in ident else f"analysis/{number}-{title}.md")
items.append({"id": ident, "title": title, "origin": origin})
return items
def coverage_table(base: str) -> str:
path = os.path.join(base, "state.json")
if not os.path.exists(path):
return ""
state = json.load(open(path, encoding="utf-8"))
scopes = state.get("scopes", [])
if not scopes:
return ""
rows = ["| 스코프 | 경로 | 상태 | 전량 통독 | 구조만 | 제외 | 옮겨 간 자리 |",
"|---|---|---|---:|---:|---:|---|"]
for s in scopes:
cov = s.get("coverage") or {}
origin = s.get("analysisFile", "")
m = re.search(r"analysis/(\d+)", origin)
where = f"§A{m.group(1)}" if m else ""
rows.append(f"| `{s.get('id','')}` | `{s.get('path','')}` | {s.get('status','')} | "
f"{cov.get('fullRead', 0)} | {cov.get('structuralOnly', 0)} | "
f"{cov.get('excluded', 0)} | {where} |")
revision = state.get("gitRevision") or ""
head = (f"분석한 리비전은 `{revision}` 이다.\n\n" if revision else "")
return head + "\n".join(rows) + "\n"
def build_part(base: str, items: list[dict]) -> str:
out = ["", "---", "", "# 제2부 — 모듈 분석 전문", "",
"제1부는 이 부의 종합이다. 여기 실린 것이 근거이고, 분석하는 동안에는 "
"`analysis/` 아래에 파일로 나뉘어 있었다. 파일이 아니라 이 문서가 정본이므로 "
"그대로 옮겨 왔다 — 제목 수준만 내렸고 본문은 손대지 않았다.", ""]
for it in items:
text = open(it["path"], encoding="utf-8").read().rstrip()
lines = len(text.splitlines())
# 유래 줄에는 `analysis/` 접두어를 쓰지 않는다 — 앵커 치환에 같이 걸린다
origin = it["origin"].split("analysis/", 1)[-1]
out += ["---", "", f"## {it['id'].upper()}. {it['title']}", "",
f"> 분석 중에는 `{origin}` 파일이었다. {lines:,}줄.", "",
shift_headings(text), ""]
out += ["---", "", "# 제3부 — 분석 재료", "",
"분석하는 동안 따로 두었던 목록과 기록이다. 폴더가 아니라 이 문서에 남는다.", ""]
index = os.path.join(base, "source-index.md")
if os.path.exists(index):
out += ["---", "", "## D. 분석한 코드의 목록", "",
"> 분석 중에는 `source-index.md` 였다.", "",
shift_headings(open(index, encoding="utf-8").read().rstrip()), ""]
table = coverage_table(base)
if table:
out += ["---", "", "## E. 스코프별 커버리지", "",
"> 분석 중에는 `state.json` 의 `scopes` 였다. 리프 단위 정본이던 자리다.", "",
table, ""]
notes = [p for p in sorted(glob.glob(os.path.join(base, "notes", "*.md")))
if os.path.basename(p)[:-3] not in STUDIO_NOTES]
if notes:
out += ["---", "", "## F. 분석 과정 기록", "",
"> 분석 중에는 `notes/` 였다. 무엇을 어디까지 어떻게 확인했는지의 기록이다.", ""]
for path in notes:
out += [shift_headings(open(path, encoding="utf-8").read().rstrip(), by=3), ""]
return "\n".join(out)
def rewrite_anchors(base: str, rules: list[tuple[re.Pattern, str]], dry: bool) -> dict:
counts = collections.Counter()
targets = [os.path.join(base, "final", "document.md")]
# 증거 메타데이터도 분석 파일을 근거로 적고 있다
targets += sorted(glob.glob(os.path.join(base, "final", "evidence", "meta", "*.json")))
studio = os.path.join(base, "tech-log-studio")
targets += sorted(glob.glob(os.path.join(studio, "*.md")))
targets += sorted(glob.glob(os.path.join(studio, "*.json")))
targets += [p for p in sorted(glob.glob(os.path.join(studio, "*", "*", "*.md")))
if not os.path.relpath(p, studio).startswith("_")]
# _meta/checkpoints 는 지난 상태를 얼려 둔 것이라 고치지 않는다
targets += [p for p in sorted(glob.glob(os.path.join(studio, "_meta", "**", "*.*"),
recursive=True))
if os.path.splitext(p)[1] in {".md", ".json"}
and "checkpoints" not in os.path.relpath(p, studio).split(os.sep)]
for path in targets:
text = open(path, encoding="utf-8").read()
new = text
for pattern, replacement in rules:
new, n = pattern.subn(replacement, new)
if n:
counts[os.path.relpath(path, base)] += n
if new != text and not dry:
open(path, "w", encoding="utf-8").write(new)
return counts
def main() -> int:
ap = argparse.ArgumentParser(description="분석 작업 재료를 final/document.md 로 옮긴다.")
ap.add_argument("project")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--keep", action="store_true", help="옮기기만 하고 지우지 않는다")
ap.add_argument("--anchors-only", action="store_true",
help="본문은 이미 옮겼고 앵커만 마저 고친다")
args = ap.parse_args()
base = os.path.join(ROOT, "docs", args.project)
document = os.path.join(base, "final", "document.md")
if args.anchors_only:
items = anchors_from_document(document)
counts = rewrite_anchors(base, anchor_rules(items), dry=args.dry_run)
print(f"{args.project}: 앵커 {sum(counts.values()):,}건 · 파일 {len(counts):,}")
for name, n in counts.most_common(8):
print(f" {n:>5} {name}")
return 0
if not os.path.isdir(os.path.join(base, "analysis")):
print(f"{args.project}: analysis/ 가 없다 — 이미 옮겼거나 분석한 적이 없다")
return 0
items = collect(base)
part = build_part(base, items)
before = len(open(document, encoding="utf-8").read().splitlines())
print(f"{args.project}: 모듈 문서 {len(items)}")
print(f" final/document.md {before:,}줄 → {before + len(part.splitlines()):,}")
rules = anchor_rules(items)
counts = rewrite_anchors(base, rules, dry=True)
print(f" 앵커 {sum(counts.values()):,}건 · 파일 {len(counts):,}")
if args.dry_run:
print(" (--dry-run: 쓰지 않았다)")
return 0
with open(document, "a", encoding="utf-8") as fh:
fh.write(part if part.endswith("\n") else part + "\n")
rewrite_anchors(base, rules, dry=False)
if not args.keep:
studio_meta = os.path.join(base, "tech-log-studio", "_meta")
os.makedirs(studio_meta, exist_ok=True)
for name in STUDIO_NOTES:
src = os.path.join(base, "notes", f"{name}.md")
if os.path.exists(src):
shutil.move(src, os.path.join(studio_meta, f"{name}.md"))
checkpoints = os.path.join(base, "checkpoints")
if os.path.isdir(checkpoints):
shutil.move(checkpoints, os.path.join(studio_meta, "checkpoints"))
for name in ("analysis", "notes"):
shutil.rmtree(os.path.join(base, name), ignore_errors=True)
for name in ("state.json", "source-index.md"):
path = os.path.join(base, name)
if os.path.exists(path):
os.remove(path)
print(" 작업 재료를 지웠다 — 글감 선별 기록은 tech-log-studio/_meta/ 로 옮겼다")
digest = techlog.sha256_of(document)
tree = os.path.join(base, "tech-log-studio", "root-tree.md")
if os.path.exists(tree):
text = open(tree, encoding="utf-8").read()
text, n = re.subn(r"^sourceDocumentSha256:.*$",
f"sourceDocumentSha256: {digest}", text, count=1, flags=re.M)
if n:
open(tree, "w", encoding="utf-8").write(text)
print(" root-tree.md 의 sourceDocumentSha256 을 갱신했다")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+292
View File
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""분해 작업 재료를 `tech-log-tree.json` 으로 옮기고 폴더에서 지운다.
`analysis/` 를 `final/document.md` 로 옮긴 것과 같은 일을 `tech-log-studio/` 에서 한다.
끝난 프로젝트의 `tech-log-studio/` 에는 `tech-log-tree.json` 과 기록 폴더만 있다.
옮기는 것
root-tree.md 사람이 읽는 트리 + Node Specifications → topics
candidate-ledger.json 후보와 처분 → candidates
root-tree-source-manifest.json 원본 해시 → ssotSha256
_meta/state.json 생성·편집·검증 이력 → history
_meta/** 편집 과정 기록 → 지운다 (git 에 남는다)
python3 scripts/fold-studio-contract-into-index.py <프로젝트> [--dry-run] [--keep]
**옮기는 것이지 요약하는 것이 아니다.** 노드의 칸은 하나도 버리지 않는다.
"""
from __future__ import annotations
import argparse
import datetime
import hashlib
import json
import os
import re
import shutil
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import techlog # noqa: E402
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"}
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")
MULTI = {"source", "code", "evidence", "relations", "grounds", "decision-evidence",
"known", "unknown", "scope", "exceptions"}
_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*$")
_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*$")
_INLINE = re.compile(r"\s+·\s+(" + "|".join(FIELD_NAMES) + r"):\s*")
_READER_Q = re.compile(r"^독자\s*질문\s*[—:-]\s*(.+?)\s*$")
EMPTY = ("(추가 없음)", "(없음)", "(none)", "-")
def _set(fields: dict, key: str, value: str) -> None:
value = value.strip()
if not value:
fields.setdefault(key, [])
return
items = [v.strip() for v in value.split(" · ")] if key in MULTI else [value]
fields[key] = [v for v in items if v]
def parse_root_tree(path: str) -> dict:
"""사람이 읽는 트리와 Node Specifications 를 하나로 읽는다."""
text = open(path, encoding="utf-8").read()
header, body = {}, text
if text.startswith("---"):
end = text.find("\n---", 3)
for line in text[3:end].splitlines():
m = re.match(r"^([A-Za-z][A-Za-z0-9_]*):\s*(.*)$", line)
if m:
header[m.group(1)] = m.group(2).strip().strip('"')
body = text[end + 4:]
topics, specs, prose = [], [], []
in_specs = False
topic = branch = spec = None
spec_topic = ""
pending = None
expect = 0
for raw in body.splitlines():
line = raw.rstrip()
stripped = line.strip()
if line.startswith("# Node Specifications"):
in_specs = True
topic = branch = None
continue
if not in_specs:
if stripped.startswith(">"):
prose.append(stripped.lstrip("> ").rstrip())
continue
if stripped == "---":
continue
if stripped == "PROJECT":
expect = -1
continue
if expect == -1:
if stripped:
expect = 0
continue
if stripped == "TOPIC":
topic = {"topic": "", "title": "", "readerQuestion": "", "nodes": []}
topics.append(topic)
branch, expect = None, 1
continue
if topic is not None and expect in (1, 2, 3):
if expect == 1 and stripped:
topic["title"] = stripped
expect = 2
continue
if expect == 2 and stripped:
topic["topic"] = stripped
expect = 3
continue
if expect == 3:
m = _READER_Q.match(stripped)
if m:
topic["readerQuestion"] = m.group(1)
expect = 0
continue
if stripped:
expect = 0
m = _BRANCH.match(stripped)
if m:
branch = KIND_LABELS[m.group(1)]
continue
m = _ITEM.match(line)
if m and topic is not None and branch:
title = m.group(1).strip()
if title not in EMPTY:
topic["nodes"].append({"kind": branch, "title": title})
continue
m = _TOPIC_HEAD.match(line)
if m:
spec_topic, spec, pending = m.group(1).strip(), None, None
continue
m = _SPEC_HEAD.match(line)
if m:
spec = {"topic": spec_topic, "kind": KIND_LABELS[m.group(1)],
"title": m.group(2).strip(), "fields": {}}
specs.append(spec)
pending = None
continue
if spec is None:
continue
m = _FIELD.match(line)
if m:
key, value = m.group(1), m.group(2).strip()
parts = _INLINE.split(value)
_set(spec["fields"], key, parts[0])
rest = parts[1:]
while rest:
_set(spec["fields"], rest[0], rest[1])
rest = rest[2:]
pending = key if not rest else None
continue
m = _CONT.match(line)
if m and pending:
spec["fields"].setdefault(pending, []).append(m.group(1).strip())
continue
if not stripped:
pending = None
return {"header": header, "topics": topics, "specs": specs,
"prose": [p for p in prose if p]}
def node_from_spec(spec: dict) -> dict:
node = {"title": spec["title"], "kind": spec["kind"]}
for key in ("slug", "readiness"):
values = spec["fields"].get(key) or []
node[key] = values[0].strip("`").strip() if values else ""
node["readiness"] = node["readiness"].upper()
for key, values in spec["fields"].items():
if key in ("slug", "readiness") or not values:
continue
node[key] = values if key in MULTI or len(values) > 1 else values[0]
return node
def main() -> int:
ap = argparse.ArgumentParser(description="분해 작업 재료를 tech-log-tree.json 으로 옮긴다.")
ap.add_argument("project")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--keep", action="store_true", help="옮기기만 하고 지우지 않는다")
args = ap.parse_args()
base = os.path.join(ROOT, "docs", args.project)
studio = os.path.join(base, "tech-log-studio")
tree_path = os.path.join(studio, "root-tree.md")
index_path = os.path.join(studio, "tech-log-tree.json")
if not os.path.exists(tree_path):
print(f"{args.project}: root-tree.md 가 없다 — 이미 옮겼거나 분해 계약을 쓴 적이 없다")
return 0
parsed = parse_root_tree(tree_path)
header = parsed["header"]
by_key = {(s["topic"], s["kind"], s["title"]): s for s in parsed["specs"]}
topics = {}
used = set()
for t in parsed["topics"]:
entry = {"topic": t["topic"], "title": t["title"],
"readerQuestion": t["readerQuestion"], "kinds": {k: [] for k in KINDS}}
for n in t["nodes"]:
key = (t["topic"], n["kind"], n["title"])
spec = by_key.get(key)
entry["kinds"][n["kind"]].append(
node_from_spec(spec) if spec else {"title": n["title"], "kind": n["kind"],
"slug": "", "readiness": ""})
used.add(key)
topics[t["topic"]] = entry
# 사람이 읽는 트리에 줄이 없던 Node Specification 도 잃지 않는다
orphans = 0
for key, spec in by_key.items():
if key in used:
continue
orphans += 1
entry = topics.setdefault(spec["topic"], {
"topic": spec["topic"], "title": "", "readerQuestion": "",
"kinds": {k: [] for k in KINDS}})
node = node_from_spec(spec)
node["listedInTree"] = False
entry["kinds"][spec["kind"]].append(node)
ledger_path = os.path.join(studio, "candidate-ledger.json")
ledger = json.load(open(ledger_path, encoding="utf-8")) if os.path.exists(ledger_path) else {}
meta_state_path = os.path.join(studio, "_meta", "state.json")
meta_state = json.load(open(meta_state_path, encoding="utf-8")) \
if os.path.exists(meta_state_path) else {}
index = json.load(open(index_path, encoding="utf-8")) if os.path.exists(index_path) else {}
ssot = header.get("sourceDocument", "final/document.md")
out = {
"schemaVersion": 4,
"project": args.project,
"ssot": ssot,
"ssotSha256": techlog.sha256_of(os.path.join(base, ssot)),
"sourceRevision": header.get("sourceRevision", ""),
"generatedAt": datetime.date.today().isoformat(),
"note": ("이 프로젝트의 글감 전부다. 분해 계약이자 색인이고, 이 파일이 정본이다. "
"노드의 칸(readiness·source·classification·relations…)은 사람이 적고, "
"file·publication·status 는 기록 파일에서 읽어 채운다 — "
"python3 scripts/build-tech-log-tree.py <프로젝트>"),
"contract": {
"decomposition": parsed["prose"],
"readinessValues": techlog.READINESS,
"dispositionValues": ledger.get("dispositionValues", {}),
},
"counts": {},
"topics": topics,
"candidates": ledger.get("candidates", []),
"history": {k: v for k, v in meta_state.items()
if k not in ("schemaVersion", "project", "rootTreePath")},
}
for key in ("counts", "unmapped", "explicitAnalysisCandidates", "cycle2", "cycle3",
"conceptRecall", "migration", "fold"):
if key in ledger:
out["history"].setdefault("ledger", {})[key] = ledger[key]
total = sum(len(v) for t in topics.values() for v in t["kinds"].values())
out["counts"] = {"topics": len(topics), "nodes": total,
"candidates": len(out["candidates"])}
print(f"{args.project}: 주제 {len(topics)} · 글감 {total} · 후보 {len(out['candidates'])}")
if orphans:
print(f" 사람이 읽는 트리에 줄이 없던 노드 {orphans}건은 listedInTree=false 로 옮겼다")
if args.dry_run:
print(" (--dry-run: 쓰지 않았다)")
return 0
with open(index_path, "w", encoding="utf-8") as fh:
json.dump(out, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f" tech-log-tree.json {os.path.getsize(index_path):,} bytes")
if not args.keep:
for name in ("root-tree.md", "candidate-ledger.json", "root-tree-source-manifest.json"):
path = os.path.join(studio, name)
if os.path.exists(path):
os.remove(path)
shutil.rmtree(os.path.join(studio, "_meta"), ignore_errors=True)
print(" root-tree.md · candidate-ledger.json · manifest · _meta/ 를 지웠다")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Tech Log 파이프라인이 함께 쓰는 어휘와 보고 형식.
`tech-log-tree.json` 이 프로젝트의 분해 계약이자 색인이고 정본이다. 만드는 쪽
(`build-tech-log-tree.py`)과 검사하는 쪽(`verify-tech-log-tree.py`)이 같은 값을 쓰도록
여기 모은다.
"""
from __future__ import annotations
import collections
import hashlib
import json
import os
KINDS = ["case", "concept", "reference", "question", "decision"]
READINESS = ["READY", "OPEN", "NEEDS_EVIDENCE", "NEEDS_DECISION", "BLOCKED"]
DISPOSITIONS = ["PROMOTE", "MERGE_INTO", "KEEP_IN_SSOT",
"NEEDS_EVIDENCE", "NEEDS_DECISION", "BLOCKED"]
def sha256_of(path: str) -> str | None:
if not os.path.exists(path):
return None
return hashlib.sha256(open(path, "rb").read()).hexdigest()
def load_index(path: str) -> dict | None:
"""`tech-log-tree.json` 을 읽는다. 없으면 None."""
if not os.path.exists(path):
return None
return json.load(open(path, encoding="utf-8"))
def nodes(index: dict):
"""(주제 slug, 종류, 노드) 를 차례로 낸다."""
for slug, topic in (index.get("topics") or {}).items():
for kind, items in (topic.get("kinds") or {}).items():
for node in items:
yield slug, kind, node
class Report:
"""검사기가 규칙별로 모아 내는 결과.
한 규칙에 수백 건이 걸리는 것이 정상이라 개별 줄이 아니라 규칙으로 센다.
error 는 계약 위반이고 warn 은 편집 판단이 필요한 자리다.
"""
def __init__(self, project: str) -> None:
self.project = project
self.errors: dict[str, list[str]] = collections.defaultdict(list)
self.warns: dict[str, list[str]] = collections.defaultdict(list)
self.facts: dict[str, object] = {}
def error(self, rule: str, detail: str = "") -> None:
self.errors[rule].append(detail)
def warn(self, rule: str, detail: str = "") -> None:
self.warns[rule].append(detail)
@property
def error_count(self) -> int:
return sum(len(v) for v in self.errors.values())
@property
def warn_count(self) -> int:
return sum(len(v) for v in self.warns.values())
+384
View File
@@ -0,0 +1,384 @@
#!/usr/bin/env python3
"""글감 계약과 폴더 배치를 작은 픽스처로 확인한다.
python3 -m unittest discover -s scripts/tests
"""
from __future__ import annotations
import contextlib
import copy
import hashlib
import importlib.util
import io
import json
import os
import sys
import tempfile
import unittest
SCRIPTS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, SCRIPTS)
import techlog # noqa: E402
def _load(name: str, filename: str):
spec = importlib.util.spec_from_file_location(name, os.path.join(SCRIPTS, filename))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
verifier = _load("verify_tech_log_tree", "verify-tech-log-tree.py")
builder = _load("build_tech_log_tree", "build-tech-log-tree.py")
layout = _load("verify_project_layout", "verify-project-layout.py")
CASE = {
"title": "세션이 두 노드에 반씩 남아 로그인이 번갈아 깨졌다",
"kind": "case", "slug": "session-split-across-nodes", "readiness": "READY",
"source": ["final/document.md#4-2"], "code": ["SessionStore.java:41"],
"evidence": ["evidence/raw/session-probe.txt"],
"classification": "두 노드에 요청을 번갈아 보내 재현했고 로그로 확인했다",
"missing-verification": "없음",
"relations": ["concept:authorization-code-exchange"],
}
CONCEPT = {
"title": "Authorization Code 교환이 한 번 더 일어나는 자리",
"kind": "concept", "slug": "authorization-code-exchange", "readiness": "READY",
"source": ["final/document.md#3-1"], "basis-version": "Keycloak 26.7.0",
"classification": "이 교환을 알아야 아래 Case 의 관측을 읽을 수 있다",
"relations": ["case:session-split-across-nodes"],
}
INDEX = {
"schemaVersion": 4, "project": "fixture", "ssot": "final/document.md",
"sourceRevision": "abc1234", "generatedAt": "2026-09-05",
"candidateScope": {"document": "final/document.md", "sections": ["§3", "§11"]},
"sourceRepository": {"path": "https://example.invalid/fixture.git",
"revision": "0" * 40, "verified": "픽스처"},
"contract": {"readinessValues": techlog.READINESS},
"topics": {
"session-custody": {
"topic": "session-custody", "title": "세션을 누가 보관하는가",
"readerQuestion": "자격증명과 세션을 누가 보관하고 보호 자원은 무엇을 신뢰하는가?",
"kinds": {"case": [CASE], "concept": [CONCEPT],
"reference": [], "question": [], "decision": []},
}
},
"candidates": [
{"id": "F001", "disposition": "PROMOTE", "dispositionReview": "CONFIRMED",
"target": "case:session-split-across-nodes"},
{"id": "F002", "disposition": "PROMOTE", "dispositionReview": "CONFIRMED",
"target": "concept:authorization-code-exchange"},
{"id": "F003", "disposition": "KEEP_IN_SSOT", "dispositionReview": "CONFIRMED"},
],
}
RECORD = """\
---
kind: CASE
slug: session-split-across-nodes
title: 세션이 두 노드에 반씩 남아 로그인이 번갈아 깨졌다
topic: session-custody
project: fixture
status: 게시 전
---
"""
class Fixture:
def __init__(self, index: dict | None = None, with_record: bool = True) -> None:
self.dir = tempfile.TemporaryDirectory()
self.root = self.dir.name
self.base = os.path.join(self.root, "docs/fixture")
self.studio = os.path.join(self.base, "tech-log-studio")
os.makedirs(os.path.join(self.studio, "session-custody/case"))
os.makedirs(os.path.join(self.base, "final"))
ssot = os.path.join(self.base, "final/document.md")
open(ssot, "w", encoding="utf-8").write("# fixture\n")
data = copy.deepcopy(index if index is not None else INDEX)
data["ssotSha256"] = hashlib.sha256(open(ssot, "rb").read()).hexdigest()
self.index_path = os.path.join(self.studio, "tech-log-tree.json")
self.write(data)
if with_record:
open(os.path.join(self.studio, "session-custody/case/case-session-split.md"),
"w", encoding="utf-8").write(RECORD)
def write(self, data: dict) -> None:
with open(self.index_path, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
def read(self) -> dict:
return json.load(open(self.index_path, encoding="utf-8"))
def diagram(self, name: str, *, bundled: bool = True, with_source: bool = True) -> None:
assets = os.path.join(self.base, "final/assets")
target = os.path.join(assets, name) if bundled else assets
os.makedirs(target, exist_ok=True)
open(os.path.join(target, f"{name}.svg"), "w", encoding="utf-8").write("<svg/>")
if with_source:
src = os.path.join(self.base, "final/.techviz", name)
os.makedirs(src, exist_ok=True)
open(os.path.join(src, "spec.json"), "w", encoding="utf-8").write("{}")
def __enter__(self):
self._saved = (verifier.ROOT, builder.ROOT, layout.ROOT)
verifier.ROOT = builder.ROOT = layout.ROOT = self.root
return self
def __exit__(self, *exc):
verifier.ROOT, builder.ROOT, layout.ROOT = self._saved
self.dir.cleanup()
def mutate(**changes):
"""글감 하나의 칸을 바꾼 색인을 만든다."""
index = copy.deepcopy(INDEX)
case = index["topics"]["session-custody"]["kinds"]["case"][0]
for key, value in changes.items():
if value is None:
case.pop(key, None)
else:
case[key] = value
return index
class ContractTest(unittest.TestCase):
def test_clean_fixture_has_no_errors(self):
with Fixture():
with contextlib.redirect_stdout(io.StringIO()):
builder.main(["build", "fixture"])
report = verifier.verify("fixture")
self.assertEqual(report.errors, {}, report.errors)
def test_missing_reader_question_is_an_error(self):
index = copy.deepcopy(INDEX)
index["topics"]["session-custody"]["readerQuestion"] = ""
with Fixture(index):
self.assertIn("Topic 에 독자 질문이 없다", verifier.verify("fixture").errors)
def test_concept_without_basis_version_is_an_error(self):
index = copy.deepcopy(INDEX)
del index["topics"]["session-custody"]["kinds"]["concept"][0]["basis-version"]
with Fixture(index):
self.assertIn("CONCEPT 노드에 `basis-version` 가 없다",
verifier.verify("fixture").errors)
def test_case_without_classification_is_an_error(self):
with Fixture(mutate(classification=None)):
self.assertIn("CASE 노드에 `classification` 가 없다",
verifier.verify("fixture").errors)
def test_source_anchored_outside_the_ssot_is_flagged(self):
with Fixture(mutate(source=["analysis/05-persistence.md §3.5"])):
self.assertIn("근거가 SSOT 밖에만 있다", verifier.verify("fixture").warns)
def test_readiness_is_not_publication(self):
with Fixture(mutate(readiness="NEEDS_EVIDENCE")):
self.assertIn("글을 쓰면 안 되는 readiness 인데 기록이 있다",
verifier.verify("fixture").errors)
def test_unknown_readiness_is_an_error(self):
with Fixture(mutate(readiness="REJECTED")):
self.assertIn("readiness 값이 계약에 없다", verifier.verify("fixture").errors)
def test_record_outside_the_contract_is_an_error(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.studio, "orphan-topic/concept"))
open(os.path.join(fx.studio, "orphan-topic/concept/c.md"), "w",
encoding="utf-8").write("---\nkind: CONCEPT\nslug: nobody-listed-me\n---\n")
self.assertIn("계약에 없는 기록", verifier.verify("fixture").errors)
def test_stale_ssot_hash_is_an_error(self):
with Fixture() as fx:
open(os.path.join(fx.base, "final/document.md"), "a",
encoding="utf-8").write("바뀌었다\n")
self.assertIn("SSOT 가 바뀐 뒤 글감을 다시 보지 않았다",
verifier.verify("fixture").errors)
def test_pending_disposition_is_an_error(self):
index = copy.deepcopy(INDEX)
index["candidates"][0]["dispositionReview"] = "PENDING"
with Fixture(index):
report = verifier.verify("fixture")
self.assertIn("disposition 을 다시 판정하지 않은 후보", report.errors)
self.assertNotIn("disposition 을 다시 판정하지 않은 후보", report.warns)
def test_promote_without_a_node_is_an_error(self):
index = copy.deepcopy(INDEX)
index["candidates"][0]["target"] = "case:never-written"
with Fixture(index):
self.assertIn("PROMOTE 후보가 글감에 없다", verifier.verify("fixture").errors)
def test_node_without_a_promote_candidate_is_an_error(self):
index = copy.deepcopy(INDEX)
index["candidates"][0]["disposition"] = "KEEP_IN_SSOT"
index["candidates"][0]["target"] = None
with Fixture(index):
self.assertIn("글감을 낳은 PROMOTE 후보가 없다",
verifier.verify("fixture").errors)
def test_missing_candidate_scope_is_an_error(self):
index = copy.deepcopy(INDEX)
del index["candidateScope"]
with Fixture(index):
self.assertIn("candidateScope 가 없다", verifier.verify("fixture").errors)
def test_candidate_scope_pointing_at_another_document_is_an_error(self):
index = copy.deepcopy(INDEX)
index["candidateScope"]["document"] = "analysis/05-persistence.md"
with Fixture(index):
self.assertIn("candidateScope.document 가 ssot 과 다르다",
verifier.verify("fixture").errors)
def test_a_node_sourced_only_outside_the_candidate_scope_is_an_error(self):
index = mutate(source=["final/document.md#a19-messaging-runtime-core"])
index["candidateScope"]["excludedAnchorPattern"] = r"#a\d+-"
with Fixture(index):
self.assertIn("후보를 찾는 범위 밖에서만 나온 글감",
verifier.verify("fixture").errors)
def test_a_project_without_the_contract_is_an_error(self):
index = copy.deepcopy(INDEX)
index["schemaVersion"] = 2
del index["contract"]
del index["candidateScope"]
with Fixture(index):
report = verifier.verify("fixture")
self.assertIn("글감 계약을 아직 쓰지 않았다", report.errors)
self.assertNotIn("글감 계약을 아직 쓰지 않았다", report.warns)
def test_an_old_index_is_not_flooded_with_per_field_errors(self):
index = copy.deepcopy(INDEX)
index["schemaVersion"] = 2
del index["contract"]
del index["candidateScope"]
index["topics"]["session-custody"]["readerQuestion"] = ""
for kind in ("case", "concept"):
for node in index["topics"]["session-custody"]["kinds"][kind]:
node.pop("classification", None)
node.pop("basis-version", None)
with Fixture(index):
rules = set(verifier.verify("fixture").errors)
self.assertEqual(
{r for r in rules if "노드에" in r or "독자 질문" in r}, set(),
"계약을 안 쓴 프로젝트에 칸마다 error 를 내면 안 된다")
def test_a_tree_without_the_source_repository_is_an_error(self):
index = copy.deepcopy(INDEX)
del index["sourceRepository"]
with Fixture(index):
self.assertIn("sourceRepository.path 가 없다", verifier.verify("fixture").errors)
def test_a_repository_without_a_pinned_revision_is_a_warning(self):
index = copy.deepcopy(INDEX)
index["sourceRepository"]["revision"] = None
with Fixture(index):
report = verifier.verify("fixture")
self.assertIn("sourceRepository 에 리비전이 없다", report.warns)
self.assertNotIn("sourceRepository 에 리비전이 없다", report.errors)
def test_branch_tips_count_as_a_pinned_revision(self):
index = copy.deepcopy(INDEX)
index["sourceRepository"]["revision"] = None
index["sourceRepository"]["revisions"] = {"pattern1": "0" * 40, "pattern2": "1" * 40}
with Fixture(index):
report = verifier.verify("fixture")
self.assertNotIn("sourceRepository 에 리비전이 없다", report.warns)
self.assertNotIn("sourceRepository 에 리비전이 없다", report.errors)
def test_duplicate_slug_is_an_error(self):
index = copy.deepcopy(INDEX)
index["topics"]["session-custody"]["kinds"]["concept"][0]["slug"] = \
"session-split-across-nodes"
with Fixture(index):
self.assertIn("slug 가 두 글감에 있다", verifier.verify("fixture").errors)
class BuildTest(unittest.TestCase):
def test_derived_fields_come_from_the_record_file(self):
with Fixture():
index, warnings = builder.build("fixture")
case = index["topics"]["session-custody"]["kinds"]["case"][0]
self.assertEqual(case["file"], "session-custody/case/case-session-split.md")
self.assertEqual(case["publication"], "초안")
self.assertEqual(case["readiness"], "READY", "readiness 는 사람이 적는다")
concept = index["topics"]["session-custody"]["kinds"]["concept"][0]
self.assertEqual(concept["publication"], "미작성")
self.assertNotIn("file", concept)
self.assertEqual(index["counts"]["written"], 1)
self.assertEqual(warnings, [])
def test_human_written_fields_survive_a_rebuild(self):
with Fixture() as fx:
with contextlib.redirect_stdout(io.StringIO()):
builder.main(["build", "fixture"])
builder.main(["build", "fixture"])
case = fx.read()["topics"]["session-custody"]["kinds"]["case"][0]
self.assertEqual(case["classification"], CASE["classification"])
self.assertEqual(case["relations"], CASE["relations"])
def test_directory_left_behind_does_not_become_a_topic(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.studio, "deleted-from-the-contract/case"))
open(os.path.join(fx.studio, "deleted-from-the-contract/case/x.md"), "w",
encoding="utf-8").write("---\nkind: CASE\nslug: revived-by-its-folder\n---\n")
index, warnings = builder.build("fixture")
self.assertEqual(set(index["topics"]), {"session-custody"})
self.assertEqual(index["unlisted"], ["deleted-from-the-contract/case/x.md"])
self.assertTrue(warnings)
class LayoutTest(unittest.TestCase):
def test_a_diagram_with_its_source_is_clean(self):
with Fixture() as fx:
fx.diagram("session-custody-map")
report = layout.verify("fixture")
self.assertEqual(report.errors, {}, report.errors)
self.assertEqual(report.warns, {}, report.warns)
def test_svg_without_a_techviz_source_is_counted(self):
with Fixture() as fx:
fx.diagram("hand-drawn", with_source=False)
self.assertIn("techviz 정본이 없는 그림", layout.verify("fixture").warns)
def test_studio_presentation_copies_need_no_source(self):
with Fixture() as fx:
assets = os.path.join(fx.base, "final/assets/tech-log-studio")
os.makedirs(assets)
open(os.path.join(assets, "custody.svg"), "w", encoding="utf-8").write("<svg/>")
self.assertEqual(layout.verify("fixture").warns, {})
def test_evidence_folder_outside_the_convention_is_an_error(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.base, "final/evidence/screenshots"))
self.assertIn("evidence 하위 폴더 이름이 규약 밖이다",
layout.verify("fixture").errors)
def test_finished_analysis_must_not_leave_working_material(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.base, "analysis"))
open(os.path.join(fx.base, "source-index.md"), "w", encoding="utf-8").write("#\n")
with open(os.path.join(fx.base, "state.json"), "w", encoding="utf-8") as fh:
json.dump({"analysisStatus": "COMPLETE"}, fh)
self.assertIn("분석이 끝났는데 작업 재료가 남아 있다",
layout.verify("fixture").warns)
def test_analysis_in_progress_is_not_debt(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.base, "analysis"))
open(os.path.join(fx.base, "source-index.md"), "w", encoding="utf-8").write("#\n")
with open(os.path.join(fx.base, "state.json"), "w", encoding="utf-8") as fh:
json.dump({"analysisStatus": "IN_PROGRESS"}, fh)
report = layout.verify("fixture")
self.assertEqual(report.errors, {}, report.errors)
self.assertEqual(report.warns, {}, report.warns)
def test_import_source_left_behind_is_counted(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.base, "source/docs"))
open(os.path.join(fx.base, "source/docs/lab.md"), "w",
encoding="utf-8").write("원본\n")
self.assertIn("반입 원본이 남아 있다", layout.verify("fixture").warns)
if __name__ == "__main__":
unittest.main()
+125 -32
View File
@@ -2,6 +2,9 @@
from __future__ import annotations
import argparse
import importlib.util
import json
import sys
from pathlib import Path
REQUIRED_PATHS = (
@@ -9,8 +12,10 @@ REQUIRED_PATHS = (
".agents/skills/analyzing-codebase-for-tech-log/SKILL.md",
".agents/skills/deriving-tech-log-root-tree/SKILL.md",
".agents/skills/deriving-tech-log-root-tree/references/decomposition-checklist.md",
".agents/skills/deriving-tech-log-root-tree/references/candidate-disposition.md",
".agents/skills/deriving-tech-log-root-tree/references/example-tech-log-tree.md",
".agents/skills/writing-tech-log-records/SKILL.md",
".agents/skills/writing-tech-log-records/references/root-tree-contract.md",
".agents/skills/writing-tech-log-records/references/tech-log-tree-contract.md",
".agents/skills/writing-tech-log-records/references/record-kinds.md",
".agents/skills/writing-tech-log-records/references/body-syntax.md",
".agents/skills/writing-tech-log-records/references/code-tables-diagrams.md",
@@ -27,34 +32,49 @@ REQUIRED_PATHS = (
".agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs",
".agents/skills/technical-visualizer/SKILL.md",
".agents/skills/refactoring-from-analysis/SKILL.md",
#
"docs/_templates/state.json",
"docs/_templates/source-index.md",
"docs/_templates/root-tree.md",
"docs/_templates/analysis/00-project-overview.md",
"docs/_templates/analysis/module.md",
# 프로젝트 폴더 틀 — 끝난 프로젝트의 모양. 작업 재료는 여기 없다
"docs/_templates/README.md",
"docs/_templates/final/document.md",
"docs/_templates/final/evidence/meta/evidence.json",
"docs/_templates/tech-log-studio/tech-log-tree.json",
# 분석하는 동안에만 있는 작업 재료의 틀
".agents/skills/analyzing-codebase-for-tech-log/templates/state.json",
".agents/skills/analyzing-codebase-for-tech-log/templates/source-index.md",
".agents/skills/analyzing-codebase-for-tech-log/templates/analysis/00-project-overview.md",
".agents/skills/analyzing-codebase-for-tech-log/templates/analysis/module.md",
".agents/skills/writing-tech-log-records/templates/case.md",
".agents/skills/writing-tech-log-records/templates/concept.md",
".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",
# 도구
"scripts/techviz",
"scripts/build-tech-log-tree.py",
"scripts/techlog.py",
"scripts/verify-tech-log-tree.py",
"scripts/verify-project-layout.py",
"scripts/fold-analysis-into-final.py",
"scripts/fold-studio-contract-into-index.py",
"scripts/terminal-evidence/render_terminal.py",
"scripts/terminal-evidence/README.md",
".agents/skills/writing-tech-log-records/scripts/check_body.mjs",
".agents/skills/writing-tech-log-records/scripts/check_evidence.mjs",
)
ROOT_TREE_TOKENS = (
"PROJECT",
"TOPIC",
"├── CASE",
"├── REFERENCE",
"├── OPEN QUESTION",
"└── DECISION",
"# Node Specifications",
"readiness:",
"source:",
"classification:",
# 글감 계약이 요구하는 칸. 틀이 이것들을 보여 주지 않으면 아무도 채우지 않는다
INDEX_TOKENS = (
"readerQuestion",
"candidateScope",
"sourceRepository",
"readinessValues",
"dispositionValues",
"KEEP_IN_SSOT",
"classification",
"missing-verification",
"basis-version",
"relations",
"candidates",
"dispositionReview",
)
QUEUE_TOKENS = ("version:", "activeProject:", "projects:")
@@ -169,19 +189,20 @@ def verify_pipeline(shared_root: Path) -> list[str]:
if refactor_queue.exists():
errors.extend(_verify_refactor_queue(refactor_queue))
state_template = shared_root / "docs/_templates/state.json"
state_template = shared_root / (
".agents/skills/analyzing-codebase-for-tech-log/templates/state.json")
if state_template.exists():
state_text = state_template.read_text(encoding="utf-8", errors="replace")
for token in STATE_REANALYSIS_TOKENS:
if token not in state_text:
errors.append(f"state template missing reanalysis token: {token}")
root_tree = shared_root / "docs/_templates/root-tree.md"
if root_tree.exists():
text = root_tree.read_text(encoding="utf-8", errors="replace")
for token in ROOT_TREE_TOKENS:
index = shared_root / "docs/_templates/tech-log-studio/tech-log-tree.json"
if index.exists():
text = index.read_text(encoding="utf-8", errors="replace")
for token in INDEX_TOKENS:
if token not in text:
errors.append(f"root-tree template missing token: {token}")
errors.append(f"tech-log-tree template missing token: {token}")
for path in _iter_pipeline_text_files(shared_root):
text = path.read_text(encoding="utf-8", errors="replace")
@@ -200,25 +221,97 @@ def verify_pipeline(shared_root: Path) -> list[str]:
return errors
def _load(shared_root: Path, filename: str, name: str):
path = shared_root / "scripts" / filename
if not path.exists():
return None
sys.path.insert(0, str(shared_root / "scripts"))
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def verify_projects(shared_root: Path) -> list:
"""실제 프로젝트의 분해 계약 정합성. 템플릿에 토큰이 있는지와는 다른 것이다."""
verifier = _load(shared_root, "verify-tech-log-tree.py", "verify_tech_log_tree")
if verifier is None:
return []
projects = sorted(p.parent.name for p in shared_root.glob("docs/*/tech-log-studio")
if not p.parent.name.startswith("_"))
return [verifier.verify(name) for name in projects]
def verify_layouts(shared_root: Path) -> list:
"""프로젝트 폴더가 같은 모양인지. 틀은 docs/_templates 다."""
verifier = _load(shared_root, "verify-project-layout.py", "verify_project_layout")
if verifier is None:
return []
projects = sorted({p.parent.parent.name
for p in shared_root.glob("docs/*/final/document.md")
if not p.parent.parent.name.startswith("_")})
return [verifier.verify(name) for name in projects]
def main() -> int:
parser = argparse.ArgumentParser(description="Verify the Tech Log documentation pipeline workspace.")
parser.add_argument("shared_root", nargs="?", type=Path,
default=Path(__file__).resolve().parent.parent)
parser.add_argument("--skip-projects", action="store_true",
help="틀과 스킬만 본다. 프로젝트 트리 정합성은 보지 않는다")
parser.add_argument("--samples", type=int, default=2)
args = parser.parse_args()
errors = verify_pipeline(args.shared_root)
reports = [] if args.skip_projects else verify_projects(args.shared_root)
layouts = [] if args.skip_projects else verify_layouts(args.shared_root)
project_errors = sum(r.error_count for r in reports) + sum(r.error_count for r in layouts)
if errors:
print("PIPELINE VERIFICATION: FAIL")
print("PIPELINE CONTRACT: FAIL")
for error in errors:
print(f"- {error}")
return 1
else:
print("PIPELINE CONTRACT: PASS")
print(f"- required paths: {len(REQUIRED_PATHS)}")
print("- analysis queue contract: valid")
print("- tech-log-tree contract: present")
print("- forbidden legacy dependency: absent")
print("PIPELINE VERIFICATION: PASS")
print(f"- required paths: {len(REQUIRED_PATHS)}")
print("- analysis queue contract: valid")
print("- root-tree contract: present")
print("- forbidden legacy dependency: absent")
return 0
if layouts:
layout_errors = sum(r.error_count for r in layouts)
print()
print(f"PROJECT LAYOUT: {'FAIL' if layout_errors else 'PASS'}"
f" — 프로젝트 {len(layouts)} · error {layout_errors} ·"
f" warn {sum(r.warn_count for r in layouts)}")
for report in layouts:
verifier_render(report, args.samples)
if reports:
tree_errors = sum(r.error_count for r in reports)
print()
print(f"TECH LOG TREES: {'FAIL' if tree_errors else 'PASS'}"
f" — 프로젝트 {len(reports)} · error {tree_errors} ·"
f" warn {sum(r.warn_count for r in reports)}")
for report in reports:
verifier_render(report, args.samples)
return 1 if errors or project_errors else 0
def verifier_render(report, samples: int) -> None:
facts = " · ".join(
f"{k}={json.dumps(v, ensure_ascii=False) if isinstance(v, dict) else v}"
for k, v in report.facts.items())
print(f" [{report.project}] {facts}")
for label, bucket, mark in (("error", report.errors, ""), ("warn", report.warns, "!")):
for rule, details in sorted(bucket.items(), key=lambda kv: -len(kv[1])):
print(f" {mark} {label} {len(details):>4} {rule}")
for detail in details[:samples]:
if detail:
print(f" · {detail}")
if samples and len(details) > samples:
print(f" … 외 {len(details) - samples}")
if __name__ == "__main__":
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""프로젝트 문서 폴더가 같은 모양인지 본다.
프로젝트 하나가 폴더 하나다. 그 안의 배치는 `docs/_templates/` 가 정본이고
CLAUDE.md 「문서 위치」가 같은 것을 말로 적은 것이다.
docs/<프로젝트>/
├── source/ 밖에서 가져온 원본
├── state.json · source-index.md
├── analysis/ · notes/ · checkpoints/
├── final/ SSOT
│ ├── document.md
│ ├── assets/<이름>/ 그림 하나가 폴더 하나
│ ├── assets/tech-log-studio/ Studio 에 올릴 표현물
│ ├── .techviz/<이름>/ 그림의 정본
│ └── evidence/{raw,meta,rendered,browser}
└── tech-log-studio/
python3 scripts/verify-project-layout.py [프로젝트 ...] [--strict] [--samples N]
**SVG 는 정본이 아니다.** `.techviz/<이름>/` 없이 남은 그림은 다시 만들 수 없다.
이 검사기는 그것을 세지만 실패로 만들지는 않는다 — 언제 다시 만들지는 편집 판단이다.
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from techlog import Report # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
EVIDENCE_DIRS = {"raw", "meta", "rendered", "browser"}
# 분석하는 동안에만 있는 작업 재료. 분석이 끝나면 final/document.md 로 합치고 지운다.
# 끝난 프로젝트의 폴더는 final/ 과 tech-log-studio/ (밖에서 가져왔으면 source/) 뿐이다
WORKING_MATERIAL = ("analysis", "notes", "checkpoints", "state.json", "source-index.md")
# 밖에서 가져올 때만 있는 재료. final/ 이 그 내용을 담으면 원본은 사본이 된다
IMPORT_MATERIAL = "source"
# Studio 에 올릴 표현물이 사는 곳. 여기 SVG 는 그림의 정본을 따로 갖지 않는다
STUDIO_ASSETS = "tech-log-studio"
def _svg_stems(assets: str) -> list[tuple[str, str]]:
"""(stem, 상대경로). assets/tech-log-studio/ 아래는 표현물이라 뺀다."""
out = []
for path in sorted(glob.glob(os.path.join(assets, "**", "*.svg"), recursive=True)):
rel = os.path.relpath(path, assets)
if rel.split(os.sep)[0] == STUDIO_ASSETS:
continue
out.append((os.path.basename(path)[:-4], rel))
return out
def verify(project: str) -> Report:
rep = Report(project)
base = os.path.join(ROOT, "docs", project)
final = os.path.join(base, "final")
studio = os.path.join(base, "tech-log-studio")
# ── SSOT ───────────────────────────────────────────────────────
if not os.path.exists(os.path.join(final, "document.md")):
rep.error("final/document.md 가 없다", project)
return rep
# ── 분석 작업 재료 ─────────────────────────────────────────────
# 분석 중이면 있어야 하고, 끝났으면 final 로 합치고 없어야 한다
left = [n for n in WORKING_MATERIAL if os.path.exists(os.path.join(base, n))]
status = None
state_path = os.path.join(base, "state.json")
if os.path.exists(state_path):
try:
status = json.load(open(state_path, encoding="utf-8")).get("analysisStatus")
except (json.JSONDecodeError, OSError):
rep.error("state.json 을 읽지 못했다", project)
if os.path.isdir(os.path.join(base, "analysis")):
for name in ("state.json", "source-index.md"):
if not os.path.exists(os.path.join(base, name)):
rep.error(f"analysis/ 가 있는데 {name} 이 없다", project)
if left:
rep.facts["analysis"] = status or "진행 중"
if status == "COMPLETE":
rep.warn("분석이 끝났는데 작업 재료가 남아 있다",
f"{' · '.join(left)} — final/document.md 로 합치고 지운다")
imported = os.path.join(base, IMPORT_MATERIAL)
if os.path.isdir(imported):
n = sum(1 for _ in glob.iglob(os.path.join(imported, "**", "*"), recursive=True))
rep.warn("반입 원본이 남아 있다",
f"source/ {n}개 — final/ 이 그 내용을 담고 있으면 사본이다")
# ── 증거 ───────────────────────────────────────────────────────
evidence = os.path.join(final, "evidence")
if os.path.isdir(evidence):
for name in sorted(os.listdir(evidence)):
if os.path.isdir(os.path.join(evidence, name)) and name not in EVIDENCE_DIRS:
rep.error("evidence 하위 폴더 이름이 규약 밖이다",
f"final/evidence/{name} — raw · meta · rendered · browser")
raw = os.path.join(evidence, "raw")
counts = {}
for name in EVIDENCE_DIRS:
d = os.path.join(evidence, name)
counts[name] = len(glob.glob(os.path.join(d, "**", "*"), recursive=True)) \
if os.path.isdir(d) else 0
rep.facts["evidence"] = counts
# 6개월 뒤에 파일 이름만으로는 못 읽는다
for d in sorted(glob.glob(os.path.join(raw, "*"))):
if os.path.isdir(d) and not os.path.exists(os.path.join(d, "README.txt")):
rep.warn("evidence/raw 하위 폴더에 README.txt 가 없다",
os.path.relpath(d, base))
if counts.get("rendered") and not counts.get("meta"):
rep.error("터미널 SVG 는 있는데 meta 가 없다",
"실행한 명령의 원문과 메타데이터가 정본이다")
elif counts.get("raw") and not counts.get("meta"):
rep.warn("raw 는 있는데 meta 가 없다",
f"raw {counts['raw']}건 — command·cwd·executedAt·exitCode·revision 이 없다")
# ── 그림 ───────────────────────────────────────────────────────
assets = os.path.join(final, "assets")
techviz = os.path.join(final, ".techviz")
if os.path.isdir(assets):
# 정본은 그림 이름 폴더다. .techviz 에 놓인 파일은 정본이 아니다
sources = {n for n in os.listdir(techviz)
if os.path.isdir(os.path.join(techviz, n))} \
if os.path.isdir(techviz) else set()
svgs = _svg_stems(assets)
rep.facts["diagrams"] = {"svg": len(svgs), "techviz": len(sources)}
for stem, rel in svgs:
if stem not in sources:
rep.warn("techviz 정본이 없는 그림", f"final/assets/{rel}")
if os.path.dirname(rel) in ("", "diagrams"):
rep.warn("그림이 이름 폴더로 묶여 있지 않다", f"final/assets/{rel}")
stems = {s for s, _ in svgs}
for name in sorted(sources - stems):
rep.warn("정본만 있고 그림이 없다", f"final/.techviz/{name}")
# ── 기록이 가리키는 그림 ───────────────────────────────────────
if os.path.isdir(studio):
wrong = 0
broken = 0
for path in sorted(glob.glob(os.path.join(studio, "*", "*", "*.md"))):
if os.path.basename(os.path.dirname(os.path.dirname(path))).startswith("_"):
continue
text = open(path, encoding="utf-8").read()
for m in re.finditer(r"^ file: (\S+)$", text, re.M):
target = os.path.normpath(os.path.join(os.path.dirname(path), m.group(1)))
if not os.path.exists(target):
broken += 1
if broken <= 5:
rep.error("기록이 가리키는 그림이 없다",
f"{os.path.relpath(path, base)}{m.group(1)}")
continue
if f"assets{os.sep}{STUDIO_ASSETS}{os.sep}" not in target:
wrong += 1
if wrong:
rep.warn("Studio 자산이 assets/tech-log-studio/ 밖에 있다",
f"{wrong}건 — 다른 프로젝트는 전부 그 폴더를 쓴다")
return rep
def render(rep: Report, samples: int) -> None:
facts = " · ".join(f"{k}={json.dumps(v, ensure_ascii=False) if isinstance(v, dict) else v}"
for k, v in rep.facts.items())
print(f" [{rep.project}] {facts or ''}")
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[:samples]:
if d:
print(f" · {d}")
if samples and len(details) > samples:
print(f" … 외 {len(details) - samples}")
def main() -> int:
ap = argparse.ArgumentParser(description="프로젝트 문서 폴더 배치를 본다.")
ap.add_argument("projects", nargs="*")
ap.add_argument("--samples", type=int, default=3)
ap.add_argument("--strict", action="store_true")
args = ap.parse_args()
projects = args.projects or sorted(
name for name in (
os.path.basename(os.path.dirname(os.path.dirname(p)))
for p in glob.glob(os.path.join(ROOT, "docs/*/final/document.md"))
) if not name.startswith("_")
)
reports = [verify(p) for p in projects]
e = sum(r.error_count for r in reports)
w = sum(r.warn_count for r in reports)
print(f"PROJECT LAYOUT: {'FAIL' if e or (args.strict and w) else 'PASS'}"
f" — 프로젝트 {len(reports)} · error {e} · warn {w}")
for r in reports:
render(r, args.samples)
return 1 if e or (args.strict and w) else 0
if __name__ == "__main__":
raise SystemExit(main())
+342
View File
@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""분해 계약이 스스로 맞는지, 그리고 기록·색인이 계약을 따르는지 본다.
`verify-pipeline.py` 는 스킬과 틀이 제자리에 있는지만 본다. 이 검사기는 한 프로젝트의
실제 트리를 본다 — `tech-log-tree.json` 의 주제·글감·후보와 디스크의 기록이 같은 것을
말하는지.
python3 scripts/verify-tech-log-tree.py [프로젝트 ...] [--strict] [--samples N] [--json]
정본 순서는 이렇다.
코드·설정·실행 증거 사실의 근거
final/document.md 글감 범위의 SSOT. candidateScope 가 그 범위를 말한다
analysis/**/*.md 이미 채택한 주장을 상세 확인하는 보조 근거 (분석 중에만 있다)
tech-log-tree.json 사람이 고른 글감. 분해 계약이자 색인이고 정본이다
error 가 하나라도 있으면 실패다. warn 은 편집 판단이 필요한 자리이고 `--strict` 에서만
실패가 된다. 선별을 마치지 않은 상태 — `dispositionReview: PENDING`, PROMOTE 후보와
글감이 1:1 이 아닌 것 — 는 warn 이 아니라 error 다. 경고로 두면 재판정하지 않은 트리로
글을 쓰기 시작할 수 있다.
**계약을 아직 채택하지 않은 프로젝트도 error 다.** 칸마다 error 를 내지는 않는다 —
「아직 쓰지 않았다」가 「잘못 썼다」로 보이기 때문이다. 대신 계약 미채택 자체를 한 건의
error 로 센다. warn 으로 두면 옛 스키마로 남아 있는 한 검사를 영원히 피한다.
"""
from __future__ import annotations
import argparse
import collections
import glob
import json
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import techlog # noqa: E402
from techlog import KINDS, DISPOSITIONS, READINESS, Report # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 종류마다 글감이 반드시 갖는 칸
REQUIRED_FIELDS = {
"case": ("slug", "readiness", "source", "classification", "missing-verification", "relations"),
"concept": ("slug", "readiness", "source", "basis-version", "classification", "relations"),
"reference": ("slug", "readiness", "source", "classification", "scope", "exceptions", "relations"),
"question": ("slug", "readiness", "source", "known", "unknown",
"next-verification", "decision-criterion", "relations"),
"decision": ("slug", "readiness", "source", "decision-status", "decision-evidence",
"grounds", "classification", "relations"),
}
# 글을 써도 되는 readiness. 나머지는 글감으로만 남는다
GENERATABLE = {"case": {"READY"}, "concept": {"READY"}, "reference": {"READY"},
"question": {"OPEN"}, "decision": {"READY"}}
DECISION_STATUS = {"PROPOSED", "ADOPTED", "SUPERSEDED", "NOT_DECIDED"}
# 분석 문서의 절 제목을 그대로 옮겨 온 자리
COPIED_HEADING = (
(re.compile(r"^\(\d+(?:\.\d+)*\)"), "분석 문서의 절 번호가 제목에 남아 있다"),
(re.compile(r"^(Confirmed|P[1-3])\s*[—–-]"), "분석 문서의 finding 등급이 제목에 남아 있다"),
)
# Concept 은 「남의 것이 어떻게 동작하는가」다
CONCEPT_NOT_A_MECHANISM = (
(r"없다|없음|부재|미배선|배선되지|호출자|호출되지 않|실행되지 않|도달하지 않", "부재·미배선 사실"),
(r"refs?\s*=\s*0|카운트|개수|몇 개|전부 읽|샘플링|denominator", "분석 범위·계수"),
(r"보류|남은 것|다음 사이클|이번 pass|커버리지|coverage|레인과 복원|기록이다", "분석 진행 기록"),
(r"드리프트|불일치|어긋|틀렸|실패했|누락|검증되지", "Finding 문장"),
)
def _front_matter(path: str) -> dict:
text = open(path, encoding="utf-8").read()
if not text.startswith("---"):
return {}
end = text.find("\n---", 3)
out = {}
for line in text[3:end].splitlines():
m = re.match(r"^([a-zA-Z_]+):\s*(.*)$", line)
if m:
out[m.group(1)] = m.group(2).strip().strip('"')
return out
def _records_on_disk(studio: str) -> dict[tuple[str, str], str]:
found = {}
for path in sorted(glob.glob(os.path.join(studio, "*", "*", "*.md"))):
parts = path.split(os.sep)
topic_dir, kind_dir = parts[-3], parts[-2]
if topic_dir.startswith("_") or kind_dir not in KINDS:
continue
fm = _front_matter(path)
found[(kind_dir, fm.get("slug") or os.path.basename(path)[:-3])] = path
return found
def _values(node: dict, key: str) -> list[str]:
value = node.get(key)
if value is None:
return []
if isinstance(value, list):
return [str(v) for v in value if str(v).strip()]
return [str(value)] if str(value).strip() else []
def verify(project: str) -> Report:
rep = Report(project)
base = os.path.join(ROOT, "docs", project)
studio = os.path.join(base, "tech-log-studio")
records = _records_on_disk(studio)
rep.facts["records"] = len(records)
index_path = os.path.join(studio, "tech-log-tree.json")
index = techlog.load_index(index_path)
if index is None:
rep.warn("분해 계약 없음",
f"{project}: tech-log-tree.json 이 없다. 디렉터리가 정본 노릇을 하고 있다")
return rep
# ── 원본 무결성 ────────────────────────────────────────────────
ssot_rel = index.get("ssot") or "final/document.md"
ssot_path = os.path.join(base, ssot_rel)
if not os.path.exists(ssot_path):
rep.error("SSOT 파일 없음", ssot_rel)
else:
declared = index.get("ssotSha256") or ""
actual = techlog.sha256_of(ssot_path)
if not declared:
rep.error("ssotSha256 없음", ssot_rel)
elif declared != actual:
rep.error("SSOT 가 바뀐 뒤 글감을 다시 보지 않았다",
"python3 scripts/build-tech-log-tree.py 를 다시 돌린다")
if not index.get("sourceRevision"):
rep.warn("sourceRevision 없음", project)
# 옛 색인은 디렉터리를 훑어 만든 것이라 계약 칸이 아예 없다. 칸마다 error 를 내면
# 「아직 쓰지 않았다」가 「잘못 썼다」로 보인다
has_contract = index.get("schemaVersion", 1) >= 4 or "contract" in index
rep.facts["contract"] = "있음" if has_contract else "없음"
if not has_contract:
rep.error("글감 계약을 아직 쓰지 않았다",
f"{project}: 옛 색인이다. 주제·독자 질문·글감의 칸을 사람이 적어야 한다 "
"— 칸마다 error 를 내지 않는 대신 미채택 자체를 여기서 한 번 센다")
# ── 분석한 저장소 ──────────────────────────────────────────────
repo = index.get("sourceRepository") or {}
if has_contract:
if not repo.get("path"):
rep.error("sourceRepository.path 가 없다",
f"{project}: 어느 저장소를 읽고 쓴 글인지 적혀 있지 않다")
elif not os.path.isdir(repo["path"]) and "://" not in repo["path"]:
rep.warn("sourceRepository.path 가 이 기계에 없다", repo["path"])
# 갈래가 여럿이면 단일 커밋으로 표현되지 않는다. revisions 로 적는다
if not repo.get("revision") and not repo.get("revisions"):
rep.warn("sourceRepository 에 리비전이 없다",
f"{project}: 문서가 서술한 상태의 커밋을 고정하지 않았다")
elif not repo.get("verified"):
rep.warn("sourceRepository.verified 가 없다",
f"{project}: 그 리비전이 맞다고 판단한 근거가 없다")
# ── 후보를 찾는 범위 ───────────────────────────────────────────
scope = index.get("candidateScope") or {}
excluded_anchor = None
if has_contract:
if not scope:
rep.error("candidateScope 가 없다",
f"{project}: SSOT 의 어느 부분에서 후보를 찾는지 적지 않았다")
else:
if scope.get("document") and scope["document"] != ssot_rel:
rep.error("candidateScope.document 가 ssot 과 다르다",
f"{scope['document']}{ssot_rel}")
if not scope.get("sections"):
rep.error("candidateScope 에 sections 가 없다", project)
pattern = scope.get("excludedAnchorPattern")
if pattern:
try:
excluded_anchor = re.compile(pattern)
except re.error as exc:
rep.error("candidateScope.excludedAnchorPattern 이 정규식이 아니다",
f"{pattern}{exc}")
# ── 주제 ───────────────────────────────────────────────────────
topics = index.get("topics") or {}
rep.facts["topics"] = len(topics)
for slug, topic in topics.items():
if topic.get("topic") and topic["topic"] != slug:
rep.error("주제 키와 topic 이 다르다", f"{slug}{topic['topic']}")
if not (topic.get("readerQuestion") or "").strip():
if has_contract:
rep.error("Topic 에 독자 질문이 없다", slug)
elif not topic["readerQuestion"].rstrip().endswith("?"):
rep.warn("독자 질문이 물음이 아니다", f"{slug}: {topic['readerQuestion'][:60]}")
n = sum(len(v) for v in (topic.get("kinds") or {}).values())
if n == 1:
rep.warn("Topic 에 노드가 하나뿐이다", slug)
if n == 0:
rep.error("Topic 에 글감이 없다", slug)
# ── 글감 ───────────────────────────────────────────────────────
slugs: dict[str, str] = {}
listed: set[tuple[str, str]] = set()
total = 0
for topic_slug, kind, node in techlog.nodes(index):
total += 1
where = f"{topic_slug} · {kind.upper()} · {str(node.get('title',''))[:44]}"
if kind not in REQUIRED_FIELDS:
rep.error("종류 이름이 계약에 없다", f"{where}{kind}")
continue
if has_contract:
for key in REQUIRED_FIELDS[kind]:
if not _values(node, key):
rep.error(f"{kind.upper()} 노드에 `{key}` 가 없다", where)
slug = str(node.get("slug") or "")
if slug:
if slug in slugs:
rep.error("slug 가 두 글감에 있다", f"{slug}{slugs[slug]} / {where}")
slugs[slug] = where
listed.add((kind, slug))
readiness = str(node.get("readiness") or "").upper()
if readiness and readiness not in READINESS:
rep.error("readiness 값이 계약에 없다", f"{where}{readiness}")
if kind == "question" and readiness and readiness != "OPEN":
rep.error("OPEN QUESTION 의 readiness 는 OPEN 이다", f"{where}{readiness}")
if kind == "decision":
status = str(node.get("decision-status") or "").strip("`").upper()
if status and status not in DECISION_STATUS:
rep.error("decision-status 값이 계약에 없다", f"{where}{status}")
if has_contract and not _values(node, "relations"):
rep.warn("관계가 없는 노드", where)
anchors = " ".join(_values(node, "source"))
if anchors and ssot_rel not in anchors:
rep.warn("근거가 SSOT 밖에만 있다", f"{where}{anchors[:60]}")
if excluded_anchor:
outside = [a for a in _values(node, "source") if excluded_anchor.search(a)]
if outside and len(outside) == len(_values(node, "source")):
rep.error("후보를 찾는 범위 밖에서만 나온 글감",
f"{where}{outside[0][:60]}")
title = str(node.get("title") or "")
for pattern, why in COPIED_HEADING:
if pattern.match(title):
rep.warn(why, where)
break
if kind == "concept":
for pattern, why in CONCEPT_NOT_A_MECHANISM:
if re.search(pattern, title):
rep.warn(f"Concept 제목이 메커니즘이 아니다 — {why}", where)
break
rep.facts["nodes"] = total
# ── readiness ↔ 실제로 쓴 글 ───────────────────────────────────
written = 0
for (kind, slug), path in sorted(records.items()):
rel = os.path.relpath(path, ROOT)
if (kind, slug) not in listed:
rep.error("계약에 없는 기록", rel)
continue
written += 1
node = next((n for _, k, n in techlog.nodes(index)
if k == kind and n.get("slug") == slug), None)
readiness = str((node or {}).get("readiness") or "").upper()
if readiness and readiness not in GENERATABLE[kind]:
rep.error("글을 쓰면 안 되는 readiness 인데 기록이 있다",
f"{rel} — readiness={readiness}")
rep.facts["written"] = written
rep.facts["unwritten"] = total - written
# ── 후보와 처분 ────────────────────────────────────────────────
candidates = index.get("candidates") or []
if candidates:
counts = collections.Counter()
promoted: set[str] = set()
for c in candidates:
d = c.get("disposition")
counts[d] += 1
if d not in DISPOSITIONS:
rep.error("disposition 값이 계약에 없다", f"{c.get('id')}{d}")
if c.get("dispositionReview") != "CONFIRMED":
rep.error("disposition 을 다시 판정하지 않은 후보",
f"{c.get('id')} — 선별이 아니라 recall 로 방출됐다")
if d == "PROMOTE":
target = c.get("target") or ""
slug = target.split(":", 1)[1] if ":" in target else target
if not slug:
rep.error("PROMOTE 후보에 target 이 없다", str(c.get("id")))
continue
promoted.add(slug)
if slug not in slugs:
rep.error("PROMOTE 후보가 글감에 없다", f"{c.get('id')}{target}")
rep.facts["candidates"] = dict(counts)
# 반대 방향 — 후보 대장을 거치지 않고 트리에 올라온 글감
for slug, where in sorted(slugs.items()):
if slug not in promoted:
rep.error("글감을 낳은 PROMOTE 후보가 없다", f"{slug}{where}")
return rep
def render(rep: Report, samples: int) -> None:
facts = " · ".join(
f"{k}={json.dumps(v, ensure_ascii=False) if isinstance(v, dict) else v}"
for k, v in rep.facts.items())
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[:samples]:
if d:
print(f" · {d}")
if samples and len(details) > samples:
print(f" … 외 {len(details) - samples}")
def main() -> int:
ap = argparse.ArgumentParser(description="한 프로젝트의 글감 계약 정합성을 본다.")
ap.add_argument("projects", nargs="*")
ap.add_argument("--samples", type=int, default=3)
ap.add_argument("--strict", action="store_true", help="warn 도 실패로 센다")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
projects = args.projects or sorted(
name for name in (
os.path.basename(os.path.dirname(p))
for p in glob.glob(os.path.join(ROOT, "docs/*/tech-log-studio"))
) if not name.startswith("_")
)
reports = [verify(p) for p in projects]
if args.json:
print(json.dumps([{"project": r.project, "facts": r.facts,
"errors": dict(r.errors), "warns": dict(r.warns)}
for r in reports], ensure_ascii=False, indent=2))
return 1 if sum(r.error_count for r in reports) else 0
for r in reports:
render(r, args.samples)
e = sum(r.error_count for r in reports)
w = sum(r.warn_count for r in reports)
print(f"\nTECH LOG TREE: {'FAIL' if e or (args.strict and w) else 'PASS'}"
f" — 프로젝트 {len(reports)} · error {e} · warn {w}")
return 1 if e or (args.strict and w) else 0
if __name__ == "__main__":
raise SystemExit(main())