Files
govoplan/tools/checks/check-shared-webui-layouts.py
T
zemion ef8fd45457
Dependency Audit / dependency-audit (push) Successful in 1m46s
Deployment Installer / deployment-installer (push) Successful in 7s
Security Audit / security-audit (push) Successful in 12m5s
Enforce semantic page layout contracts
2026-08-19 14:26:25 +02:00

247 lines
12 KiB
Python

#!/usr/bin/env python3
"""Keep raw module page frames from growing while shared layouts are adopted."""
from __future__ import annotations
import pathlib
import re
import sys
META_ROOT = pathlib.Path(__file__).resolve().parents[2]
REPOS_ROOT = META_ROOT.parent
BASELINE_PATH = pathlib.Path(__file__).with_name("shared-webui-layout-baseline.txt")
WORKSPACE_BASELINE_PATH = pathlib.Path(__file__).with_name(
"shared-webui-workspace-baseline.txt"
)
RAW_PAGE_FRAME = 'className="content-pad workspace-data-page'
RAW_WORKSPACE = re.compile(r'<div\s+className="workspace(?:\s|\")')
PAGE_LAYOUT_USAGE = re.compile(r"<PageLayout\b")
SEMANTIC_PAGE_LAYOUT_USAGE = re.compile(
r"<PageLayout\s+(?:\n\s*)?archetype="
)
LOCAL_PAGE_LAYOUT = re.compile(r"\b(?:function|class|const)\s+PageLayout\b")
LOCAL_WORKSPACE_LAYOUT = re.compile(
r"\b(?:function|class|const)\s+WorkspaceLayout\b"
)
CENTRAL_LAYOUT = pathlib.Path("govoplan-core/webui/src/components/PageLayout.tsx")
CENTRAL_WORKSPACE_LAYOUT = pathlib.Path(
"govoplan-core/webui/src/components/WorkspaceLayout.tsx"
)
REQUIRED_CONSUMERS = (
pathlib.Path("govoplan-core/webui/src/components/admin/AdminPageLayout.tsx"),
pathlib.Path("govoplan-core/webui/src/features/dashboard/DashboardPage.tsx"),
pathlib.Path("govoplan-dashboard/webui/src/features/dashboard/DashboardPage.tsx"),
pathlib.Path("govoplan-ops/webui/src/features/ops/OpsPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/AttachmentsDataPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignAuditPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignFieldsPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignJsonView.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignListPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignOverviewPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignReportPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/GlobalSettingsPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/MailSettingsPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/ReviewSendPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/TemplateDataPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/components/CampaignDraftPageScaffold.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/wizard/WizardDirectoryPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/operator/OperatorQueuePage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/reports/AggregateReportsPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/templates/TemplatesPage.tsx"),
pathlib.Path("govoplan-access/webui/src/features/admin/AdminPage.tsx"),
pathlib.Path("govoplan-core/webui/src/features/settings/SettingsPage.tsx"),
pathlib.Path("govoplan-docs/webui/src/features/docs/DocsPage.tsx"),
pathlib.Path("govoplan-mail/webui/src/features/mail/MailBouncePage.tsx"),
pathlib.Path("govoplan-payments/webui/src/features/payments/PaymentsPage.tsx"),
)
REQUIRED_WORKSPACE_CONSUMERS = (
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignWorkspace.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignModulePage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/templates/TemplatesPage.tsx"),
pathlib.Path("govoplan-approvals/webui/src/features/approvals/ApprovalsPage.tsx"),
pathlib.Path("govoplan-access/webui/src/features/admin/AdminPage.tsx"),
pathlib.Path("govoplan-core/webui/src/features/settings/SettingsPage.tsx"),
pathlib.Path("govoplan-docs/webui/src/features/docs/DocsPage.tsx"),
pathlib.Path("govoplan-organizations/webui/src/features/organizations/OrganizationsPage.tsx"),
)
HEADERLESS_PAGE_LAYOUT_CONSUMERS = {
pathlib.Path("govoplan-access/webui/src/features/admin/AdminPage.tsx"),
}
EDITOR_PAGE_CONSUMERS = (
pathlib.Path("govoplan-core/webui/src/features/settings/SettingsPage.tsx"),
pathlib.Path("govoplan-dashboard/webui/src/features/dashboard/DashboardPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/AttachmentsDataPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignFieldsPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignOverviewPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/GlobalSettingsPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/MailSettingsPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/TemplateDataPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/components/CampaignDraftPageScaffold.tsx"),
)
DESTRUCTIVE_PAGE_CONSUMERS = (
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignOverviewPage.tsx"),
)
def baseline_paths(path: pathlib.Path) -> set[pathlib.Path]:
return {
pathlib.Path(line.strip())
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
}
def source_paths() -> list[pathlib.Path]:
paths: list[pathlib.Path] = []
for repository in sorted(REPOS_ROOT.glob("govoplan*")):
source_root = repository / "webui" / "src"
if source_root.is_dir():
paths.extend(sorted(source_root.rglob("*.tsx")))
return paths
def relative(path: pathlib.Path) -> pathlib.Path:
return path.relative_to(REPOS_ROOT)
def main() -> int:
sources = source_paths()
source_text = {relative(path): path.read_text(encoding="utf-8") for path in sources}
raw_frames = {path for path, text in source_text.items() if RAW_PAGE_FRAME in text}
baseline = baseline_paths(BASELINE_PATH)
available_baseline = {
path for path in baseline if (REPOS_ROOT / path.parts[0]).is_dir()
}
errors: list[str] = []
unexpected = sorted(raw_frames - available_baseline)
if unexpected:
errors.append("New raw page frames must use @govoplan/core-webui PageLayout:")
errors.extend(f"- {path}" for path in unexpected)
resolved = sorted(available_baseline - raw_frames)
if resolved:
errors.append("Remove migrated page frames from the shared-layout baseline:")
errors.extend(f"- {path}" for path in resolved)
raw_workspaces = {
path for path, text in source_text.items() if RAW_WORKSPACE.search(text)
}
workspace_baseline = baseline_paths(WORKSPACE_BASELINE_PATH)
available_workspace_baseline = {
path
for path in workspace_baseline
if (REPOS_ROOT / path.parts[0]).is_dir()
}
unexpected_workspaces = sorted(raw_workspaces - available_workspace_baseline)
if unexpected_workspaces:
errors.append("New raw workspaces must use @govoplan/core-webui WorkspaceLayout:")
errors.extend(f"- {path}" for path in unexpected_workspaces)
resolved_workspaces = sorted(available_workspace_baseline - raw_workspaces)
if resolved_workspaces:
errors.append("Remove migrated workspaces from the shared-workspace baseline:")
errors.extend(f"- {path}" for path in resolved_workspaces)
for path, text in source_text.items():
if path != CENTRAL_LAYOUT and LOCAL_PAGE_LAYOUT.search(text):
errors.append(f"Module-local PageLayout definition is not allowed: {path}")
if path != CENTRAL_WORKSPACE_LAYOUT and LOCAL_WORKSPACE_LAYOUT.search(text):
errors.append(f"Module-local WorkspaceLayout definition is not allowed: {path}")
page_layout_count = len(PAGE_LAYOUT_USAGE.findall(text))
semantic_layout_count = len(SEMANTIC_PAGE_LAYOUT_USAGE.findall(text))
if page_layout_count and semantic_layout_count != page_layout_count:
errors.append(
"Every PageLayout must declare its semantic archetype immediately "
f"after the component name: {path} ({semantic_layout_count}/{page_layout_count})"
)
if (
page_layout_count
and "actions=" in text
and path not in HEADERLESS_PAGE_LAYOUT_CONSUMERS
and path != pathlib.Path("govoplan-core/webui/src/components/admin/AdminPageLayout.tsx")
and "<PageActionBar" not in text
):
errors.append(
f"Headed page actions must use the semantic PageActionBar: {path}"
)
if "<PageActionBar" in text and "consequentialActions=" in text:
errors.append(
f"Ambiguous consequential action slots are forbidden; use destructiveActions: {path}"
)
for path in REQUIRED_CONSUMERS:
absolute_path = REPOS_ROOT / path
if not absolute_path.exists():
continue
text = absolute_path.read_text(encoding="utf-8")
if "<PageLayout" not in text:
errors.append(f"Required shared-layout consumer no longer uses PageLayout: {path}")
if RAW_PAGE_FRAME in text:
errors.append(f"Required shared-layout consumer restored the raw page frame: {path}")
for path in REQUIRED_WORKSPACE_CONSUMERS:
absolute_path = REPOS_ROOT / path
if not absolute_path.exists():
continue
text = absolute_path.read_text(encoding="utf-8")
if "<WorkspaceLayout" not in text:
errors.append(f"Required shared-workspace consumer no longer uses WorkspaceLayout: {path}")
if RAW_WORKSPACE.search(text):
errors.append(f"Required shared-workspace consumer restored the raw workspace: {path}")
for path in EDITOR_PAGE_CONSUMERS:
absolute_path = REPOS_ROOT / path
if not absolute_path.exists():
continue
text = absolute_path.read_text(encoding="utf-8")
for required in ('variant="editor"', "dirty=", "discardAction=", "saveAction="):
if required not in text:
errors.append(f"Editor page is missing {required}: {path}")
if path.name != "CampaignDraftPageScaffold.tsx" and not any(
guard in text
for guard in (
"useUnsavedDraftGuard",
"useCampaignDraftEditor",
"useRegisterUnsavedChanges",
)
):
errors.append(f"Editor page is missing an unsaved-change guard: {path}")
for path in DESTRUCTIVE_PAGE_CONSUMERS:
absolute_path = REPOS_ROOT / path
if not absolute_path.exists():
continue
text = absolute_path.read_text(encoding="utf-8")
if "destructiveActions=" not in text or 'variant="danger"' not in text:
errors.append(f"Destructive page actions lost their separated slot: {path}")
core_index = REPOS_ROOT / "govoplan-core/webui/src/index.ts"
if core_index.exists() and "PageLayout, PageHeader" not in core_index.read_text(encoding="utf-8"):
errors.append("Core must export PageLayout and PageHeader from @govoplan/core-webui.")
if core_index.exists() and "WorkspaceLayout" not in core_index.read_text(encoding="utf-8"):
errors.append("Core must export WorkspaceLayout from @govoplan/core-webui.")
if core_index.exists() and "PageActionBar" not in core_index.read_text(encoding="utf-8"):
errors.append("Core must export PageActionBar from @govoplan/core-webui.")
if errors:
print("\n".join(errors), file=sys.stderr)
return 1
print(
"Shared WebUI layout contract passed: "
f"{len(REQUIRED_CONSUMERS)} central consumers, "
f"{sum(len(PAGE_LAYOUT_USAGE.findall(text)) for text in source_text.values())} semantic pages, "
f"{len(EDITOR_PAGE_CONSUMERS)} guarded editor consumers, "
f"{len(raw_frames)} registered legacy page-frame files; "
f"{len(REQUIRED_WORKSPACE_CONSUMERS)} workspace consumers, "
f"{len(raw_workspaces)} registered legacy workspace files."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())