#!/usr/bin/env python3 """Guardrail checks for AI observability UI templates. This script is intentionally lightweight: it scans the observability templates and shared CSS for regression signals that previously hurt the war-room UI, including unreadable typography, raw SQL errors, oversized titles, and inline chart sizing. """ from __future__ import annotations import re import sys from dataclasses import dataclass from pathlib import Path from observability_contract import OBSERVABILITY_PAGES ROOT = Path(__file__).resolve().parents[1] TEMPLATE_PATHS = [page.template for page in OBSERVABILITY_PAGES] CSS_PATH = Path("static/css/observability-system.css") WEB_CSS_PATH = Path("web/static/css/observability-system.css") SHELL_PATH = Path("templates/components/_ewoooc_shell.html") BASE_PATH = Path("templates/ewoooc_base.html") ROUTE_PATH = Path("routes/admin_observability_routes.py") @dataclass(frozen=True) class Rule: code: str pattern: re.Pattern[str] message: str TEMPLATE_RULES = [ Rule( "legacy_serif_font", re.compile(r"\b(Georgia|Times New Roman)\b", re.IGNORECASE), "觀測台標題不得回退到 Times/Georgia;使用 Noto Sans TC / Inter 與 CSS token。", ), Rule( "raw_error_copy", re.compile( r"(查詢失敗:|ProgrammingError|OperationalError|SQLAlchemyError|sqlite3\.|UndefinedError|Traceback|Internal Server Error|relation\s+"|relation\s+\"|alert\(['\"]Error[::]|載入錯誤:\$\{e\}|unknown)" ), "不得把 SQL/Jinja exception 或 raw failure 文案直接顯示給使用者。", ), Rule( "inline_height", re.compile(r"style=\"[^\"]*height\s*:", re.IGNORECASE), "圖表/區塊高度請使用 .obs-chart-frame 系列或 CSS class,不要 inline height。", ), Rule( "static_inline_width", re.compile(r"style=\"(?=[^\"]*width\s*:)(?![^\"]*\{\{)[^\"]*\"", re.IGNORECASE), "靜態寬度請移到 CSS class;僅允許 progress 類動態 width={{ ... }}。", ), Rule( "oversized_title", re.compile(r"font-size:\s*clamp\([^;]*(3\.8rem|4\.45rem)", re.IGNORECASE), "頁面大標不得使用過大 clamp;使用 --obs-title-size token。", ), Rule( "pure_white_surface", re.compile(r"background\s*:\s*#fff\s*;", re.IGNORECASE), "觀測台 hover/card surface 不用純白;使用暖色 paper/surface token。", ), Rule( "wrong_business_active_page", re.compile(r"active_page\s*=\s*['\"]obs_business['\"]"), "商業戰情頁 active_page 必須是 obs_business_intel,否則觀測台 CSS 不會載入。", ), ] REQUIRED_CSS_SNIPPETS = [ "--obs-title-size", "--obs-value-size", ".momo-observability-mode", ".obs-chart-frame", ".obs-chart-frame-tall", ".obs-chart-frame-slim", ".obs-modal-preview", ".obs-progress-xs", ".obs-table-shell", ] REQUIRED_SHELL_SNIPPETS = [ "AI 中樞", "AI 觀測台", "戰情室", "系統與成本", "RAG 與品質", "momo-nav-tree", "momo-nav-subtree", "momo-nav-subtitle", "momo-nav-sublink", ] FORBIDDEN_SHELL_PATTERNS = [ Rule( "pure_black_sidebar", re.compile(r"\.momo-sidebar\s*\{[^}]*background\s*:\s*(#000|black)\b", re.IGNORECASE | re.DOTALL), "側欄不得回退成純黑背景;必須維持暖色 token 化背景。", ), Rule( "low_contrast_sublink", re.compile(r"\.momo-nav-sublink\s*\{[^}]*color\s*:\s*rgba\([^)]*,\s*0\.(?:[0-5][0-9]|60)\)", re.IGNORECASE | re.DOTALL), "第二/三層導覽文字透明度過低,會看不清楚;需維持足夠對比。", ), ] REQUIRED_BASE_SNIPPETS = [ "observability-system.css", "momo-observability-mode", "/observability/overview", "/observability/api/health_indicator", "momo-obs-link", "momo-obs-badge", "classList.add('is-alert')", ] FORBIDDEN_BASE_PATTERNS = [ Rule( "inline_obs_badge_style", re.compile(r"id=\"momo-obs-badge\"[^>]*style=", re.IGNORECASE | re.DOTALL), "Topbar 觀測台 badge 不得使用 inline style;請使用 .momo-obs-badge。", ), Rule( "hardcoded_obs_alert_color", re.compile(r"(#dc3545|link\.style\.color|badge\.style\.display)", re.IGNORECASE), "Topbar 觀測台告警狀態不得用 JS/inline 硬改色;請切換 is-alert / hidden。", ), ] def line_number(text: str, index: int) -> int: return text.count("\n", 0, index) + 1 def scan_file(relative_path: Path) -> list[str]: path = ROOT / relative_path if not path.exists(): return [f"{relative_path}: missing required observability file"] text = path.read_text(encoding="utf-8") findings: list[str] = [] for rule in TEMPLATE_RULES: for match in rule.pattern.finditer(text): line = line_number(text, match.start()) snippet = match.group(0).replace("\n", " ")[:90] findings.append( f"{relative_path}:{line}: [{rule.code}] {rule.message} ({snippet})" ) return findings def scan_css() -> list[str]: path = ROOT / CSS_PATH if not path.exists(): return [f"{CSS_PATH}: missing required observability CSS"] text = path.read_text(encoding="utf-8") findings: list[str] = [] for snippet in REQUIRED_CSS_SNIPPETS: if snippet not in text: findings.append(f"{CSS_PATH}: missing required token/class `{snippet}`") web_path = ROOT / WEB_CSS_PATH if not web_path.exists(): findings.append( f"{WEB_CSS_PATH}: missing mirrored CSS served by Flask static route" ) else: web_text = web_path.read_text(encoding="utf-8") if web_text != text: findings.append( f"{WEB_CSS_PATH}: must match {CSS_PATH} so production /static CSS is in sync; run `python3 scripts/sync_observability_css.py`" ) return findings def scan_required_snippets(relative_path: Path, snippets: list[str], label: str) -> list[str]: path = ROOT / relative_path if not path.exists(): return [f"{relative_path}: missing required {label} file"] text = path.read_text(encoding="utf-8") findings: list[str] = [] for snippet in snippets: if snippet not in text: findings.append(f"{relative_path}: missing required {label} snippet `{snippet}`") return findings def scan_base_topbar() -> list[str]: path = ROOT / BASE_PATH if not path.exists(): return [f"{BASE_PATH}: missing required base/topbar file"] text = path.read_text(encoding="utf-8") findings = scan_required_snippets(BASE_PATH, REQUIRED_BASE_SNIPPETS, "base/topbar") for rule in FORBIDDEN_BASE_PATTERNS: for match in rule.pattern.finditer(text): line = line_number(text, match.start()) snippet = match.group(0).replace("\n", " ")[:90] findings.append( f"{BASE_PATH}:{line}: [{rule.code}] {rule.message} ({snippet})" ) return findings def scan_shell() -> list[str]: path = ROOT / SHELL_PATH if not path.exists(): return [f"{SHELL_PATH}: missing required shell file"] text = path.read_text(encoding="utf-8") findings = scan_required_snippets(SHELL_PATH, REQUIRED_SHELL_SNIPPETS, "sidebar/nav") for rule in FORBIDDEN_SHELL_PATTERNS: for match in rule.pattern.finditer(text): line = line_number(text, match.start()) snippet = match.group(0).replace("\n", " ")[:90] findings.append( f"{SHELL_PATH}:{line}: [{rule.code}] {rule.message} ({snippet})" ) return findings def scan_nav_contract() -> list[str]: shell_path = ROOT / SHELL_PATH base_path = ROOT / BASE_PATH route_path = ROOT / ROUTE_PATH if not shell_path.exists() or not base_path.exists(): return [] shell_text = shell_path.read_text(encoding="utf-8") base_text = base_path.read_text(encoding="utf-8") route_text = route_path.read_text(encoding="utf-8") if route_path.exists() else "" findings: list[str] = [] for page in OBSERVABILITY_PAGES: template = ROOT / page.template if not template.exists(): findings.append(f"{page.template}: missing observability template for `{page.label}`") continue template_text = template.read_text(encoding="utf-8") active_pattern = re.compile( r"set\s+active_page\s*=\s*['\"]" + re.escape(page.active_page) + r"['\"]" ) route_has_contract = ( page.template.replace("templates/", "") in route_text and page.active_page in route_text ) if not active_pattern.search(template_text) and not route_has_contract: findings.append( f"{page.template}: active_page must be `{page.active_page}` in template or route for `{page.label}`" ) if page.active_page not in shell_text: findings.append(f"{SHELL_PATH}: sidebar missing active_page `{page.active_page}`") if page.url not in shell_text: findings.append(f"{SHELL_PATH}: sidebar missing URL `{page.url}` for `{page.label}`") if page.label not in shell_text: findings.append(f"{SHELL_PATH}: sidebar missing label `{page.label}`") if page.active_page not in base_text: findings.append(f"{BASE_PATH}: momo-observability-mode list missing `{page.active_page}`") route_pattern = re.compile( r"@admin_observability_bp\.route\(\s*['\"]" + re.escape(page.route_suffix) + r"['\"]" ) if route_text and not route_pattern.search(route_text): findings.append(f"{ROUTE_PATH}: missing route `{page.route_suffix}` for `{page.label}`") sidebar_observability_links = set(re.findall(r'href="(/observability/[^"]+)"', shell_text)) expected_links = {page.url for page in OBSERVABILITY_PAGES} extra_links = sorted(sidebar_observability_links - expected_links) missing_links = sorted(expected_links - sidebar_observability_links) for url in extra_links: findings.append(f"{SHELL_PATH}: unexpected observability sidebar URL `{url}`") for url in missing_links: findings.append(f"{SHELL_PATH}: missing observability sidebar URL `{url}`") return findings def main() -> int: findings: list[str] = [] for template_path in TEMPLATE_PATHS: findings.extend(scan_file(Path(template_path))) findings.extend(scan_css()) findings.extend(scan_shell()) findings.extend(scan_base_topbar()) findings.extend(scan_nav_contract()) if findings: print("Observability UI guard: FAIL") for finding in findings: print(f"- {finding}") return 1 print("Observability UI guard: PASS") print(f"- templates checked: {len(TEMPLATE_PATHS)}") print(f"- css guardrails checked: {len(REQUIRED_CSS_SNIPPETS)}") print(f"- sidebar/nav guardrails checked: {len(REQUIRED_SHELL_SNIPPETS)}") print(f"- base/topbar guardrails checked: {len(REQUIRED_BASE_SNIPPETS) + len(FORBIDDEN_BASE_PATTERNS)}") print(f"- nav contract checked: {len(OBSERVABILITY_PAGES)} pages") return 0 if __name__ == "__main__": sys.exit(main())