Skip to content

Commit 8685bcc

Browse files
BenBtgCopilot
andcommitted
fix(presets): reconcile constitution after removal
When the removed preset supplied constitution-template, rematerialize the winning remaining resolver layer only if provenance proves the live file is still generated and unchanged. Preserve edited constitutions and report post-removal reconciliation failures as non-fatal warnings. Add coverage for restoring the core layer, falling back from a removed higher-priority preset, and preserving edited generated content. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1b2c095d-b45c-4d52-8d56-bd6121d96ab6
1 parent 116ab2c commit 8685bcc

2 files changed

Lines changed: 56 additions & 18 deletions

File tree

src/specify_cli/presets/__init__.py

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,23 +1729,8 @@ def _seed_constitution_from_preset(self, manifest: PresetManifest) -> None:
17291729
if not provides_constitution:
17301730
return
17311731

1732-
memory_constitution = (
1733-
self.project_root / ".specify" / "memory" / "constitution.md"
1734-
)
17351732
try:
1736-
resolver = PresetResolver(self.project_root)
1737-
layers = resolver.collect_all_layers(
1738-
"constitution-template", "template"
1739-
)
1740-
if memory_constitution.exists() and not _constitution_is_generated(
1741-
self.project_root, memory_constitution, layers
1742-
):
1743-
return
1744-
result = _materialize_constitution_template(
1745-
self.project_root, memory_constitution
1746-
)
1747-
if result is None:
1748-
return
1733+
self._reconcile_constitution()
17491734
except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc:
17501735
import warnings
17511736

@@ -1754,6 +1739,19 @@ def _seed_constitution_from_preset(self, manifest: PresetManifest) -> None:
17541739
stacklevel=2,
17551740
)
17561741

1742+
def _reconcile_constitution(self) -> None:
1743+
"""Materialize the winning constitution layer when the live file is generated."""
1744+
memory_constitution = (
1745+
self.project_root / ".specify" / "memory" / "constitution.md"
1746+
)
1747+
resolver = PresetResolver(self.project_root)
1748+
layers = resolver.collect_all_layers("constitution-template", "template")
1749+
if memory_constitution.exists() and not _constitution_is_generated(
1750+
self.project_root, memory_constitution, layers
1751+
):
1752+
return
1753+
_materialize_constitution_template(self.project_root, memory_constitution)
1754+
17571755
def install_from_zip(
17581756
self,
17591757
zip_path: Path,
@@ -1833,13 +1831,19 @@ def remove(self, pack_id: str) -> bool:
18331831
# Also include aliases from the manifest as a safety net for registries
18341832
# populated by older versions that may not track aliases.
18351833
removed_cmd_names = set()
1834+
removed_constitution = False
18361835
for cmd_names in registered_commands.values():
18371836
removed_cmd_names.update(cmd_names)
18381837
manifest_path = pack_dir / "preset.yml"
18391838
if manifest_path.exists():
18401839
try:
18411840
manifest = PresetManifest(manifest_path)
18421841
for tmpl in manifest.templates:
1842+
if (
1843+
tmpl.get("type") == "template"
1844+
and tmpl.get("name") == "constitution-template"
1845+
):
1846+
removed_constitution = True
18431847
if tmpl.get("type") == "command":
18441848
for alias in tmpl.get("aliases", []):
18451849
if isinstance(alias, str):
@@ -1886,6 +1890,18 @@ def remove(self, pack_id: str) -> bool:
18861890
stacklevel=2,
18871891
)
18881892

1893+
if removed_constitution:
1894+
try:
1895+
self._reconcile_constitution()
1896+
except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc:
1897+
import warnings
1898+
1899+
warnings.warn(
1900+
f"Post-removal constitution reconciliation failed for {pack_id}: "
1901+
f"{exc}. The live constitution may be stale.",
1902+
stacklevel=2,
1903+
)
1904+
18891905
return True
18901906

18911907
def list_installed(self) -> List[Dict[str, Any]]:

tests/test_presets.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2704,6 +2704,24 @@ def test_self_test_removal_restores_core(self, project_dir):
27042704
assert result is not None
27052705
assert result["source"] == "core"
27062706

2707+
memory = project_dir / ".specify" / "memory" / "constitution.md"
2708+
assert memory.read_text() == "# Core constitution-template\n"
2709+
2710+
def test_self_test_removal_preserves_edited_constitution(self, project_dir):
2711+
"""Removing a preset does not overwrite an edited generated constitution."""
2712+
templates_dir = project_dir / ".specify" / "templates"
2713+
(templates_dir / "constitution-template.md").write_text("# Core Constitution\n")
2714+
2715+
manager = PresetManager(project_dir)
2716+
install_self_test_preset(manager)
2717+
memory = project_dir / ".specify" / "memory" / "constitution.md"
2718+
edited = memory.read_text() + "\n## Authored amendment\n"
2719+
memory.write_text(edited)
2720+
2721+
manager.remove("self-test")
2722+
2723+
assert memory.read_text() == edited
2724+
27072725
def test_self_test_not_in_catalog(self):
27082726
"""Verify the self-test preset is NOT in the catalog (it's local-only)."""
27092727
catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json"
@@ -2893,10 +2911,10 @@ def test_constitution_seed_composes_wrap_strategy(self, project_dir, temp_dir):
28932911
assert "# Wrapper Constitution" in content
28942912
assert "## Core Principle" in content
28952913

2896-
def test_higher_priority_preset_reseeds_unchanged_generated_constitution(
2914+
def test_constitution_follows_priority_when_winning_preset_removed(
28972915
self, project_dir, temp_dir
28982916
):
2899-
"""An unchanged generated constitution follows resolver priority."""
2917+
"""An unchanged generated constitution follows priority and fallback layers."""
29002918
manager = PresetManager(project_dir)
29012919
install_self_test_preset(manager)
29022920

@@ -2936,6 +2954,10 @@ def test_higher_priority_preset_reseeds_unchanged_generated_constitution(
29362954
memory = project_dir / ".specify" / "memory" / "constitution.md"
29372955
assert memory.read_text() == "# Higher Priority Constitution\n"
29382956

2957+
manager.remove("higher-priority")
2958+
2959+
assert "preset:self-test" in memory.read_text()
2960+
29392961
def test_constitution_seed_rejects_symlinked_memory_directory(
29402962
self, project_dir, temp_dir
29412963
):

0 commit comments

Comments
 (0)