Files
planet/scripts/harness/frontend-rules-check.sh
linkong 19d5ac0fee
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.72.0
2026-06-29 14:05:06 +08:00

466 lines
17 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 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 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")
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_native_button_safety()
check_icon_button_accessibility()
check_no_nested_cards()
check_no_antd_layout_primitives()
check_connection_test_input_pattern()
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 "$@"