From ad905340474265a97f076b8bbc109e429b28cac9 Mon Sep 17 00:00:00 2001 From: ProtocolWarden <32967198+ProtocolWarden@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:33:32 -0400 Subject: [PATCH 1/2] feat(registry): shared private-manifest role resolver + instance-ref generalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds repograph.resolve_private_manifest() — the canonical role resolver ($PRIVATE_MANIFEST_DIR override, else registry discovery by the private_manifest* manifest-type filename, never a repo-instance name) — promoting ContextLifecycle's proven reconcile discovery into the shared public API (phase 1 of the role-generalization design). 5 new tests. Also: pre-push hook drops its literal instance candidate path (the generic dist/policy globs already cover it), docstring examples generalized, test fixtures' source_graph_id swapped to a generic id (no production code matches the literal — verified), pre-existing F401 ruff error fixed. The PrivateManifest ontology *class* is manifest-type vocabulary and stays. Co-Authored-By: Claude Opus 4.8 --- .hooks/pre-push | 3 +- src/repograph/__init__.py | 8 +++- src/repograph/registry.py | 52 +++++++++++++++++++++++--- tests/test_boundary_artifact.py | 2 +- tests/test_projection_redaction.py | 2 +- tests/test_registry.py | 60 ++++++++++++++++++++++++++++++ tests/test_schema_governance.py | 19 +++++----- 7 files changed, 126 insertions(+), 20 deletions(-) diff --git a/.hooks/pre-push b/.hooks/pre-push index 2d57984..2b21a16 100755 --- a/.hooks/pre-push +++ b/.hooks/pre-push @@ -8,7 +8,6 @@ workspace_root="$(cd "$repo_root/.." && pwd)" if [ -z "${REPOGRAPH_BOUNDARY_ARTIFACT_FILE:-}" ]; then shopt -s nullglob for candidate in \ - "$workspace_root"/PrivateManifest/dist/boundary_disclosure_artifact.json \ "$workspace_root"/*/dist/boundary_disclosure_artifact.json \ "$workspace_root"/*/policy/boundary_disclosure_artifact.json do @@ -22,7 +21,7 @@ fi if [ -z "${REPOGRAPH_BOUNDARY_ARTIFACT_FILE:-}" ] || [ ! -f "$REPOGRAPH_BOUNDARY_ARTIFACT_FILE" ]; then echo "[custodian-pre-push] missing REPOGRAPH_BOUNDARY_ARTIFACT_FILE; failing closed" - echo " Generate artifact from PrivateManifest first:" + echo " Generate artifact from your private-manifest repo first:" echo " python -m private_manifest.export_boundary_artifact --graph-root graph --out dist/boundary_disclosure_artifact.json" exit 1 fi diff --git a/src/repograph/__init__.py b/src/repograph/__init__.py index ce62fbe..70e9f4f 100644 --- a/src/repograph/__init__.py +++ b/src/repograph/__init__.py @@ -3,7 +3,12 @@ """Shared RepoGraph public API.""" from .errors import AmbiguousAnchorError, RepoGraphConfigError -from .registry import Registry, default_registry_path, discover_manifest_yaml +from .registry import ( + Registry, + default_registry_path, + discover_manifest_yaml, + resolve_private_manifest, +) from .authorization import ( AlsoHostsEntry, AuthorizationView, @@ -78,6 +83,7 @@ "build_authorization_view", "default_registry_path", "discover_manifest_yaml", + "resolve_private_manifest", "BoundaryDisclosureArtifact", "BreakingChangePolicy", "CompatibilityPolicy", diff --git a/src/repograph/registry.py b/src/repograph/registry.py index 1103f53..3103bf2 100644 --- a/src/repograph/registry.py +++ b/src/repograph/registry.py @@ -11,8 +11,8 @@ Schema:: manifests: - - /home/dev/Documents/GitHub/PlatformManifest - - /home/dev/Documents/GitHub/PrivateManifest + - /path/to/PlatformManifest + - /path/to/ """ from __future__ import annotations @@ -197,9 +197,8 @@ def discover_manifest_yaml(repo_root: Path) -> Path | None: def discover_all_manifest_yamls(repo_root: Path) -> list[Path]: """Return all manifest YAMLs under a manifest repo root (in glob order). - A single private manifest repo (e.g. PrivateManifest) may host more than - one manifest YAML (e.g. one per managed project). Authorization needs all - of them. + A single private manifest repo may host more than one manifest YAML + (e.g. one per managed project). Authorization needs all of them. """ seen: set[Path] = set() results: list[Path] = [] @@ -215,6 +214,49 @@ def discover_all_manifest_yamls(repo_root: Path) -> list[Path]: return results +# ---------------------------------------------------------------------- +# Private-manifest role resolution +# ---------------------------------------------------------------------- + +_PRIVATE_MANIFEST_ENV = "PRIVATE_MANIFEST_DIR" +_PRIVATE_MANIFEST_PREFIX = "private_manifest" + + +def resolve_private_manifest(registry: "Registry | None" = None) -> Path | None: + """Resolve the active private-manifest repo root, or ``None``. + + The platform references the private-manifest *role*, never a baked-in + repo-instance name. Resolution order: + + 1. ``$PRIVATE_MANIFEST_DIR`` — explicit operator override (must be an + existing directory; a set-but-bogus value resolves to ``None`` rather + than silently falling through to discovery). + 2. Registry discovery — the first registered manifest repo root hosting + any manifest YAML whose basename starts with ``private_manifest`` + (matched on the manifest *type* filename, never a repo name). + + Best-effort by design: callers that require a private manifest should + treat ``None`` as their own failure condition. + """ + env = os.environ.get(_PRIVATE_MANIFEST_ENV, "").strip() + if env: + root = Path(env).expanduser() + return root.resolve() if root.is_dir() else None + if registry is None: + try: + registry = Registry.load() + except RepoGraphConfigError: + return None + for root in registry.manifests: + root = Path(root).expanduser() + if not root.is_dir(): + continue + yamls = discover_all_manifest_yamls(root) + if any(y.name.startswith(_PRIVATE_MANIFEST_PREFIX) for y in yamls): + return root.resolve() + return None + + def iter_yaml_documents(paths: Iterable[Path]) -> list[tuple[Path, dict]]: """Load YAML docs from the given paths. Skips files that don't parse to a mapping.""" results: list[tuple[Path, dict]] = [] diff --git a/tests/test_boundary_artifact.py b/tests/test_boundary_artifact.py index b8179a9..3d0138f 100644 --- a/tests/test_boundary_artifact.py +++ b/tests/test_boundary_artifact.py @@ -36,7 +36,7 @@ def test_boundary_artifact_forbids_private_names_but_not_public_aliases() -> Non ) artifact = build_boundary_artifact( graph, - source_graph_id="PrivateManifest", + source_graph_id="private-manifest-fixture", source_ref_or_commit="abc123", ) assert artifact.schema_kind == "boundary_artifact" diff --git a/tests/test_projection_redaction.py b/tests/test_projection_redaction.py index 2cde1a4..f417cc7 100644 --- a/tests/test_projection_redaction.py +++ b/tests/test_projection_redaction.py @@ -45,7 +45,7 @@ def test_alias_only_private_repo_projects_as_public_placeholder() -> None: ) ], ) - projection = build_public_projection(graph, source_graph_id="PrivateManifest") + projection = build_public_projection(graph, source_graph_id="private-manifest-fixture") assert projection.manifest["schema_kind"] == "projection" assert projection.manifest["schema_version"] == "1.0.0" assert projection.manifest["projection_profile"] == "public_safe" diff --git a/tests/test_registry.py b/tests/test_registry.py index 84073ba..6434f4c 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -192,3 +192,63 @@ def test_iter_yaml_documents_raises_on_bad_yaml(tmp_path): bad.write_text("key: : :\n - broken\n", encoding="utf-8") with pytest.raises(RepoGraphConfigError, match="parse error"): iter_yaml_documents([bad]) + + +# ---------------------------------------------------------------------- +# resolve_private_manifest — the shared private-manifest *role* resolver +# ---------------------------------------------------------------------- + + +def test_resolve_private_manifest_env_override(tmp_path, monkeypatch): + from repograph.registry import resolve_private_manifest + + privm = tmp_path / "AnyName" + privm.mkdir() + monkeypatch.setenv("PRIVATE_MANIFEST_DIR", str(privm)) + assert resolve_private_manifest() == privm.resolve() + + +def test_resolve_private_manifest_env_bogus_returns_none(tmp_path, monkeypatch): + from repograph.registry import resolve_private_manifest + + monkeypatch.setenv("PRIVATE_MANIFEST_DIR", str(tmp_path / "missing")) + assert resolve_private_manifest() is None + + +def test_resolve_private_manifest_via_registry(registry_env, tmp_path, monkeypatch): + from repograph.registry import resolve_private_manifest + + monkeypatch.delenv("PRIVATE_MANIFEST_DIR", raising=False) + pm = tmp_path / "PublicSide" + _write_public_manifest(pm, "PM", ["pubA"]) + # The private repo's NAME is arbitrary — resolution matches the manifest + # type filename (private_manifest*), never a repo-instance name. + privm = tmp_path / "ArbitraryPrivateRepoName" + _write_private_manifest(privm, "PrivM", ["privA"]) + reg = Registry.load() + reg.add(pm) + reg.add(privm) + reg.save() + + assert resolve_private_manifest() == privm.resolve() + + +def test_resolve_private_manifest_none_when_no_private(registry_env, tmp_path, monkeypatch): + from repograph.registry import resolve_private_manifest + + monkeypatch.delenv("PRIVATE_MANIFEST_DIR", raising=False) + pm = tmp_path / "PublicOnly" + _write_public_manifest(pm, "PM", ["pubA"]) + reg = Registry.load() + reg.add(pm) + reg.save() + + assert resolve_private_manifest() is None + + +def test_resolve_private_manifest_none_when_registry_missing(registry_env, monkeypatch): + from repograph.registry import resolve_private_manifest + + monkeypatch.delenv("PRIVATE_MANIFEST_DIR", raising=False) + # registry_env points at a nonexistent file → empty registry, no private root. + assert resolve_private_manifest() is None diff --git a/tests/test_schema_governance.py b/tests/test_schema_governance.py index b59d0e5..c609349 100644 --- a/tests/test_schema_governance.py +++ b/tests/test_schema_governance.py @@ -3,7 +3,6 @@ import pytest from repograph import ( - BoundaryDisclosureArtifact, RepoGraphConfigError, SchemaKind, DisclosureMode, @@ -60,7 +59,7 @@ def test_schema_versions_are_explicit_and_supported() -> None: def test_boundary_artifact_contains_schema_and_hash() -> None: artifact = build_boundary_artifact( _graph(), - source_graph_id="PrivateManifest", + source_graph_id="private-manifest-fixture", source_ref_or_commit="abc123", ) data = artifact.to_dict() @@ -71,7 +70,7 @@ def test_boundary_artifact_contains_schema_and_hash() -> None: def test_projection_manifest_includes_schema_metadata() -> None: - projection = build_public_projection(_graph(), source_graph_id="PrivateManifest") + projection = build_public_projection(_graph(), source_graph_id="private-manifest-fixture") assert projection.manifest["schema_kind"] == "projection" assert projection.manifest["schema_version"] == "1.0.0" assert projection.manifest["projection_profile"] == "public_safe" @@ -118,10 +117,10 @@ def test_repo_graph_diff_detects_visibility_escalation() -> None: def test_repo_graph_diff_detects_projection_profile_changes() -> None: graph = _graph() - public_projection = build_public_projection(graph, source_graph_id="PrivateManifest") + public_projection = build_public_projection(graph, source_graph_id="private-manifest-fixture") docs_projection = build_public_projection( graph, - source_graph_id="PrivateManifest", + source_graph_id="private-manifest-fixture", profile=ProjectionProfileKind.PUBLIC_DOCS, ) diff = RepoGraphDiff.compare_public_projection( @@ -137,12 +136,12 @@ def test_repo_graph_diff_detects_projection_profile_changes() -> None: def test_repo_graph_diff_detects_boundary_artifact_provenance_changes() -> None: before = build_boundary_artifact( _graph(), - source_graph_id="PrivateManifest", + source_graph_id="private-manifest-fixture", source_ref_or_commit="abc123", ) after = build_boundary_artifact( _graph(), - source_graph_id="PrivateManifest", + source_graph_id="private-manifest-fixture", source_ref_or_commit="def456", ) diff = RepoGraphDiff.compare_boundary_artifacts( @@ -158,7 +157,7 @@ def test_repo_graph_diff_detects_boundary_artifact_provenance_changes() -> None: def test_boundary_artifact_validation_rejects_hash_mismatch() -> None: artifact = build_boundary_artifact( _graph(), - source_graph_id="PrivateManifest", + source_graph_id="private-manifest-fixture", source_ref_or_commit="abc123", ) payload = artifact.to_dict() @@ -170,13 +169,13 @@ def test_boundary_artifact_validation_rejects_hash_mismatch() -> None: def test_boundary_artifact_validation_rejects_signature_without_verifier() -> None: artifact = build_boundary_artifact( _graph(), - source_graph_id="PrivateManifest", + source_graph_id="private-manifest-fixture", source_ref_or_commit="abc123", ) payload = artifact.to_dict() payload["signature"] = "signed" payload["signature_algorithm"] = "ed25519" - payload["issuer"] = "PrivateManifest" + payload["issuer"] = "private-manifest-fixture" payload["signed_at"] = "2026-05-13T00:00:00Z" with pytest.raises(RepoGraphConfigError): validate_boundary_artifact_payload(payload) From 8e21d4ecdb9e15f00de898859246332e41b07c10 Mon Sep 17 00:00:00 2001 From: ProtocolWarden <32967198+ProtocolWarden@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:35:20 -0400 Subject: [PATCH 2/2] ci: install dev extras so pytest exists on the runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit python-verify has been red on main since inception — 'No module named pytest' (only the runtime package was installed). Install .[dev]. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/python-verify.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-verify.yml b/.github/workflows/python-verify.yml index 8631fcb..8247d7d 100644 --- a/.github/workflows/python-verify.yml +++ b/.github/workflows/python-verify.yml @@ -17,7 +17,7 @@ jobs: - name: Install editable package run: | python -m pip install --upgrade pip - python -m pip install -e . + python -m pip install -e ".[dev]" - name: Verify import run: | python -c "import repograph"