135 lines
5.9 KiB
Python
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())
|