Compare commits

...

3 Commits

Author SHA1 Message Date
Ben Buttigieg
aa09272873 refactor(presets): unify constitution template materialization
Address latest Copilot feedback on the constitution seeding path:
- moved resolver/layer I/O behind the existing-memory fast path in init
- corrected tracker output for composed materialization
- deduplicated materialization logic shared by init and preset install seeding
  into presets._materialize_constitution_template()

Behavior is unchanged for replace strategies (copy verbatim) and remains
composed for prepend/append/wrap via resolve_content().

Assisted-by: GitHub Copilot (model: GPT-5.3-Codex, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-01 19:53:21 +01:00
Ben Buttigieg
f954e30a66 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>
2026-07-01 19:30:34 +01:00
Ben Buttigieg
ccf8dc246a fix(presets): seed constitution from preset constitution-template (#3272)
The constitution is the only template materialized to a live file
(.specify/memory/constitution.md) rather than resolved on demand, yet
ensure_constitution_from_template hardcoded a copy from the core template
and ignored PresetResolver. Combined with init seeding the constitution
before preset installation, a preset's constitution-template (e.g.
strategy: replace with a ratified constitution) could never go live.

Changes:
- ensure_constitution_from_template now resolves constitution-template
  through PresetResolver, so a preset/override/extension wins and core is
  the fallback.
- init seeds the constitution after preset installation so init --preset
  uses the resolved stack.
- install_from_directory re-seeds memory/constitution.md from the resolved
  preset template, guarded to only act when the memory file is missing or
  still contains generic placeholder tokens — authored constitutions are
  never overwritten. Covers preset add and install_from_zip.
- Tests for preset seeding, placeholder re-seed, authored-constitution
  preservation, override resolution, and resolver-aware init seeding.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-06-30 19:54:29 +01:00
3 changed files with 338 additions and 16 deletions

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
import os
import shutil
import sys
from pathlib import Path
from typing import Any
@@ -33,11 +32,17 @@ 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."""
"""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) can seed the memory file. When nothing overrides it, the
resolver falls through to the core template.
"""
from ..presets import _materialize_constitution_template
memory_constitution = project_path / ".specify" / "memory" / "constitution.md"
template_constitution = (
project_path / ".specify" / "templates" / "constitution-template.md"
)
if memory_constitution.exists():
if tracker:
@@ -45,18 +50,21 @@ def ensure_constitution_from_template(
tracker.skip("constitution", "existing file preserved")
return
if not template_constitution.exists():
if tracker:
tracker.add("constitution", "Constitution setup")
tracker.error("constitution", "template not found")
return
try:
memory_constitution.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(template_constitution, memory_constitution)
materialization = _materialize_constitution_template(
project_path, memory_constitution
)
if materialization is None:
if tracker:
tracker.add("constitution", "Constitution setup")
tracker.error("constitution", "template not found")
return
if tracker:
tracker.add("constitution", "Constitution setup")
tracker.complete("constitution", "copied from template")
if materialization == "copied":
tracker.complete("constitution", "copied from template")
else:
tracker.complete("constitution", "composed from template")
else:
console.print("[cyan]Initialized constitution from template[/cyan]")
except Exception as e:
@@ -447,8 +455,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 +582,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

View File

@@ -34,6 +34,46 @@ 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 _materialize_constitution_template(
project_root: Path,
memory_constitution: Path,
) -> str | None:
"""Materialize constitution-template content into memory/constitution.md.
Returns:
"copied" when the winning layer is ``replace`` and the source file is
copied verbatim; "composed" when a composing strategy is materialized
via ``resolve_content``; ``None`` when no constitution template resolves.
"""
resolver = PresetResolver(project_root)
layers = resolver.collect_all_layers("constitution-template", "template")
if not layers:
return None
memory_constitution.parent.mkdir(parents=True, exist_ok=True)
top_layer = layers[0]
if top_layer["strategy"] == "replace":
shutil.copy2(top_layer["path"], memory_constitution)
return "copied"
composed_content = resolver.resolve_content("constitution-template", "template")
if composed_content is None:
return None
memory_constitution.write_text(composed_content, encoding="utf-8")
return "composed"
def _substitute_core_template(
body: str,
cmd_name: str,
@@ -1615,8 +1655,58 @@ 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
try:
result = _materialize_constitution_template(
self.project_root, memory_constitution
)
if result is None:
return
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,

View File

@@ -2778,6 +2778,115 @@ 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_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"
@@ -6149,3 +6258,115 @@ 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 _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
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
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