230 lines
10 KiB
Python
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())
|