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:
co-authored by
Claude Fable 5.1
parent
73026cada6
commit
9d2a3725c5
+101
-72
@@ -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__":
|
||||
|
||||
Executable
+273
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
Executable
+202
@@ -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())
|
||||
Executable
+342
@@ -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())
|
||||
Reference in New Issue
Block a user