refactor: 문서 개선 중

This commit is contained in:
donghyeon-ka
2026-09-21 14:30:55 +09:00
parent c93cdea150
commit 805a18f486
1497 changed files with 525837 additions and 59152 deletions
+298 -4
View File
@@ -89,6 +89,273 @@ def _context_sha(path: str) -> str | None:
return hashlib.sha256(canonicalize_document(raw).encode("utf-8")).hexdigest()
TECHVIZ_MANAGED_BLOCK_RE = re.compile(
r"<!-- techviz:begin id=(?P<id>[^\s]+)[^>]*-->.*?"
r"<!-- techviz:end id=(?P=id) -->",
re.DOTALL,
)
def _collapse_techviz_blocks(text: str) -> str:
"""외부 techviz 도구 없이도 context snapshot을 비교할 수 있게 관리 블록을 접는다."""
def replace_block(match):
block = match.group(0)
generated = re.search(r"<!-- techviz:generate id=[^>]+ -->", block)
if generated:
return generated.group(0)
return f"<!-- techviz:generate id={match.group('id')} -->"
return TECHVIZ_MANAGED_BLOCK_RE.sub(replace_block, text)
def _parse_canonical_headings(lines: list[str]) -> list[dict]:
"""TechViz parse_headings와 같은 규칙으로 fence 밖 heading만 읽는다."""
headings: list[dict] = []
in_fence = False
fence_token = ""
for index, line in enumerate(lines, start=1):
stripped = line.lstrip()
if stripped.startswith("```") or stripped.startswith("~~~"):
token = stripped[:3]
if not in_fence:
in_fence = True
fence_token = token
elif token == fence_token:
in_fence = False
fence_token = ""
continue
if in_fence:
continue
match = re.match(r"^(#{1,6})\s+(.+?)\s*$", line)
if match:
headings.append({
"line": index,
"level": len(match.group(1)),
"text": match.group(2).strip(),
})
return headings
def _find_heading_line(headings: list[dict], heading_text: str) -> int | None:
"""TechViz처럼 유일한 exact/casefold heading만 anchor로 인정한다."""
exact = [item for item in headings if item["text"] == heading_text]
if len(exact) == 1:
return int(exact[0]["line"])
if len(exact) > 1:
return None
folded = [
item for item in headings
if item["text"].casefold() == heading_text.casefold()
]
return int(folded[0]["line"]) if len(folded) == 1 else None
def _section_for_line(lines: list[str], headings: list[dict],
line_number: int) -> dict | None:
if line_number < 1 or line_number > max(1, len(lines)):
return None
current = None
for heading in headings:
if heading["line"] <= line_number:
current = heading
else:
break
start_line = int(current["line"]) if current else 1
end_line = len(lines)
if current:
for heading in headings:
if heading["line"] > current["line"] and heading["level"] <= current["level"]:
end_line = int(heading["line"]) - 1
break
elif headings:
end_line = int(headings[0]["line"]) - 1
return {
"heading": current,
"start_line": start_line,
"end_line": end_line,
"text": "\n".join(lines[start_line - 1:end_line]),
}
def _sibling_sections(lines: list[str], headings: list[dict],
current: dict) -> tuple[dict | None, dict | None]:
"""TechViz sibling_sections의 parent-preamble 규칙까지 그대로 재현한다."""
current_heading = current.get("heading")
if current_heading is None:
following = (
_section_for_line(lines, headings, int(headings[0]["line"]))
if headings else None
)
return None, following
same_or_higher = [
item for item in headings
if item["level"] <= current_heading["level"]
]
current_index = next(
(
index for index, item in enumerate(same_or_higher)
if item["line"] == current_heading["line"]
),
None,
)
if current_index is None:
return None, None
previous = None
following = None
if current_index > 0:
previous_heading = same_or_higher[current_index - 1]
previous = _section_for_line(lines, headings, int(previous_heading["line"]))
if previous_heading["level"] < current_heading["level"]:
previous = {
"heading": previous_heading,
"start_line": int(previous_heading["line"]),
"end_line": int(current_heading["line"]) - 1,
"text": "\n".join(
lines[
int(previous_heading["line"]) - 1:
int(current_heading["line"]) - 1
]
),
}
if current_index + 1 < len(same_or_higher):
following = _section_for_line(
lines,
headings,
int(same_or_higher[current_index + 1]["line"]),
)
return previous, following
def _context_anchor_line(lines: list[str], headings: list[dict],
context: dict) -> int | None:
anchor = context.get("anchor") or {}
kind = anchor.get("kind")
value = anchor.get("value")
if kind is None:
# 초기 context snapshot에는 anchor가 없었다. 그 형식도 current heading이
# 유일하면 같은 구조로 재구성할 수 있어야 historical drift를 계속 잡는다.
saved_current = context.get("current_section") or {}
saved_heading = (
saved_current.get("heading")
if isinstance(saved_current, dict) else None
)
title = (
saved_heading.get("text")
if isinstance(saved_heading, dict) else None
)
if isinstance(title, str):
return _find_heading_line(headings, title)
return None
if kind == "heading" and isinstance(value, str):
return _find_heading_line(headings, value)
if kind == "marker" and isinstance(value, str):
marker = re.compile(
rf"<!--\s*techviz:generate\s+id={re.escape(value)}(?:\s+[^>]*)?-->"
)
for index, line in enumerate(lines, start=1):
if marker.search(line):
return index
return None
if kind == "line":
saved_current = context.get("current_section") or {}
saved_heading = (
saved_current.get("heading")
if isinstance(saved_current, dict) else None
)
title = (
saved_heading.get("text")
if isinstance(saved_heading, dict) else None
)
if isinstance(title, str):
found = _find_heading_line(headings, title)
if found is not None:
return found
try:
line_number = int(value)
except (TypeError, ValueError):
return None
return (
line_number
if 1 <= line_number <= max(1, len(lines))
else None
)
return None
def _same_section_snapshot(saved: object, current: object) -> bool:
if saved is None or current is None:
return saved is None and current is None
if not isinstance(saved, dict) or not isinstance(current, dict):
return False
if not isinstance(saved.get("text"), str) or not isinstance(current.get("text"), str):
return False
saved_heading = saved.get("heading")
current_heading = current.get("heading")
if saved_heading is None or current_heading is None:
if saved_heading is not None or current_heading is not None:
return False
elif not isinstance(saved_heading, dict) or not isinstance(current_heading, dict):
return False
else:
if ("level" in saved_heading and
saved_heading.get("level") != current_heading.get("level")):
return False
if ("text" in saved_heading and
saved_heading.get("text") != current_heading.get("text")):
return False
return saved["text"].rstrip() == current["text"].rstrip()
def _context_snapshot_matches(document_path: str, context_path: str) -> bool | None:
"""외부 TechViz 없이도 build_context의 neighboring-section 의미로 snapshot을 대조한다."""
try:
with open(document_path, encoding="utf-8") as handle:
document = handle.read()
with open(context_path, encoding="utf-8") as handle:
context = json.load(handle)
except (OSError, ValueError):
return None
lines = _collapse_techviz_blocks(document).splitlines()
headings = _parse_canonical_headings(lines)
anchor_line = _context_anchor_line(lines, headings, context)
if anchor_line is None:
return None
current = _section_for_line(lines, headings, anchor_line)
if current is None:
return None
previous, following = _sibling_sections(lines, headings, current)
rebuilt = {
"previous_section": previous,
"current_section": current,
"next_section": following,
}
compared = 0
for key in ("previous_section", "current_section", "next_section"):
if key not in context:
continue
compared += 1
if not _same_section_snapshot(context.get(key), rebuilt.get(key)):
return False
return True if compared else None
def verify(project: str) -> Report:
rep = Report(project)
base = os.path.join(ROOT, "docs", project)
@@ -122,8 +389,25 @@ def verify(project: str) -> Report:
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/ 이 그 내용을 담고 있으면 사본이다")
durable_snapshot = False
index_path = os.path.join(studio, "tech-log-tree.json")
try:
with open(index_path, encoding="utf-8") as handle:
index = json.load(handle)
policy = index.get("sourcePolicy") or {}
durable_snapshot = (
isinstance(policy, dict)
and policy.get("mode") == "DURABLE_IMPORT_SNAPSHOT"
)
except (OSError, ValueError):
durable_snapshot = False
if durable_snapshot:
rep.facts["source"] = (
f"durable import snapshot · source/ {n}개 — exact commit 없는 반입 바이트 보존"
)
else:
rep.warn("반입 원본이 남아 있다",
f"source/ {n}개 — final/ 이 그 내용을 담고 있으면 사본이다")
# ── 증거 ───────────────────────────────────────────────────────
evidence = os.path.join(final, "evidence")
@@ -199,8 +483,18 @@ def verify(project: str) -> Report:
rep.warn("SSOT 가 바뀐 뒤 그림을 다시 보지 않았다",
f"final/.techviz/{name}")
elif ssot_sha is None and ctx.get("document_sha256"):
rep.warn("그림이 어느 SSOT 를 보고 만들어졌는지 대조하지 못했다",
f"final/.techviz/{name} — techviz 도구가 없다")
context_path = os.path.join(techviz, name, "context.json")
snapshot_match = _context_snapshot_matches(
os.path.join(final, "document.md"), context_path)
if snapshot_match is False:
rep.warn("SSOT 문맥이 바뀐 뒤 그림을 다시 보지 않았다",
f"final/.techviz/{name} — techviz 도구 없이 context snapshot으로 확인")
elif snapshot_match is True:
rep.warn("그림 전체 SSOT hash를 대조하지 못했다",
f"final/.techviz/{name} — techviz 도구가 없다; context snapshot은 일치")
else:
rep.warn("그림이 어느 SSOT 를 보고 만들어졌는지 대조하지 못했다",
f"final/.techviz/{name} — techviz 도구가 없고 context snapshot도 없다")
nodes = spec.get("nodes") or []
if spec.get("edges") or len(nodes) < 2: