#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" source "$ROOT_DIR/scripts/harness/lib.sh" main() { cd "$ROOT_DIR" local uv_bin uv_bin="$(harness_require_tool uv)" harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python - <<'PY' import json import re from pathlib import Path from urllib.parse import urlsplit root = Path.cwd() failures: list[str] = [] warnings: list[str] = [] def fail(message: str) -> None: failures.append(message) def warn(message: str) -> None: warnings.append(message) def read_text(path: str) -> str: return (root / path).read_text(encoding="utf-8") def check_package_manager() -> None: for path in ("package.json", "frontend/package.json"): payload = json.loads(read_text(path)) package_manager = payload.get("packageManager", "") if not str(package_manager).startswith("bun@"): fail(f"{path}: packageManager must stay Bun-only, got {package_manager!r}") for script_name, command in payload.get("scripts", {}).items(): if re.search(r"\b(npm|pnpm|yarn)\b", command): fail(f"{path} script {script_name!r} uses a forbidden package manager: {command}") forbidden_lockfiles = [ "frontend/package-lock.json", "frontend/pnpm-lock.yaml", "frontend/yarn.lock", ] for path in forbidden_lockfiles: if (root / path).exists(): fail(f"{path}: forbidden frontend lockfile; frontend package management is Bun-only") def extract_admin_routes() -> tuple[set[str], set[str]]: admin_routes_text = read_text("frontend/src/admin/AdminRoutes.tsx") manifest_text = read_text("frontend/src/admin/routes/manifest.tsx") route_paths = set(re.findall(r' None: route_paths, manifest_paths = extract_admin_routes() public_manifest_paths = {"/earth", "/docs"} redirect_only_routes = {"/alerts"} missing_routes = sorted(manifest_paths - route_paths - public_manifest_paths) if missing_routes: fail( "admin route manifest points to paths that AdminRoutes does not render: " + ", ".join(missing_routes) ) missing_manifest = sorted(route_paths - manifest_paths - redirect_only_routes) if missing_manifest: fail( "AdminRoutes renders paths that are missing from admin route manifest: " + ", ".join(missing_manifest) ) def known_frontend_routes() -> set[str]: app_text = read_text("frontend/src/App.tsx") admin_text = read_text("frontend/src/admin/AdminRoutes.tsx") manifest_text = read_text("frontend/src/admin/routes/manifest.tsx") routes = set(re.findall(r' None: routes = known_frontend_routes() allowed_prefixes = ( "/api/", "/ws", "/assets/", "/earth/", ) literal_link_pattern = re.compile(r"\b(?:to|href)\s*=\s*['\"](/[^'\"#]+(?:#[^'\"]*)?)['\"]") for path in (root / "frontend/src").rglob("*.tsx"): text = path.read_text(encoding="utf-8") rel = path.relative_to(root) for match in literal_link_pattern.finditer(text): href = match.group(1) route_path = urlsplit(href).path if route_path in routes: continue if route_path.startswith("/docs/"): continue if any(route_path.startswith(prefix) for prefix in allowed_prefixes): continue line_no = jsx_line_number(text, match.start()) fail(f"{rel}:{line_no}: literal internal link points to an unknown frontend route: {href}") def check_admin_search_route_targets() -> None: routes = known_frontend_routes() text = read_text("frontend/src/admin/search/indexers.ts") for match in re.finditer(r"\broutePath:\s*'([^']+)'", text): route_path = match.group(1) if route_path in routes: continue line_no = jsx_line_number(text, match.start()) fail( "frontend/src/admin/search/indexers.ts:" f"{line_no}: admin search routePath points to an unknown frontend route: {route_path}" ) def iter_frontend_src_files() -> list[Path]: return [ path for path in (root / "frontend/src").rglob("*") if path.is_file() and path.suffix in {".ts", ".tsx", ".js", ".jsx", ".css"} ] def iter_style_family_css_files() -> list[Path]: files = sorted((root / "frontend/src").rglob("*.css")) files.extend(sorted((root / "frontend/public/earth/css").glob("*.css"))) return files def collect_inline_style_context(lines: list[str], start_index: int) -> str: block = [] for line in lines[start_index:start_index + 8]: block.append(line.strip()) if "}}" in line or "} as CSSProperties" in line: break return " ".join(block) def inline_style_is_dynamic(context: str) -> bool: dynamic_needles = ( "--", "CSSProperties", "transform:", "translate", "scale(", "width:", "height:", "thumbSize", "thumbOffset", "trackSize", "header.getSize", "summaryWidth", "detailWidth", "progress", "pan.x", "pan.y", "zoom", ) return any(needle in context for needle in dynamic_needles) def iter_css_blocks(text: str) -> list[tuple[int, str, str]]: text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) blocks = [] start_line = 1 selector_lines = [] body_lines = [] in_block = False for line_no, line in enumerate(text.splitlines(), 1): if not in_block: selector_lines.append(line) if "{" in line: in_block = True start_line = line_no before, after = line.split("{", 1) selector_lines[-1] = before body_lines = [after] continue if "}" in line: before, _after = line.split("}", 1) body_lines.append(before) blocks.append((start_line, " ".join(selector_lines).strip(), "\n".join(body_lines))) selector_lines = [] body_lines = [] in_block = False else: body_lines.append(line) return blocks def overflow_hidden_has_explicit_owner(selector: str, body: str) -> bool: owner_needles = ( "min-height: 0", "height: 100%", "height: 100vh", "display: grid", "display: flex", "text-overflow: ellipsis", "border-radius:", "position: fixed", "position: absolute", "scrollbar", ) selector_needles = ( "html", "body", "#root", "tui-scrollbar", "table-scroll", "segmented-control__label", "docs-page", "docs-shell", "docs-content-layout", "docs-toc", "admin", "an-page", "an-resource", "an-data", "an-panel", "an-playground", "markdown-renderer", "meta", "select", ) return any(needle in body for needle in owner_needles) or any( needle in selector for needle in selector_needles ) def css_block_for_selector(text: str, wanted_selector: str) -> tuple[int, str] | None: for line_no, selector, body in iter_css_blocks(text): selectors = [item.strip() for item in selector.split(",")] if wanted_selector in selectors: return line_no, body return None def css_has_declaration(body: str, prop: str, value_pattern: str) -> bool: return re.search(rf"(^|[;\n])\s*{re.escape(prop)}\s*:\s*{value_pattern}\s*;", body) is not None STYLE_FAMILY_OWNER_FILES = { "frontend/src/admin/styles.css", "frontend/src/components/tactile-ui/styles.css", "frontend/src/pages/Docs/Docs.css", "frontend/public/earth/css/base.css", "frontend/public/earth/css/coordinates-display.css", "frontend/public/earth/css/earth-stats.css", "frontend/public/earth/css/hud.css", "frontend/public/earth/css/info-panel.css", "frontend/public/earth/css/layer-panel.css", "frontend/public/earth/css/legend.css", "frontend/public/earth/css/news-panel.css", "frontend/public/earth/css/toolbar.css", "frontend/public/earth/css/tv-panel.css", } STYLE_FAMILY_PATTERN = re.compile(r"\b(?:badge|chip|pill|tag|status)\b", re.I) def style_family_selector(selector: str) -> str | None: for class_name in re.findall(r"\.([A-Za-z][A-Za-z0-9_-]*)", selector): if STYLE_FAMILY_PATTERN.search(class_name): return class_name return None def check_same_category_style_ownership() -> None: for path in iter_style_family_css_files(): rel = str(path.relative_to(root)) if rel in STYLE_FAMILY_OWNER_FILES: continue text = path.read_text(encoding="utf-8") for line_no, selector, _body in iter_css_blocks(text): class_name = style_family_selector(selector) if class_name is None: continue warn( f"{rel}:{line_no}: same-category {class_name!r} styles should reuse " "an existing shared style owner instead of introducing a page-local visual variant" ) def check_admin_shell_height_chain() -> None: text = read_text("frontend/src/admin/styles.css") required: dict[str, dict[str, str]] = { ".admin-theme-root": { "min-height": r"0", "height": r"100%", "overflow": r"hidden", }, ".admin": { "min-height": r"0", "height": r"100%", "display": r"grid", "overflow": r"hidden", }, ".admin__sider": { "min-height": r"0", "height": r"100%", "display": r"flex", "overflow": r"hidden", }, ".admin__nav-scroll": { "flex": r"1\s+1\s+auto", "min-height": r"0", "overflow": r"hidden", }, ".admin__account": { "flex": r"0\s+0\s+auto", }, ".admin__content": { "min-height": r"0", "height": r"100%", "display": r"grid", "overflow": r"hidden", }, ".admin__content-inner": { "min-height": r"0", "height": r"100%", "overflow": r"hidden", }, } for selector, declarations in required.items(): block = css_block_for_selector(text, selector) if block is None: fail(f"frontend/src/admin/styles.css: admin shell one-screen rule requires {selector}") continue line_no, body = block for prop, value_pattern in declarations.items(): if css_has_declaration(body, prop, value_pattern): continue friendly_value = re.sub(r"\\s\+", " ", value_pattern) fail( "frontend/src/admin/styles.css:" f"{line_no}: admin shell one-screen rule requires {selector} " f"to declare {prop}: {friendly_value}" ) def check_debug_output() -> None: for path in iter_frontend_src_files(): text = path.read_text(encoding="utf-8") rel = path.relative_to(root) for line_no, line in enumerate(text.splitlines(), 1): if "console.log(" in line or re.search(r"\bdebugger\b", line): fail(f"{rel}:{line_no}: remove console.log/debugger from frontend source") if "console." in line and "token" in line.lower(): fail(f"{rel}:{line_no}: console output must not include token material") EARTH_RUNTIME_COPY_FILES = ( "frontend/public/earth/js/main.js", "frontend/public/earth/js/controls.js", "frontend/public/earth/js/cables.js", "frontend/public/earth/js/news.js", "frontend/public/earth/js/tv.js", "frontend/public/earth/js/info-card.js", "frontend/public/earth/js/layer-startup-tasks.js", "frontend/public/earth/js/i18n.js", ) EARTH_RUNTIME_COPY_ENTRYPOINTS = ( "showStatusMessage", "queueStatusMessage", "showGestureStatusMessage", "showError", "setLoadingMessage", "resolveStartupMessage", ) CJK_PATTERN = re.compile(r"[\u3400-\u9fff]") def collect_balanced_js(text: str, start_index: int, opener: str, closer: str) -> str: depth = 0 quote: str | None = None template_depth = 0 cursor = start_index while cursor < len(text): char = text[cursor] if quote: if char == "\\": cursor += 2 continue if quote == "`" and char == "$" and cursor + 1 < len(text) and text[cursor + 1] == "{": template_depth += 1 cursor += 2 continue if template_depth > 0 and char == "}": template_depth -= 1 cursor += 1 continue if char == quote and template_depth == 0: quote = None cursor += 1 continue if char in ("'", '"', "`"): quote = char elif char == opener: depth += 1 elif char == closer: depth -= 1 if depth == 0: return text[start_index:cursor + 1] cursor += 1 return text[start_index:] def collect_js_call(text: str, start_index: int) -> str: open_index = text.find("(", start_index) if open_index == -1: return text[start_index:] return collect_balanced_js(text, open_index, "(", ")") def collect_js_value(text: str, start_index: int) -> str: cursor = start_index while cursor < len(text) and text[cursor].isspace(): cursor += 1 if cursor >= len(text): return "" char = text[cursor] if char == "{": return collect_balanced_js(text, cursor, "{", "}") if char == "[": return collect_balanced_js(text, cursor, "[", "]") if text.startswith("earthMessage", cursor): open_index = text.find("(", cursor) if open_index == -1: return text[cursor:] return text[cursor:open_index] + collect_balanced_js(text, open_index, "(", ")") if char in ("'", '"', "`"): quote = char cursor += 1 while cursor < len(text): if text[cursor] == "\\": cursor += 2 continue if text[cursor] == quote: return text[start_index:cursor + 1] cursor += 1 return text[start_index:] end_candidates = [ index for index in (text.find(",", cursor), text.find("\n", cursor)) if index != -1 ] end_index = min(end_candidates) if end_candidates else len(text) return text[start_index:end_index] def check_earth_runtime_copy_entrypoints() -> None: i18n_text = read_text("frontend/public/earth/js/i18n.js") for token in ("EARTH_MESSAGE_TEMPLATES", "export function earthMessage", "export function formatEarthMessage"): if token not in i18n_text: fail(f"frontend/public/earth/js/i18n.js: missing centralized Earth runtime copy token {token}") direct_literal_pattern = re.compile(r"^\(\s*['\"`]") for file_path in EARTH_RUNTIME_COPY_FILES: text = read_text(file_path) for entrypoint in EARTH_RUNTIME_COPY_ENTRYPOINTS: pattern = re.compile(rf"\b{re.escape(entrypoint)}\s*\(") for match in pattern.finditer(text): call = collect_js_call(text, match.start()) if direct_literal_pattern.search(call): fail( f"{file_path}:{jsx_line_number(text, match.start())}: " f"Earth runtime copy entrypoint {entrypoint} must use earthMessage(...), not a direct string literal" ) if CJK_PATTERN.search(call) and "earthMessage(" not in call: fail( f"{file_path}:{jsx_line_number(text, match.start())}: " f"Earth runtime copy entrypoint {entrypoint} contains CJK without earthMessage(...)" ) for match in re.finditer(r"\bstartupMessage\s*:\s*", text): value = collect_js_value(text, match.end()).strip() if value in {'""', "''", "``"}: continue if "earthMessage(" not in value: fail( f"{file_path}:{jsx_line_number(text, match.start())}: " "Earth layer startupMessage must use earthMessage(...) so startup copy has one i18n entrypoint" ) elif CJK_PATTERN.search(value) and "earthMessage(" not in value: fail( f"{file_path}:{jsx_line_number(text, match.start())}: " "Earth layer startupMessage contains CJK outside the centralized runtime copy map" ) for match in re.finditer(r"new\s+CustomEvent\(\s*['\"]earth:status['\"]", text): call = collect_js_call(text, match.start()) if re.search(r"\bmessage\s*:\s*['\"`]", call): fail( f"{file_path}:{jsx_line_number(text, match.start())}: " "earth:status event messages must use earthMessage(...) instead of direct strings" ) if CJK_PATTERN.search(call) and "earthMessage(" not in call: fail( f"{file_path}:{jsx_line_number(text, match.start())}: " "earth:status event contains CJK without earthMessage(...)" ) def jsx_line_number(text: str, index: int) -> int: return text.count("\n", 0, index) + 1 def iter_opening_tags(text: str, tag_name: str) -> list[tuple[int, str]]: tags: list[tuple[int, str]] = [] needle = f"<{tag_name}" index = 0 while True: start = text.find(needle, index) if start == -1: return tags next_char_index = start + len(needle) if next_char_index < len(text) and (text[next_char_index].isalnum() or text[next_char_index] in "_-"): index = next_char_index continue quote: str | None = None brace_depth = 0 cursor = next_char_index while cursor < len(text): char = text[cursor] if quote: if char == "\\": cursor += 2 continue if char == quote: quote = None cursor += 1 continue if char in ('"', "'", "`"): quote = char elif char == "{": brace_depth += 1 elif char == "}": brace_depth = max(0, brace_depth - 1) elif char == ">" and brace_depth == 0: tags.append((start, text[start:cursor + 1])) index = cursor + 1 break cursor += 1 else: return tags def check_native_button_safety() -> None: for path in (root / "frontend/src").rglob("*.tsx"): text = path.read_text(encoding="utf-8") rel = path.relative_to(root) for start, tag in iter_opening_tags(text, "button"): line_no = jsx_line_number(text, start) if not re.search(r"\btype\s*=", tag): fail(f"{rel}:{line_no}: native