#!/usr/bin/env python3 """design-direction 아티팩트 린터(정본). 파일/hash 무결성뿐 아니라 발산 전 조형영역 분할과 선택 전 비교 감사를 강제한다. 시각적 취향을 숫자로 위장하지는 않되, 같은 카드 셸의 색상 변주처럼 계약으로 판별 가능한 수렴은 decision 단계에 들어가기 전에 fail closed 한다. """ import os, sys, yaml, hashlib, itertools, re ROOT = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd()) _IB_REQUIRED = ["product-goal", "core-users", "core-tasks", "information-density", "required-accessibility", "brand-constraints", "avoid-cliches", "representative-screen-requirement", "tech-platform-constraints"] _IB_PROHIBITED = ["reference-cluster", "color-palette", "typography", "layout-grammar", "tokens", "visual-metaphor"] _DIR_REQUIRED = ["id", "producer-role-id", "producer-run-id", "context-package-id", "concept-artifact", "reference-cluster", "visual-thesis", "layout-grammar", "interaction-grammar", "typography-token-direction", "primitive-inventory", "reference-board-ref", "reference-board-sha256", "full-size-preview-ref", "full-size-preview-sha256", "coded-slice", "coded-slice-sha256"] _CHARTER_AXES = ["layout-topology", "navigation-model", "typography-voice", "imagery-strategy", "motion-model", "dominant-primitives"] _CHARTER_REQUIRED = ["id", "design-question", *_CHARTER_AXES, "exclusive-primitives", "forbidden-primitives"] _GENERIC = ["modern", "clean", "minimal", "sleek"] _VAGUE = ["분위기", "감성", "스타일", "느낌", "mood", "vibe", "aesthetic"] _BRIEF_PRODUCT_ANCHORS = re.compile(r"\b(duolingo|brilliant|linear|notion|stripe|figma)\b", re.I) _BRIEF_DIRECTION_EXAMPLES = re.compile( r"(?:예\s*[::]|e\.g\.|for example|가이드\s*트레일|guided[- ]?trail|playful[- ]?probe|" r"놀이형\s*탐구|이야기\s*챕터|story\s*chapter)", re.I) def _load(path): try: doc = yaml.safe_load(open(path)) or {} if isinstance(doc, dict) and doc.get("report-type") == "workflow-artifact": return doc.get("payload") if isinstance(doc.get("payload"), dict) else {} return doc except Exception as e: return {"__err__": str(e)} def _abs(p): return p if os.path.isabs(p) else os.path.join(ROOT, p) def _file_sha(p): ap = _abs(p) return hashlib.sha256(open(ap, "rb").read()).hexdigest() if os.path.isfile(ap) else None def _norm(value): return re.sub(r"[^a-z0-9가-힣]+", "-", str(value or "").strip().lower()).strip("-") def _items(value): if isinstance(value, list): return {_norm(v) for v in value if _norm(v)} if isinstance(value, str): return {_norm(value)} if _norm(value) else set() return set() def _pairs(ids): return {tuple(sorted(pair)) for pair in itertools.combinations(ids, 2)} def _pair_id(value): if isinstance(value, (list, tuple)) and len(value) == 2: return tuple(sorted(str(x) for x in value)) return None def _hash_bound_file(ref, sha, label): if not ref or not sha: return f"{label}: ref+sha256 필요" live = _file_sha(ref) if live is None: return f"{label}: 파일 없음({ref})" if live != sha: return f"{label}: sha256 불일치" return None def lint_file(path, kind): doc = _load(path) if "__err__" in doc: return ([f"{kind}: YAML 파싱 실패 — {doc['__err__']}"], []) if not isinstance(doc, dict): return ([f"{kind}: 최상위가 매핑 아님"], []) return {"direction-input-brief": _lint_input_brief, "divergence-charter": _lint_divergence_charter, "direction-set": _lint_direction_set, "comparative-divergence-audit": _lint_comparative_audit, "direction-discovery": _lint_discovery, "winner-prototype": _lint_winner}.get( kind, lambda d: ([f"unknown kind: {kind}"], []))(doc) def _lint_input_brief(doc): hard = [f"direction-input-brief: 필수 '{k}' 없음" for k in _IB_REQUIRED if not doc.get(k)] hard += [f"direction-input-brief: '{k}' 포함 금지(발산 전 고착 S1)" for k in _IB_PROHIBITED if doc.get(k)] # Problem/experience invariants belong here; competitor products and named # solution directions belong in the post-brief divergence-charter. Without # this boundary all isolated workers receive the same latent UI template. brand = str(doc.get("brand-constraints") or "") representative = doc.get("representative-screen-requirement") or {} rep_text = (str(representative.get("description") or "") if isinstance(representative, dict) else str(representative)) if _BRIEF_PRODUCT_ANCHORS.search(brand): hard.append("direction-input-brief: brand-constraints에 경쟁제품 UI anchor 금지 — brand truth만 두고 reference는 divergence 이후 정의") if _BRIEF_DIRECTION_EXAMPLES.search(rep_text): hard.append("direction-input-brief: representative-screen에 방향/메타포 예시 금지 — 동일 의미적 task/state만 명시") return (hard, []) def _lint_direction_set(doc): hard, warn = [], [] dirs = doc.get("directions") or [] if len(dirs) < 3: hard.append(f"direction-set: 방향 >= 3 필요(현재 {len(dirs)})") if not doc.get("direction-cycle-id"): hard.append("direction-set: direction-cycle-id 필요") for k in ("divergence-charter-ref", "divergence-charter-sha256"): if not doc.get(k): hard.append(f"direction-set: {k} 필요") rs = doc.get("representative-screen") or {} if not (rs.get("id") and rs.get("kind")): hard.append("direction-set: representative-screen(id/kind) 필요") cp = doc.get("comparison-preview") or {} for k in ["receipt-ref", "receipt-sha256", "gallery-path", "representative-screen-id"]: if not cp.get(k): hard.append(f"direction-set: comparison-preview.{k} 필요(실제 비교 렌더 증거)") if cp.get("representative-screen-id") and rs.get("id") and cp["representative-screen-id"] != rs["id"]: hard.append("direction-set: comparison-preview 가 대표화면과 불일치") for d in dirs: if not isinstance(d, dict): hard.append("direction-set: direction 이 매핑 아님"); continue miss = [k for k in _DIR_REQUIRED if not d.get(k)] if miss: hard.append(f"direction-set: {d.get('id','?')} 필수 누락 {miss}") cs, csha = d.get("coded-slice"), d.get("coded-slice-sha256") if cs and csha and _file_sha(cs) not in (None, csha): hard.append(f"direction-set: {d.get('id','?')} coded-slice hash 불일치") elif cs and _file_sha(cs) is None: hard.append(f"direction-set: {d.get('id','?')} coded-slice 파일 없음(실제 픽셀 필요)") for ref_key, sha_key in (("reference-board-ref", "reference-board-sha256"), ("full-size-preview-ref", "full-size-preview-sha256")): err = _hash_bound_file(d.get(ref_key), d.get(sha_key), f"direction-set: {d.get('id','?')} {ref_key}") if err: hard.append(err) refs = d.get("reference-cluster") or [] if not (isinstance(refs, list) and 3 <= len(refs) <= 6): hard.append(f"direction-set: {d.get('id','?')} reference-cluster 3~6개 필요") for ref in refs: if not isinstance(ref, dict) or any(not ref.get(k) for k in ("name", "signal", "why-relevant")): hard.append(f"direction-set: {d.get('id','?')} reference 는 name/signal/why-relevant 필수") continue if any(g in (str(ref.get("name","")) + str(ref.get("signal",""))).lower() for g in _GENERIC): warn.append(f"direction-set: {d.get('id','?')} reference 형용사(generic-risk)") # 같은 레퍼런스 집합은 독립 실행이어도 같은 latent default 로 수렴시킨다. for left, right in itertools.combinations([d for d in dirs if isinstance(d, dict)], 2): lrefs = {_norm(r.get("name")) for r in (left.get("reference-cluster") or []) if isinstance(r, dict)} rrefs = {_norm(r.get("name")) for r in (right.get("reference-cluster") or []) if isinstance(r, dict)} overlap = sorted((lrefs & rrefs) - {""}) if len(overlap) > 1: hard.append(f"direction-set: {left.get('id')}↔{right.get('id')} reference 중복 >1 ({overlap})") return (hard, warn) def _lint_divergence_charter(doc): hard, warn = [], [] dirs = doc.get("directions") or [] if len(dirs) != 3: hard.append(f"divergence-charter: 정확히 3개 방향 필요(현재 {len(dirs)})") if not doc.get("direction-cycle-id"): hard.append("divergence-charter: direction-cycle-id 필요") rs = doc.get("representative-screen") or {} if not (isinstance(rs, dict) and rs.get("id") and rs.get("kind") and rs.get("description")): hard.append("divergence-charter: representative-screen id/kind/description 필요") ids = [str(d.get("id")) for d in dirs if isinstance(d, dict) and d.get("id")] if len(set(ids)) != len(ids): hard.append("divergence-charter: direction id 중복") for d in dirs: if not isinstance(d, dict): hard.append("divergence-charter: direction 이 매핑 아님") continue miss = [k for k in _CHARTER_REQUIRED if not d.get(k)] if miss: hard.append(f"divergence-charter: {d.get('id','?')} 필수 누락 {miss}") if len(_items(d.get("dominant-primitives"))) < 2: hard.append(f"divergence-charter: {d.get('id','?')} dominant-primitives >=2") if len(_items(d.get("exclusive-primitives"))) < 2: hard.append(f"divergence-charter: {d.get('id','?')} exclusive-primitives >=2") if len(_items(d.get("forbidden-primitives"))) < 2: hard.append(f"divergence-charter: {d.get('id','?')} forbidden-primitives >=2") for left, right in itertools.combinations([d for d in dirs if isinstance(d, dict)], 2): differing = [axis for axis in _CHARTER_AXES if _norm(left.get(axis)) != _norm(right.get(axis))] if len(differing) < 4: hard.append(f"divergence-charter: {left.get('id')}↔{right.get('id')} 조형축 차이 <4 ({differing})") overlap = _items(left.get("exclusive-primitives")) & _items(right.get("exclusive-primitives")) if overlap: hard.append(f"divergence-charter: {left.get('id')}↔{right.get('id')} exclusive primitive 충돌 {sorted(overlap)}") expected = _pairs(ids) entries = doc.get("pairwise-separation") or [] actual = {_pair_id(e.get("directions")) for e in entries if isinstance(e, dict)} - {None} if expected != actual: hard.append(f"divergence-charter: pairwise-separation coverage 불일치(expected={sorted(expected)}, actual={sorted(actual)})") for e in entries: if not isinstance(e, dict): continue axes = set(e.get("differing-axes") or []) if len(axes & set(_CHARTER_AXES)) < 4: hard.append(f"divergence-charter: pair {e.get('directions')} differing-axes >=4") if e.get("allowed-overlap") in (None, ""): hard.append(f"divergence-charter: pair {e.get('directions')} allowed-overlap 명시 필요") return hard, warn def _lint_comparative_audit(doc): hard, warn = [], [] for k in ("direction-cycle-id", "divergence-charter-ref", "divergence-charter-sha256", "direction-set-ref", "direction-set-sha256", "reviewer-role-id", "reviewer-run-id", "verdict", "pairwise-comparisons", "full-size-previews"): if doc.get(k) in (None, "", []): hard.append(f"comparative-divergence-audit: {k} 필요") if doc.get("verdict") not in ("pass", "revise", "re-diverge"): hard.append("comparative-divergence-audit: verdict=pass|revise|re-diverge") if not isinstance(doc.get("blocking-findings"), list): hard.append("comparative-divergence-audit: blocking-findings 목록 필요(없으면 [])") if doc.get("verdict") == "pass" and doc.get("blocking-findings"): hard.append("comparative-divergence-audit: blocking finding 존재 시 pass 금지") return hard, warn def lint_divergence_bundle(audit_path, direction_set_path, charter_path): """charter→direction-set→comparative audit의 exact hash/cycle/pair coverage 검증.""" charter, ds, audit = _load(charter_path), _load(direction_set_path), _load(audit_path) hard, warn = [], [] for doc, name in ((charter, "divergence-charter"), (ds, "direction-set"), (audit, "comparative-divergence-audit")): if "__err__" in doc or not isinstance(doc, dict): hard.append(f"{name}: bundle 로드 실패") if hard: return hard, warn for fn, doc in ((_lint_divergence_charter, charter), (_lint_direction_set, ds), (_lint_comparative_audit, audit)): h, w = fn(doc); hard.extend(h); warn.extend(w) csha, dsha = _file_sha(charter_path), _file_sha(direction_set_path) if ds.get("divergence-charter-sha256") != csha: hard.append("divergence bundle: direction-set charter hash 불일치") if audit.get("divergence-charter-sha256") != csha: hard.append("divergence bundle: audit charter hash 불일치") if audit.get("direction-set-sha256") != dsha: hard.append("divergence bundle: audit direction-set hash 불일치") cycles = {charter.get("direction-cycle-id"), ds.get("direction-cycle-id"), audit.get("direction-cycle-id")} if len(cycles) != 1: hard.append(f"divergence bundle: cycle-id 불일치 {sorted(str(x) for x in cycles)}") ids = [str(d.get("id")) for d in (ds.get("directions") or []) if isinstance(d, dict) and d.get("id")] expected = _pairs(ids) comparisons = audit.get("pairwise-comparisons") or [] actual = {_pair_id(e.get("directions")) for e in comparisons if isinstance(e, dict)} - {None} if expected != actual: hard.append("divergence bundle: pairwise-comparisons coverage 불일치") for e in comparisons: if not isinstance(e, dict): continue if len(set(e.get("differing-axes") or []) & set(_CHARTER_AXES)) < 4: hard.append(f"divergence bundle: pair {e.get('directions')} differing-axes <4") if e.get("primitive-collisions"): hard.append(f"divergence bundle: pair {e.get('directions')} primitive collision 존재") previews = audit.get("full-size-previews") or [] preview_ids = {str(p.get("direction-id")) for p in previews if isinstance(p, dict)} if set(ids) != preview_ids: hard.append("divergence bundle: full-size-previews 방향 coverage 불일치") for p in previews: if not isinstance(p, dict): continue err = _hash_bound_file(p.get("ref"), p.get("sha256"), f"divergence bundle: {p.get('direction-id')} preview") if err: hard.append(err) if audit.get("verdict") != "pass": hard.append(f"divergence bundle: comparative audit pass 필요(현재 {audit.get('verdict')})") if audit.get("blocking-findings"): hard.append("divergence bundle: blocking findings 존재") return hard, warn def _lint_discovery(doc): hard = [f"direction-discovery: 필수 '{k}' 없음" for k in ["direction-input-brief-sha256", "findings", "constraints-restated"] if not doc.get(k)] return (hard, []) def _lint_winner(doc): hard = [f"winner-prototype: 필수 '{k}' 없음" for k in ["selected-direction-ref", "selected-direction-sha256", "prototype-path", "prototype-sha256", "preview-receipt-ref", "revision"] if not doc.get(k)] if doc.get("prototype-path"): if _file_sha(doc["prototype-path"]) is None: hard.append("winner-prototype: prototype 파일 없음") elif doc.get("prototype-sha256") and _file_sha(doc["prototype-path"]) != doc["prototype-sha256"]: hard.append("winner-prototype: prototype hash 불일치") return (hard, []) def lint_selected_direction(selected_path, direction_set_path): """selected+direction-set BUNDLE 검증(Blocker 8).""" sd, ds = _load(selected_path), _load(direction_set_path) if "__err__" in sd or not isinstance(sd, dict): return ([f"selected-direction: 로드 실패"], []) if "__err__" in ds or not isinstance(ds, dict): return ([f"selected-direction: direction-set 로드 실패"], []) hard, warn = [], [] for k in ["direction-set-ref", "direction-set-sha256", "parent-workflow-id", "product-decision-id", "direction-input-brief-sha256", "selection-acceptance-receipt"]: if not sd.get(k): hard.append(f"selected-direction: 필수 '{k}' 없음") if "secondary-influence-id" in sd: hard.append("selected-direction: secondary-influence-id 금지(평균 뒷문)") # direction-set 바인딩 hash if sd.get("direction-set-sha256"): _dss = _file_sha(direction_set_path) if _dss is None: hard.append("selected-direction: direction-set 파일 없음") elif _dss != sd["direction-set-sha256"]: hard.append("selected-direction: direction-set-sha256 불일치") set_ids = {d.get("id") for d in (ds.get("directions") or [])} decision = sd.get("selection-decision") or "selected" if decision not in ("selected", "none-of-the-above"): hard.append(f"selected-direction: selection-decision 값 오류({decision})") sel = sd.get("selected-direction-id") if decision == "selected" and not sel: hard.append("selected-direction: selected 결정은 selected-direction-id 필수") if decision == "none-of-the-above" and sel: hard.append("selected-direction: none-of-the-above는 selected-direction-id 금지") if sel and sel not in set_ids: hard.append(f"selected-direction: 선택 ID {sel} 가 direction-set 에 없음(유령)") rej = sd.get("rejected-directions") or [] rej_ids = {r.get("id") for r in rej if isinstance(r, dict)} for r in rej: if not (isinstance(r, dict) and str(r.get("reason") or "").strip()): hard.append("selected-direction: rejected reason 필수") if isinstance(r, dict) and r.get("id") not in set_ids: hard.append(f"selected-direction: rejected 유령 ID {r.get('id')}") # rejected ∪ {selected} == 전체(정확히 덮음) classified = rej_ids if decision == "none-of-the-above" else ({sel} | rej_ids) if set_ids and classified != set_ids: hard.append(f"selected-direction: rejected∪selected 가 전체 방향과 불일치(누락/여분)") if sel in rej_ids: hard.append("selected-direction: selected 가 rejected 에도 존재(중복 분류)") lock = sd.get("locked-invariants") or [] if decision == "selected" and not (isinstance(lock, list) and len(lock) >= 3): hard.append(f"selected-direction: locked-invariants >= 3(현재 {len(lock) if isinstance(lock,list) else 0})") if decision == "none-of-the-above" and (lock or sd.get("adopted-elements")): hard.append("selected-direction: none-of-the-above는 locked/adopted 요소 금지(평균 금지)") ad = sd.get("adopted-elements") or [] if isinstance(ad, list): if len(ad) > 1: hard.append("selected-direction: adopted-elements <= 1") for a in ad: if not (isinstance(a, dict) and a.get("element-id") and str(a.get("rationale") or "").strip()): hard.append("selected-direction: adopted-elements 는 element-id+rationale 필수") elif a.get("from-direction-id") not in set_ids: hard.append(f"selected-direction: adopted-elements from {a.get('from-direction-id')} 실존 안 함") elif any(v in str(a.get("element-id"))+str(a.get("rationale")) for v in _VAGUE): hard.append("selected-direction: adopted-elements 포괄표현 금지(원자 element-id만)") return (hard, warn) if __name__ == "__main__": if sys.argv[1] == "--divergence-bundle": h, w = lint_divergence_bundle(sys.argv[2], sys.argv[3], sys.argv[4]) elif sys.argv[1] == "--bundle": h, w = lint_selected_direction(sys.argv[2], sys.argv[3]) else: h, w = lint_file(sys.argv[1], sys.argv[2]) for x in h: print(f"HARD: {x}") for x in w: print(f"WARN: {x}") sys.exit(1 if h else 0)