refactor: 문서 개선 중
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user