diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index af4ae3e84..00ea68806 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -33,20 +33,19 @@ def _stdin_is_interactive() -> bool: def ensure_constitution_from_template( project_path: Path, tracker: StepTracker | None = None ) -> None: - """Copy the resolved constitution template to memory if it doesn't exist. + """Materialize the resolved constitution template to memory if missing. Resolution walks the full priority stack (project overrides → installed presets → extensions → core) via :class:`PresetResolver`, so a preset that ships a ``constitution-template`` (e.g. ``strategy: replace`` with a ratified - constitution) seeds the memory file verbatim. When nothing overrides it, the - resolver falls through to the core template, preserving legacy behavior. + constitution) can seed the memory file. When nothing overrides it, the + resolver falls through to the core template. """ from ..presets import PresetResolver memory_constitution = project_path / ".specify" / "memory" / "constitution.md" - template_constitution = PresetResolver(project_path).resolve( - "constitution-template", "template" - ) + resolver = PresetResolver(project_path) + layers = resolver.collect_all_layers("constitution-template", "template") if memory_constitution.exists(): if tracker: @@ -54,7 +53,7 @@ def ensure_constitution_from_template( tracker.skip("constitution", "existing file preserved") return - if template_constitution is None or not template_constitution.exists(): + if not layers: if tracker: tracker.add("constitution", "Constitution setup") tracker.error("constitution", "template not found") @@ -62,7 +61,16 @@ def ensure_constitution_from_template( try: memory_constitution.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(template_constitution, memory_constitution) + top_layer = layers[0] + if top_layer["strategy"] == "replace": + shutil.copy2(top_layer["path"], memory_constitution) + else: + composed_content = resolver.resolve_content( + "constitution-template", "template" + ) + if composed_content is None: + raise FileNotFoundError("constitution template not found") + memory_constitution.write_text(composed_content, encoding="utf-8") if tracker: tracker.add("constitution", "Constitution setup") tracker.complete("constitution", "copied from template") diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index b6ea84905..184f3d6b8 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -1664,15 +1664,23 @@ class PresetManager: # Legitimately authored constitution; leave it untouched. return - resolved = PresetResolver(self.project_root).resolve( - "constitution-template", "template" - ) - if resolved is None or not resolved.exists(): + resolver = PresetResolver(self.project_root) + layers = resolver.collect_all_layers("constitution-template", "template") + if not layers: return try: memory_constitution.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(resolved, memory_constitution) + top_layer = layers[0] + if top_layer["strategy"] == "replace": + shutil.copy2(top_layer["path"], memory_constitution) + else: + composed_content = resolver.resolve_content( + "constitution-template", "template" + ) + if composed_content is None: + return + memory_constitution.write_text(composed_content, encoding="utf-8") except OSError as exc: import warnings diff --git a/tests/test_presets.py b/tests/test_presets.py index 2d4cefd59..3c18af880 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -2827,6 +2827,66 @@ class TestSelfTestPreset: assert result is not None assert "preset:self-test" in result.read_text() + def test_constitution_seed_composes_wrap_strategy(self, project_dir, temp_dir): + """Seeding memory composes wrap constitution-template layers.""" + templates_dir = project_dir / ".specify" / "templates" + templates_dir.mkdir(parents=True, exist_ok=True) + (templates_dir / "constitution-template.md").write_text( + "# Core Constitution\n\n## Core Principle\n" + ) + + preset_dir = temp_dir / "constitution-wrap" + (preset_dir / "templates").mkdir(parents=True) + (preset_dir / "templates" / "constitution-template.md").write_text( + "# Wrapper Constitution\n\n{CORE_TEMPLATE}\n\n## Wrapper Footer\n" + ) + (preset_dir / "preset.yml").write_text( + yaml.dump( + { + "schema_version": "1.0", + "preset": { + "id": "constitution-wrap", + "name": "Constitution Wrap", + "version": "1.0.0", + "description": "Wrap constitution template for testing", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "constitution-template", + "file": "templates/constitution-template.md", + "strategy": "wrap", + "description": "Wrapped constitution template", + } + ] + }, + } + ) + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + memory = project_dir / ".specify" / "memory" / "constitution.md" + content = memory.read_text() + assert "{CORE_TEMPLATE}" not in content + assert "# Wrapper Constitution" in content + assert "## Core Principle" in content + + def test_constitution_placeholder_tokens_are_pinned_to_core_template(self): + """Guard placeholder token drift between code and core template.""" + from specify_cli.presets import _CONSTITUTION_PLACEHOLDER_TOKENS + + expected_tokens = {"[PROJECT_NAME]", "[PRINCIPLE_1_NAME]"} + assert set(_CONSTITUTION_PLACEHOLDER_TOKENS) == expected_tokens + + core_template = Path(__file__).parent.parent / "templates" / "constitution-template.md" + content = core_template.read_text(encoding="utf-8") + for token in expected_tokens: + assert token in content + def test_extension_command_skipped_when_extension_missing(self, project_dir, temp_dir): """Test that extension command overrides are skipped if the extension isn't installed.""" claude_dir = project_dir / ".claude" / "skills" @@ -6219,6 +6279,39 @@ class TestEnsureConstitutionResolverAware: "# [PROJECT_NAME] Constitution\n\n### [PRINCIPLE_1_NAME]\n" ) + def _wrap_constitution_preset(self, temp_dir): + preset_dir = temp_dir / "ensure-wrap-preset" + (preset_dir / "templates").mkdir(parents=True) + (preset_dir / "templates" / "constitution-template.md").write_text( + "# Ensure Wrapper\n\n{CORE_TEMPLATE}\n\n## Tail\n" + ) + (preset_dir / "preset.yml").write_text( + yaml.dump( + { + "schema_version": "1.0", + "preset": { + "id": "ensure-wrap", + "name": "Ensure Wrap", + "version": "1.0.0", + "description": "Wrap strategy for ensure() coverage", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "constitution-template", + "file": "templates/constitution-template.md", + "strategy": "wrap", + "description": "Wrapped constitution", + } + ] + }, + } + ) + ) + return preset_dir + def test_seeds_from_core_when_no_preset(self, project_dir): from specify_cli.commands.init import ensure_constitution_from_template @@ -6260,3 +6353,20 @@ class TestEnsureConstitutionResolverAware: ensure_constitution_from_template(project_dir) assert memory.read_text() == authored + + def test_composes_wrap_strategy_when_ensuring(self, project_dir, temp_dir): + from specify_cli.commands.init import ensure_constitution_from_template + + self._core_constitution(project_dir) + manager = PresetManager(project_dir) + manager.install_from_directory(self._wrap_constitution_preset(temp_dir), "0.1.5") + + # Ensure we validate ensure() behavior directly. + memory = project_dir / ".specify" / "memory" / "constitution.md" + memory.unlink() + ensure_constitution_from_template(project_dir) + + content = memory.read_text() + assert "{CORE_TEMPLATE}" not in content + assert "# Ensure Wrapper" in content + assert "[PROJECT_NAME]" in content