Skip to content

Commit f954e30

Browse files
BenBtgCopilot
andcommitted
fix(presets): compose constitution-template when seeding memory
Take on review feedback from Copilot and gglachant: - constitution seeding previously copied the top layer file path verbatim even when the winning layer used a composing strategy (prepend/append/wrap), which could leave {CORE_TEMPLATE} unresolved. - both seeding paths now inspect resolver layers and only copy verbatim for replace; non-replace strategies materialize composed content via PresetResolver.resolve_content(). - add regression tests for wrap strategy composition in both PresetManager seeding and ensure_constitution_from_template. - add a drift-guard test pinning _CONSTITUTION_PLACEHOLDER_TOKENS to the placeholders in templates/constitution-template.md. Assisted-by: GitHub Copilot (model: GPT-5.3-Codex, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent ccf8dc2 commit f954e30

3 files changed

Lines changed: 139 additions & 13 deletions

File tree

src/specify_cli/commands/init.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,36 +33,44 @@ def _stdin_is_interactive() -> bool:
3333
def ensure_constitution_from_template(
3434
project_path: Path, tracker: StepTracker | None = None
3535
) -> None:
36-
"""Copy the resolved constitution template to memory if it doesn't exist.
36+
"""Materialize the resolved constitution template to memory if missing.
3737
3838
Resolution walks the full priority stack (project overrides → installed
3939
presets → extensions → core) via :class:`PresetResolver`, so a preset that
4040
ships a ``constitution-template`` (e.g. ``strategy: replace`` with a ratified
41-
constitution) seeds the memory file verbatim. When nothing overrides it, the
42-
resolver falls through to the core template, preserving legacy behavior.
41+
constitution) can seed the memory file. When nothing overrides it, the
42+
resolver falls through to the core template.
4343
"""
4444
from ..presets import PresetResolver
4545

4646
memory_constitution = project_path / ".specify" / "memory" / "constitution.md"
47-
template_constitution = PresetResolver(project_path).resolve(
48-
"constitution-template", "template"
49-
)
47+
resolver = PresetResolver(project_path)
48+
layers = resolver.collect_all_layers("constitution-template", "template")
5049

5150
if memory_constitution.exists():
5251
if tracker:
5352
tracker.add("constitution", "Constitution setup")
5453
tracker.skip("constitution", "existing file preserved")
5554
return
5655

57-
if template_constitution is None or not template_constitution.exists():
56+
if not layers:
5857
if tracker:
5958
tracker.add("constitution", "Constitution setup")
6059
tracker.error("constitution", "template not found")
6160
return
6261

6362
try:
6463
memory_constitution.parent.mkdir(parents=True, exist_ok=True)
65-
shutil.copy2(template_constitution, memory_constitution)
64+
top_layer = layers[0]
65+
if top_layer["strategy"] == "replace":
66+
shutil.copy2(top_layer["path"], memory_constitution)
67+
else:
68+
composed_content = resolver.resolve_content(
69+
"constitution-template", "template"
70+
)
71+
if composed_content is None:
72+
raise FileNotFoundError("constitution template not found")
73+
memory_constitution.write_text(composed_content, encoding="utf-8")
6674
if tracker:
6775
tracker.add("constitution", "Constitution setup")
6876
tracker.complete("constitution", "copied from template")

src/specify_cli/presets/__init__.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1664,15 +1664,23 @@ def _seed_constitution_from_preset(self, manifest: PresetManifest) -> None:
16641664
# Legitimately authored constitution; leave it untouched.
16651665
return
16661666

1667-
resolved = PresetResolver(self.project_root).resolve(
1668-
"constitution-template", "template"
1669-
)
1670-
if resolved is None or not resolved.exists():
1667+
resolver = PresetResolver(self.project_root)
1668+
layers = resolver.collect_all_layers("constitution-template", "template")
1669+
if not layers:
16711670
return
16721671

16731672
try:
16741673
memory_constitution.parent.mkdir(parents=True, exist_ok=True)
1675-
shutil.copy2(resolved, memory_constitution)
1674+
top_layer = layers[0]
1675+
if top_layer["strategy"] == "replace":
1676+
shutil.copy2(top_layer["path"], memory_constitution)
1677+
else:
1678+
composed_content = resolver.resolve_content(
1679+
"constitution-template", "template"
1680+
)
1681+
if composed_content is None:
1682+
return
1683+
memory_constitution.write_text(composed_content, encoding="utf-8")
16761684
except OSError as exc:
16771685
import warnings
16781686

tests/test_presets.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2827,6 +2827,66 @@ def test_self_test_override_resolves_constitution_template(self, project_dir):
28272827
assert result is not None
28282828
assert "preset:self-test" in result.read_text()
28292829

2830+
def test_constitution_seed_composes_wrap_strategy(self, project_dir, temp_dir):
2831+
"""Seeding memory composes wrap constitution-template layers."""
2832+
templates_dir = project_dir / ".specify" / "templates"
2833+
templates_dir.mkdir(parents=True, exist_ok=True)
2834+
(templates_dir / "constitution-template.md").write_text(
2835+
"# Core Constitution\n\n## Core Principle\n"
2836+
)
2837+
2838+
preset_dir = temp_dir / "constitution-wrap"
2839+
(preset_dir / "templates").mkdir(parents=True)
2840+
(preset_dir / "templates" / "constitution-template.md").write_text(
2841+
"# Wrapper Constitution\n\n{CORE_TEMPLATE}\n\n## Wrapper Footer\n"
2842+
)
2843+
(preset_dir / "preset.yml").write_text(
2844+
yaml.dump(
2845+
{
2846+
"schema_version": "1.0",
2847+
"preset": {
2848+
"id": "constitution-wrap",
2849+
"name": "Constitution Wrap",
2850+
"version": "1.0.0",
2851+
"description": "Wrap constitution template for testing",
2852+
},
2853+
"requires": {"speckit_version": ">=0.1.0"},
2854+
"provides": {
2855+
"templates": [
2856+
{
2857+
"type": "template",
2858+
"name": "constitution-template",
2859+
"file": "templates/constitution-template.md",
2860+
"strategy": "wrap",
2861+
"description": "Wrapped constitution template",
2862+
}
2863+
]
2864+
},
2865+
}
2866+
)
2867+
)
2868+
2869+
manager = PresetManager(project_dir)
2870+
manager.install_from_directory(preset_dir, "0.1.5")
2871+
2872+
memory = project_dir / ".specify" / "memory" / "constitution.md"
2873+
content = memory.read_text()
2874+
assert "{CORE_TEMPLATE}" not in content
2875+
assert "# Wrapper Constitution" in content
2876+
assert "## Core Principle" in content
2877+
2878+
def test_constitution_placeholder_tokens_are_pinned_to_core_template(self):
2879+
"""Guard placeholder token drift between code and core template."""
2880+
from specify_cli.presets import _CONSTITUTION_PLACEHOLDER_TOKENS
2881+
2882+
expected_tokens = {"[PROJECT_NAME]", "[PRINCIPLE_1_NAME]"}
2883+
assert set(_CONSTITUTION_PLACEHOLDER_TOKENS) == expected_tokens
2884+
2885+
core_template = Path(__file__).parent.parent / "templates" / "constitution-template.md"
2886+
content = core_template.read_text(encoding="utf-8")
2887+
for token in expected_tokens:
2888+
assert token in content
2889+
28302890
def test_extension_command_skipped_when_extension_missing(self, project_dir, temp_dir):
28312891
"""Test that extension command overrides are skipped if the extension isn't installed."""
28322892
claude_dir = project_dir / ".claude" / "skills"
@@ -6219,6 +6279,39 @@ def _core_constitution(self, project_dir):
62196279
"# [PROJECT_NAME] Constitution\n\n### [PRINCIPLE_1_NAME]\n"
62206280
)
62216281

