Skip to content

Commit d52098a

Browse files
BenBtgCopilot
andcommitted
fix(presets): tighten legacy constitution provenance
Trust only the immutable bundled/source constitution template when migrating legacy projects without provenance. Do not infer core provenance from mutable project templates or preset source labels, including IDs beginning with core. Also detect convention-based constitution-template files before preset removal so unchanged generated constitutions reconcile to the next resolver layer. 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 8685bcc commit d52098a

2 files changed

Lines changed: 160 additions & 14 deletions

File tree

src/specify_cli/presets/__init__.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def _content_sha256(content: bytes) -> str:
5050
def _constitution_is_generated(
5151
project_root: Path,
5252
memory_constitution: Path,
53-
layers: list[dict[str, Any]],
53+
resolver: "PresetResolver",
5454
) -> bool:
5555
"""Return whether the live constitution is an unchanged generated file."""
5656
_ensure_safe_shared_destination(project_root, memory_constitution)
@@ -69,12 +69,12 @@ def _constitution_is_generated(
6969
):
7070
return True
7171

72-
# Older projects have no provenance sidecar. Only the exact known core
73-
# template is safe to treat as generated; placeholder substrings are not.
74-
for layer in layers:
75-
if layer["source"].startswith("core") and layer["path"].read_bytes() == content:
76-
return True
77-
return False
72+
# Older projects have no provenance sidecar. Only the immutable bundled or
73+
# source-checkout core template is safe to treat as generated.
74+
core = resolver._find_bundled_core(
75+
"constitution-template", "template", ".md"
76+
)
77+
return core is not None and core.read_bytes() == content
7878

7979

8080
def _materialize_constitution_template(
@@ -1745,9 +1745,8 @@ def _reconcile_constitution(self) -> None:
17451745
self.project_root / ".specify" / "memory" / "constitution.md"
17461746
)
17471747
resolver = PresetResolver(self.project_root)
1748-
layers = resolver.collect_all_layers("constitution-template", "template")
17491748
if memory_constitution.exists() and not _constitution_is_generated(
1750-
self.project_root, memory_constitution, layers
1749+
self.project_root, memory_constitution, resolver
17511750
):
17521751
return
17531752
_materialize_constitution_template(self.project_root, memory_constitution)
@@ -1831,7 +1830,13 @@ def remove(self, pack_id: str) -> bool:
18311830
# Also include aliases from the manifest as a safety net for registries
18321831
# populated by older versions that may not track aliases.
18331832
removed_cmd_names = set()
1834-
removed_constitution = False
1833+
removed_constitution = any(
1834+
path.exists()
1835+
for path in (
1836+
pack_dir / "templates" / "constitution-template.md",
1837+
pack_dir / "constitution-template.md",
1838+
)
1839+
)
18351840
for cmd_names in registered_commands.values():
18361841
removed_cmd_names.update(cmd_names)
18371842
manifest_path = pack_dir / "preset.yml"

tests/test_presets.py

