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

135 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Check an implementation against its exact organization design release binding."""
import argparse
import hashlib
import os
import re
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))
RAW_COLOR = re.compile(r"(?<![-\w])#[0-9a-fA-F]{3,8}\b|\brgba?\s*\(")
CODE_EXTENSIONS = (".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte")
IGNORED_DIRS = {"node_modules", ".git", "dist", "build", "coverage", "generated"}
IGNORED_SUFFIXES = (".test", ".spec", ".stories", ".story")
def _sha(path):
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _component_id(value):
if isinstance(value, dict):
value = value.get("id") or value.get("component-id")
value = str(value or "").strip()
value = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", value)
value = re.sub(r"[^a-zA-Z0-9]+", "-", value).strip("-").lower()
return value
def _local_component_files(target):
found = {}
for base, dirs, files in os.walk(target):
dirs[:] = [name for name in dirs if name not in IGNORED_DIRS]
parts = {part.lower() for part in os.path.relpath(base, target).split(os.sep)}
if not parts.intersection({"component", "components", "ui"}):
continue
for name in files:
stem, ext = os.path.splitext(name)
if ext.lower() not in CODE_EXTENSIONS or stem.lower() == "index":
continue
lower = stem.lower()
if any(lower.endswith(suffix) for suffix in IGNORED_SUFFIXES):
continue
cid = _component_id(stem.removesuffix(".component"))
if cid:
found.setdefault(cid, []).append(os.path.join(base, name))
return found
def main(argv=None):
parser = argparse.ArgumentParser()
parser.add_argument("--ui-report", required=True)
parser.add_argument("--target", required=True)
args = parser.parse_args(argv)
errors = []
try:
with open(args.ui_report, encoding="utf-8") as handle:
report = yaml.safe_load(handle) or {}
body = report.get("payload") if isinstance(report.get("payload"), dict) else report
binding = next((item for item in body.get("design-system-bindings", [])
if isinstance(item, dict) and item.get("release-id")), None)
if not binding:
errors.append("exact design-system release binding 없음")
else:
ref = binding.get("release-ref")
path = ref if os.path.isabs(str(ref or "")) else os.path.join(ROOT, str(ref or ""))
if not os.path.isfile(path):
errors.append("release-ref 파일 없음")
elif _sha(path) != binding.get("release-sha256"):
errors.append("release-ref SHA 불일치")
else:
with open(path, encoding="utf-8") as release_handle:
release = (yaml.safe_load(release_handle) or {}).get("design-system-release", {})
release_components = {_component_id(value) for value in release.get("components") or []}
bound_components = {_component_id(value) for value in binding.get("component-ids") or []}
if not bound_components.issubset(release_components):
errors.append("component-ids에 release 미등록 component 포함")
except Exception as exc:
errors.append(f"ui report 로드 실패: {exc}")
binding = None
target = os.path.abspath(args.target)
if not os.path.isdir(target):
errors.append("target directory 없음")
else:
for base, _dirs, files in os.walk(target):
for name in files:
if not name.endswith((".css", ".scss", ".sass", ".less")):
continue
if "token" in name.lower() or "theme" in name.lower():
continue
path = os.path.join(base, name)
try:
content = open(path, encoding="utf-8").read()
except Exception:
continue
if RAW_COLOR.search(content):
errors.append(f"raw color token 사용: {os.path.relpath(path, target)}")
if binding:
local = _local_component_files(target)
for cid, paths in sorted(local.items()):
if len(paths) > 1:
rels = [os.path.relpath(path, target) for path in paths]
errors.append(f"local duplicate component id={cid}: {rels}")
delta = binding.get("delta") or {}
declared_delta = {_component_id(value) for value in delta.get("components") or []}
delta_reasons = {
_component_id(value): str(value.get("reason") or "").strip()
for value in delta.get("components") or [] if isinstance(value, dict)
}
bound = {_component_id(value) for value in binding.get("component-ids") or []}
for cid in sorted((set(local) & bound) - declared_delta):
rels = [os.path.relpath(path, target) for path in local[cid]]
errors.append(
f"organization component local duplicate id={cid}: {rels}; "
"재사용하거나 delta.components에 예외를 명시해야 함")
for cid in sorted(set(local) & bound & declared_delta):
if not delta_reasons.get(cid):
errors.append(f"organization component delta id={cid}: reason 필수")
if errors:
for error in errors:
print(f"[design-adherence] ERROR: {error}", file=sys.stderr)
return 2
print(f"[design-adherence] OK: release={binding.get('release-id')}")
return 0
if __name__ == "__main__":
raise SystemExit(main())