346 lines
12 KiB
Python
346 lines
12 KiB
Python
#!/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",
|
||
"--obs-matrix-dot",
|
||
"v3.10 terminal dot-matrix layer",
|
||
".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}`")
|
||
|
||
terminal_marker = (
|
||
"/* v3.10 terminal dot-matrix layer: this must stay at EOF to win the cascade. */"
|
||
)
|
||
marker_count = text.count(terminal_marker)
|
||
if marker_count != 1:
|
||
findings.append(
|
||
f"{CSS_PATH}: expected exactly one terminal dot-matrix layer, found {marker_count}"
|
||
)
|
||
else:
|
||
marker_pos = text.rfind(terminal_marker)
|
||
tail = text[marker_pos:]
|
||
if len(tail) > 4200:
|
||
findings.append(
|
||
f"{CSS_PATH}: terminal dot-matrix layer must remain near EOF so later rules cannot override it"
|
||
)
|
||
|
||
for legacy_marker in (
|
||
"v3.8 dot-matrix integration",
|
||
"v3.9 final dot-matrix layer",
|
||
):
|
||
if legacy_marker in text:
|
||
findings.append(
|
||
f"{CSS_PATH}: remove obsolete `{legacy_marker}`; keep only the v3.10 terminal layer"
|
||
)
|
||
|
||
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())
|