diff --git a/CHANGELOG.md b/CHANGELOG.md index 1527fca2..a107c975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,41 @@ PyPI version — not the changelog header. --- +## [2026-07-12] + +### Fixed + +- **Legacy `builder` citizen_class migration + birth-certificate template + (fixes #692).** `builder` was renamed to `aipass_framework` on 2026-07-01 + (13463c0c) as a pure rename, but passports minted pre-rename kept the retired + name, and the seedgo Architecture checker requires + `spawn/templates//` — hard-capping those citizens below 100% + (Vera Studio's @vera/@writer stuck at 99%; same legacy class found in 6 + external projects). @spawn completed the rename instead of resurrecting a + `builder` template: `sync-registry --fix` now migrates the exact value + `builder` → `aipass_framework` in passports (idempotent, dry-run safe, 3 new + tests), so external projects self-heal via `aipass doctor --fix`. Also fixed + the template leftover that kept minting the retired name: + `birth_certificate.json` now renders `{{CITIZEN_CLASS}}` like the passport + does. Verified: dry-run against Vera Studio's live registry plans exactly the + two migrations with zero writes; spawn 347 tests green. #695 closed won't-fix + (armed Monitor-tool watchdog is the dispatch indicator; always-arm is the + rule). +- **Order-dependent `test_missing_file` + skills test litter (fixes #694).** + Root cause was @prax's `json_handler_module` fixture popping EVERY branch's + json_handler from `sys.modules` (never restored), orphaning the module object + @skills' conftest had patched — `test_missing_file` then re-imported a fresh + module pointed at the real `skills_json/`, planted `ghost_config.json`, and + failed on it every later full-repo run (the only failure in an 11k-test + sweep). @prax scoped the eviction to `aipass.prax.*` via + `monkeypatch.delitem` (auto-restore). @skills made all 4 resilience tests + hermetic (patch `SKILLS_JSON_DIR` → `tmp_path` inside the test body, immune + to sys.modules state), fully-qualified the legacy bare `skills.` + `BRANCH_MODULE` in 3 test files (the source of the remaining litter), and + fixed a latent wrong-variable assert. Verified: original failing pair now + passes both orders, prax+skills+spawn 1576 tests green, `skills_json/` stays + clean after a full run. + ## [2026-07-11] ### Added diff --git a/src/aipass/prax/tests/test_json_handler.py b/src/aipass/prax/tests/test_json_handler.py index eb5d1ab5..9ef8facb 100644 --- a/src/aipass/prax/tests/test_json_handler.py +++ b/src/aipass/prax/tests/test_json_handler.py @@ -51,10 +51,10 @@ def cleanup_temp(tmp_path): @pytest.fixture def json_handler_module(mock_prax_infrastructure, tmp_path, monkeypatch): """Import json_handler with mocked dependencies and temp directories.""" - # Remove cached module to get fresh import + # Remove cached prax json_handler modules to get fresh import (scoped to prax only) for key in list(sys.modules.keys()): - if "json_handler" in key and "aipass" in key: - sys.modules.pop(key, None) + if "json_handler" in key and key.startswith("aipass.prax."): + monkeypatch.delitem(sys.modules, key) mod = MagicMock() mod.PRAX_JSON_DIR = tmp_path / "prax_json" diff --git a/src/aipass/skills/tests/test_contracts.py b/src/aipass/skills/tests/test_contracts.py index 945ee279..ed015213 100644 --- a/src/aipass/skills/tests/test_contracts.py +++ b/src/aipass/skills/tests/test_contracts.py @@ -22,7 +22,7 @@ import json from pathlib import Path -BRANCH_MODULE = "skills" +BRANCH_MODULE = "aipass.skills" _json_mod_path = f"{BRANCH_MODULE}.apps.handlers.json.json_handler" diff --git a/src/aipass/skills/tests/test_error_resilience.py b/src/aipass/skills/tests/test_error_resilience.py index b6805920..78749410 100644 --- a/src/aipass/skills/tests/test_error_resilience.py +++ b/src/aipass/skills/tests/test_error_resilience.py @@ -3,7 +3,7 @@ # Description: Error Resilience Tests for skills branch # Version: 1.0.0 # Created: 2026-03-28 -# Modified: 2026-03-28 +# Modified: 2026-07-11 # ============================================= """ @@ -16,6 +16,7 @@ Covers 4 tests: import importlib import json from pathlib import Path +from unittest.mock import patch BRANCH_MODULE = "aipass.skills" @@ -23,7 +24,7 @@ _json_mod_path = f"{BRANCH_MODULE}.apps.handlers.json.json_handler" def _import_handler(): - """Import json_handler.""" + """Import json_handler, re-resolving from sys.modules.""" return importlib.import_module(_json_mod_path) @@ -32,59 +33,57 @@ def _import_handler(): # ============================================================================ -def test_missing_file() -> None: +def test_missing_file(tmp_path: Path) -> None: """Loading a non-existent file returns a graceful default, not a crash.""" handler = _import_handler() - target = handler.get_json_path("ghost", "config") - assert not target.exists() + with patch.object(handler, "SKILLS_JSON_DIR", tmp_path): + target = handler.get_json_path("ghost", "config") + assert not target.exists() - try: - result = handler.load_json("ghost", "config") - except FileNotFoundError: - return + try: + result = handler.load_json("ghost", "config") + except FileNotFoundError: + return - assert result is not None - assert isinstance(result, dict) + assert result is not None + assert isinstance(result, dict) -def test_corrupt_json() -> None: +def test_corrupt_json(tmp_path: Path) -> None: """Corrupt JSON on disk is handled gracefully -- file is regenerated.""" handler = _import_handler() - json_dir = handler.SKILLS_JSON_DIR - json_dir.mkdir(parents=True, exist_ok=True) - target = handler.get_json_path("corrupt", "data") - target.write_bytes(b"\x00\x01NOT-JSON{{{broken") + with patch.object(handler, "SKILLS_JSON_DIR", tmp_path): + target = handler.get_json_path("corrupt", "data") + target.write_bytes(b"\x00\x01NOT-JSON{{{broken") - result = handler.ensure_json_exists("corrupt", "data") - assert result is True + result = handler.ensure_json_exists("corrupt", "data") + assert result is True - raw = target.read_text(encoding="utf-8") - data = json.loads(raw) - assert isinstance(data, dict) - assert "created" in data - assert "last_updated" in data + raw = target.read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + assert "created" in data + assert "last_updated" in data -def test_empty_file() -> None: +def test_empty_file(tmp_path: Path) -> None: """An empty file (0 bytes) is handled gracefully.""" handler = _import_handler() - json_dir = handler.SKILLS_JSON_DIR - json_dir.mkdir(parents=True, exist_ok=True) - target = handler.get_json_path("empty", "log") - target.write_text("", encoding="utf-8") + with patch.object(handler, "SKILLS_JSON_DIR", tmp_path): + target = handler.get_json_path("empty", "log") + target.write_text("", encoding="utf-8") - result = handler.ensure_json_exists("empty", "log") - assert result is True + result = handler.ensure_json_exists("empty", "log") + assert result is True - raw = target.read_text(encoding="utf-8") - data = json.loads(raw) - assert isinstance(data, list) + raw = target.read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, list) def test_nonexistent_dir(tmp_path: Path) -> None: """Missing parent directory is handled gracefully.""" handler = _import_handler() - from unittest.mock import patch nested_dir = tmp_path / "does_not_exist" / "nested" assert not nested_dir.exists() diff --git a/src/aipass/skills/tests/test_init_provisioning.py b/src/aipass/skills/tests/test_init_provisioning.py index bddab684..05be95e7 100644 --- a/src/aipass/skills/tests/test_init_provisioning.py +++ b/src/aipass/skills/tests/test_init_provisioning.py @@ -3,7 +3,7 @@ # Description: Init/Provisioning Tests for skills branch # Version: 1.0.0 # Created: 2026-03-28 -# Modified: 2026-03-28 +# Modified: 2026-07-11 # ============================================= """ @@ -21,7 +21,7 @@ from unittest.mock import patch import pytest -BRANCH_MODULE = "skills" +BRANCH_MODULE = "aipass.skills" _json_mod_path = f"{BRANCH_MODULE}.apps.handlers.json.json_handler" @@ -35,21 +35,21 @@ def _import_handler(): # ============================================================================ -def test_creates_expected_files() -> None: +def test_creates_expected_files(tmp_path: Path) -> None: """ensure_json_exists creates expected files on disk.""" handler = _import_handler() - json_dir = handler.SKILLS_JSON_DIR - for json_type in ("config", "data", "log"): - result = handler.ensure_json_exists("prov_mod", json_type) - assert result is True + with patch.object(handler, "SKILLS_JSON_DIR", tmp_path): + for json_type in ("config", "data", "log"): + result = handler.ensure_json_exists("prov_mod", json_type) + assert result is True - expected = json_dir / f"prov_mod_{json_type}.json" - assert expected.exists() + expected = tmp_path / f"prov_mod_{json_type}.json" + assert expected.exists() - raw = expected.read_text(encoding="utf-8") - parsed = json.loads(raw) - assert parsed is not None + raw = expected.read_text(encoding="utf-8") + parsed = json.loads(raw) + assert parsed is not None def test_auto_creates_directory(tmp_path: Path) -> None: @@ -68,41 +68,41 @@ def test_auto_creates_directory(tmp_path: Path) -> None: pytest.skip("Branch does not auto-create missing directories") -def test_no_overwrite_on_second_call() -> None: +def test_no_overwrite_on_second_call(tmp_path: Path) -> None: """Second call must not overwrite existing data (no_overwrite idempotency).""" handler = _import_handler() - json_dir = handler.SKILLS_JSON_DIR - json_dir.mkdir(parents=True, exist_ok=True) - handler.ensure_json_exists("idem_mod", "data") + with patch.object(handler, "SKILLS_JSON_DIR", tmp_path): + handler.ensure_json_exists("idem_mod", "data") - target = json_dir / "idem_mod_data.json" - original = json.loads(target.read_text(encoding="utf-8")) - original["custom_field"] = "do_not_overwrite" - target.write_text(json.dumps(original, indent=2), encoding="utf-8") + target = tmp_path / "idem_mod_data.json" + original = json.loads(target.read_text(encoding="utf-8")) + original["custom_field"] = "do_not_overwrite" + target.write_text(json.dumps(original, indent=2), encoding="utf-8") - handler.ensure_json_exists("idem_mod", "data") + handler.ensure_json_exists("idem_mod", "data") - after = json.loads(target.read_text(encoding="utf-8")) - assert after.get("custom_field") == "do_not_overwrite" + after = json.loads(target.read_text(encoding="utf-8")) + assert after.get("custom_field") == "do_not_overwrite" -def test_returns_dict_with_expected_keys() -> None: +def test_returns_dict_with_expected_keys(tmp_path: Path) -> None: """Provisioned files contain the correct structure keys.""" handler = _import_handler() - handler.ensure_json_exists("key_mod", "config") - config = handler.load_json("key_mod", "config") - assert isinstance(config, dict) - assert "module_name" in config - assert "version" in config + with patch.object(handler, "SKILLS_JSON_DIR", tmp_path): + handler.ensure_json_exists("key_mod", "config") + config = handler.load_json("key_mod", "config") + assert isinstance(config, dict) + assert "module_name" in config + assert "version" in config - handler.ensure_json_exists("key_mod", "data") - data = handler.load_json("key_mod", "data") - assert isinstance(data, dict) - assert "created" in data - assert "last_updated" in data + handler.ensure_json_exists("key_mod", "data") + data = handler.load_json("key_mod", "data") + assert isinstance(data, dict) + assert "created" in data + assert "last_updated" in data - handler.ensure_json_exists("key_mod", "log") - log = handler.load_json("key_mod", "log") - assert isinstance(log, list) + handler.ensure_json_exists("key_mod", "log") + log = handler.load_json("key_mod", "log") + assert isinstance(log, list) diff --git a/src/aipass/skills/tests/test_json_handler.py b/src/aipass/skills/tests/test_json_handler.py index 8db6a272..e929ec67 100644 --- a/src/aipass/skills/tests/test_json_handler.py +++ b/src/aipass/skills/tests/test_json_handler.py @@ -26,7 +26,7 @@ import pytest # Import helper # --------------------------------------------------------------------------- -BRANCH_MODULE = "skills" +BRANCH_MODULE = "aipass.skills" _json_mod_path = f"{BRANCH_MODULE}.apps.handlers.json.json_handler" diff --git a/src/aipass/spawn/README.md b/src/aipass/spawn/README.md index cac9fb9a..ce321186 100644 --- a/src/aipass/spawn/README.md +++ b/src/aipass/spawn/README.md @@ -177,7 +177,7 @@ spawn/ ## Tests -**344 tests | 0 skipped | 0 failed** across 14 test files: +**347 tests | 0 skipped | 0 failed** across 14 test files: | File | Focus | |------|-------| diff --git a/src/aipass/spawn/apps/handlers/sync_registry_ops.py b/src/aipass/spawn/apps/handlers/sync_registry_ops.py index a8ba469f..e6b29b74 100644 --- a/src/aipass/spawn/apps/handlers/sync_registry_ops.py +++ b/src/aipass/spawn/apps/handlers/sync_registry_ops.py @@ -608,6 +608,29 @@ def fix_owner_identity(registry_path=None, dry_run=False): actions.extend(passport_actions) + # --- migrate legacy citizen_class "builder" → "aipass_framework" --- + for branch in branches: + branch_dir = project_root / branch.get("path", "") + passport_path = branch_dir / ".trinity" / "passport.json" + if not passport_path.exists(): + continue + try: + passport = json.loads(passport_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, IOError) as e: + logger.warning("[fix-identity] Cannot read passport for migration: %s", e) + continue + current_class = passport.get("identity", {}).get("citizen_class", "") + if current_class != "builder": + continue + passport.setdefault("identity", {})["citizen_class"] = "aipass_framework" + if not dry_run: + try: + passport_path.write_text(json.dumps(passport, indent=2, ensure_ascii=False), encoding="utf-8") + except IOError as e: + logger.warning("[fix-identity] Failed to migrate citizen_class for %s: %s", branch.get("name", "?"), e) + continue + actions.append(f"Migrate citizen_class for {branch.get('name', '?')}: builder → aipass_framework") + applied = False if registry_changed and not dry_run: applied = save_registry(registry_path, reg_data) @@ -616,6 +639,9 @@ def fix_owner_identity(registry_path=None, dry_run=False): else: logger.error("[fix-identity] Failed to save registry") + if not registry_changed and actions and not dry_run: + applied = True + if dry_run and actions: logger.info("[fix-identity] Dry-run: %d action(s) planned", len(actions)) diff --git a/src/aipass/spawn/templates/aipass_framework/.spawn/.template_registry.json b/src/aipass/spawn/templates/aipass_framework/.spawn/.template_registry.json index 0c8d32e8..5385ff58 100644 --- a/src/aipass/spawn/templates/aipass_framework/.spawn/.template_registry.json +++ b/src/aipass/spawn/templates/aipass_framework/.spawn/.template_registry.json @@ -301,7 +301,7 @@ "path": "artifacts/README.md" }, "f029": { - "content_hash": "0b6e4319781e", + "content_hash": "b089aef46e8c", "has_branch_placeholder": false, "name": "birth_certificate.json", "path": "artifacts/birth_certificate.json" @@ -429,7 +429,7 @@ }, "metadata": { "description": "Template file tracking registry for ID-based updates", - "last_updated": "2026-07-11", + "last_updated": "2026-07-12", "version": "1.0.0" } } diff --git a/src/aipass/spawn/templates/aipass_framework/artifacts/birth_certificate.json b/src/aipass/spawn/templates/aipass_framework/artifacts/birth_certificate.json index f61f1def..efb44502 100644 --- a/src/aipass/spawn/templates/aipass_framework/artifacts/birth_certificate.json +++ b/src/aipass/spawn/templates/aipass_framework/artifacts/birth_certificate.json @@ -5,10 +5,10 @@ "creator": "SYSTEM", "owner": "{{BRANCHNAME}}", "rarity": "unique", - "description": "Official birth certificate for {{BRANCHNAME}}. Citizen #{{CITIZEN_NUMBER}}, builder class. Purpose: {{PURPOSE_BRIEF}}", + "description": "Official birth certificate for {{BRANCHNAME}}. Citizen #{{CITIZEN_NUMBER}}, {{CITIZEN_CLASS}} class. Purpose: {{PURPOSE_BRIEF}}", "metadata": { "citizen_number": "{{CITIZEN_NUMBER}}", - "citizen_class": "builder", + "citizen_class": "{{CITIZEN_CLASS}}", "purpose": "{{PURPOSE_BRIEF}}" }, "created_at": "{{DATE}}" diff --git a/src/aipass/spawn/tests/test_check_fix_identity.py b/src/aipass/spawn/tests/test_check_fix_identity.py index 1397c674..7a0ffbf1 100644 --- a/src/aipass/spawn/tests/test_check_fix_identity.py +++ b/src/aipass/spawn/tests/test_check_fix_identity.py @@ -454,6 +454,67 @@ class TestFixOwnerIdentity: assert result["applied"] is False +class TestLegacyCitizenClassMigration: + """Tests for builder → aipass_framework passport migration.""" + + def test_migrates_builder_to_aipass_framework(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import fix_owner_identity + + _make_branch(tmp_path, "vera", "src/vera", citizen_class="builder", passport_rid="proj-id") + _make_branch(tmp_path, "writer", "src/writer", citizen_class="builder", passport_rid="proj-id") + _make_branch(tmp_path, "modern", "src/modern", citizen_class="aipass_framework", passport_rid="proj-id") + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "proj-id"}, + branches=[ + _entry("vera", "src/vera", owner=True, registry_id="uid-v"), + _entry("writer", "src/writer", registry_id="uid-w"), + _entry("modern", "src/modern", registry_id="uid-m"), + ], + ) + + result = fix_owner_identity(registry_path=reg) + assert any("Migrate" in a and "vera" in a for a in result["actions"]) + assert any("Migrate" in a and "writer" in a for a in result["actions"]) + assert not any("modern" in a and "Migrate" in a for a in result["actions"]) + + vera_passport = json.loads((tmp_path / "src/vera/.trinity/passport.json").read_text(encoding="utf-8")) + assert vera_passport["identity"]["citizen_class"] == "aipass_framework" + + modern_passport = json.loads((tmp_path / "src/modern/.trinity/passport.json").read_text(encoding="utf-8")) + assert modern_passport["identity"]["citizen_class"] == "aipass_framework" + + def test_migration_idempotent(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import fix_owner_identity + + _make_branch(tmp_path, "alpha", "src/alpha", citizen_class="builder", passport_rid="proj-id") + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "proj-id"}, + branches=[_entry("alpha", "src/alpha", owner=True, registry_id="uid-a")], + ) + + fix_owner_identity(registry_path=reg) + result2 = fix_owner_identity(registry_path=reg) + assert not any("Migrate" in a for a in result2["actions"]) + + def test_migration_dry_run_no_write(self, tmp_path): + from aipass.spawn.apps.handlers.sync_registry_ops import fix_owner_identity + + _make_branch(tmp_path, "alpha", "src/alpha", citizen_class="builder", passport_rid="proj-id") + reg = _write_registry( + tmp_path, + metadata={"version": "1.0.0", "last_updated": "2026-07-11", "id": "proj-id"}, + branches=[_entry("alpha", "src/alpha", owner=True, registry_id="uid-a")], + ) + + result = fix_owner_identity(registry_path=reg, dry_run=True) + assert any("Migrate" in a for a in result["actions"]) + + passport = json.loads((tmp_path / "src/alpha/.trinity/passport.json").read_text(encoding="utf-8")) + assert passport["identity"]["citizen_class"] == "builder" + + class TestAdoptCallsEnsureOwner: """Test that _adopt_existing calls ensure_project_has_owner."""