Files
company-haness/.claude/hooks/compile_design_system.py

230 lines
10 KiB
Python

#!/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())