release: bump version to 0.72.0
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

This commit is contained in:
linkong
2026-06-29 14:05:06 +08:00
parent 3265d22af5
commit 19d5ac0fee
60 changed files with 3702 additions and 678 deletions

View File

@@ -0,0 +1,60 @@
#!/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 ast
from pathlib import Path
root = Path.cwd()
failures: list[str] = []
def fail(message: str) -> None:
failures.append(message)
class BackendDebugCallVisitor(ast.NodeVisitor):
def __init__(self, path: Path) -> None:
self.path = path
def visit_Call(self, node: ast.Call) -> None:
if isinstance(node.func, ast.Name) and node.func.id in {"print", "breakpoint"}:
fail(f"{self.path}:{node.lineno}: backend app code must use structured logging, not {node.func.id}()")
if (
isinstance(node.func, ast.Attribute)
and node.func.attr == "set_trace"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "pdb"
):
fail(f"{self.path}:{node.lineno}: remove pdb.set_trace() from backend app code")
self.generic_visit(node)
for path in sorted((root / "backend/app").rglob("*.py")):
rel = path.relative_to(root)
try:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(rel))
except SyntaxError as exc:
fail(f"{rel}:{exc.lineno}: Python syntax error: {exc.msg}")
continue
BackendDebugCallVisitor(rel).visit(tree)
if failures:
for message in failures:
print(f"fail: {message}")
raise SystemExit(f"backend rules check failed: {len(failures)} failure(s)")
print("backend rules check passed")
PY
}
main "$@"

View File