6282+
def _wrap_constitution_preset(self, temp_dir):
6283+
preset_dir = temp_dir / "ensure-wrap-preset"
6284+
(preset_dir / "templates").mkdir(parents=True)
6285+
(preset_dir / "templates" / "constitution-template.md").write_text(
6286+
"# Ensure Wrapper\n\n{CORE_TEMPLATE}\n\n## Tail\n"
6287+
)
6288+
(preset_dir / "preset.yml").write_text(
6289+
yaml.dump(
6290+
{
6291+
"schema_version": "1.0",
6292+
"preset": {
6293+
"id": "ensure-wrap",
6294+
"name": "Ensure Wrap",
6295+
"version": "1.0.0",
6296+
"description": "Wrap strategy for ensure() coverage",
6297+
},
6298+
"requires": {"speckit_version": ">=0.1.0"},
6299+
"provides": {
6300+
"templates": [
6301+
{
6302+
"type": "template",
6303+
"name": "constitution-template",
6304+
"file": "templates/constitution-template.md",
6305+
"strategy": "wrap",
6306+
"description": "Wrapped constitution",
6307+
}
6308+
]
6309+
},
6310+
}
6311+
)
6312+
)
6313+
return preset_dir
6314+
62226315
def test_seeds_from_core_when_no_preset(self, project_dir):
62236316
from specify_cli.commands.init import ensure_constitution_from_template
62246317

@@ -6260,3 +6353,20 @@ def test_preserves_existing_memory(self, project_dir):
62606353
ensure_constitution_from_template(project_dir)
62616354

62626355
assert memory.read_text() == authored
6356+
6357+
def test_composes_wrap_strategy_when_ensuring(self, project_dir, temp_dir):
6358+
from specify_cli.commands.init import ensure_constitution_from_template
6359+
6360+
self._core_constitution(project_dir)
6361+
manager = PresetManager(project_dir)
6362+
manager.install_from_directory(self._wrap_constitution_preset(temp_dir), "0.1.5")
6363+
6364+
# Ensure we validate ensure() behavior directly.
6365+
memory = project_dir / ".specify" / "memory" / "constitution.md"
6366+
memory.unlink()
6367+
ensure_constitution_from_template(project_dir)
6368+
6369+
content = memory.read_text()
6370+
assert "{CORE_TEMPLATE}" not in content
6371+
assert "# Ensure Wrapper" in content
6372+
assert "[PROJECT_NAME]" in content

0 commit comments

Comments
 (0)