466 lines
17 KiB
Bash
Executable File
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 "$@"
|