이번 파이프라인 작업과 무관하게 작업 트리에 남아 있던 것을 그대로 올린다. 사용자가 「전부 커밋」으로 정했고, 이번 작업과 섞이지 않게 커밋만 나눴다. 대부분은 clean-architecture-backend-template 의 그림 정본 재배치다 — final/assets/diagrams/<이름>/ 에 있던 것이 CLAUDE.md 가 적은 배치인 final/assets/<이름>/ 로 옮겨졌고 .techviz/<이름>/ 이 함께 들어왔다. 삽입 줄의 대부분(3.15M)이 그 .techviz context.json 이다. 그 밖에 ca-tmpl·document-haness 의 정리, .claude/agents/ 열한 개, writing-practitioner-guides 스킬, .playwright-mcp 세션 산출물, scripts/check-ssot-facts.py 와 그 시험이 들어 있다. 이 커밋의 내용은 내가 만든 것이 아니라 이전 세션이 남긴 것이고 검증하지 않았다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
164 lines
7.0 KiB
Python
164 lines
7.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Tech Log 파이프라인이 함께 쓰는 어휘와 보고 형식.
|
|
|
|
`tech-log-tree.json` 이 프로젝트의 분해 계약이자 색인이고 정본이다. 만드는 쪽
|
|
(`build-tech-log-tree.py`)과 검사하는 쪽(`verify-tech-log-tree.py`)이 같은 값을 쓰도록
|
|
여기 모은다.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import collections
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
|
|
|
|
|
|
# ── 검사할 것이 없을 때 관문이 무엇을 내야 하는가 ────────────────────────────
|
|
# CLAUDE.md 「검사」 절의 표. 「볼 것이 없어서 통과」를 「문제 없음」이라고 쓰지 않는다.
|
|
#
|
|
# 봤고 괜찮다 exit 0 문제 없음 · error 0
|
|
# 대상이 성립하지 않는다 exit 2 대상이 성립하지 않는다 — <이유>
|
|
# 볼 것이 아직 없다 exit 0 기록 0건 — 계약의 글감 N개가 아직 안 쓰였다
|
|
#
|
|
# 가운데는 error 로 센다. 아래는 error 가 아니다 — 아직 안 쓴 것은 결함이 아니다.
|
|
# 다만 초록으로 보이면 안 된다.
|
|
NO_TARGET_EXIT = 2
|
|
|
|
|
|
def project_root(project: str, root: str) -> str:
|
|
return os.path.join(root, "docs", project)
|
|
|
|
|
|
def missing_target(project: str, why: str) -> int:
|
|
"""대상이 성립하지 않는다. 규범 문구를 찍고 2 를 돌려준다."""
|
|
import sys as _sys
|
|
print(f"대상이 성립하지 않는다 — {project}: {why}", file=_sys.stderr)
|
|
return NO_TARGET_EXIT
|
|
|
|
|
|
def check_files(paths) -> int | None:
|
|
"""`--file` 로 직접 준 경로가 성립하는지 본다. 하나라도 없으면 2, 전부 있으면 None.
|
|
|
|
프로젝트 이름으로 부르는 쪽만 고치면 `--file` 로 오타를 내는 순간 다시 조용히
|
|
0건이 된다. 같은 규칙을 여기 함께 둔다.
|
|
"""
|
|
for path in paths:
|
|
if not os.path.isfile(path):
|
|
return missing_target(path, "그런 파일이 없다")
|
|
return None
|
|
|
|
|
|
def check_targets(projects, root: str, needs: str = "") -> int | None:
|
|
"""지정한 프로젝트들이 성립하는지 본다. 하나라도 아니면 2, 전부 성립하면 None.
|
|
|
|
`needs` 를 주면 그 하위 경로까지 있어야 성립으로 본다
|
|
(예: `tech-log-studio` — 계약이 없는 프로젝트를 걸러 낸다).
|
|
"""
|
|
for project in projects:
|
|
base = project_root(project, root)
|
|
if not os.path.isdir(base):
|
|
return missing_target(project, "docs 아래 그런 프로젝트가 없다")
|
|
if needs and not os.path.exists(os.path.join(base, needs)):
|
|
return missing_target(project, f"{needs} 가 없다")
|
|
return None
|
|
|
|
|
|
# ── 종류 ─────────────────────────────────────────────────────────────────────
|
|
# **한 곳에서 정하고 나머지가 여기를 쓴다.** 손으로 나열한 목록에 새 종류를 빠뜨리는 일이
|
|
# 반복됐다 — 프론트엔드가 같은 실패를 먼저 적어 두었다(`application/ports/studio-gateway.ts:8-12`):
|
|
# 「여기 손으로 적어 두었던 동안 개념과 환경 구성이 빠져 있었고, 작업본 목록의 종류 필터는
|
|
# 그 둘을 아예 고를 수 없었다」. 계약의 `RecordKind` 는 여섯이다
|
|
# (`studio-api.openapi.yaml:838-840` · tech-log-frontend @ 9e5642c).
|
|
KINDS = ["case", "concept", "reference", "question", "decision", "setup"]
|
|
|
|
# 폴더 이름 → frontmatter 의 `kind`. 하나만 다르다 — `decision/` 의 kind 는 PROJECT_DECISION 이다
|
|
KIND_OF_DIR = {"case": "CASE", "concept": "CONCEPT", "reference": "REFERENCE",
|
|
"question": "QUESTION", "decision": "PROJECT_DECISION", "setup": "SETUP"}
|
|
DIR_OF_KIND = {v: k for k, v in KIND_OF_DIR.items()}
|
|
|
|
# 본문(`bodyMarkdown`)을 갖는 종류. **셋이다** — 환경 구성의 본문도 Case 와 같은 파서를 탄다
|
|
# (`SetupInput.required` 에 `bodyMarkdown` 이 있다 · `setup-document-page.tsx:11`).
|
|
# 나머지 셋(Reference·Question·Decision)의 칸은 평문으로 렌더링된다
|
|
BODY_KINDS = {"case", "concept", "setup"}
|
|
BODY_KIND_CODES = {KIND_OF_DIR[k] for k in BODY_KINDS}
|
|
|
|
READINESS = ["READY", "OPEN", "NEEDS_EVIDENCE", "NEEDS_DECISION", "BLOCKED"]
|
|
DISPOSITIONS = ["PROMOTE", "MERGE_INTO", "KEEP_IN_SSOT",
|
|
"NEEDS_EVIDENCE", "NEEDS_DECISION", "BLOCKED"]
|
|
|
|
def front_matter_block(text: str, key: str) -> str:
|
|
"""frontmatter 의 `key:` 아래 들여쓴 블록. 없으면 빈 문자열.
|
|
|
|
이 저장소의 frontmatter 파서들은 `^키: 값$` 한 줄만 읽는다. 그래서 값이 아래 줄에 있는
|
|
칸(`source:` · `assets:` · `pinnedVersions:`)은 **빈 값으로 읽힌다.** 스칼라 칸만
|
|
요구하는 동안에는 드러나지 않았는데, 환경 구성의 `pinnedVersions` 는 목록이면서
|
|
그 기록의 유효 범위라 「비었다」와 「채웠다」를 갈라야 한다 — 그 칸만 따로 읽는다.
|
|
"""
|
|
if not text.startswith("---"):
|
|
return ""
|
|
end = text.find("\n---", 3)
|
|
if end < 0:
|
|
return ""
|
|
lines = text[3:end].splitlines()
|
|
for i, line in enumerate(lines):
|
|
if not re.match(rf"^{re.escape(key)}:\s*$", line):
|
|
continue
|
|
block = []
|
|
for nxt in lines[i + 1:]:
|
|
if nxt.strip() and not nxt[:1].isspace():
|
|
break # 들여쓰기가 끝났다 — 다음 칸이다
|
|
block.append(nxt)
|
|
return "\n".join(block).strip("\n")
|
|
return ""
|
|
|
|
|
|
def sha256_of(path: str) -> str | None:
|
|
if not os.path.exists(path):
|
|
return None
|
|
return hashlib.sha256(open(path, "rb").read()).hexdigest()
|
|
|
|
|
|
def load_index(path: str) -> dict | None:
|
|
"""`tech-log-tree.json` 을 읽는다. 없으면 None."""
|
|
if not os.path.exists(path):
|
|
return None
|
|
return json.load(open(path, encoding="utf-8"))
|
|
|
|
|
|
def nodes(index: dict):
|
|
"""(주제 slug, 종류, 노드) 를 차례로 낸다."""
|
|
for slug, topic in (index.get("topics") or {}).items():
|
|
for kind, items in (topic.get("kinds") or {}).items():
|
|
for node in items:
|
|
yield slug, kind, node
|
|
|
|
|
|
class Report:
|
|
"""검사기가 규칙별로 모아 내는 결과.
|
|
|
|
한 규칙에 수백 건이 걸리는 것이 정상이라 개별 줄이 아니라 규칙으로 센다.
|
|
error 는 계약 위반이고 warn 은 편집 판단이 필요한 자리다.
|
|
"""
|
|
|
|
def __init__(self, project: str) -> None:
|
|
self.project = project
|
|
self.errors: dict[str, list[str]] = collections.defaultdict(list)
|
|
self.warns: dict[str, list[str]] = collections.defaultdict(list)
|
|
self.facts: dict[str, object] = {}
|
|
|
|
def error(self, rule: str, detail: str = "") -> None:
|
|
self.errors[rule].append(detail)
|
|
|
|
def warn(self, rule: str, detail: str = "") -> None:
|
|
self.warns[rule].append(detail)
|
|
|
|
@property
|
|
def error_count(self) -> int:
|
|
return sum(len(v) for v in self.errors.values())
|
|
|
|
@property
|
|
def warn_count(self) -> int:
|
|
return sum(len(v) for v in self.warns.values())
|