diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index dd815b8c5..af4ae3e84 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -33,10 +33,19 @@ def _stdin_is_interactive() -> bool: def ensure_constitution_from_template( project_path: Path, tracker: StepTracker | None = None ) -> None: - """Copy constitution template to memory if it doesn't exist.""" + """Copy the resolved constitution template to memory if it doesn't exist. + + 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. + """ + from ..presets import PresetResolver + memory_constitution = project_path / ".specify" / "memory" / "constitution.md" - template_constitution = ( - project_path / ".specify" / "templates" / "constitution-template.md" + template_constitution = PresetResolver(project_path).resolve( + "constitution-template", "template" ) if memory_constitution.exists(): @@ -45,7 +54,7 @@ def ensure_constitution_from_template( tracker.skip("constitution", "existing file preserved") return - if not template_constitution.exists(): + if template_constitution is None or not template_constitution.exists(): if tracker: tracker.add("constitution", "Constitution setup") tracker.error("constitution", "template not found") @@ -447,8 +456,6 @@ def register(app: typer.Typer) -> None: "shared-infra", f"scripts ({selected_script}) + templates" ) - ensure_constitution_from_template(project_path, tracker=tracker) - try: bundled_wf = _locate_bundled_workflow("speckit") if bundled_wf: @@ -576,6 +583,11 @@ def register(app: typer.Typer) -> None: continuing="Continuing without the optional preset.", ) + # Seed the constitution AFTER preset installation so that a + # preset-provided constitution-template (resolved via the + # priority stack) wins over the core template. + ensure_constitution_from_template(project_path, tracker=tracker) + tracker.complete("final", "project ready") except (typer.Exit, SystemExit): raise diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 863b6ef7d..b6ea84905 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -34,6 +34,17 @@ from .._utils import dump_frontmatter, version_satisfies from ..shared_infra import verify_archive_sha256 +# Tokens that mark an unmodified, generic constitution that has not yet been +# authored. Used to decide whether seeding/re-seeding memory/constitution.md +# from a preset-provided template is safe (i.e. won't clobber authored content). +_CONSTITUTION_PLACEHOLDER_TOKENS = ("[PROJECT_NAME]", "[PRINCIPLE_1_NAME]") + + +def _constitution_is_placeholder(content: str) -> bool: + """Return True if a constitution body is still the generic placeholder.""" + return any(token in content for token in _CONSTITUTION_PLACEHOLDER_TOKENS) + + def _substitute_core_template( body: str, cmd_name: str, @@ -1615,8 +1626,61 @@ class PresetManager: stacklevel=2, ) + # Seed/re-seed memory/constitution.md from a preset-provided + # constitution-template. The constitution is the only template that is + # materialized to a live file rather than resolved on demand, so a + # preset that ships one (e.g. strategy: replace with a ratified + # constitution) must be propagated here. Guard against clobbering an + # already-authored constitution by only seeding when the memory file is + # missing or still contains generic placeholder tokens. + self._seed_constitution_from_preset(manifest) + return manifest + def _seed_constitution_from_preset(self, manifest: PresetManifest) -> None: + """Seed memory/constitution.md from a preset constitution-template. + + Only runs when the preset declares a ``type: template`` entry named + ``constitution-template`` and the live memory file is either missing or + still the generic placeholder. Authored constitutions are never + overwritten. + """ + provides_constitution = any( + t.get("type") == "template" and t.get("name") == "constitution-template" + for t in manifest.templates + ) + if not provides_constitution: + return + + memory_constitution = ( + self.project_root / ".specify" / "memory" / "constitution.md" + ) + if memory_constitution.exists(): + try: + existing = memory_constitution.read_text(encoding="utf-8") + except OSError: + return + if not _constitution_is_placeholder(existing): + # Legitimately authored constitution; leave it untouched. + return + + resolved = PresetResolver(self.project_root).resolve( + "constitution-template", "template" + ) + if resolved is None or not resolved.exists(): + return + + try: + memory_constitution.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(resolved, memory_constitution) + except OSError as exc: + import warnings + + warnings.warn( + f"Failed to seed constitution from preset {manifest.id}: {exc}.", + stacklevel=2, + ) + def install_from_zip( self, zip_path: Path, diff --git a/tests/test_presets.py b/tests/test_presets.py index 054018b7a..2d4cefd59 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -2778,6 +2778,55 @@ class TestSelfTestPreset: metadata = manager.registry.get("self-test") assert metadata["registered_commands"] == {} + def test_self_test_seeds_constitution_when_memory_absent(self, project_dir): + """Installing a preset seeds memory/constitution.md from its template.""" + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + memory = project_dir / ".specify" / "memory" / "constitution.md" + assert memory.exists(), "constitution.md was not seeded from the preset" + assert "preset:self-test" in memory.read_text(), ( + "constitution.md was not seeded from the self-test preset template" + ) + + def test_self_test_reseeds_placeholder_constitution(self, project_dir): + """A placeholder memory constitution is re-seeded from the preset template.""" + memory = project_dir / ".specify" / "memory" / "constitution.md" + memory.parent.mkdir(parents=True, exist_ok=True) + memory.write_text("# [PROJECT_NAME] Constitution\n\n### [PRINCIPLE_1_NAME]\n") + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + content = memory.read_text() + assert "preset:self-test" in content, "placeholder constitution was not re-seeded" + assert "[PROJECT_NAME]" not in content + + def test_self_test_preserves_authored_constitution(self, project_dir): + """An authored (placeholder-free) constitution is never overwritten.""" + memory = project_dir / ".specify" / "memory" / "constitution.md" + memory.parent.mkdir(parents=True, exist_ok=True) + authored = "# Acme Constitution\n\n### I. Ship It\nAuthored by a human.\n" + memory.write_text(authored) + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + assert memory.read_text() == authored, "authored constitution was overwritten" + + def test_self_test_override_resolves_constitution_template(self, project_dir): + """The preset override of constitution-template resolves to the preset file.""" + templates_dir = project_dir / ".specify" / "templates" + (templates_dir / "constitution-template.md").write_text("# Core constitution\n") + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + resolver = PresetResolver(project_dir) + result = resolver.resolve("constitution-template", "template") + assert result is not None + assert "preset:self-test" in result.read_text() + 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" @@ -6149,3 +6198,65 @@ def test_preset_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monke ) assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/9" assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v2"] + + +# ===== ensure_constitution_from_template resolver-awareness ===== + + +class TestEnsureConstitutionResolverAware: + """`ensure_constitution_from_template` must resolve through PresetResolver. + + The constitution is the only template materialized to a live file rather + than resolved on demand. These tests pin the regression from issue #3272: + a preset-provided ``constitution-template`` must seed memory, while the + core template is used when no preset overrides it. + """ + + def _core_constitution(self, project_dir): + templates_dir = project_dir / ".specify" / "templates" + templates_dir.mkdir(parents=True, exist_ok=True) + (templates_dir / "constitution-template.md").write_text( + "# [PROJECT_NAME] Constitution\n\n### [PRINCIPLE_1_NAME]\n" + ) + + def test_seeds_from_core_when_no_preset(self, project_dir): + from specify_cli.commands.init import ensure_constitution_from_template + + self._core_constitution(project_dir) + ensure_constitution_from_template(project_dir) + + memory = project_dir / ".specify" / "memory" / "constitution.md" + assert memory.exists() + assert "[PROJECT_NAME]" in memory.read_text() + + def test_seeds_from_preset_when_installed(self, project_dir): + from specify_cli.commands.init import ensure_constitution_from_template + + self._core_constitution(project_dir) + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + # Remove the memory file seeded during install to test ensure() in + # isolation; it must re-seed from the preset, not the core template. + memory = project_dir / ".specify" / "memory" / "constitution.md" + memory.unlink() + + ensure_constitution_from_template(project_dir) + + assert memory.exists() + content = memory.read_text() + assert "preset:self-test" in content + assert "[PROJECT_NAME]" not in content + + def test_preserves_existing_memory(self, project_dir): + from specify_cli.commands.init import ensure_constitution_from_template + + self._core_constitution(project_dir) + memory = project_dir / ".specify" / "memory" / "constitution.md" + memory.parent.mkdir(parents=True, exist_ok=True) + authored = "# Acme Constitution\nAuthored.\n" + memory.write_text(authored) + + ensure_constitution_from_template(project_dir) + + assert memory.read_text() == authored