@@ -0,0 +1,645 @@
#!/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 ast
import re
from pathlib import Path
from urllib.parse import parse_qs, 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 literal_assignment(path: str, name: str) -> object:
tree = ast.parse(read_text(path))
for node in tree.body:
if not isinstance(node, ast.Assign):
continue
if not any(isinstance(target, ast.Name) and target.id == name for target in node.targets):
continue
return ast.literal_eval(node.value)
raise ValueError(f"could not find literal assignment {name} in {path}")
def slug_from_filename(filename: str) -> str:
return "overview" if filename == "README.md" else filename.removesuffix(".md")
def frontend_docs_metadata() -> dict[str, dict[str, object]]:
text = read_text("frontend/src/pages/Docs/docs-content.ts")
entries: dict[str, dict[str, object]] = {}
pattern = re.compile(
r"(?:\[(?P<constant>DOCS_README_FILENAME)\]|'(?P<filename>[^']+\.md)'):\s*\{\s*"
r"zh:\s*\{\s*title:\s*'(?P<zh_title>[^']+)',\s*group:\s*'(?P<zh_group>[^']+)',\s*order:\s*(?P<zh_order>\d+)\s*\},\s*"
r"en:\s*\{\s*title:\s*'(?P<en_title>[^']+)',\s*group:\s*'(?P<en_group>[^']+)',\s*order:\s*(?P<en_order>\d+)\s*\}",
re.S,
)
for match in pattern.finditer(text):
filename = "README.md" if match.group("constant") else match.group("filename")
if filename in entries:
fail(f"frontend Docs metadata registers {filename} more than once")
continue
entries[filename] = {
"slug": slug_from_filename(filename),
"zh_title": match.group("zh_title"),
"en_title": match.group("en_title"),
"zh_group": match.group("zh_group"),
"en_group": match.group("en_group"),
"zh_order": int(match.group("zh_order")),
"en_order": int(match.group("en_order")),
}
return entries
def backend_docs_metadata() -> dict[str, dict[str, object]]:
tree = ast.parse(read_text("backend/app/services/docs_gatekeeper.py"))
constants: dict[str, object] = {}
for node in tree.body:
if not isinstance(node, ast.Assign) or len(node.targets) != 1:
continue
target = node.targets[0]
if isinstance(target, ast.Name) and isinstance(node.value, ast.Constant):
constants[target.id] = node.value.value
def read_arg(node: ast.AST) -> object:
if isinstance(node, ast.Constant):
return node.value
if isinstance(node, ast.Name) and node.id in constants:
return constants[node.id]
raise ValueError(f"unsupported DocsMetadata argument: {ast.dump(node)}")
entries: dict[str, dict[str, object]] = {}
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if not isinstance(node.func, ast.Name) or node.func.id != "DocsMetadata":
continue
if len(node.args) != 7:
fail("backend DocsMetadata entries must keep the expected seven positional fields")
continue
filename, slug, access, group, order, zh_title, en_title = [read_arg(arg) for arg in node.args]
if not isinstance(filename, str):
fail("backend DocsMetadata filename must be a string")
continue
if filename in entries:
fail(f"backend Docs metadata registers {filename} more than once")
continue
entries[filename] = {
"slug": slug,
"access": access,
"group": group,
"order": order,
"zh_title": zh_title,
"en_title": en_title,
}
return entries
def docs_metadata_filenames() -> set[str]:
return set(frontend_docs_metadata())
def check_docs_metadata_alignment() -> None:
frontend = frontend_docs_metadata()
backend = backend_docs_metadata()
if not frontend:
fail("frontend Docs metadata must expose at least one document")
if not backend:
fail("backend Gatekeeper Docs metadata must expose at least one document")
frontend_only = sorted(set(frontend) - set(backend))
backend_only = sorted(set(backend) - set(frontend))
if frontend_only:
fail("frontend Docs metadata has files missing from backend Gatekeeper metadata: " + ", ".join(frontend_only))
if backend_only:
fail("backend Gatekeeper metadata has files missing from frontend Docs metadata: " + ", ".join(backend_only))
for filename in sorted(set(frontend) & set(backend)):
frontend_entry = frontend[filename]
backend_entry = backend[filename]
if frontend_entry["zh_group"] != frontend_entry["en_group"]:
fail(f"frontend Docs metadata group differs by language for {filename}")
if frontend_entry["zh_order"] != frontend_entry["en_order"]:
fail(f"frontend Docs metadata order differs by language for {filename}")
expected = {
"slug": frontend_entry["slug"],
"group": frontend_entry["zh_group"],
"order": frontend_entry["zh_order"],
"zh_title": frontend_entry["zh_title"],
"en_title": frontend_entry["en_title"],
}
actual = {key: backend_entry[key] for key in expected}
if actual != expected:
fail(f"{filename}: frontend Docs metadata and backend Gatekeeper metadata differ; frontend={expected}, backend={actual}")
def check_public_docs_registry() -> None:
filenames = docs_metadata_filenames()
for filename in sorted(filenames):
for lang in ("zh", "en"):
path = root / "docs/technical" / lang / filename
if not path.exists():
fail(f"public docs metadata references missing file: {path.relative_to(root)}")
for readme in (root / "docs/technical/zh/README.md", root / "docs/technical/en/README.md"):
if not readme.exists():
continue
for href in re.findall(r"\]\(([^)]+\.md)\)", readme.read_text(encoding="utf-8")):
if "docs/technical/" not in href:
continue
filename = Path(href).name
if filename not in filenames:
fail(f"{readme.relative_to(root)} links {filename}, but DOCS_METADATA does not expose it")
def check_all_technical_docs_have_bilingual_pairs() -> None:
zh_dir = root / "docs/technical/zh"
en_dir = root / "docs/technical/en"
zh_files = {path.name for path in zh_dir.glob("*.md")}
en_files = {path.name for path in en_dir.glob("*.md")}
zh_only = sorted(zh_files - en_files)
en_only = sorted(en_files - zh_files)
if zh_only:
fail("technical docs missing English counterparts: " + ", ".join(zh_only))
if en_only:
fail("technical docs missing Chinese counterparts: " + ", ".join(en_only))
def check_bilingual_docs_are_not_copies() -> None:
same: list[str] = []
for en in sorted((root / "docs/technical/en").glob("*.md")):
zh = root / "docs/technical/zh" / en.name
if zh.exists() and en.read_text(encoding="utf-8") == zh.read_text(encoding="utf-8"):
same.append(en.name)
if same:
fail("identical en/zh technical docs: " + ", ".join(same))
def check_public_doc_links_exist() -> None:
repo_prefix = f"{root}/"
for doc in sorted((root / "docs/technical").glob("*/*.md")):
text = doc.read_text(encoding="utf-8")
for href in re.findall(r"\]\(([^)#]+\.md)(?:#[^)]+)?\)", text):
if not href.startswith(repo_prefix):
continue
target = Path(href)
if not target.exists():
fail(f"{doc.relative_to(root)} links missing markdown file: {href}")
def check_language_scoped_technical_links() -> None:
technical_docs_prefix = re.escape(str(root / "docs/technical"))
pattern = re.compile(rf"{technical_docs_prefix}/(?!zh/|en/)[^)#\s]+")
for doc in sorted((root / "docs/technical").glob("*/*.md")):
text = doc.read_text(encoding="utf-8")
for match in pattern.finditer(text):
line_no = text.count("\n", 0, match.start()) + 1
fail(
f"{doc.relative_to(root)}:{line_no}: technical doc link must include "
f"the language directory: {match.group(0)}"
)
def check_public_doc_link_titles() -> None:
pattern = re.compile(r"\[([^]\n]+\.md)\]\(")
for doc in sorted((root / "docs/technical").glob("*/*.md")):
text = doc.read_text(encoding="utf-8")
for match in pattern.finditer(text):
line_no = text.count("\n", 0, match.start()) + 1
fail(
f"{doc.relative_to(root)}:{line_no}: public docs should use readable "
f"link text instead of raw filename {match.group(1)!r}"
)
def check_credential_collector_contracts() -> None:
defaults = literal_assignment("backend/app/core/datasource_defaults.py", "DEFAULT_DATASOURCES")
if not isinstance(defaults, dict):
fail("backend/app/core/datasource_defaults.py DEFAULT_DATASOURCES must stay a dict")
return
supported_sources: dict[str, str] = {}
for source, info in defaults.items():
if not isinstance(info, dict):
fail(f"DEFAULT_DATASOURCES entry {source!r} must be a dict")
continue
if not info.get("requires_credentials"):
continue
if info.get("credential_status") != "supported":
continue
provider = info.get("credential_provider")
if not isinstance(provider, str) or not provider.strip():
fail(f"{source}: supported credential collector is missing credential_provider")
continue
supported_sources[str(source)] = provider
if not supported_sources:
return
guides_text = read_text("backend/app/services/credential_guides.py")
default_guides = set(re.findall(r"CredentialGuideDefault\(\s*provider=\"([^\"]+)\"", guides_text))
connectivity_providers = literal_assignment(
"backend/app/services/datasource_connectivity.py",
"SUPPORTED_CREDENTIAL_PROVIDERS",
)
if not isinstance(connectivity_providers, set):
fail("SUPPORTED_CREDENTIAL_PROVIDERS must stay a literal set")
connectivity_providers = set()
frontend_text = read_text("frontend/src/admin/pages/PlainResourcePages.tsx")
tests_text = read_text("backend/tests/test_collectors.py")
zh_doc = read_text("docs/technical/zh/datasource-collector-settings-connectivity.md")
en_doc = read_text("docs/technical/en/datasource-collector-settings-connectivity.md")
if "test_supported_credential_collectors_have_guides_and_connectivity_provider" not in tests_text:
fail("backend/tests/test_collectors.py must keep the supported credential collector contract test")
if "loadCredentialGuide" not in frontend_text or "credentialGuideProvider" not in frontend_text:
fail("collector credential UI must keep guide-loading and provider-normalization helpers")
for source, provider in sorted(supported_sources.items()):
if provider not in default_guides:
fail(f"{source}: missing default credential guide for provider {provider}")
if provider not in connectivity_providers:
fail(f"{source}: missing supported connectivity provider {provider}")
if provider not in frontend_text and source not in frontend_text:
fail(f"{source}: collector credential UI does not mention provider/source {provider}")
for doc_path, doc_text in (
("docs/technical/zh/datasource-collector-settings-connectivity.md", zh_doc),
("docs/technical/en/datasource-collector-settings-connectivity.md", en_doc),
):
if provider not in doc_text and source not in doc_text:
fail(f"{doc_path}: missing supported credential collector provider/source {provider}/{source}")
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({"/", "/playground", "/docs/:slug"})
routes.discard("*")
return routes
def admin_manifest_routes() -> dict[str, str]:
manifest_text = read_text("frontend/src/admin/routes/manifest.tsx")
routes: dict[str, str] = {}
pattern = re.compile(r"\{\s*path:\s*'([^']+)',\s*label:\s*'([^']+)'", re.S)
for path, label in pattern.findall(manifest_text):
routes[path] = label
if not routes:
fail("could not parse admin route manifest for manual coverage checks")
return routes
def manual_console_route_rows(doc_path: Path, heading: str) -> dict[str, str]:
text = doc_path.read_text(encoding="utf-8")
heading_index = text.find(heading)
if heading_index == -1:
fail(f"{doc_path.relative_to(root)} is missing {heading!r}")
return {}
next_heading = re.search(r"\n##\s+", text[heading_index + len(heading):])
section = text[heading_index:] if not next_heading else text[heading_index:heading_index + len(heading) + next_heading.start()]
rows: dict[str, str] = {}
for line in section.splitlines():
match = re.match(r"\|\s*([^|`][^|]*?)\s*\|\s*`([^`]+)`\s*\|", line)
if not match:
continue
label = match.group(1).strip()
path = match.group(2).strip()
rows[path] = label
return rows
def normalize_zh_label(label: str) -> str:
return re.sub(r"\s+", "", label)
def check_manual_console_route_tables() -> None:
manifest = admin_manifest_routes()
zh_manual = root / "docs/technical/zh/manual.md"
en_manual = root / "docs/technical/en/manual.md"
manual_rows = {
"zh": manual_console_route_rows(zh_manual, "## 控制台总览"),
"en": manual_console_route_rows(en_manual, "## Console Overview"),
}
for lang, rows in manual_rows.items():
missing = sorted(set(manifest) - set(rows))
extra = sorted(set(rows) - set(manifest))
if missing:
fail(f"docs/technical/{lang}/manual.md console overview misses admin manifest route(s): " + ", ".join(missing))
if extra:
fail(f"docs/technical/{lang}/manual.md console overview lists route(s) missing from admin manifest: " + ", ".join(extra))
zh_rows = manual_rows["zh"]
for path, expected_label in manifest.items():
actual = zh_rows.get(path)
if actual is None:
continue
if normalize_zh_label(actual) != normalize_zh_label(expected_label):
fail(
"docs/technical/zh/manual.md console overview label mismatch for "
f"{path}: expected {expected_label!r}, got {actual!r}"
)
def extract_balanced(text: str, start: int, open_char: str, close_char: str) -> str:
depth = 0
quote: str | None = None
escape = False
for index in range(start, len(text)):
char = text[index]
if quote:
if escape:
escape = False
elif char == "\\":
escape = True
elif char == quote:
quote = None
continue
if char in ("'", '"', "`"):
quote = char
continue
if char == open_char:
depth += 1
elif char == close_char:
depth -= 1
if depth == 0:
return text[start:index + 1]
raise ValueError(f"could not find balanced {open_char}{close_char} block")
def find_balanced_after(text: str, marker: str, open_char: str, close_char: str) -> str:
marker_index = text.index(marker)
start = text.index(open_char, marker_index)
return extract_balanced(text, start, open_char, close_char)
def nesting_depth(text: str, stop: int, open_char: str, close_char: str) -> int:
depth = 0
quote: str | None = None
escape = False
for char in text[:stop]:
if quote:
if escape:
escape = False
elif char == "\\":
escape = True
elif char == quote:
quote = None
continue
if char in ("'", '"', "`"):
quote = char
continue
if char == open_char:
depth += 1
elif char == close_char:
depth -= 1
return depth
def top_level_config_blocks(configs_block: str) -> dict[str, str]:
blocks: dict[str, str] = {}
index = 1
while index < len(configs_block) - 1:
match = re.search(r"\b([A-Za-z][A-Za-z0-9_]*)\s*:\s*\{", configs_block[index:])
if not match:
break
name = match.group(1)
start = index + match.end() - 1
if nesting_depth(configs_block, start, "{", "}") != 1:
index = start + 1
continue
block = extract_balanced(configs_block, start, "{", "}")
blocks[name] = block
index = start + len(block)
return blocks
def direct_section_keys(config_block: str) -> set[str]:
sections_match = re.search(r"\bsections\s*:\s*\[", config_block)
if not sections_match:
return set()
sections_block = extract_balanced(config_block, sections_match.end() - 1, "[", "]")
keys: set[str] = set()
index = 1
while index < len(sections_block) - 1:
match = re.search(r"\{\s*key\s*:\s*'([^']+)'", sections_block[index:])
if not match:
break
start = index + match.start()
if (
nesting_depth(sections_block, start, "[", "]") == 1
and nesting_depth(sections_block, start, "{", "}") == 0
):
keys.add(match.group(1))
block = extract_balanced(sections_block, start, "{", "}")
index = start + len(block)
else:
index = start + 1
return keys
def known_admin_sections() -> dict[str, set[str]]:
plain_text = read_text("frontend/src/admin/pages/PlainResourcePages.tsx")
admin_text = read_text("frontend/src/admin/AdminRoutes.tsx")
configs_block = find_balanced_after(plain_text, "const configs =", "{", "}")
configs = top_level_config_blocks(configs_block)
component_to_config = dict(re.findall(
r"export function (\w+)\(\) \{\s*return <ModuleConsole config=\{configs\.([A-Za-z0-9_]+)\} />\s*\}",
plain_text,
))
path_to_component = dict(re.findall(
r'<Route\s+path="([^"]+)"\s+element=\{<([A-Za-z0-9_]+)\s*/>\}',
admin_text,
))
section_map: dict[str, set[str]] = {}
for path, component in path_to_component.items():
config_key = component_to_config.get(component)
if not config_key:
continue
if config_key not in configs:
fail(f"Admin route {path} uses unknown PlainResourcePages config: {config_key}")
continue
section_map[path] = direct_section_keys(configs[config_key])
return section_map
def check_documented_section_deep_links() -> None:
section_map = known_admin_sections()
pattern = re.compile(r"/[a-z][a-z0-9/-]*\?section=[a-z0-9_/-]+")
docs = [
*sorted((root / "docs/technical").glob("*/*.md")),
*sorted((root / "docs/plans").glob("*.md")),
]
for doc in docs:
text = doc.read_text(encoding="utf-8")
for match in pattern.finditer(text):
value = match.group(0)
parsed = urlsplit(value)
section = parse_qs(parsed.query).get("section", [""])[0]
if parsed.path not in section_map:
line_no = text.count("\n", 0, match.start()) + 1
fail(f"{doc.relative_to(root)}:{line_no}: documented section link uses a route with no known sections: {value}")
continue
if section not in section_map[parsed.path]:
line_no = text.count("\n", 0, match.start()) + 1
known = ", ".join(sorted(section_map[parsed.path]))
fail(f"{doc.relative_to(root)}:{line_no}: documented section link {value} is not a known section; expected one of: {known}")
def check_documented_ui_routes() -> None:
routes = known_frontend_routes()
allowed_prefixes = (
"/api/",
"/ws",
"/health",
"/legacy/",
"/earth/",
"/docs/",
"/assets/",
"/components/",
"/dev/",
"/etc/",
"/home/",
"/tmp/",
"/root",
"/app/",
"/planet",
"/localhost",
"/example",
"/your-",
)
route_docs = [
root / "docs/technical/zh/README.md",
root / "docs/technical/en/README.md",
root / "docs/technical/zh/manual.md",
root / "docs/technical/en/manual.md",
root / "docs/technical/zh/quickstart.md",
root / "docs/technical/en/quickstart.md",
root / "docs/technical/zh/frontend-admin-frontend-context.md",
root / "docs/technical/en/frontend-admin-frontend-context.md",
]
route_pattern = re.compile(r"(?<![\w:_}-])/(?:[a-z][a-z0-9-]*)(?:/[a-z0-9:_-]+)*")
for doc in route_docs:
if not doc.exists():
continue
text = doc.read_text(encoding="utf-8")
for candidate in sorted(set(route_pattern.findall(text))):
if candidate in routes:
continue
if candidate.startswith(allowed_prefixes):
continue
if re.search(r"/(start|stop|restart|reset|status|stream|generate|cancel|tasks?|task-status|events)$", candidate):
continue
if re.fullmatch(r"/[a-z]{2,3}", candidate):
continue
if candidate == "/admin-next" or candidate.startswith("/admin-next/"):
fail(f"{doc.relative_to(root)} references stale admin-next route {candidate}")
elif candidate.startswith("/") and candidate.count("/") <= 2:
warn(f"{doc.relative_to(root)} references route-like path not in current frontend routes: {candidate}")
def check_stale_admin_doc_terms() -> None:
stale_patterns = [
(re.compile(r"React\s*\+\s*Ant Design"), "console frontend is no longer React + Ant Design"),
(re.compile(r"Ant Design Pro"), "console frontend is no longer Ant Design Pro"),
(re.compile(r"Old AntD legacy pages"), "old AntD legacy pages are not part of the active frontend"),
(re.compile(r"Task Queue:\s*Celery"), "Celery is not part of the active local task stack"),
(re.compile(r"Message Queue:\s*Kafka"), "Kafka is not part of the active local message stack"),
(re.compile(r"UI Library:\s*Ant Design Pro"), "console frontend is no longer Ant Design Pro"),
(re.compile(r"├── unreal/"), "UE5 is not an active checked-in local project tree"),
(re.compile(r"Unreal Engine 5 3D visualization"), "UE5 is future/optional, not the active local visualization shell"),
(re.compile(r"Polarized 3D large display \(4K, 120Hz\)"), "physical display work is future/optional, not the active local loop"),
(re.compile(r"\?tab="), "admin deep links use ?section=, not ?tab="),
]
docs = [
root / "AGENTS.md",
root / "README.md",
root / "project_context.md",
*sorted((root / "docs/technical").glob("*/*.md")),
*sorted((root / "docs/plans").glob("*.md")),
]
for doc in docs:
text = doc.read_text(encoding="utf-8")
for pattern, reason in stale_patterns:
for match in pattern.finditer(text):
line_no = text.count("\n", 0, match.start()) + 1
fail(f"{doc.relative_to(root)}:{line_no}: stale admin documentation: {reason}")
def check_harness_rule_coverage_notes() -> None:
harness_doc = read_text("docs/HARNESS.md")
audit_doc = read_text("docs/harness-audit.md")
if "Rules Coverage Evidence" not in harness_doc:
fail("docs/HARNESS.md must point frontend/docs audits to the Rules Coverage Evidence section")
if "## Rules Coverage Evidence" not in audit_doc:
fail("docs/harness-audit.md must keep the Rules Coverage Evidence section")
for module in ("core", "security", "workflow", "docs", "uiux", "frontend", "earth"):
if f"| `{module}` |" not in audit_doc:
fail(f"docs/harness-audit.md Rules Coverage Evidence must include the `{module}` rules module")
def main() -> None:
check_docs_metadata_alignment()
check_public_docs_registry()
check_all_technical_docs_have_bilingual_pairs()
check_bilingual_docs_are_not_copies()
check_public_doc_links_exist()
check_language_scoped_technical_links()
check_public_doc_link_titles()
check_credential_collector_contracts()
check_manual_console_route_tables()
check_documented_section_deep_links()
check_documented_ui_routes()
check_stale_admin_doc_terms()
check_harness_rule_coverage_notes()
for message in warnings:
print(f"warn: {message}")
if failures:
for message in failures:
print(f"fail: {message}")
raise SystemExit(f"docs consistency check failed: {len(failures)} failure(s), {len(warnings)} warning(s)")
print(f"docs consistency check passed: {len(warnings)} warning(s)")
main()
PY
}
main "$@"

