fix: #692 builder->aipass_framework legacy migration + birth-cert template; #694 test-order pollution (prax fixture scope + skills hermetic tests). Verified: repro pair passes, 1576 tests green, Vera dry-run plans exactly 2 migrations zero writes.
This commit is contained in:
@@ -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/<citizen_class>/` — 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|------|-------|
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user