Compare commits

...

5 Commits

Author SHA1 Message Date
65cc099839 chore(meta): register current workspace modules
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 15s
Security Audit / security-audit (push) Failing after 12s
2026-07-20 20:01:35 +02:00
1ce3d02239 fix(dev): make browser launch opt in 2026-07-20 20:01:27 +02:00
9805d085fa test(release): keep release tooling coverage with its owner 2026-07-20 20:01:19 +02:00
2e21397868 chore(checks): discover workspace module boundaries 2026-07-20 20:01:14 +02:00
4b217c7aba docs(ui): require central component reuse 2026-07-20 17:56:14 +02:00
13 changed files with 837 additions and 95 deletions

View File

@@ -30,6 +30,12 @@ Start the development stack through the meta repository:
./tools/launch/launch-dev.sh
```
Open the WebUI in a browser after launch only when explicitly requested:
```sh
GOVOPLAN_OPEN_BROWSER=1 ./tools/launch/launch-dev.sh
```
Start the shared development PostgreSQL service:
```sh
@@ -119,6 +125,8 @@ Meta ownership and module install/contract boundaries are documented in
`docs/META_REPO_SCAN.md` and `docs/MODULE_CONTRACTS_AND_INSTALLS.md`.
Frontend layout principles for module pages are documented in
`docs/FRONTEND_LAYOUT_PRINCIPLES.md`.
The first Campaign-centric capability and infrastructure fit assessment is in
`docs/CAPABILITY_AND_INFRASTRUCTURE_FIT.md`.
# GovOPlaN Docker

View File

@@ -80,6 +80,12 @@ cd /mnt/DATA/git/govoplan
tools/launch/launch-dev.sh
```
The launcher does not open a browser by default. To opt in:
```bash
GOVOPLAN_OPEN_BROWSER=1 tools/launch/launch-dev.sh
```
To force the old SQLite fallback for a disposable local run:
```bash

View File

