mirror of
https://github.com/github/spec-kit.git
synced 2026-07-06 14:01:01 +08:00
Compare commits
1 Commits
main
...
fix/3272-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0487a9a7fd |
@@ -33,11 +33,12 @@ 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 constitution template to memory if it doesn't exist.
|
||||
|
||||
Resolves the template through the preset priority stack so that a preset's
|
||||
replacement constitution-template is used instead of the generic core file.
|
||||
"""
|
||||
memory_constitution = project_path / ".specify" / "memory" / "constitution.md"
|
||||
template_constitution = (
|
||||
project_path / ".specify" / "templates" / "constitution-template.md"
|
||||
)
|
||||
|
||||
if memory_constitution.exists():
|
||||
if tracker:
|
||||
@@ -45,6 +46,21 @@ def ensure_constitution_from_template(
|
||||
tracker.skip("constitution", "existing file preserved")
|
||||
return
|
||||
|
||||
# Resolve through the preset priority stack so preset replacements are honoured.
|
||||
template_constitution: Path | None = None
|
||||
try:
|
||||
from ..presets import PresetResolver as _PresetResolver
|
||||
template_constitution = _PresetResolver(project_path).resolve(
|
||||
"constitution-template", "template"
|
||||
)
|
||||
except Exception:
|
||||
template_constitution = None
|
||||
|
||||
if template_constitution is None:
|
||||
template_constitution = (
|
||||
project_path / ".specify" / "templates" / "constitution-template.md"
|
||||
)
|
||||
|
||||
if not template_constitution.exists():
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
@@ -447,8 +463,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 +590,8 @@ def register(app: typer.Typer) -> None:
|
||||
continuing="Continuing without the optional preset.",
|
||||
)
|
||||
|
||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||
|
||||
tracker.complete("final", "project ready")
|
||||
except (typer.Exit, SystemExit):
|
||||
raise
|
||||
|
||||
@@ -1615,8 +1615,56 @@ class PresetManager:
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# Re-seed constitution memory if the preset provides a replacement
|
||||
# constitution-template and the existing memory file is still a generic placeholder.
|
||||
self._maybe_reseed_constitution(manifest)
|
||||
|
||||
return manifest
|
||||
|
||||
def _maybe_reseed_constitution(self, manifest: "PresetManifest") -> None:
|
||||
"""Re-seed .specify/memory/constitution.md from a preset's constitution-template.
|
||||
|
||||
Only re-seeds when:
|
||||
1. The preset provides a ``constitution-template`` template entry.
|
||||
2. The memory file exists and still contains generic placeholder tokens
|
||||
(``[PROJECT_NAME]`` or ``[PRINCIPLE_1_NAME]``), meaning it has not
|
||||
been legitimately authored yet.
|
||||
|
||||
This handles ``specify preset add`` on an existing project so the
|
||||
preset's ratified constitution lands in memory without requiring
|
||||
``ensure_constitution_from_template`` to run again.
|
||||
"""
|
||||
has_constitution_template = any(
|
||||
t.get("type") == "template" and t.get("name") == "constitution-template"
|
||||
for t in manifest.templates
|
||||
)
|
||||
if not has_constitution_template:
|
||||
return
|
||||
|
||||
memory_constitution = self.project_root / ".specify" / "memory" / "constitution.md"
|
||||
if not memory_constitution.exists():
|
||||
# Will be seeded by ensure_constitution_from_template later; nothing to do here.
|
||||
return
|
||||
|
||||
try:
|
||||
content = memory_constitution.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return
|
||||
|
||||
# Skip re-seed if the file has been legitimately authored (no placeholder tokens).
|
||||
if "[PROJECT_NAME]" not in content and "[PRINCIPLE_1_NAME]" not in content:
|
||||
return
|
||||
|
||||
resolver = PresetResolver(self.project_root)
|
||||
resolved = resolver.resolve("constitution-template", "template")
|
||||
if resolved is None or not resolved.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
shutil.copy2(resolved, memory_constitution)
|
||||
except OSError:
|
||||
pass # best-effort; don't fail preset installation for this
|
||||
|
||||
def install_from_zip(
|
||||
self,
|
||||
zip_path: Path,
|
||||
|
||||
@@ -6149,3 +6149,188 @@ 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"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for preset constitution re-seed (issue #3272)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestConstitutionReseedOnPresetInstall:
|
||||
"""Tests for _maybe_reseed_constitution and the post-install re-seed hook.
|
||||
|
||||
Verifies that install_from_directory re-seeds .specify/memory/constitution.md
|
||||
from the preset's constitution-template when the memory file still contains
|
||||
generic placeholder tokens, and does NOT overwrite legitimately authored ones.
|
||||
"""
|
||||
|
||||
def test_install_reseeds_generic_constitution_memory(self, project_dir):
|
||||
"""install_from_directory re-seeds memory/constitution.md when it still
|
||||
contains the generic [PROJECT_NAME] placeholder and the preset provides a
|
||||
constitution-template replacement."""
|
||||
memory_dir = project_dir / ".specify" / "memory"
|
||||
memory_dir.mkdir(parents=True, exist_ok=True)
|
||||
memory_constitution = memory_dir / "constitution.md"
|
||||
memory_constitution.write_text(
|
||||
"# [PROJECT_NAME] Constitution\n\n## Core Principles\n\n"
|
||||
"### [PRINCIPLE_1_NAME]\n[PRINCIPLE_1_DESCRIPTION]\n"
|
||||
)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
result = memory_constitution.read_text()
|
||||
assert "preset:self-test" in result, "Expected preset's constitution content after re-seed"
|
||||
assert "[PROJECT_NAME]" not in result
|
||||
|
||||
def test_install_reseeds_on_principle_placeholder(self, project_dir):
|
||||
"""Re-seeds when [PRINCIPLE_1_NAME] is present even if [PROJECT_NAME] is absent."""
|
||||
memory_dir = project_dir / ".specify" / "memory"
|
||||
memory_dir.mkdir(parents=True, exist_ok=True)
|
||||
memory_constitution = memory_dir / "constitution.md"
|
||||
memory_constitution.write_text(
|
||||
"# My Company Constitution\n\n### [PRINCIPLE_1_NAME]\n[PRINCIPLE_1_DESCRIPTION]\n"
|
||||
)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
result = memory_constitution.read_text()
|
||||
assert "preset:self-test" in result
|
||||
|
||||
def test_install_does_not_overwrite_authored_constitution(self, project_dir):
|
||||
"""install_from_directory must NOT overwrite a legitimately authored
|
||||
constitution (one that no longer contains placeholder tokens)."""
|
||||
authored_content = "# Acme Corp Constitution\n\n## Values\n\n1. Build great things.\n"
|
||||
|
||||
memory_dir = project_dir / ".specify" / "memory"
|
||||
memory_dir.mkdir(parents=True, exist_ok=True)
|
||||
memory_constitution = memory_dir / "constitution.md"
|
||||
memory_constitution.write_text(authored_content)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
assert memory_constitution.read_text() == authored_content, (
|
||||
"install_from_directory must not overwrite an authored constitution"
|
||||
)
|
||||
|
||||
def test_install_skips_reseed_when_no_constitution_template(self, project_dir, temp_dir):
|
||||
"""install_from_directory does not touch memory/constitution.md when the
|
||||
preset provides no constitution-template entry."""
|
||||
pack_data = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "no-constitution",
|
||||
"name": "No Constitution Preset",
|
||||
"version": "1.0.0",
|
||||
"description": "A preset without a constitution-template override",
|
||||
"author": "Test",
|
||||
"license": "MIT",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "template",
|
||||
"name": "spec-template",
|
||||
"file": "templates/spec-template.md",
|
||||
"replaces": "spec-template",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
pack_dir = temp_dir / "no-constitution"
|
||||
pack_dir.mkdir()
|
||||
(pack_dir / "preset.yml").write_text(yaml.dump(pack_data))
|
||||
(pack_dir / "templates").mkdir()
|
||||
(pack_dir / "templates" / "spec-template.md").write_text("# Custom Spec\n")
|
||||
|
||||
placeholder_content = "# [PROJECT_NAME] Constitution\n"
|
||||
memory_dir = project_dir / ".specify" / "memory"
|
||||
memory_dir.mkdir(parents=True, exist_ok=True)
|
||||
memory_constitution = memory_dir / "constitution.md"
|
||||
memory_constitution.write_text(placeholder_content)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
assert memory_constitution.read_text() == placeholder_content, (
|
||||
"Memory constitution must be unchanged when preset has no constitution-template"
|
||||
)
|
||||
|
||||
def test_install_skips_reseed_when_memory_absent(self, project_dir):
|
||||
"""install_from_directory must not create memory/constitution.md — that
|
||||
responsibility belongs to ensure_constitution_from_template."""
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
memory_constitution = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
assert not memory_constitution.exists(), (
|
||||
"install_from_directory must not create memory/constitution.md"
|
||||
)
|
||||
|
||||
|
||||
class TestEnsureConstitutionFromTemplate:
|
||||
"""Tests for ensure_constitution_from_template using the preset resolver.
|
||||
|
||||
Verifies that the function seeds .specify/memory/constitution.md from the
|
||||
highest-priority resolved constitution-template, picking up preset overrides
|
||||
instead of always copying the generic core file.
|
||||
"""
|
||||
|
||||
def test_uses_preset_constitution_when_available(self, project_dir):
|
||||
"""ensure_constitution_from_template seeds from the preset's constitution-template
|
||||
rather than the generic core file when a replacement preset is installed."""
|
||||
from specify_cli.commands.init import ensure_constitution_from_template
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
# Provide a core template with generic content so we can confirm the
|
||||
# function chose the preset's version over the core one.
|
||||
core_tpl = project_dir / ".specify" / "templates" / "constitution-template.md"
|
||||
core_tpl.write_text("# [PROJECT_NAME] Constitution\n\n[PRINCIPLE_1_NAME]\n")
|
||||
|
||||
ensure_constitution_from_template(project_dir)
|
||||
|
||||
memory_constitution = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
assert memory_constitution.exists()
|
||||
content = memory_constitution.read_text()
|
||||
assert "preset:self-test" in content, (
|
||||
"Expected preset's constitution, got core template"
|
||||
)
|
||||
assert "[PROJECT_NAME]" not in content
|
||||
|
||||
def test_falls_back_to_core_template_without_preset(self, project_dir):
|
||||
"""ensure_constitution_from_template falls back to the core template when
|
||||
no preset overrides constitution-template."""
|
||||
from specify_cli.commands.init import ensure_constitution_from_template
|
||||
|
||||
core_tpl = project_dir / ".specify" / "templates" / "constitution-template.md"
|
||||
core_tpl.write_text("# [PROJECT_NAME] Constitution\n# Core template marker\n")
|
||||
|
||||
ensure_constitution_from_template(project_dir)
|
||||
|
||||
memory_constitution = project_dir / ".specify" / "memory" / "constitution.md"
|
||||
assert memory_constitution.exists()
|
||||
assert "Core template marker" in memory_constitution.read_text()
|
||||
|
||||
def test_skips_when_memory_already_exists(self, project_dir):
|
||||
"""ensure_constitution_from_template is a no-op when memory/constitution.md
|
||||
already exists, even if a preset replacement is available."""
|
||||
from specify_cli.commands.init import ensure_constitution_from_template
|
||||
|
||||
memory_dir = project_dir / ".specify" / "memory"
|
||||
memory_dir.mkdir(parents=True, exist_ok=True)
|
||||
existing = memory_dir / "constitution.md"
|
||||
existing.write_text("# Existing authored constitution\n")
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
ensure_constitution_from_template(project_dir)
|
||||
|
||||
assert existing.read_text() == "# Existing authored constitution\n", (
|
||||
"ensure_constitution_from_template must not overwrite existing memory file"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user