리뷰 두 건을 반영했다. 계약 - 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>
274 lines
12 KiB
Python
Executable File
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())
|