View File

@@ -3,6 +3,8 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/harness/lib.sh"
failures=0
warnings=0
@@ -22,8 +24,10 @@ fail() {
check_cmd() {
local cmd="$1"
if command -v "$cmd" >/dev/null 2>&1; then
ok "found command: $cmd"
local found
if found="$(harness_find_cmd "$cmd")"; then
harness_prepend_tool_dir "$found"
ok "found command: $cmd ($found)"
else
fail "missing required command: $cmd"
fi
@@ -32,8 +36,10 @@ check_cmd() {
check_optional_cmd() {
local cmd="$1"
local reason="$2"
if command -v "$cmd" >/dev/null 2>&1; then
ok "found optional command: $cmd"
local found
if found="$(harness_find_cmd "$cmd")"; then
harness_prepend_tool_dir "$found"
ok "found optional command: $cmd ($found)"
else
warn "missing optional command: $cmd ($reason)"
fi
@@ -64,7 +70,6 @@ main() {
check_file README.md
check_file rules.md
check_file project_context.md
check_file agents.md
check_file AGENTS.md
check_file CODEMAP.md
check_file docs/HARNESS.md
@@ -73,6 +78,11 @@ main() {
check_file planet.sh
check_file pyproject.toml
check_file frontend/package.json
check_file scripts/harness/security-check.sh
check_file scripts/harness/backend-rules-check.sh
check_file scripts/harness/frontend-rules-check.sh
check_file scripts/harness/docs-consistency-check.sh
check_file scripts/harness/frontend-smoke.mjs
check_file .gitea/workflows/ci.yaml
check_cmd git
@@ -86,6 +96,7 @@ main() {
check_absent frontend/package-lock.json "frontend package management is Bun-only"
check_absent frontend/pnpm-lock.yaml "frontend package management is Bun-only"
check_absent frontend/yarn.lock "frontend package management is Bun-only"
check_absent agents.md "AGENTS.md is the single agent guide"
if [ ! -x "$ROOT_DIR/planet.sh" ]; then
warn "planet.sh is not executable; run it with zsh or restore executable bit"

View File

@@ -0,0 +1,465 @@
#!/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 "$@"

1232
scripts/harness/frontend-smoke.mjs Executable file

File diff suppressed because it is too large Load Diff

84
scripts/harness/lib.sh Executable file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -euo pipefail
harness_candidate_shells() {
local shell
local -a shells=()
if [ -n "${SHELL:-}" ]; then
shells+=("$SHELL")
fi
shells+=(zsh bash)
local seen=""
for shell in "${shells[@]}"; do
if [ -z "$shell" ]; then
continue
fi
if ! command -v "$shell" >/dev/null 2>&1; then
continue
fi
shell="$(command -v "$shell")"
case ":$seen:" in
*":$shell:"*) continue ;;
esac
seen="${seen:+$seen:}$shell"
printf "%s\n" "$shell"
done
}
harness_find_cmd() {
local cmd="$1"
if [[ ! "$cmd" =~ ^[A-Za-z0-9_.+-]+$ ]]; then
printf "invalid command name: %s\n" "$cmd" >&2
return 2
fi
local found=""
found="$(command -v "$cmd" 2>/dev/null || true)"
if [ -n "$found" ] && [ -x "$found" ]; then
printf "%s\n" "$found"
return 0
fi
local shell
while IFS= read -r shell; do
found="$("$shell" -lic "command -v $cmd" 2>/dev/null | sed -n '1p' || true)"
if [ -n "$found" ] && [ -x "$found" ]; then
printf "%s\n" "$found"
return 0
fi
done < <(harness_candidate_shells)
return 1
}
harness_require_tool() {
local cmd="$1"
local found
if ! found="$(harness_find_cmd "$cmd")"; then
printf "missing required command: %s\n" "$cmd" >&2
printf "looked in the current non-interactive PATH and login interactive shells\n" >&2
return 1
fi
harness_prepend_tool_dir "$found"
printf "%s\n" "$found"
}
harness_prepend_tool_dir() {
local path="$1"
local dir
dir="$(dirname "$path")"
case ":$PATH:" in
*":$dir:"*) ;;
*)
PATH="$dir:$PATH"
export PATH
;;
esac
}
harness_run() {
printf "+ %s\n" "$*" >&2
"$@"
}

View File

@@ -3,26 +3,33 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
run() {
printf "+ %s\n" "$*" >&2
"$@"
}
source "$ROOT_DIR/scripts/harness/lib.sh"
main() {
cd "$ROOT_DIR"
local uv_bin
uv_bin="$(harness_require_tool uv)"
run scripts/harness/doctor.sh
run git diff --check
run zsh -n planet.sh
run bash -n scripts/bootstrap-dev.sh
run bash -n scripts/harness/doctor.sh
run bash -n scripts/harness/quick-check.sh
run bash -n scripts/harness/validate.sh
harness_run scripts/harness/doctor.sh
harness_run git diff --check
harness_run zsh -n planet.sh
harness_run bash -n scripts/bootstrap-dev.sh
harness_run bash -n scripts/harness/lib.sh
harness_run bash -n scripts/harness/doctor.sh
harness_run bash -n scripts/harness/quick-check.sh
harness_run bash -n scripts/harness/validate.sh
harness_run bash -n scripts/harness/security-check.sh
harness_run bash -n scripts/harness/backend-rules-check.sh
harness_run bash -n scripts/harness/frontend-rules-check.sh
harness_run bash -n scripts/harness/docs-consistency-check.sh
harness_run scripts/harness/security-check.sh
harness_run scripts/harness/backend-rules-check.sh
harness_run scripts/harness/frontend-rules-check.sh
harness_run scripts/harness/docs-consistency-check.sh
(
cd "$ROOT_DIR/backend"
run uv run --frozen --group dev --project "$ROOT_DIR" python -m pytest -s \
harness_run "$uv_bin" run --frozen --group dev --project "$ROOT_DIR" python -m pytest -s \
tests/test_api.py \
tests/test_realtime_sources.py \
-q

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
main() {
cd "$ROOT_DIR"
python - <<'PY'
import re
import subprocess
from pathlib import Path
root = Path.cwd()
failures: list[str] = []
def fail(message: str) -> None:
failures.append(message)
tracked = subprocess.check_output(["git", "ls-files"], text=True).splitlines()
for path in tracked:
name = Path(path).name
if name == ".env" or (name.startswith(".env.") and name not in {".env.example"}):
fail(f"{path}: environment files must not be tracked")
if re.search(r"\.(pem|key|p12|pfx)$", name):
fail(f"{path}: private key/certificate material must not be tracked")
secret_patterns = [
("private key block", re.compile(r"BEGIN [A-Z ]*PRIVATE KEY")),
("AWS access key", re.compile(r"AKIA[0-9A-Z]{16}")),
("Google API key", re.compile(r"AIza[0-9A-Za-z_-]{35}")),
("OpenAI-style secret key", re.compile(r"sk-[A-Za-z0-9]{32,}")),
("Slack token", re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}")),
("GitHub token", re.compile(r"gh[pousr]_[A-Za-z0-9_]{30,}")),
]
skip_parts = {
".git",
".venv",
"node_modules",
"dist",
"build",
".pytest_cache",
".mypy_cache",
"__pycache__",
}
for path in root.rglob("*"):
if not path.is_file():
continue
if skip_parts.intersection(path.relative_to(root).parts):
continue
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
rel = path.relative_to(root)
for label, pattern in secret_patterns:
for match in pattern.finditer(text):
line_no = text.count("\n", 0, match.start()) + 1
fail(f"{rel}:{line_no}: possible {label} committed to repository")
if failures:
for message in failures:
print(f"fail: {message}")
raise SystemExit(f"security check failed: {len(failures)} failure(s)")
print("security check passed")
PY
}
main "$@"

View File

@@ -3,21 +3,21 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
DOCKER_SMOKE="${PLANET_HARNESS_DOCKER_SMOKE:-0}"
source "$ROOT_DIR/scripts/harness/lib.sh"
run() {
printf "+ %s\n" "$*" >&2
"$@"
}
DOCKER_SMOKE="${PLANET_HARNESS_DOCKER_SMOKE:-0}"
FRONTEND_SMOKE="${PLANET_HARNESS_FRONTEND_SMOKE:-1}"
FRONTEND_SMOKE_PORT="${PLANET_HARNESS_FRONTEND_SMOKE_PORT:-4173}"
run_helm_smoke_if_available() {
if ! command -v helm >/dev/null 2>&1; then
local helm_bin
if ! helm_bin="$(harness_find_cmd helm)"; then
printf "warn: helm not found; skipping Helm lint/template smoke\n"
return 0
fi
run helm lint deploy/helm/planet
run helm template planet-staging deploy/helm/planet \
harness_run "$helm_bin" lint deploy/helm/planet
harness_run "$helm_bin" template planet-staging deploy/helm/planet \
--namespace planet-staging \
-f deploy/helm/planet/values.single-node.yaml \
--set image.tag=harness-smoke >/tmp/planet-harness-rendered.yaml
@@ -33,27 +33,83 @@ run_docker_smoke_if_requested() {
;;
esac
if ! command -v docker >/dev/null 2>&1; then
local docker_bin
if ! docker_bin="$(harness_find_cmd docker)"; then
printf "fail: docker not found; cannot run requested image smoke builds\n"
return 1
fi
run docker build -t planet-harness/frontend:smoke ./frontend
run docker build -t planet-harness/backend:smoke -f backend/Dockerfile .
run docker build -t planet-harness/aiprovider:smoke -f aiprovider/Dockerfile .
harness_run "$docker_bin" build -t planet-harness/frontend:smoke ./frontend
harness_run "$docker_bin" build -t planet-harness/backend:smoke -f backend/Dockerfile .
harness_run "$docker_bin" build -t planet-harness/aiprovider:smoke -f aiprovider/Dockerfile .
}
wait_for_frontend_preview() {
local bun_bin="$1"
local url="$2"
local attempts=60
local index=0
while [ "$index" -lt "$attempts" ]; do
if PLANET_FRONTEND_SMOKE_URL="$url" "$bun_bin" -e \
'fetch(process.env.PLANET_FRONTEND_SMOKE_URL).then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))' \
>/dev/null 2>&1; then
return 0
fi
index=$((index + 1))
sleep 1
done
return 1
}
run_frontend_smoke_if_enabled() {
local bun_bin="$1"
case "$FRONTEND_SMOKE" in
0|false|no|off)
printf "info: frontend Playwright smoke skipped; set PLANET_HARNESS_FRONTEND_SMOKE=1 to enable\n"
return 0
;;
esac
local url="http://127.0.0.1:${FRONTEND_SMOKE_PORT}"
local log_path="/tmp/planet-harness-frontend-preview.log"
(
cd "$ROOT_DIR/frontend"
"$bun_bin" ./node_modules/vite/bin/vite.js preview \
--host 127.0.0.1 \
--port "$FRONTEND_SMOKE_PORT" \
--strictPort
) >"$log_path" 2>&1 &
local preview_pid=$!
if ! wait_for_frontend_preview "$bun_bin" "$url"; then
printf "fail: frontend preview did not become ready at %s\n" "$url"
printf "preview log: %s\n" "$log_path"
kill "$preview_pid" >/dev/null 2>&1 || true
wait "$preview_pid" >/dev/null 2>&1 || true
return 1
fi
local smoke_status=0
PLANET_FRONTEND_SMOKE_URL="$url" harness_run "$bun_bin" "$ROOT_DIR/scripts/harness/frontend-smoke.mjs" || smoke_status=$?
kill "$preview_pid" >/dev/null 2>&1 || true
wait "$preview_pid" >/dev/null 2>&1 || true
return "$smoke_status"
}
main() {
cd "$ROOT_DIR"
local bun_bin
bun_bin="$(harness_require_tool bun)"
run scripts/harness/quick-check.sh
harness_run scripts/harness/quick-check.sh
(
cd "$ROOT_DIR/frontend"
run bun install --frozen-lockfile
run bun run build
harness_run "$bun_bin" install --frozen-lockfile
harness_run "$bun_bin" run build
)
run_frontend_smoke_if_enabled "$bun_bin"
run_helm_smoke_if_available
run_docker_smoke_if_requested