Compare commits
5 Commits
8b80afbd60
...
65cc099839
| Author | SHA1 | Date | |
|---|---|---|---|
| 65cc099839 | |||
| 1ce3d02239 | |||
| 9805d085fa | |||
| 2e21397868 | |||
| 4b217c7aba |
@@ -30,6 +30,12 @@ Start the development stack through the meta repository:
|
|||||||
./tools/launch/launch-dev.sh
|
./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:
|
Start the shared development PostgreSQL service:
|
||||||
|
|
||||||
```sh
|
```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`.
|
`docs/META_REPO_SCAN.md` and `docs/MODULE_CONTRACTS_AND_INSTALLS.md`.
|
||||||
Frontend layout principles for module pages are documented in
|
Frontend layout principles for module pages are documented in
|
||||||
`docs/FRONTEND_LAYOUT_PRINCIPLES.md`.
|
`docs/FRONTEND_LAYOUT_PRINCIPLES.md`.
|
||||||
|
The first Campaign-centric capability and infrastructure fit assessment is in
|
||||||
|
`docs/CAPABILITY_AND_INFRASTRUCTURE_FIT.md`.
|
||||||
|
|
||||||
# GovOPlaN Docker
|
# GovOPlaN Docker
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ cd /mnt/DATA/git/govoplan
|
|||||||
tools/launch/launch-dev.sh
|
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:
|
To force the old SQLite fallback for a disposable local run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ rules:
|
|||||||
decoration.
|
decoration.
|
||||||
- Keep optional modules optional. Compose through core route and UI-capability
|
- Keep optional modules optional. Compose through core route and UI-capability
|
||||||
contracts, never sibling-private components.
|
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.
|
- Preserve a stable way back to the containing object and the broader system.
|
||||||
- Do not let navigation, selection, or a view switch imply consent.
|
- 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.
|
- Workflow, configuration, dashboard, and explanatory pages may use a heading.
|
||||||
The heading names the task or scoped object and contains only route-level
|
The heading names the task or scoped object and contains only route-level
|
||||||
actions.
|
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
|
- 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
|
above or beside the current selection. Put object actions with the object
|
||||||
detail, not in the global title bar.
|
detail, not in the global title bar.
|
||||||
@@ -311,7 +319,30 @@ implementation.
|
|||||||
|
|
||||||
Core already exports shell, navigation, access-boundary, form, dialog, loading,
|
Core already exports shell, navigation, access-boundary, form, dialog, loading,
|
||||||
status, policy/provenance, blocker, review, table, tree, message-display, and
|
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
|
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
|
`@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
|
domain composition, wording, and policy semantics; core owns generic contracts
|
||||||
and appearance.
|
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
|
## Test Expectations
|
||||||
|
|
||||||
For every changed surface, select tests from each applicable layer:
|
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
|
- Keyboard, focus, announcement, responsive, theme, density, motion, and i18n
|
||||||
behavior are covered in proportion to the surface.
|
behavior are covered in proportion to the surface.
|
||||||
- Optional modules remain optional and no sibling-private UI import was added.
|
- 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.
|
- Behavioral/accessibility evidence is linked from the rollout matrix and issue.
|
||||||
- Configured-system help can reach the applicable pattern or reference topic
|
- 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)
|
when [Docs #15](https://git.add-ideas.de/add-ideas/govoplan-docs/issues/15)
|
||||||
|
|||||||
@@ -245,6 +245,12 @@
|
|||||||
"description": "GovOPlaN Payments module behavior or integration.",
|
"description": "GovOPlaN Payments module behavior or integration.",
|
||||||
"exclusive": false
|
"exclusive": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "module/permits",
|
||||||
|
"color": "fbca04",
|
||||||
|
"description": "GovOPlaN Permits module behavior or integration.",
|
||||||
|
"exclusive": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "module/policy",
|
"name": "module/policy",
|
||||||
"color": "d93f0b",
|
"color": "d93f0b",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
-e ../govoplan-tenancy
|
-e ../govoplan-tenancy
|
||||||
-e ../govoplan-organizations
|
-e ../govoplan-organizations
|
||||||
-e ../govoplan-identity
|
-e ../govoplan-identity
|
||||||
|
-e ../govoplan-idm
|
||||||
-e ../govoplan-access
|
-e ../govoplan-access
|
||||||
-e ../govoplan-admin
|
-e ../govoplan-admin
|
||||||
-e ../govoplan-policy
|
-e ../govoplan-policy
|
||||||
|
|||||||
144
tests/test_dependency_boundaries.py
Normal file
144
tests/test_dependency_boundaries.py
Normal 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()
|
||||||
191
tests/test_release_doctor.py
Normal file
191
tests/test_release_doctor.py
Normal 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()
|
||||||
190
tests/test_release_migration_audit.py
Normal file
190
tests/test_release_migration_audit.py
Normal 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()
|
||||||
@@ -37,23 +37,10 @@ import pathlib
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
root = pathlib.Path.cwd()
|
root = pathlib.Path.cwd()
|
||||||
scan_roots = [
|
scan_roots = [root / "tests"] + [
|
||||||
root / "src",
|
repo / "src"
|
||||||
root / "tests",
|
for repo in sorted(root.parent.glob("govoplan*"))
|
||||||
root.parent / "govoplan-access" / "src",
|
if (repo / "src").is_dir()
|
||||||
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",
|
|
||||||
]
|
]
|
||||||
deprecated_tokens = {
|
deprecated_tokens = {
|
||||||
"HTTP_422_UNPROCESSABLE_ENTITY": "Use HTTP_422_UNPROCESSABLE_CONTENT; the numeric status remains 422.",
|
"HTTP_422_UNPROCESSABLE_ENTITY": "Use HTTP_422_UNPROCESSABLE_CONTENT; the numeric status remains 422.",
|
||||||
|
|||||||
@@ -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"
|
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
|
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 "$PYTHON" "$META_ROOT/tools/checks/check-manifest-shapes.py"
|
||||||
|
|
||||||
"$PYTHON" - <<'PY'
|
"$PYTHON" - <<'PY'
|
||||||
import ast
|
import ast
|
||||||
import pathlib
|
import pathlib
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
repos_root = pathlib.Path("/mnt/DATA/git")
|
||||||
roots = [
|
roots = [
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-core/src"),
|
repo / "src"
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-access/src"),
|
for repo in sorted(repos_root.glob("govoplan*"))
|
||||||
pathlib.Path("/mnt/DATA/git/govoplan-admin/src"),
|
if (repo / "src").is_dir()
|
||||||
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"),
|
|
||||||
]
|
]
|
||||||
|
roots.extend(
|
||||||
|
repo / "tests"
|
||||||
|
for repo in sorted(repos_root.glob("govoplan*"))
|
||||||
|
if (repo / "tests").is_dir()
|
||||||
|
)
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
count = 0
|
count = 0
|
||||||
|
|||||||
113
tools/checks/check-manifest-shapes.py
Normal file
113
tools/checks/check-manifest-shapes.py
Normal 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())
|
||||||
@@ -11,52 +11,89 @@ from pathlib import Path
|
|||||||
META_ROOT = Path(__file__).resolve().parents[2]
|
META_ROOT = Path(__file__).resolve().parents[2]
|
||||||
CORE_ROOT = Path(os.environ.get("GOVOPLAN_CORE_ROOT", META_ROOT.parent / "govoplan-core")).resolve()
|
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_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",
|
def _discover_python_repos(
|
||||||
"admin": REPOS_ROOT / "govoplan-admin" / "src" / "govoplan_admin",
|
repos_root: Path = REPOS_ROOT,
|
||||||
"addresses": REPOS_ROOT / "govoplan-addresses" / "src" / "govoplan_addresses",
|
) -> tuple[dict[str, Path], dict[str, str], tuple[str, ...]]:
|
||||||
"tenancy": REPOS_ROOT / "govoplan-tenancy" / "src" / "govoplan_tenancy",
|
repos: dict[str, Path] = {}
|
||||||
"organizations": REPOS_ROOT / "govoplan-organizations" / "src" / "govoplan_organizations",
|
prefixes: dict[str, str] = {}
|
||||||
"identity": REPOS_ROOT / "govoplan-identity" / "src" / "govoplan_identity",
|
errors: list[str] = []
|
||||||
"policy": REPOS_ROOT / "govoplan-policy" / "src" / "govoplan_policy",
|
for repo_root in sorted(repos_root.glob("govoplan*")):
|
||||||
"audit": REPOS_ROOT / "govoplan-audit" / "src" / "govoplan_audit",
|
source_root = repo_root / "src"
|
||||||
"dashboard": REPOS_ROOT / "govoplan-dashboard" / "src" / "govoplan_dashboard",
|
if not source_root.is_dir():
|
||||||
"files": REPOS_ROOT / "govoplan-files" / "src" / "govoplan_files",
|
continue
|
||||||
"mail": REPOS_ROOT / "govoplan-mail" / "src" / "govoplan_mail",
|
packages = sorted(
|
||||||
"campaign": REPOS_ROOT / "govoplan-campaign" / "src" / "govoplan_campaign",
|
path
|
||||||
}
|
for path in source_root.glob("govoplan_*")
|
||||||
PREFIXES = {
|
if path.is_dir() and (path / "__init__.py").is_file()
|
||||||
"core": "govoplan_core",
|
)
|
||||||
"access": "govoplan_access",
|
if len(packages) != 1:
|
||||||
"admin": "govoplan_admin",
|
discovered = ", ".join(path.name for path in packages) or "none"
|
||||||
"addresses": "govoplan_addresses",
|
errors.append(
|
||||||
"tenancy": "govoplan_tenancy",
|
f"{repo_root.name}: expected exactly one importable govoplan_* package "
|
||||||
"organizations": "govoplan_organizations",
|
f"under {source_root}, found {len(packages)} ({discovered})"
|
||||||
"identity": "govoplan_identity",
|
)
|
||||||
"policy": "govoplan_policy",
|
continue
|
||||||
"audit": "govoplan_audit",
|
owner = repo_root.name
|
||||||
"dashboard": "govoplan_dashboard",
|
prefix = packages[0].name
|
||||||
"files": "govoplan_files",
|
duplicate_owner = next(
|
||||||
"mail": "govoplan_mail",
|
(candidate_owner for candidate_owner, candidate_prefix in prefixes.items() if candidate_prefix == prefix),
|
||||||
"campaign": "govoplan_campaign",
|
None,
|
||||||
}
|
)
|
||||||
FEATURE_OWNERS = ("addresses", "files", "mail", "campaign")
|
if duplicate_owner is not None:
|
||||||
PLATFORM_OWNERS = ("admin", "tenancy", "organizations", "identity", "policy", "audit", "dashboard")
|
errors.append(
|
||||||
WEBUI_REPOS = {
|
f"{owner}: import package {prefix!r} is already owned by {duplicate_owner}; "
|
||||||
"core": CORE_ROOT / "webui",
|
"Python package ownership must be unique"
|
||||||
"addresses": REPOS_ROOT / "govoplan-addresses" / "webui",
|
)
|
||||||
"files": REPOS_ROOT / "govoplan-files" / "webui",
|
continue
|
||||||
"mail": REPOS_ROOT / "govoplan-mail" / "webui",
|
repos[owner] = packages[0]
|
||||||
"campaign": REPOS_ROOT / "govoplan-campaign" / "webui",
|
prefixes[owner] = prefix
|
||||||
}
|
return repos, prefixes, tuple(errors)
|
||||||
WEBUI_PACKAGES = {
|
|
||||||
"core": "@govoplan/core-webui",
|
|
||||||
"addresses": "@govoplan/addresses-webui",
|
def _discover_webui_repos(
|
||||||
"files": "@govoplan/files-webui",
|
repos_root: Path = REPOS_ROOT,
|
||||||
"mail": "@govoplan/mail-webui",
|
) -> tuple[dict[str, Path], dict[str, str], tuple[str, ...]]:
|
||||||
"campaign": "@govoplan/campaign-webui",
|
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(
|
WEBUI_IMPORT_RE = re.compile(
|
||||||
r"(?:from\s+|import\s*\(\s*|require\s*\(\s*|export\s+[^;]*?\s+from\s+)"
|
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_./-]+)?[\"']"
|
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)
|
imported_owner = _import_owner(imported)
|
||||||
if imported_owner is None or imported_owner == owner:
|
if imported_owner is None or imported_owner == owner:
|
||||||
continue
|
continue
|
||||||
if owner == "core" and imported_owner in FEATURE_OWNERS:
|
if imported_owner != CORE_OWNER and not _allowed(owner, path, imported_owner):
|
||||||
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":
|
|
||||||
violations.append(Violation("python", owner, path, lineno, imported, imported_owner))
|
violations.append(Violation("python", owner, path, lineno, imported, imported_owner))
|
||||||
violations.extend(_webui_violations())
|
violations.extend(_webui_violations())
|
||||||
return 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))
|
imported_owner = _webui_package_owner(str(package_name))
|
||||||
if imported_owner is None or imported_owner == owner:
|
if imported_owner is None or imported_owner == owner:
|
||||||
continue
|
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))
|
violations.append(Violation("webui-package", owner, package_path, 1, str(package_name), imported_owner))
|
||||||
return violations
|
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:
|
if imported_owner is None or imported_owner == owner:
|
||||||
continue
|
continue
|
||||||
lineno = text.count("\n", 0, match.start()) + 1
|
lineno = text.count("\n", 0, match.start()) + 1
|
||||||
if owner == "core" and imported_owner in FEATURE_OWNERS:
|
if imported_owner != CORE_OWNER:
|
||||||
violations.append(Violation("webui-source", owner, path, lineno, package_name, imported_owner))
|
|
||||||
elif owner in FEATURE_OWNERS and imported_owner in FEATURE_OWNERS:
|
|
||||||
violations.append(Violation("webui-source", owner, path, lineno, package_name, imported_owner))
|
violations.append(Violation("webui-source", owner, path, lineno, package_name, imported_owner))
|
||||||
return violations
|
return violations
|
||||||
|
|
||||||
@@ -224,9 +253,21 @@ def _webui_violations() -> list[Violation]:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
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()
|
violations = _violations()
|
||||||
if not 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
|
return 0
|
||||||
|
|
||||||
print("Dependency boundary violations:")
|
print("Dependency boundary violations:")
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ BACKEND_HOST="${GOVOPLAN_BACKEND_HOST:-127.0.0.1}"
|
|||||||
BACKEND_PORT="${GOVOPLAN_BACKEND_PORT:-8000}"
|
BACKEND_PORT="${GOVOPLAN_BACKEND_PORT:-8000}"
|
||||||
FRONTEND_HOST="${GOVOPLAN_FRONTEND_HOST:-127.0.0.1}"
|
FRONTEND_HOST="${GOVOPLAN_FRONTEND_HOST:-127.0.0.1}"
|
||||||
FRONTEND_PORT="${GOVOPLAN_FRONTEND_PORT:-5173}"
|
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_FORCE_RELOAD="${GOVOPLAN_FRONTEND_FORCE_RELOAD:-1}"
|
||||||
FRONTEND_CLEAR_VITE_CACHE="${GOVOPLAN_FRONTEND_CLEAR_VITE_CACHE:-1}"
|
FRONTEND_CLEAR_VITE_CACHE="${GOVOPLAN_FRONTEND_CLEAR_VITE_CACHE:-1}"
|
||||||
FRONTEND_USE_POLLING="${GOVOPLAN_FRONTEND_USE_POLLING:-1}"
|
FRONTEND_USE_POLLING="${GOVOPLAN_FRONTEND_USE_POLLING:-1}"
|
||||||
@@ -219,6 +219,7 @@ Frontend reload:
|
|||||||
Vite cache cleared: $FRONTEND_CLEAR_VITE_CACHE
|
Vite cache cleared: $FRONTEND_CLEAR_VITE_CACHE
|
||||||
Vite --force: $FRONTEND_FORCE_RELOAD
|
Vite --force: $FRONTEND_FORCE_RELOAD
|
||||||
Watch polling: $FRONTEND_USE_POLLING (${FRONTEND_POLLING_INTERVAL}ms)
|
Watch polling: $FRONTEND_USE_POLLING (${FRONTEND_POLLING_INTERVAL}ms)
|
||||||
|
Open browser: $OPEN_BROWSER
|
||||||
|
|
||||||
Press Ctrl+C to stop both processes.
|
Press Ctrl+C to stop both processes.
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
Reference in New Issue
Block a user