341 lines
20 KiB
Python
341 lines
20 KiB
Python
#!/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)
|