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
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())
|
||||
Reference in New Issue
Block a user