Files
document-haness/scripts/fold-analysis-into-final.py
DongHyeonkaandClaude Fable 5.1 9d2a3725c5 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>
2026-09-07 12:39:20 +09:00

274 lines
12 KiB
Python
Executable File

#!/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())