Lines changed: 145 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2597,6 +2597,40 @@ def install_self_test_preset(manager: PresetManager, speckit_version: str = "0.1
25972597
return manager.install_from_directory(SELF_TEST_PRESET_DIR, speckit_version)
25982598

25992599

2600+
def _make_convention_constitution_preset(temp_dir: Path) -> Path:
2601+
"""Create a preset whose constitution is found by convention, not its manifest."""
2602+
preset_dir = temp_dir / "convention-constitution"
2603+
(preset_dir / "templates").mkdir(parents=True)
2604+
(preset_dir / "templates" / "constitution-template.md").write_text(
2605+
"# Convention Constitution\n"
2606+
)
2607+
(preset_dir / "templates" / "spec-template.md").write_text("# Spec\n")
2608+
(preset_dir / "preset.yml").write_text(
2609+
yaml.dump(
2610+
{
2611+
"schema_version": "1.0",
2612+
"preset": {
2613+
"id": "convention-constitution",
2614+
"name": "Convention Constitution",
2615+
"version": "1.0.0",
2616+
"description": "Convention-based constitution for testing",
2617+
},
2618+
"requires": {"speckit_version": ">=0.1.0"},
2619+
"provides": {
2620+
"templates": [
2621+
{
2622+
"type": "template",
2623+
"name": "spec-template",
2624+
"file": "templates/spec-template.md",
2625+
}
2626+
]
2627+
},
2628+
}
2629+
)
2630+
)
2631+
return preset_dir
2632+
2633+
26002634
class TestSelfTestPreset:
26012635
"""Tests using the self-test preset that ships with the repo.
26022636
@@ -2809,13 +2843,15 @@ def test_self_test_seeds_constitution_when_memory_absent(self, project_dir):
28092843

28102844
def test_self_test_reseeds_exact_core_constitution(self, project_dir):
28112845
"""An unchanged core constitution is re-seeded from the preset template."""
2812-
core = "# [PROJECT_NAME] Constitution\n\n### [PRINCIPLE_1_NAME]\n"
2813-
(project_dir / ".specify" / "templates" / "constitution-template.md").write_text(
2814-
core
2846+
resolver = PresetResolver(project_dir)
2847+
bundled_core = resolver._find_bundled_core(
2848+
"constitution-template", "template", ".md"
28152849
)
2850+
assert bundled_core is not None
2851+
core = bundled_core.read_bytes()
28162852
memory = project_dir / ".specify" / "memory" / "constitution.md"
28172853
memory.parent.mkdir(parents=True, exist_ok=True)
2818-
memory.write_text(core)
2854+
memory.write_bytes(core)
28192855

28202856
manager = PresetManager(project_dir)
28212857
install_self_test_preset(manager)
@@ -2824,6 +2860,69 @@ def test_self_test_reseeds_exact_core_constitution(self, project_dir):
28242860
assert "preset:self-test" in content, "placeholder constitution was not re-seeded"
28252861
assert "[PROJECT_NAME]" not in content
28262862

2863+
def test_self_test_preserves_mutable_project_core_copy(self, project_dir):
2864+
"""A project template copy does not establish generated provenance."""
2865+
authored = "# Acme Organization Constitution\n\nOrganization policy.\n"
2866+
project_template = (
2867+
project_dir / ".specify" / "templates" / "constitution-template.md"
2868+
)
2869+
project_template.write_text(authored)
2870+
memory = project_dir / ".specify" / "memory" / "constitution.md"
2871+
memory.parent.mkdir(parents=True, exist_ok=True)
2872+
memory.write_text(authored)
2873+
2874+
manager = PresetManager(project_dir)
2875+
install_self_test_preset(manager)
2876+
2877+
assert memory.read_text() == authored
2878+
assert not (memory.parent / ".constitution-template.json").exists()
2879+
2880+
def test_core_prefixed_preset_does_not_establish_generated_provenance(
2881+
self, project_dir, temp_dir
2882+
):
2883+
"""A preset ID beginning with core is not an immutable core source."""
2884+
authored = "# Acme Organization Constitution\n\nOrganization policy.\n"
2885+
memory = project_dir / ".specify" / "memory" / "constitution.md"
2886+
memory.parent.mkdir(parents=True, exist_ok=True)
2887+
memory.write_text(authored)
2888+
2889+
preset_dir = temp_dir / "core-company"
2890+
(preset_dir / "templates").mkdir(parents=True)
2891+
(preset_dir / "templates" / "constitution-template.md").write_text(authored)
2892+
(preset_dir / "preset.yml").write_text(
2893+
yaml.safe_dump(
2894+
{
2895+
"schema_version": "1.0",
2896+
"preset": {
2897+
"id": "core-company",
2898+
"name": "Core Company",
2899+
"version": "1.0.0",
2900+
"description": "Company constitution preset",
2901+
"author": "Test Author",
2902+
"repository": "https://github.com/test/core-company",
2903+
"license": "MIT",
2904+
},
2905+
"requires": {"speckit_version": ">=0.1.0"},
2906+
"provides": {
2907+
"templates": [
2908+
{
2909+
"type": "template",
2910+
"name": "constitution-template",
2911+
"file": "templates/constitution-template.md",
2912+
"description": "Company constitution",
2913+
"replaces": "constitution-template",
2914+
}
2915+
]
2916+
},
2917+
}
2918+
)
2919+
)
2920+
2921+
PresetManager(project_dir).install_from_directory(preset_dir, "0.1.5")
2922+
2923+
assert memory.read_text() == authored
2924+
assert not (memory.parent / ".constitution-template.json").exists()
2925+
28272926
def test_self_test_preserves_authored_constitution_with_placeholder(
28282927
self, project_dir
28292928
):
@@ -2958,6 +3057,48 @@ def test_constitution_follows_priority_when_winning_preset_removed(
29583057

29593058
assert "preset:self-test" in memory.read_text()
29603059

3060+
def test_convention_constitution_removal_restores_remaining_layer(
3061+
self, project_dir, temp_dir
3062+
):
3063+
"""Removing a convention layer rematerializes the remaining resolver layer."""
3064+
from specify_cli.commands.init import ensure_constitution_from_template
3065+
3066+
manager = PresetManager(project_dir)
3067+
install_self_test_preset(manager)
3068+
manager.install_from_directory(
3069+
_make_convention_constitution_preset(temp_dir), "0.1.5", priority=1
3070+
)
3071+
3072+
memory = project_dir / ".specify" / "memory" / "constitution.md"
3073+
memory.unlink()
3074+
ensure_constitution_from_template(project_dir)
3075+
assert memory.read_text() == "# Convention Constitution\n"
3076+
3077+
manager.remove("convention-constitution")
3078+
3079+
assert "preset:self-test" in memory.read_text()
3080+
3081+
def test_convention_constitution_removal_preserves_edited_content(
3082+
self, project_dir, temp_dir
3083+
):
3084+
"""Removing a convention layer does not overwrite edited generated content."""
3085+
from specify_cli.commands.init import ensure_constitution_from_template
3086+
3087+
templates_dir = project_dir / ".specify" / "templates"
3088+
(templates_dir / "constitution-template.md").write_text("# Core Constitution\n")
3089+
manager = PresetManager(project_dir)
3090+
manager.install_from_directory(
3091+
_make_convention_constitution_preset(temp_dir), "0.1.5"
3092+
)
3093+
ensure_constitution_from_template(project_dir)
3094+
memory = project_dir / ".specify" / "memory" / "constitution.md"
3095+
edited = memory.read_text() + "\n## Authored amendment\n"
3096+
memory.write_text(edited)
3097+
3098+
manager.remove("convention-constitution")
3099+
3100+
assert memory.read_text() == edited
3101+
29613102
def test_constitution_seed_rejects_symlinked_memory_directory(
29623103
self, project_dir, temp_dir
29633104
):

0 commit comments

Comments
 (0)