#!/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 # ── 검사할 것이 없을 때 관문이 무엇을 내야 하는가 ──────────────────────────── # 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 KINDS = ["case", "concept", "reference", "question", "decision"] READINESS = ["READY", "OPEN", "NEEDS_EVIDENCE", "NEEDS_DECISION", "BLOCKED"] DISPOSITIONS = ["PROMOTE", "MERGE_INTO", "KEEP_IN_SSOT", "NEEDS_EVIDENCE", "NEEDS_DECISION", "BLOCKED"] 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())