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