@@ -47,6 +47,9 @@ rules:
decoration.
- Keep optional modules optional. Compose through core route and UI-capability
contracts, never sibling-private components.
- Use the centrally exported core components wherever a matching contract
exists. A module-local replacement is not an implementation choice: it is a
product exception that requires explicit product-owner authorization.
- Preserve a stable way back to the containing object and the broader system.
- Do not let navigation, selection, or a view switch imply consent.
@@ -95,6 +98,11 @@ one.
- Workflow, configuration, dashboard, and explanatory pages may use a heading.
The heading names the task or scoped object and contains only route-level
actions.
- Put a collection-wide create action in the heading of the collection it
affects. Use a short, specific label such as `Add` when the heading already
names the object. Do not duplicate that action in a permanently visible side
panel. A side panel used as the creation surface appears for creation and is
otherwise absent or returns to its documented non-creation purpose.
- Put filters beside the list or pane they affect. Put bulk actions immediately
above or beside the current selection. Put object actions with the object
detail, not in the global title bar.
@@ -311,7 +319,30 @@ implementation.
Core already exports shell, navigation, access-boundary, form, dialog, loading,
status, policy/provenance, blocker, review, table, tree, message-display, and
unsaved-change primitives. Reuse them when their contract fits.
unsaved-change primitives. These centrally exported components are mandatory
across GovOPlaN wherever their contract covers the interaction. In particular,
use the core `Card` for logical sections, `DataGrid` for tabular collections and
row actions, and `ToggleSwitch` (the standard Toggle control) for boolean
settings. Styling a native element or a module-local component to imitate one
of these controls is duplication, not reuse.
A route or domain composition assembled from central primitives is not a custom
control. Any new reusable UI control, presentation primitive, or module-local
substitute is a custom component and requires explicit product-owner
authorization before implementation. Record the authorization in the owning
decision or issue together with:
- the narrowly defined purpose and consumers
- why no central component or composition satisfies the need
- the exact scope in which the exception may be used
- its accessibility, state, theme, and test contract
- whether it should remain domain-specific or later become a core component
An authorized custom component serves only that specific purpose. It must not
duplicate, fork, restyle into a substitute for, or silently broaden beyond a
central component. Code review convenience, an existing local implementation,
or a small visual difference is not authorization. When core gains the required
contract, migrate the exception unless the product owner explicitly retains it.
Do not promote a component only because two screens look similar. Promote it to
`@govoplan/core-webui` after a second consumer or a clear platform contract has
@@ -319,6 +350,29 @@ proved shared behavior, accessibility, state, and extension needs. Modules own
domain composition, wording, and policy semantics; core owns generic contracts
and appearance.
### Scheduling Request Composition Reference
The Scheduling request surface is the first explicit reference composition for
these rules:
- The `Scheduling requests` page heading owns one `Add` action.
- The left panel is shown for the creation view; it is not a second permanent
creation launcher beside the request list.
- `Basic information`, `Calendar integration`, `Candidate slots`, and
`Participants` are logical sections rendered with the central `Card`.
- Candidate slots and participants are row collections rendered with the
central `DataGrid`, including its stable action column.
- Calendar integration is a boolean choice rendered with the central
`ToggleSwitch`; dependent calendar controls are disclosed only when enabled.
- Each participant is one structured row containing name, email address, and
ordered row actions. An address-parsing text area is not the ordinary editor;
parsing pasted address lists belongs only in an explicitly designed bulk
import flow.
Apply the underlying placement and component rules to equivalent collection and
create/edit surfaces throughout the system; the Scheduling domain names are an
example, not a module-local convention.
## Test Expectations
For every changed surface, select tests from each applicable layer:
@@ -355,6 +409,9 @@ reason to infer that a pattern is satisfied.
- Keyboard, focus, announcement, responsive, theme, density, motion, and i18n
behavior are covered in proportion to the surface.
- Optional modules remain optional and no sibling-private UI import was added.
- Every matching central component is reused. Any custom-component exception
has recorded product-owner authorization, narrow scope, rationale, and tests,
and does not duplicate a central component.
- Behavioral/accessibility evidence is linked from the rollout matrix and issue.
- Configured-system help can reach the applicable pattern or reference topic
when [Docs #15](https://git.add-ideas.de/add-ideas/govoplan-docs/issues/15)

View File

@@ -245,6 +245,12 @@
"description": "GovOPlaN Payments module behavior or integration.",
"exclusive": false
},
{
"name": "module/permits",
"color": "fbca04",
"description": "GovOPlaN Permits module behavior or integration.",
"exclusive": false
},
{
"name": "module/policy",
"color": "d93f0b",

View File

@@ -7,6 +7,7 @@
-e ../govoplan-tenancy
-e ../govoplan-organizations
-e ../govoplan-identity
-e ../govoplan-idm
-e ../govoplan-access
-e ../govoplan-admin
-e ../govoplan-policy

View File

@@ -0,0 +1,144 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import unittest
META_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = META_ROOT / "tools" / "checks" / "check_dependency_boundaries.py"
def load_boundary_module():
spec = importlib.util.spec_from_file_location("check_dependency_boundaries", SCRIPT)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load {SCRIPT}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class DependencyBoundaryDiscoveryTests(unittest.TestCase):
def test_editable_install_metadata_is_not_treated_as_a_source_package(self) -> None:
boundary = load_boundary_module()
with tempfile.TemporaryDirectory(prefix="govoplan-boundary-discovery-") as directory:
root = Path(directory)
package = root / "govoplan-example" / "src" / "govoplan_example"
package.mkdir(parents=True)
(package / "__init__.py").write_text("", encoding="utf-8")
(package.parent / "govoplan_example.egg-info").mkdir()
repos, prefixes, errors = boundary._discover_python_repos(root)
self.assertEqual(errors, ())
self.assertEqual(repos, {"govoplan-example": package})
self.assertEqual(prefixes, {"govoplan-example": "govoplan_example"})
def test_ambiguous_or_missing_source_packages_fail_discovery(self) -> None:
boundary = load_boundary_module()
with tempfile.TemporaryDirectory(prefix="govoplan-boundary-discovery-") as directory:
root = Path(directory)
missing_source = root / "govoplan-missing" / "src"
missing_source.mkdir(parents=True)
(missing_source / "govoplan_missing.egg-info").mkdir()
ambiguous_source = root / "govoplan-ambiguous" / "src"
for name in ("govoplan_alpha", "govoplan_beta"):
package = ambiguous_source / name
package.mkdir(parents=True)
(package / "__init__.py").write_text("", encoding="utf-8")
repos, prefixes, errors = boundary._discover_python_repos(root)
self.assertEqual(repos, {})
self.assertEqual(prefixes, {})
self.assertEqual(len(errors), 2)
self.assertTrue(any("govoplan-missing" in error and "found 0" in error for error in errors))
self.assertTrue(any("govoplan-ambiguous" in error and "found 2" in error for error in errors))
def test_duplicate_python_package_ownership_fails_discovery(self) -> None:
boundary = load_boundary_module()
with tempfile.TemporaryDirectory(prefix="govoplan-boundary-discovery-") as directory:
root = Path(directory)
for owner in ("govoplan-first", "govoplan-second"):
package = root / owner / "src" / "govoplan_shared"
package.mkdir(parents=True)
(package / "__init__.py").write_text("", encoding="utf-8")
repos, prefixes, errors = boundary._discover_python_repos(root)
self.assertEqual(set(repos), {"govoplan-first"})
self.assertEqual(prefixes, {"govoplan-first": "govoplan_shared"})
self.assertEqual(len(errors), 1)
self.assertIn("already owned", errors[0])
def test_workspace_discovery_covers_changed_capability_repositories(self) -> None:
boundary = load_boundary_module()
self.assertEqual(boundary.PYTHON_DISCOVERY_ERRORS, ())
self.assertGreaterEqual(len(boundary.REPOS), 40)
for owner in (
"govoplan-core",
"govoplan-identity",
"govoplan-idm",
"govoplan-calendar",
"govoplan-poll",
"govoplan-scheduling",
):
self.assertIn(owner, boundary.REPOS)
def test_webui_discovery_is_fail_closed(self) -> None:
boundary = load_boundary_module()
with tempfile.TemporaryDirectory(prefix="govoplan-boundary-webui-") as directory:
root = Path(directory)
good = root / "govoplan-good" / "webui"
good.mkdir(parents=True)
(good / "package.json").write_text(
json.dumps({"name": "@govoplan/good-webui"}),
encoding="utf-8",
)
missing = root / "govoplan-missing" / "webui"
missing.mkdir(parents=True)
invalid = root / "govoplan-invalid" / "webui"
invalid.mkdir(parents=True)
(invalid / "package.json").write_text("{", encoding="utf-8")
unnamed = root / "govoplan-unnamed" / "webui"
unnamed.mkdir(parents=True)
(unnamed / "package.json").write_text("{}", encoding="utf-8")
duplicate = root / "govoplan-zduplicate" / "webui"
duplicate.mkdir(parents=True)
(duplicate / "package.json").write_text(
json.dumps({"name": "@govoplan/good-webui"}),
encoding="utf-8",
)
repos, packages, errors = boundary._discover_webui_repos(root)
self.assertEqual(repos, {"govoplan-good": good})
self.assertEqual(packages, {"govoplan-good": "@govoplan/good-webui"})
self.assertEqual(len(errors), 4)
self.assertTrue(any("govoplan-missing" in error and "no package.json" in error for error in errors))
self.assertTrue(any("govoplan-invalid" in error and "invalid" in error for error in errors))
self.assertTrue(any("govoplan-unnamed" in error and "package name" in error for error in errors))
self.assertTrue(any("govoplan-zduplicate" in error and "already owned" in error for error in errors))
def test_workspace_webui_discovery_covers_composition_repositories(self) -> None:
boundary = load_boundary_module()
self.assertEqual(boundary.WEBUI_DISCOVERY_ERRORS, ())
self.assertGreaterEqual(len(boundary.WEBUI_REPOS), 17)
for owner in (
"govoplan-core",
"govoplan-calendar",
"govoplan-scheduling",
"govoplan-notifications",
):
self.assertIn(owner, boundary.WEBUI_REPOS)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,191 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sys
import unittest
META_ROOT = Path(__file__).resolve().parents[1]
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
SCRIPT = RELEASE_TOOLS_ROOT / "release-doctor.py"
if str(RELEASE_TOOLS_ROOT) not in sys.path:
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
def load_doctor_module():
spec = importlib.util.spec_from_file_location("release_doctor", SCRIPT)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load {SCRIPT}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class ReleaseDoctorTests(unittest.TestCase):
def test_release_repo_names_include_catalog_and_migration_baseline_owners(self) -> None:
doctor = load_doctor_module()
names = set(doctor.release_repo_names(META_ROOT.parent))
self.assertIn("govoplan-core", names)
self.assertIn("govoplan-files", names)
self.assertIn("govoplan-idm", names)
def test_repo_findings_detect_dirty_repositories(self) -> None:
doctor = load_doctor_module()
repo = doctor.RepoState(
name="govoplan-files",
path="/tmp/govoplan-files",
exists=True,
dirty_entries=(" M pyproject.toml",),
pyproject_version="0.1.7",
local_tag_exists=True,
)
findings = doctor.repo_findings((repo,), target_version="0.1.7", target_tag="v0.1.7")
self.assertEqual("blocker", findings[0].severity)
self.assertEqual("repo-dirty", findings[0].check_id)
self.assertIn("git status --short", [item.command for item in findings[0].commands])
def test_dubious_ownership_errors_suggest_safe_directory_command(self) -> None:
doctor = load_doctor_module()
repo = doctor.RepoState(
name="govoplan-files",
path="/tmp/govoplan-files",
exists=True,
safe_directory_required=True,
safe_directory_command="git config --global --add safe.directory /tmp/govoplan-files",
errors=("fatal: detected dubious ownership in repository",),
)
findings = doctor.repo_findings((repo,), target_version="0.1.7", target_tag="v0.1.7")
self.assertEqual("repo-safe-directory", findings[0].check_id)
self.assertEqual("blocker", findings[0].severity)
self.assertTrue(findings[0].commands[0].mutating)
self.assertIn("safe.directory", findings[0].commands[0].command)
def test_dubious_ownership_detection_matches_git_error(self) -> None:
doctor = load_doctor_module()
self.assertTrue(
doctor.is_dubious_ownership_error(
"fatal: detected dubious ownership in repository at '/workspace/repo'\n"
"git config --global --add safe.directory /workspace/repo"
)
)
def test_release_migration_strict_errors_suggest_recording_baseline(self) -> None:
doctor = load_doctor_module()
findings = doctor.migration_findings(
{
"release": {
"ok": True,
"graph_errors": [],
"strict_errors": ["current migration heads are not recorded"],
},
"dev": {"ok": True, "graph_errors": [], "strict_errors": []},
},
target_version="0.2.0",
)
self.assertEqual("migration-release-baseline", findings[0].check_id)
self.assertEqual("blocker", findings[0].severity)
commands = [item.command for item in findings[0].commands]
self.assertTrue(any("--record-release 0.2.0" in item for item in commands))
def test_catalog_findings_detect_missing_signatures(self) -> None:
doctor = load_doctor_module()
findings = doctor.catalog_findings(
{
"catalog_exists": True,
"catalog_path": "/tmp/stable.json",
"core_release_version": "0.1.7",
"signature_count": 0,
"keyring_exists": True,
"key_count": 1,
},
target_version="0.1.7",
online=False,
)
self.assertEqual("catalog-signatures", findings[0].check_id)
self.assertEqual("blocker", findings[0].severity)
def test_suggested_commands_are_deduplicated(self) -> None:
doctor = load_doctor_module()
suggested = doctor.SuggestedCommand(title="Status", command="git status --short", cwd="/tmp/repo")
report = doctor.DoctorReport(
generated_at="2026-07-11T00:00:00Z",
workspace_root="/tmp",
target_version="0.1.7",
target_tag="v0.1.7",
online=False,
overall_status="blocked",
repos=(),
findings=(
doctor.Finding("a", "blocker", "A", "", (suggested,)),
doctor.Finding("b", "warning", "B", "", (suggested,)),
),
)
self.assertEqual((suggested,), doctor.collect_suggested_commands(report))
def test_default_report_is_concise_and_keeps_details_out(self) -> None:
doctor = load_doctor_module()
repo = doctor.RepoState(
name="govoplan-files",
path="/tmp/govoplan-files",
exists=True,
dirty_entries=(" M pyproject.toml",),
)
report = doctor.DoctorReport(
generated_at="2026-07-11T00:00:00Z",
workspace_root="/tmp",
target_version="0.1.7",
target_tag="v0.1.7",
online=False,
overall_status="blocked",
repos=(repo,),
findings=(doctor.Finding("repo-dirty", "blocker", "govoplan-files has uncommitted changes", "M pyproject.toml"),),
)
concise = doctor.render_text_report(report, detailed=False, include_commands=False)
detailed = doctor.render_text_report(report, detailed=True, include_commands=False)
self.assertIn("dirty=1", concise)
self.assertNotIn("M pyproject.toml", concise)
self.assertIn("M pyproject.toml", detailed)
def test_interactive_actions_offer_dirty_bulk_commit_push(self) -> None:
doctor = load_doctor_module()
repo = doctor.RepoState(
name="govoplan-files",
path="/tmp/govoplan-files",
exists=True,
dirty_entries=(" M pyproject.toml",),
)
report = doctor.DoctorReport(
generated_at="2026-07-11T00:00:00Z",
workspace_root="/tmp",
target_version="0.1.7",
target_tag="v0.1.7",
online=False,
overall_status="blocked",
repos=(repo,),
findings=(),
)
actions = dict(doctor.interactive_actions(report))
self.assertIn("commit-push-dirty", actions)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,190 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sys
import tempfile
import unittest
META_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = META_ROOT / "tools" / "release" / "release-migration-audit.py"
def load_audit_module():
spec = importlib.util.spec_from_file_location("release_migration_audit", SCRIPT)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load {SCRIPT}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class ReleaseMigrationAuditTests(unittest.TestCase):
def test_typed_alembic_variables_are_parsed(self) -> None:
audit = load_audit_module()
with tempfile.TemporaryDirectory(prefix="migration-audit-test-") as directory:
path = Path(directory) / "1234_example.py"
path.write_text(
"""
from __future__ import annotations
from typing import Sequence, Union
revision: str = "1234"
down_revision: Union[str, None] = "base"
depends_on: Union[str, None] = "core"
branch_labels: Union[str, Sequence[str], None] = None
""",
encoding="utf-8",
)
migration = audit.parse_migration_file("govoplan-core", path)
self.assertIsNotNone(migration)
self.assertEqual(migration.revision, "1234")
self.assertEqual(migration.down_revisions, ("base",))
self.assertEqual(migration.depends_on, ("core",))
self.assertEqual(migration.branch_labels, ())
def test_release_baseline_matches_current_heads_in_strict_report(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
audit.Migration("govoplan-core", Path("head.py"), "head", ("base",), (), ()),
]
report = audit.build_report(
migrations,
baseline={
"version": 1,
"releases": [
{
"release": "0.1.0",
"heads": [{"owner": "govoplan-core", "revision": "head"}],
}
],
},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertEqual(report["current_heads"], ["head"])
self.assertEqual(report["graph_errors"], [])
self.assertEqual(report["strict_errors"], [])
def test_owner_heads_are_accepted_for_subset_installs(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
audit.Migration("govoplan-core", Path("core_head.py"), "core-head", ("base",), (), ()),
]
report = audit.build_report(
migrations,
baseline={
"version": 1,
"releases": [
{
"release": "0.1.0",
"heads": [{"owner": "govoplan-files", "revision": "files-head"}],
"owner_heads": [{"owner": "govoplan-core", "revisions": ["core-head"]}],
}
],
},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertEqual(report["current_heads"], ["core-head"])
self.assertEqual(report["strict_errors"], [])
def test_records_current_release_baseline(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
audit.Migration("govoplan-core", Path("core_head.py"), "core-head", ("base",), (), ()),
audit.Migration("govoplan-files", Path("files_head.py"), "files-head", ("core-head",), (), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
baseline = audit.record_release_baseline(
{"version": 1, "releases": []},
report,
release="0.2.0",
replace=False,
)
release = baseline["releases"][0]
self.assertEqual(release["release"], "0.2.0")
self.assertEqual(release["squash_policy"], "reviewed-manual")
self.assertEqual(release["heads"], [{"owner": "govoplan-files", "revision": "files-head"}])
self.assertIn({"owner": "govoplan-core", "revisions": ["core-head"]}, release["owner_heads"])
def test_missing_down_revision_is_a_graph_error(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("head.py"), "head", ("missing",), (), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertIn("references missing down_revision", report["graph_errors"][0])
def test_depends_on_participates_in_graph_validation_and_heads(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("core.py"), "core", (), (), ()),
audit.Migration("govoplan-files", Path("files.py"), "files", (), ("core",), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertEqual(report["graph_errors"], [])
self.assertEqual(report["current_heads"], ["files"])
def test_missing_depends_on_is_a_graph_error(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-files", Path("files.py"), "files", (), ("missing",), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertIn("references missing depends_on", report["graph_errors"][0])
def test_dev_track_does_not_emit_release_baseline_warnings(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
audit.Migration("govoplan-core", Path("head.py"), "head", ("base",), (), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
track="dev",
)
self.assertEqual(report["track"], "dev")
self.assertEqual(report["strict_errors"], [])
if __name__ == "__main__":
unittest.main()

View File

@@ -37,23 +37,10 @@ import pathlib
import sys
root = pathlib.Path.cwd()
scan_roots = [
root / "src",
root / "tests",
root.parent / "govoplan-access" / "src",
root.parent / "govoplan-admin" / "src",
root.parent / "govoplan-addresses" / "src",
root.parent / "govoplan-audit" / "src",
root.parent / "govoplan-calendar" / "src",
root.parent / "govoplan-campaign" / "src",
root.parent / "govoplan-dashboard" / "src",
root.parent / "govoplan-files" / "src",
root.parent / "govoplan-identity" / "src",
root.parent / "govoplan-mail" / "src",
root.parent / "govoplan-ops" / "src",
root.parent / "govoplan-organizations" / "src",
root.parent / "govoplan-policy" / "src",
root.parent / "govoplan-tenancy" / "src",
scan_roots = [root / "tests"] + [
repo / "src"
for repo in sorted(root.parent.glob("govoplan*"))
if (repo / "src").is_dir()
]
deprecated_tokens = {
"HTTP_422_UNPROCESSABLE_ENTITY": "Use HTTP_422_UNPROCESSABLE_CONTENT; the numeric status remains 422.",

View File

@@ -28,27 +28,24 @@ cd "$ROOT"
GOVOPLAN_CORE_ROOT="$ROOT" PYTHON="$PYTHON" CHECK_TESTCLIENT_DEPRECATIONS=1 bash "$META_ROOT/tools/checks/check-dependency-hygiene.sh"
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
PYTHONDONTWRITEBYTECODE=1 "$PYTHON" "$META_ROOT/tools/checks/check-manifest-shapes.py"
"$PYTHON" - <<'PY'
import ast
import pathlib
import sys
repos_root = pathlib.Path("/mnt/DATA/git")
roots = [
pathlib.Path("/mnt/DATA/git/govoplan-core/src"),
pathlib.Path("/mnt/DATA/git/govoplan-access/src"),
pathlib.Path("/mnt/DATA/git/govoplan-admin/src"),
pathlib.Path("/mnt/DATA/git/govoplan-addresses/src"),
pathlib.Path("/mnt/DATA/git/govoplan-tenancy/src"),
pathlib.Path("/mnt/DATA/git/govoplan-organizations/src"),
pathlib.Path("/mnt/DATA/git/govoplan-identity/src"),
pathlib.Path("/mnt/DATA/git/govoplan-policy/src"),
pathlib.Path("/mnt/DATA/git/govoplan-audit/src"),
pathlib.Path("/mnt/DATA/git/govoplan-mail/src"),
pathlib.Path("/mnt/DATA/git/govoplan-files/src"),
pathlib.Path("/mnt/DATA/git/govoplan-campaign/src"),
pathlib.Path("/mnt/DATA/git/govoplan-mail/tests"),
repo / "src"
for repo in sorted(repos_root.glob("govoplan*"))
if (repo / "src").is_dir()
]
roots.extend(
repo / "tests"
for repo in sorted(repos_root.glob("govoplan*"))
if (repo / "tests").is_dir()
)
errors = []
count = 0

View File

@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Load and validate every available GovOPlaN module manifest from source."""
from __future__ import annotations
import argparse
import importlib
import json
import sys
import tomllib
from pathlib import Path
META_ROOT = Path(__file__).resolve().parents[2]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--workspace-root",
type=Path,
default=None,
help="Directory containing the GovOPlaN repositories. Defaults to repositories.json default_parent.",
)
args = parser.parse_args()
catalog = json.loads((META_ROOT / "repositories.json").read_text(encoding="utf-8"))
workspace_root = (args.workspace_root or Path(catalog["default_parent"])).resolve()
repositories = tuple(catalog["repositories"])
source_roots: list[Path] = []
manifest_sources: list[tuple[str, Path, Path]] = []
for repository in repositories:
repository_root = workspace_root / repository["path"]
source_root = repository_root / "src"
if not source_root.is_dir():
continue
source_roots.append(source_root)
manifest_sources.extend(
(repository["name"], source_root, manifest_path)
for manifest_path in sorted(source_root.glob("*/backend/manifest.py"))
)
if not manifest_sources:
print(f"No module manifests found below {workspace_root}.", file=sys.stderr)
return 1
core_source = workspace_root / "govoplan-core" / "src"
ordered_source_roots = [core_source, *(root for root in source_roots if root != core_source)]
sys.path[:0] = [str(root) for root in ordered_source_roots]
from govoplan_core.core.modules import ModuleManifest # noqa: PLC0415
from govoplan_core.core.registry import PlatformRegistry, RegistryError # noqa: PLC0415
manifests: list[ModuleManifest] = []
errors: list[str] = []
for repository_name, source_root, manifest_path in manifest_sources:
module_name = ".".join(manifest_path.relative_to(source_root).with_suffix("").parts)
try:
loaded_module = importlib.import_module(module_name)
get_manifest = getattr(loaded_module, "get_manifest")
manifest = get_manifest()
except Exception as exc: # pragma: no cover - rendered as a check failure
errors.append(f"{repository_name}: could not load {module_name}: {exc}")
continue
if not isinstance(manifest, ModuleManifest):
errors.append(f"{repository_name}: {module_name}.get_manifest() did not return ModuleManifest")
continue
entry_points = _module_entry_points(manifest_path.parents[3] / "pyproject.toml")
expected_target = f"{module_name}:get_manifest"
actual_target = entry_points.get(manifest.id)
if actual_target != expected_target:
declared = ", ".join(f"{name}={target}" for name, target in sorted(entry_points.items())) or "none"
errors.append(
f"{repository_name}: module {manifest.id!r} must declare entry point "
f"{manifest.id}={expected_target}; found {declared}"
)
continue
manifests.append(manifest)
if errors:
print("\n".join(errors), file=sys.stderr)
return 1
registry = PlatformRegistry()
try:
for manifest in manifests:
registry.register(manifest)
snapshot = registry.validate()
except RegistryError as exc:
print(f"Manifest registry validation failed: {exc}", file=sys.stderr)
return 1
print(
f"Manifest registry check passed: {len(snapshot.manifests)} manifests "
f"from {len(manifest_sources)} source files."
)
return 0
def _module_entry_points(pyproject_path: Path) -> dict[str, str]:
if not pyproject_path.is_file():
return {}
pyproject = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
values = pyproject.get("project", {}).get("entry-points", {}).get("govoplan.modules", {})
return {str(name): str(target) for name, target in values.items()}
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -11,52 +11,89 @@ from pathlib import Path
META_ROOT = Path(__file__).resolve().parents[2]
CORE_ROOT = Path(os.environ.get("GOVOPLAN_CORE_ROOT", META_ROOT.parent / "govoplan-core")).resolve()
REPOS_ROOT = Path(os.environ.get("GOVOPLAN_REPOS_ROOT", CORE_ROOT.parent)).resolve()
REPOS = {
"core": CORE_ROOT / "src" / "govoplan_core",
"access": REPOS_ROOT / "govoplan-access" / "src" / "govoplan_access",
"admin": REPOS_ROOT / "govoplan-admin" / "src" / "govoplan_admin",
"addresses": REPOS_ROOT / "govoplan-addresses" / "src" / "govoplan_addresses",
"tenancy": REPOS_ROOT / "govoplan-tenancy" / "src" / "govoplan_tenancy",
"organizations": REPOS_ROOT / "govoplan-organizations" / "src" / "govoplan_organizations",
"identity": REPOS_ROOT / "govoplan-identity" / "src" / "govoplan_identity",
"policy": REPOS_ROOT / "govoplan-policy" / "src" / "govoplan_policy",
"audit": REPOS_ROOT / "govoplan-audit" / "src" / "govoplan_audit",
"dashboard": REPOS_ROOT / "govoplan-dashboard" / "src" / "govoplan_dashboard",
"files": REPOS_ROOT / "govoplan-files" / "src" / "govoplan_files",
"mail": REPOS_ROOT / "govoplan-mail" / "src" / "govoplan_mail",
"campaign": REPOS_ROOT / "govoplan-campaign" / "src" / "govoplan_campaign",
}
PREFIXES = {
"core": "govoplan_core",
"access": "govoplan_access",
"admin": "govoplan_admin",
"addresses": "govoplan_addresses",
"tenancy": "govoplan_tenancy",
"organizations": "govoplan_organizations",
"identity": "govoplan_identity",
"policy": "govoplan_policy",
"audit": "govoplan_audit",
"dashboard": "govoplan_dashboard",
"files": "govoplan_files",
"mail": "govoplan_mail",
"campaign": "govoplan_campaign",
}
FEATURE_OWNERS = ("addresses", "files", "mail", "campaign")
PLATFORM_OWNERS = ("admin", "tenancy", "organizations", "identity", "policy", "audit", "dashboard")
WEBUI_REPOS = {
"core": CORE_ROOT / "webui",
"addresses": REPOS_ROOT / "govoplan-addresses" / "webui",
"files": REPOS_ROOT / "govoplan-files" / "webui",
"mail": REPOS_ROOT / "govoplan-mail" / "webui",
"campaign": REPOS_ROOT / "govoplan-campaign" / "webui",
}
WEBUI_PACKAGES = {
"core": "@govoplan/core-webui",
"addresses": "@govoplan/addresses-webui",
"files": "@govoplan/files-webui",
"mail": "@govoplan/mail-webui",
"campaign": "@govoplan/campaign-webui",
}
def _discover_python_repos(
repos_root: Path = REPOS_ROOT,
) -> tuple[dict[str, Path], dict[str, str], tuple[str, ...]]:
repos: dict[str, Path] = {}
prefixes: dict[str, str] = {}
errors: list[str] = []
for repo_root in sorted(repos_root.glob("govoplan*")):
source_root = repo_root / "src"
if not source_root.is_dir():
continue
packages = sorted(
path
for path in source_root.glob("govoplan_*")
if path.is_dir() and (path / "__init__.py").is_file()
)
if len(packages) != 1:
discovered = ", ".join(path.name for path in packages) or "none"
errors.append(
f"{repo_root.name}: expected exactly one importable govoplan_* package "
f"under {source_root}, found {len(packages)} ({discovered})"
)
continue
owner = repo_root.name
prefix = packages[0].name
duplicate_owner = next(
(candidate_owner for candidate_owner, candidate_prefix in prefixes.items() if candidate_prefix == prefix),
None,
)
if duplicate_owner is not None:
errors.append(
f"{owner}: import package {prefix!r} is already owned by {duplicate_owner}; "
"Python package ownership must be unique"
)
continue
repos[owner] = packages[0]
prefixes[owner] = prefix
return repos, prefixes, tuple(errors)
def _discover_webui_repos(
repos_root: Path = REPOS_ROOT,
) -> tuple[dict[str, Path], dict[str, str], tuple[str, ...]]:
repos: dict[str, Path] = {}
packages: dict[str, str] = {}
errors: list[str] = []
for repo_root in sorted(repos_root.glob("govoplan*")):
webui_root = repo_root / "webui"
if not webui_root.is_dir():
continue
package_path = webui_root / "package.json"
if not package_path.is_file():
errors.append(f"{repo_root.name}: WebUI directory has no package.json: {package_path}")
continue
try:
payload = json.loads(package_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
errors.append(f"{repo_root.name}: invalid {package_path}: {exc}")
continue
package_name = payload.get("name") if isinstance(payload, dict) else None
if not isinstance(package_name, str) or not package_name:
errors.append(f"{repo_root.name}: {package_path} must declare a non-empty package name")
continue
owner = repo_root.name
duplicate_owner = next(
(candidate_owner for candidate_owner, candidate_package in packages.items() if candidate_package == package_name),
None,
)
if duplicate_owner is not None:
errors.append(
f"{owner}: WebUI package {package_name!r} is already owned by {duplicate_owner}; "
"WebUI package ownership must be unique"
)
continue
repos[owner] = webui_root
packages[owner] = package_name
return repos, packages, tuple(errors)
REPOS, PREFIXES, PYTHON_DISCOVERY_ERRORS = _discover_python_repos()
WEBUI_REPOS, WEBUI_PACKAGES, WEBUI_DISCOVERY_ERRORS = _discover_webui_repos()
CORE_OWNER = CORE_ROOT.name
WEBUI_IMPORT_RE = re.compile(
r"(?:from\s+|import\s*\(\s*|require\s*\(\s*|export\s+[^;]*?\s+from\s+)"
r"[\"'](?P<package>@govoplan/[a-z0-9_-]+-webui)(?:/[A-Za-z0-9_./-]+)?[\"']"
@@ -127,18 +164,7 @@ def _violations() -> list[Violation]:
imported_owner = _import_owner(imported)
if imported_owner is None or imported_owner == owner:
continue
if owner == "core" and imported_owner in FEATURE_OWNERS:
if not _allowed(owner, path, imported_owner):
violations.append(Violation("python", owner, path, lineno, imported, imported_owner))
elif owner == "access" and imported_owner in FEATURE_OWNERS:
violations.append(Violation("python", owner, path, lineno, imported, imported_owner))
elif owner in PLATFORM_OWNERS and imported_owner == "access":
if not _allowed(owner, path, imported_owner):
violations.append(Violation("python", owner, path, lineno, imported, imported_owner))
elif owner in FEATURE_OWNERS and imported_owner in FEATURE_OWNERS:
if not _allowed(owner, path, imported_owner):
violations.append(Violation("python", owner, path, lineno, imported, imported_owner))
elif owner in FEATURE_OWNERS and imported_owner == "access":
if imported_owner != CORE_OWNER and not _allowed(owner, path, imported_owner):
violations.append(Violation("python", owner, path, lineno, imported, imported_owner))
violations.extend(_webui_violations())
return violations
@@ -191,7 +217,12 @@ def _webui_package_json_violations(owner: str, root: Path) -> list[Violation]:
imported_owner = _webui_package_owner(str(package_name))
if imported_owner is None or imported_owner == owner:
continue
if owner in FEATURE_OWNERS and imported_owner in FEATURE_OWNERS:
# The core WebUI package is also the development composition host;
# package declarations make independently built module frontends
# available to Vite without authorizing source-level imports.
if owner == CORE_OWNER:
continue
if imported_owner != CORE_OWNER:
violations.append(Violation("webui-package", owner, package_path, 1, str(package_name), imported_owner))
return violations
@@ -206,9 +237,7 @@ def _webui_source_violations(owner: str, root: Path) -> list[Violation]:
if imported_owner is None or imported_owner == owner:
continue
lineno = text.count("\n", 0, match.start()) + 1
if owner == "core" and imported_owner in FEATURE_OWNERS:
violations.append(Violation("webui-source", owner, path, lineno, package_name, imported_owner))
elif owner in FEATURE_OWNERS and imported_owner in FEATURE_OWNERS:
if imported_owner != CORE_OWNER:
violations.append(Violation("webui-source", owner, path, lineno, package_name, imported_owner))
return violations
@@ -224,9 +253,21 @@ def _webui_violations() -> list[Violation]:
def main() -> int:
discovery_errors = (*PYTHON_DISCOVERY_ERRORS, *WEBUI_DISCOVERY_ERRORS)
if discovery_errors:
print("Dependency boundary discovery errors:")
for error in discovery_errors:
print(f"- {error}")
print("\nThe boundary scan is incomplete; fix package discovery before accepting this gate.")
return 1
violations = _violations()
if not violations:
print(f"Dependency boundary check passed ({len(ALLOWLIST)} transitional exceptions tracked)")
print(
"Dependency boundary check passed "
f"({len(REPOS)} Python repos, {len(WEBUI_REPOS)} WebUI repos, "
f"{len(ALLOWLIST)} transitional exceptions tracked)"
)
return 0
print("Dependency boundary violations:")

View File

@@ -15,7 +15,7 @@ BACKEND_HOST="${GOVOPLAN_BACKEND_HOST:-127.0.0.1}"
BACKEND_PORT="${GOVOPLAN_BACKEND_PORT:-8000}"
FRONTEND_HOST="${GOVOPLAN_FRONTEND_HOST:-127.0.0.1}"
FRONTEND_PORT="${GOVOPLAN_FRONTEND_PORT:-5173}"
OPEN_BROWSER="${OPEN_BROWSER:-1}"
OPEN_BROWSER="${GOVOPLAN_OPEN_BROWSER:-${OPEN_BROWSER:-0}}"
FRONTEND_FORCE_RELOAD="${GOVOPLAN_FRONTEND_FORCE_RELOAD:-1}"
FRONTEND_CLEAR_VITE_CACHE="${GOVOPLAN_FRONTEND_CLEAR_VITE_CACHE:-1}"
FRONTEND_USE_POLLING="${GOVOPLAN_FRONTEND_USE_POLLING:-1}"
@@ -219,6 +219,7 @@ Frontend reload:
Vite cache cleared: $FRONTEND_CLEAR_VITE_CACHE
Vite --force: $FRONTEND_FORCE_RELOAD
Watch polling: $FRONTEND_USE_POLLING (${FRONTEND_POLLING_INTERVAL}ms)
Open browser: $OPEN_BROWSER
Press Ctrl+C to stop both processes.
EOF