209 lines
8.6 KiB
Python
209 lines
8.6 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"
|
|
)
|
|
CENTRAL_ACTION_BARS = {
|
|
pathlib.Path("govoplan-core/webui/src/components/PageActionBar.tsx"),
|
|
pathlib.Path("govoplan-core/webui/src/components/WorkspaceActionBar.tsx"),
|
|
}
|
|
SEMANTIC_ACTION_USAGE = re.compile(r"<(?:Page|Workspace)ActionBar\b")
|
|
EDITOR_ACTION_USAGE = re.compile(
|
|
r"<(?:Page|Workspace)ActionBar\b[\s\S]{0,1200}?variant=\"editor\""
|
|
)
|
|
PANEL_HEADER_ACTION_TOOLBAR = re.compile(
|
|
r"<ActionToolbar\b[^>]*\bsurface=\"panel-header\""
|
|
)
|
|
UNSAVED_GUARD_MARKERS = (
|
|
"useUnsavedDraftGuard",
|
|
"useCampaignDraftEditor",
|
|
"useRegisterUnsavedChanges",
|
|
"semantic-editor-guard:",
|
|
)
|
|
|
|
|
|
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}
|
|
page_consumers = {
|
|
path for path, text in source_text.items()
|
|
if path != CENTRAL_LAYOUT and PAGE_LAYOUT_USAGE.search(text)
|
|
}
|
|
workspace_consumers = {
|
|
path for path, text in source_text.items()
|
|
if path != CENTRAL_WORKSPACE_LAYOUT and "<WorkspaceLayout" in text
|
|
}
|
|
action_consumers = {
|
|
path for path, text in source_text.items()
|
|
if path not in CENTRAL_ACTION_BARS and SEMANTIC_ACTION_USAGE.search(text)
|
|
}
|
|
editor_consumers = {
|
|
path for path, text in source_text.items()
|
|
if path not in CENTRAL_ACTION_BARS and EDITOR_ACTION_USAGE.search(text)
|
|
}
|
|
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 != pathlib.Path("govoplan-core/webui/src/components/admin/AdminPageLayout.tsx")
|
|
and "<PageActionBar" not in text
|
|
and "semantic-page-actions: delegated" 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}"
|
|
)
|
|
|
|
if (
|
|
path not in CENTRAL_ACTION_BARS
|
|
and PANEL_HEADER_ACTION_TOOLBAR.search(text)
|
|
):
|
|
errors.append(
|
|
"Panel-header actions must use WorkspaceActionBar so their ordering, "
|
|
f"state, and destructive separation remain semantic: {path}"
|
|
)
|
|
|
|
if "<WorkspaceFrame" in text and not SEMANTIC_ACTION_USAGE.search(text):
|
|
errors.append(
|
|
f"WorkspaceFrame routes must declare a semantic page or pane action bar: {path}"
|
|
)
|
|
|
|
for path in editor_consumers:
|
|
text = source_text[path]
|
|
for required in ('variant="editor"', "state=", "discardAction=", "saveAction="):
|
|
if required not in text:
|
|
errors.append(f"Editor page is missing {required}: {path}")
|
|
if not any(guard in text for guard in UNSAVED_GUARD_MARKERS):
|
|
errors.append(f"Editor page is missing an unsaved-change guard: {path}")
|
|
|
|
for path, text in source_text.items():
|
|
if "destructiveActions=" in text and 'variant="danger"' not in text:
|
|
errors.append(f"Destructive page actions must contain a danger action: {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 core_index.exists() and "WorkspaceActionBar" not in core_index.read_text(encoding="utf-8"):
|
|
errors.append("Core must export WorkspaceActionBar from @govoplan/core-webui.")
|
|
|
|
if errors:
|
|
print("\n".join(errors), file=sys.stderr)
|
|
return 1
|
|
|
|
print(
|
|
"Shared WebUI layout contract passed: "
|
|
f"{len(page_consumers)} discovered page consumers, "
|
|
f"{sum(len(PAGE_LAYOUT_USAGE.findall(text)) for text in source_text.values())} semantic pages, "
|
|
f"{len(action_consumers)} semantic action consumers, "
|
|
f"{len(editor_consumers)} guarded editor consumers, "
|
|
f"{len(raw_frames)} registered legacy page-frame files; "
|
|
f"{len(workspace_consumers)} discovered workspace consumers, "
|
|
f"{len(raw_workspaces)} registered legacy workspace files."
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|