release: bump version to 0.72.0
This commit is contained in:
60
scripts/harness/backend-rules-check.sh
Executable file
60
scripts/harness/backend-rules-check.sh
Executable 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 "$@"
|
||||
645
scripts/harness/docs-consistency-check.sh
Executable file
645
scripts/harness/docs-consistency-check.sh
Executable 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 "$@"
|
||||
@@ -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"
|
||||
|
||||
465
scripts/harness/frontend-rules-check.sh
Executable file
465
scripts/harness/frontend-rules-check.sh
Executable 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
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
84
scripts/harness/lib.sh
Executable 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
|
||||
"$@"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
74
scripts/harness/security-check.sh
Executable file
74
scripts/harness/security-check.sh
Executable 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 "$@"
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user