Files
planet/scripts/harness/frontend-rules-check.sh
linkong 5bdb55f3f1
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.74.0
2026-06-30 13:52:52 +08:00

741 lines
27 KiB
Bash
Executable File

#!/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'<Route\s+path="([^"*][^"]*)"', admin_routes_text))
route_paths.discard("*")
manifest_paths = set(re.findall(r"path:\s*'([^']+)'", manifest_text))
return route_paths, manifest_paths
def check_route_manifest_consistency() -> 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'<Route\s+path="([^"*][^"]*)"', app_text))
routes.update(re.findall(r'<Route\s+path="([^"*][^"]*)"', admin_text))
routes.update(re.findall(r"path:\s*'([^']+)'", manifest_text))
routes.update({"/", "/docs/:slug"})
routes.discard("*")
routes.discard("/*")
return routes
def check_literal_internal_links() -> 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 <button> must declare an explicit type")
if re.search(r"\bdisabled\s*=", tag) and re.search(r"\{\s*\.\.\.", tag):
spread_index = tag.find("{...")
disabled_index = tag.find("disabled")
if disabled_index < spread_index:
fail(
f"{rel}:{line_no}: button disabled state can be overridden by a later props spread"
)
def check_icon_button_accessibility() -> 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"):
if not re.search(r"\bsize\s*=\s*['\"]icon['\"]", tag):
continue
if "aria-label" not in tag and "ariaLabel" not in tag:
fail(f"{rel}:{jsx_line_number(text, start)}: icon Button must include aria-label")
if not re.search(r"\btitle\s*=", tag):
fail(f"{rel}:{jsx_line_number(text, start)}: icon Button must include title")
def check_no_nested_cards() -> None:
for path in (root / "frontend/src").rglob("*.tsx"):
text = path.read_text(encoding="utf-8")
rel = path.relative_to(root)
depth = 0
for match in re.finditer(r"</?Card\b", text):
token = match.group(0)
if token.startswith("</"):
depth = max(0, depth - 1)
continue
line_no = jsx_line_number(text, match.start())
if depth > 0:
fail(f"{rel}:{line_no}: do not nest Card components inside other Cards")
tag_end = text.find(">", match.start())
tag = text[match.start():tag_end + 1] if tag_end != -1 else ""
if not tag.rstrip().endswith("/>"):
depth += 1
def check_no_antd_layout_primitives() -> None:
for path in (root / "frontend/src").rglob("*.tsx"):
text = path.read_text(encoding="utf-8")
rel = path.relative_to(root)
if re.search(r"\bfrom\s+['\"]antd['\"]", text):
fail(f"{rel}: active frontend must not import AntD; use Tactile UI/Radix project components")
for match in re.finditer(r"<Space\b", text):
line_no = jsx_line_number(text, match.start())
fail(f"{rel}:{line_no}: avoid implicit Space layout primitives in height-critical UI")
def check_connection_test_input_pattern() -> None:
text = read_text("frontend/src/admin/pages/PlainResourcePages.tsx")
if "function ConnectionTestInput" not in text:
fail("AI/WebSearch connection tests must use the shared ConnectionTestInput pattern")
for label in ("测试 AI Provider 连通性", "测试 Web Search 连通性"):
if re.search(rf"<Button\b(?=[^>]*\btitle=['\"]{re.escape(label)}['\"])", text):
fail(f"{label}: connection test must be attached to the Base URL input suffix, not a standalone Button")
provider_pattern = re.compile(
r"key:\s*'base_url'[\s\S]{0,260}inputAction:[\s\S]{0,260}"
r"title:\s*'测试 AI Provider 连通性'",
)
if not provider_pattern.search(text):
fail("AI Provider Base URL field must expose its connection test through inputAction")
web_pattern = re.compile(
r"key:\s*'base_url'[\s\S]{0,320}disabled:\s*webSearchDisabled[\s\S]{0,260}"
r"title:\s*'测试 Web Search 连通性'",
)
if not web_pattern.search(text):
fail("WebSearch Base URL field must expose a disabled-aware connection test through inputAction")
def check_uiux_static_warnings() -> None:
viewport_font_pattern = re.compile(r"\b(?:font-size|fontSize)\s*[:=]\s*[^;\n}]*(?:vw|vmin|vmax|cqw|cqi)")
letter_spacing_pattern = re.compile(r"\b(?:letter-spacing|letterSpacing)\s*[:=]\s*([^;\n}]+)")
shell_viewport_height_pattern = re.compile(r"\b(?:height|min-height)\s*:\s*100v[hw]\s*;")
for path in iter_frontend_src_files():
text = path.read_text(encoding="utf-8")
rel = path.relative_to(root)
for match in viewport_font_pattern.finditer(text):
line_no = jsx_line_number(text, match.start())
fail(f"{rel}:{line_no}: font size must not scale with viewport width units under uiux rules")
for match in letter_spacing_pattern.finditer(text):
value = match.group(1).strip().strip("'\"")
allowed = (
value in {"0", "0em", "0rem", "normal", "inherit", "initial", "unset"}
or re.fullmatch(r"var\([^,]+,\s*0(?:em|rem)?\s*\)", value)
)
if allowed:
continue
line_no = jsx_line_number(text, match.start())
fail(f"{rel}:{line_no}: letter spacing must stay 0 under uiux rules, got {value!r}")
if str(rel) in {"frontend/src/admin/styles.css", "frontend/src/pages/Docs/Docs.css"}:
for match in shell_viewport_height_pattern.finditer(text):
line_no = jsx_line_number(text, match.start())
fail(
f"{rel}:{line_no}: admin/docs shells must use the root 100% height chain, "
"not exact 100vh/100vw sizing"
)
if path.suffix == ".css":
continue
lines = text.splitlines()
for line_no, line in enumerate(lines, 1):
if "style={{" not in line:
continue
context = collect_inline_style_context(lines, line_no - 1)
if not inline_style_is_dynamic(context):
warn(f"{rel}:{line_no}: inline style found; rules prefer CSS classes unless values are truly dynamic")
if re.search(r"\bas\s+any\b|<any>", text):
warn(f"{rel}: broad TypeScript cast found; prefer specific types where practical")
for path in (root / "frontend/src").rglob("*.css"):
text = path.read_text(encoding="utf-8")
rel = path.relative_to(root)
for line_no, selector, body in iter_css_blocks(text):
if "overflow: hidden" not in body:
continue
if not overflow_hidden_has_explicit_owner(selector, body):
warn(
f"{rel}:{line_no}: overflow:hidden needs an explicit child scroll owner "
f"or a documented clipping reason under uiux rules"
)
def main() -> None:
check_package_manager()
check_route_manifest_consistency()
check_literal_internal_links()
check_admin_search_route_targets()
check_debug_output()
check_earth_runtime_copy_entrypoints()
check_native_button_safety()
check_icon_button_accessibility()
check_no_nested_cards()
check_no_antd_layout_primitives()
check_connection_test_input_pattern()
check_admin_shell_height_chain()
check_same_category_style_ownership()
check_uiux_static_warnings()
for message in warnings:
print(f"warn: {message}")
if failures:
for message in failures:
print(f"fail: {message}")
raise SystemExit(f"frontend rules check failed: {len(failures)} failure(s), {len(warnings)} warning(s)")
print(f"frontend rules check passed: {len(warnings)} warning(s)")
main()
PY
}
main "$@"