#!/usr/bin/env python3 """Compile the organization design SSOT into tool-facing adapters.""" from __future__ import annotations import argparse import difflib import hashlib import json import os import sys import yaml HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname(os.path.dirname(HERE)) BASE = os.path.join(ROOT, "org-os", "08-design") GENERATED = os.path.join(BASE, "generated") SOURCES = { "principles": "principles.yaml", "taste": "taste-profile.yaml", "tokens": "tokens.yaml", "components": "components/registry.yaml", "patterns": "patterns/registry.yaml", "page-archetypes": "page-archetypes/registry.yaml", "releases": "releases/index.yaml", } STATES = ("experimental", "candidate", "stable", "deprecated") def _load(rel): with open(os.path.join(BASE, rel), encoding="utf-8") as handle: return yaml.safe_load(handle) or {} def _sha(rel): digest = hashlib.sha256() with open(os.path.join(BASE, rel), "rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def _flatten_tokens(node, prefix=""): result = {} for key, value in (node or {}).items(): name = f"{prefix}.{key}" if prefix else key if isinstance(value, dict) and "value" in value: result[name] = value elif isinstance(value, dict): result.update(_flatten_tokens(value, name)) return result def _validate(documents): errors = [] principles = documents["principles"].get("design-principles", {}).get("principles", []) components = documents["components"].get("design-components", {}).get("components", []) patterns = documents["patterns"].get("design-patterns", {}).get("patterns", []) pages = documents["page-archetypes"].get("design-page-archetypes", {}).get("page-archetypes", []) collections = {"principles": principles, "components": components, "patterns": patterns, "page-archetypes": pages} ids = {} for label, items in collections.items(): values = [item.get("id") for item in items if isinstance(item, dict)] ids[label] = set(values) if None in values or len(values) != len(set(values)): errors.append(f"{label}: id 누락/중복") for item in items: state = item.get("state") if isinstance(item, dict) else None if label != "principles" and state not in STATES: errors.append(f"{label}/{item.get('id')}: state {state!r} 오류") for item in components: recipe = item.get("recipe") if not recipe or not os.path.isfile(os.path.join(BASE, "components", recipe)): errors.append(f"component/{item.get('id')}: recipe 파일 없음") for item in patterns: unknown = sorted(set(item.get("components") or []) - ids["components"]) if unknown: errors.append(f"pattern/{item.get('id')}: unknown components {unknown}") for item in pages: unknown = sorted(set(item.get("patterns") or []) - ids["patterns"]) if unknown: errors.append(f"page-archetype/{item.get('id')}: unknown patterns {unknown}") releases = documents["releases"].get("design-system-releases", {}) release_ids = [item.get("release-id") for item in releases.get("releases", []) or []] if releases.get("current") not in release_ids: errors.append("release index current가 releases에 없음") for item in releases.get("releases", []) or []: if item.get("state") not in STATES: errors.append(f"release/{item.get('release-id')}: state 오류") ref = item.get("ref") path = os.path.join(BASE, ref or "") if not ref or not os.path.isfile(path): errors.append(f"release/{item.get('release-id')}: ref 파일 없음") continue release = _load(ref).get("design-system-release", {}) for field, known in (("principles", ids["principles"]), ("components", ids["components"]), ("patterns", ids["patterns"]), ("page-archetypes", ids["page-archetypes"])): unknown = sorted(set(release.get(field) or []) - known) if unknown: errors.append(f"release/{item.get('release-id')}: unknown {field} {unknown}") return errors, collections def _design_md(documents, collections, source_hashes): taste = documents["taste"].get("taste-profile", {}) tokens = _flatten_tokens(documents["tokens"].get("design-tokens", {}).get("tokens", {})) lines = [ "# DESIGN.md (generated)", "", "> Adapter generated from `org-os/08-design`. Do not edit. This is not a product-strategy, IA, or wireframe source.", "", "## Taste thesis", "", taste.get("thesis", ""), "", "## Principles", "", ] for item in collections["principles"]: lines += [f"- **{item['title']}** — {item['rule']} Anti-example: {item['anti-example']}"] lines += ["", "## Preferred signals", ""] lines += [f"- {item['signal']}" for item in taste.get("preferred-signals", [])] lines += ["", "## Anti-signals", ""] lines += [f"- {item}" for item in taste.get("anti-signals", [])] lines += ["", "## Token boundaries", ""] lines += [f"- `{name}` = `{entry['value']}` — {entry.get('description', '')}" for name, entry in tokens.items()] for label in ("components", "patterns", "page-archetypes"): lines += ["", f"## {label.replace('-', ' ').title()}", ""] for item in collections[label]: anti = ", ".join(item.get("anti-examples") or []) lines += [f"- `{item['id']}` ({item['state']}) — surfaces: {', '.join(item.get('surfaces') or [])}; anti: {anti or 'see source'}"] lines += ["", "## Source hashes", ""] lines += [f"- `{path}`: `{digest}`" for path, digest in sorted(source_hashes.items())] return "\n".join(lines) + "\n" def compile_outputs(): documents = {key: _load(rel) for key, rel in SOURCES.items()} errors, collections = _validate(documents) if errors: return None, errors extra_sources = [ item.get("ref") for item in documents["releases"].get("design-system-releases", {}).get("releases", []) or [] if item.get("ref") ] extra_sources += [ os.path.join("components", item.get("recipe")) for item in collections["components"] if item.get("recipe") ] extra_sources.append("design-engine-adapters.yaml") source_hashes = { f"org-os/08-design/{rel}": _sha(rel) for rel in list(SOURCES.values()) + extra_sources } tokens = _flatten_tokens(documents["tokens"].get("design-tokens", {}).get("tokens", {})) registry = { "version": 1, "generated-by": ".claude/hooks/compile_design_system.py", "source-sha256": source_hashes, "current-release": documents["releases"].get("design-system-releases", {}).get("current"), **collections, } css_lines = ["/* generated from org-os/08-design/tokens.yaml; do not edit */", ":root {"] for name, entry in tokens.items(): css_lines.append(f" --org-{name.replace('.', '-')}: {entry['value']};") css_lines += ["}", ""] tailwind = {name.replace(".", "-"): entry["value"] for name, entry in tokens.items()} dtcg = {"$schema": "https://design-tokens.github.io/community-group/format/", "tokens": {name: {"$value": entry["value"], "$type": entry.get("type"), "$description": entry.get("description", "")} for name, entry in tokens.items()}} outputs = { "DESIGN.md": _design_md(documents, collections, source_hashes), "registry.json": json.dumps(registry, ensure_ascii=False, indent=2, sort_keys=True) + "\n", "tokens.css": "\n".join(css_lines), "tailwind.tokens.json": json.dumps(tailwind, ensure_ascii=False, indent=2, sort_keys=True) + "\n", "tokens.dtcg.json": json.dumps(dtcg, ensure_ascii=False, indent=2, sort_keys=True) + "\n", } return outputs, [] def main(argv=None): parser = argparse.ArgumentParser() mode = parser.add_mutually_exclusive_group() mode.add_argument("--check", action="store_true", help="lint sources and fail if generated adapters drift") mode.add_argument("--diff", metavar="DESIGN_MD", help="print a unified diff from DESIGN_MD to the canonical generated DESIGN.md") args = parser.parse_args(argv) outputs, errors = compile_outputs() if errors: for error in errors: print(f"[design-system] ERROR: {error}", file=sys.stderr) return 2 if args.diff: expected = outputs["DESIGN.md"] try: with open(args.diff, encoding="utf-8") as handle: actual = handle.read() except OSError as exc: print(f"[design-system] ERROR: diff target unreadable: {exc}", file=sys.stderr) return 2 if actual == expected: print(f"[design-system] OK: DESIGN.md matches canonical source ({args.diff})") return 0 diff = difflib.unified_diff( actual.splitlines(keepends=True), expected.splitlines(keepends=True), fromfile=args.diff, tofile="canonical:org-os/08-design/generated/DESIGN.md") sys.stdout.writelines(diff) return 1 drift = [] for name, expected in outputs.items(): path = os.path.join(GENERATED, name) if args.check: try: with open(path, encoding="utf-8") as handle: actual = handle.read() except OSError: actual = "" if actual != expected: drift.append(name) else: os.makedirs(GENERATED, exist_ok=True) with open(path, "w", encoding="utf-8") as handle: handle.write(expected) if drift: print("[design-system] ERROR: generated drift: " + ", ".join(drift), file=sys.stderr) return 2 print(f"[design-system] {'OK' if args.check else 'wrote'}: {len(outputs)} adapters") return 0 if __name__ == "__main__": raise SystemExit(main())