Files
github-spec-kit/tests/test_presets.py
Copilot a067d4c2e3 feat(presets): Composition strategies (prepend, append, wrap) for templates, commands, and scripts (#2133)
* fix: rebase onto upstream/main, resolve conflicts with PR #2189

upstream/main merged PR #2189 (wrap-only strategy) which overlaps with
our comprehensive composition strategies (prepend/append/wrap). Resolved
conflicts keeping our implementation as source of truth:

- README: keep our future considerations (composition is now fully
  implemented, not a future item)
- presets.py: keep our composition architecture (_reconcile_composed_commands,
  collect_all_layers, resolve_content) while preserving #2189's
  _substitute_core_template which is used by agents.py for skill
  generation
- tests: keep both test sets (our composition tests + #2189's wrap
  tests), removed TestReplayWrapsForCommand and
  TestInstallRemoveWrapLifecycle which test the superseded
  _replay_wraps_for_command API; our composition tests cover equivalent
  scenarios
- Restored missing _unregister_commands call in remove() that was lost
  during #2189 merge

* fix: re-create skill directory in _reconcile_skills after removal

After _unregister_skills removes a skill directory, _register_skills
skips writing because the dir no longer passes the is_dir() check.
Fix by ensuring the skill subdirectory exists before calling
_register_skills so the next winning preset's content gets registered.

Fixes the Claude E2E failure where removing a top-priority override
preset left skill-based agents without any SKILL.md file.

* fix: address twenty-third round of Copilot PR review feedback

- Protect reconciliation in remove(): wrap _reconcile_composed_commands
  and _reconcile_skills in try/except so failures emit a warning instead
  of leaving the project in an inconsistent state
- Protect reconciliation in install(): same pattern for post-install
  reconciliation so partial installs don't lack cleanup
- Inherit scripts/agent_scripts from base frontmatter: when composing
  commands, merge scripts and agent_scripts keys from the base command's
  frontmatter into the top layer's frontmatter if missing, preventing
  composed commands from losing required script references
- Add tier-5 bundled core fallback to collect_all_layers(): check the
  bundled core_pack (wheel) or repo-root templates (source checkout) when
  .specify/templates/ doesn't contain the core file, matching resolve()'s
  tier-5 fallback so composition can always find a base layer

* fix: address twenty-fourth round of Copilot PR review feedback

- Use yaml.safe_load for frontmatter parsing in resolve_content instead
  of CommandRegistrar.parse_frontmatter which uses naive find('---',3);
  strip strategy key from final frontmatter to prevent leaking internal
  composition directives into rendered agent command files
- Filter _reconcile_skills to specific commands: use _FilteredManifest
  wrapper so only the commands being reconciled get their skills updated,
  preventing accidental overwrites of other commands' skills that may be
  owned by higher-priority presets

* fix: address twenty-fifth round of Copilot PR review feedback

- Support legacy command-frontmatter strategy: when preset.yml doesn't
  declare a strategy, check the command file's YAML frontmatter for
  strategy: wrap as a fallback so legacy wrap presets participate in
  composition and multi-preset chaining
- Guard skill dir creation in _reconcile_skills: only re-create the
  skill directory if the skill was previously managed (listed in some
  preset's registered_skills), avoiding creation of new skill dirs
  that _register_skills would normally skip

* fix: add explanatory comment to empty except in legacy frontmatter parsing

* fix: address twenty-sixth round of Copilot PR review feedback

- Unregister stale commands when composition fails: when resolve_content
  returns None during reconciliation (base layer removed), unregister
  the command from non-skill agents and emit a warning
- Load extension aliases during reconciliation: _register_command_from_path
  now checks extension.yml for aliases when the winning layer is an
  extension, so alias files are restored after preset removal
- Use line-based fence detection for legacy frontmatter strategy fallback:
  scan for --- on its own line instead of split('---',2) to avoid
  mis-parsing YAML values containing ---

* fix: address twenty-seventh round of Copilot PR review feedback

- Handle non-preset winners in _reconcile_skills: when the winning
  layer is core/extension/project-override, restore skills via
  _unregister_skills so skill-based agents stay consistent with the
  priority stack
- Update base_frontmatter_text on replace layers: when a higher-priority
  replace layer occurs during composition, update both top and base
  frontmatter so scripts/agent_scripts inheritance reflects the
  effective base beneath the top composed layer

* fix: address twenty-eighth round of Copilot PR review feedback

- Parse only interior lines in _parse_fm_yaml: use lines[1:-1] instead
  of filtering all --- lines, preventing corruption when YAML values
  contain a line that is exactly ---
- Omit empty frontmatter: skip re-rendering when top_fm is empty dict
  to avoid emitting ---/{}/--- for intentionally empty frontmatter
- Update scaffold wrap comment: mention both {CORE_TEMPLATE} and
  $CORE_SCRIPT placeholders for templates/commands vs scripts
- Clarify shell composition scope in ARCHITECTURE.md: note that bash/PS1
  resolve_template_content only handles templates; command/script
  composition is handled by the Python resolver

* fix: address twenty-ninth round of Copilot PR review feedback

- Fix TestCollectAllLayers docstring: reference collect_all_layers()
- Add default/unknown strategy handling in bash/PS1 composition: error
  on unrecognized strategy values instead of silently skipping
- Fix comment: .composed/ is a persistent dir, not temporary
- Fix comment: legacy fallback checks all valid strategies, not just wrap
- Cache PresetRegistry in _reconcile_skills: build presets_by_priority
  once instead of constructing registry per-command

* fix: address thirtieth round of Copilot PR review feedback

- Guard legacy frontmatter fallback: only check command file frontmatter
  for strategy when the manifest entry doesn't explicitly include the
  strategy key, preventing override of manifest-declared strategies
- Document rollback limitation: note that mid-registration failures may
  leave orphaned agent command files since partial progress isn't
  captured by the local vars

* fix: handle project override skills and extension context in reconciliation

* fix: add comment to empty except in extension registration fallback

* fix: filter extension commands in reconciliation and fix type annotation

* fix: filter extension commands from post-install reconciliation

Apply the same extension-installed check used in _register_commands to
the reconciliation command list, preventing reconciliation from
registering commands for extensions that are not installed.

* fix: skip convention fallback for explicit file paths and add stem fallback to tier-5

When a preset manifest provides an explicit file path that does not
exist, skip the convention-based fallback to avoid masking typos.
Also add speckit.<stem> to <stem>.md fallback in tier-5 bundled/source
core lookup for consistency with tier-4.

* fix: scan past non-replace layers to find base in resolve_content

The base-finding scan now skips non-replace layers below a replace
layer instead of stopping at the first non-replace. This fixes the
case where a low-priority append/prepend layer sits below a replace
that should serve as the base for composition.

* fix: add context_note to non-skill agent registration for extensions

Add context_note parameter to register_commands_for_non_skill_agents
and pass extension name/id during reconciliation so rendered command
files preserve the extension-specific context markers.

* fix: Optional type, rollback safety, and override skill restoration

- Fix context_note type to Optional[str]
- Wrap shutil.rmtree in try/except during install rollback
- Separate override-backed skills from core/extension in _reconcile_skills

* fix: align bash/PS1 base-finding with Python resolver

Rewrite bash and PowerShell composition loops to find the effective
base replace layer first (scanning bottom-up, skipping non-replace
layers below it), then compose only from the base upward. This
prevents evaluation of irrelevant lower layers (e.g. a wrap with
no placeholder below a replace) and matches resolve_content behavior.

* fix: PS1 no-python warning, integration hook for override skills, alias cleanup

- Warn when no Python 3 found in PS1 and presets use composition strategies
- Apply post_process_skill_content integration hook when restoring
  override-backed skills so agent-specific flags are preserved
- Unregister command aliases alongside primary name when composition
  fails to prevent orphaned alias files

* fix: include aliases in removed_cmd_names during preset removal

Read aliases from preset manifest before deleting pack_dir so alias
command files are included in unregistration and reconciliation.

* fix: add comment to empty except in alias extraction during removal

* fix: scan top-down for effective base in all resolvers

Change base-finding to scan from highest priority downward to find the
nearest replace layer, then compose only layers above it. Prevents
evaluation of irrelevant lower layers (e.g. a wrap without placeholder
below a higher-priority replace) across Python, bash, and PowerShell.

* fix: align CLI composition chain display with top-down base-finding

Show only contributing layers (base and above) in preset resolve
output, matching resolve_content top-down semantics. Layers below
the effective base are omitted since they do not contribute.

* fix: guard corrupted registry entries and make manifest authoritative

- Add isinstance(meta, dict) guard in bash registry parsing so corrupted
  entries are skipped instead of breaking priority ordering
- Only use convention-based file lookup when the manifest does not list
  the requested template, making preset.yml authoritative and preventing
  stray on-disk files from creating unintended layers

* fix: align resolve() with manifest file paths and match extension context_note

- Update resolve() preset tier to consult manifest file paths before
  convention-based lookup, matching collect_all_layers behavior
- Use exact extension context_note format matching extensions.CommandRegistrar
- Update test to declare template in manifest (authoritative manifest)

* revert: restore resolve() convention-based behavior for backwards compatibility

resolve() is the existing public API used by shell scripts and other
callers. Changing it to manifest-authoritative breaks backward compat
for presets that rely on convention-based file lookup. Only the new
collect_all_layers/resolve_content path uses manifest-authoritative
logic.

* fix: only pre-compose when this preset is the top composing layer

Skip composition in _register_commands when a higher-priority replace
layer already wins for the command. Register the raw file instead and
let reconciliation write the correct final content.

* fix: deduplicate PyYAML warnings and use self.registry in reconciliation

- Emit PyYAML-missing warning once per function call in bash/PS1 instead
  of per-preset to avoid spamming stderr
- Use self.registry.list_by_priority() in reconciliation methods instead
  of constructing new PresetRegistry instances to avoid redundant I/O
  and potential consistency issues

* fix: document strategy handling consistency between layers and registrar

Composed output already strips strategy from frontmatter (resolve_content
pops it). Raw file registration preserves legacy frontmatter strategy
for backward compat; reconciliation corrects the final state.

* fix: correct stale comments for alias tracking and base-finding algorithm

* security: validate manifest file paths in bash/PowerShell resolvers

Reject absolute paths and parent directory traversal (..) in the
manifest-declared file field before joining with the preset directory.
Matches the Python-side validation in PresetManifest._validate().

---------

Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
2026-04-23 10:07:52 -05:00

4402 lines
181 KiB
Python

"""
Unit tests for the preset system.
Tests cover:
- Preset manifest validation
- Preset registry operations
- Preset manager installation/removal
- Template catalog search
- Template resolver priority stack
- Extension-provided templates
"""
import pytest
import json
import tempfile
import shutil
import zipfile
from pathlib import Path
from datetime import datetime, timezone
import yaml
from tests.conftest import strip_ansi
from specify_cli.presets import (
PresetManifest,
PresetRegistry,
PresetManager,
PresetCatalog,
PresetCatalogEntry,
PresetResolver,
PresetError,
PresetValidationError,
PresetCompatibilityError,
VALID_PRESET_TEMPLATE_TYPES,
)
from specify_cli.extensions import ExtensionRegistry
# ===== Fixtures =====
@pytest.fixture
def temp_dir():
"""Create a temporary directory for tests."""
tmpdir = tempfile.mkdtemp()
yield Path(tmpdir)
shutil.rmtree(tmpdir)
@pytest.fixture
def valid_pack_data():
"""Valid preset manifest data."""
return {
"schema_version": "1.0",
"preset": {
"id": "test-pack",
"name": "Test Preset",
"version": "1.0.0",
"description": "A test preset",
"author": "Test Author",
"repository": "https://github.com/test/test-pack",
"license": "MIT",
},
"requires": {
"speckit_version": ">=0.1.0",
},
"provides": {
"templates": [
{
"type": "template",
"name": "spec-template",
"file": "templates/spec-template.md",
"description": "Custom spec template",
"replaces": "spec-template",
}
]
},
"tags": ["testing", "example"],
}
@pytest.fixture
def pack_dir(temp_dir, valid_pack_data):
"""Create a complete preset directory structure."""
p_dir = temp_dir / "test-pack"
p_dir.mkdir()
# Write manifest
manifest_path = p_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
# Create templates directory
templates_dir = p_dir / "templates"
templates_dir.mkdir()
# Write template file
tmpl_file = templates_dir / "spec-template.md"
tmpl_file.write_text("# Custom Spec Template\n\nThis is a custom template.\n")
return p_dir
@pytest.fixture
def project_dir(temp_dir):
"""Create a mock spec-kit project directory."""
proj_dir = temp_dir / "project"
proj_dir.mkdir()
# Create .specify directory
specify_dir = proj_dir / ".specify"
specify_dir.mkdir()
# Create templates directory with core templates
templates_dir = specify_dir / "templates"
templates_dir.mkdir()
# Create core spec-template
core_spec = templates_dir / "spec-template.md"
core_spec.write_text("# Core Spec Template\n")
# Create core plan-template
core_plan = templates_dir / "plan-template.md"
core_plan.write_text("# Core Plan Template\n")
# Create commands subdirectory
commands_dir = templates_dir / "commands"
commands_dir.mkdir()
return proj_dir
# ===== PresetManifest Tests =====
class TestPresetManifest:
"""Test PresetManifest validation and parsing."""
def test_valid_manifest(self, pack_dir):
"""Test loading a valid manifest."""
manifest = PresetManifest(pack_dir / "preset.yml")
assert manifest.id == "test-pack"
assert manifest.name == "Test Preset"
assert manifest.version == "1.0.0"
assert manifest.description == "A test preset"
assert manifest.author == "Test Author"
assert manifest.requires_speckit_version == ">=0.1.0"
assert len(manifest.templates) == 1
assert manifest.tags == ["testing", "example"]
def test_missing_manifest(self, temp_dir):
"""Test that missing manifest raises error."""
with pytest.raises(PresetValidationError, match="Manifest not found"):
PresetManifest(temp_dir / "nonexistent.yml")
def test_invalid_yaml(self, temp_dir):
"""Test that invalid YAML raises error."""
bad_file = temp_dir / "bad.yml"
bad_file.write_text(": invalid: yaml: {{{")
with pytest.raises(PresetValidationError, match="Invalid YAML"):
PresetManifest(bad_file)
def test_missing_schema_version(self, temp_dir, valid_pack_data):
"""Test missing schema_version field."""
del valid_pack_data["schema_version"]
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(PresetValidationError, match="Missing required field: schema_version"):
PresetManifest(manifest_path)
def test_wrong_schema_version(self, temp_dir, valid_pack_data):
"""Test unsupported schema version."""
valid_pack_data["schema_version"] = "2.0"
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(PresetValidationError, match="Unsupported schema version"):
PresetManifest(manifest_path)
def test_missing_pack_id(self, temp_dir, valid_pack_data):
"""Test missing preset.id field."""
del valid_pack_data["preset"]["id"]
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(PresetValidationError, match="Missing preset.id"):
PresetManifest(manifest_path)
def test_invalid_pack_id_format(self, temp_dir, valid_pack_data):
"""Test invalid pack ID format."""
valid_pack_data["preset"]["id"] = "Invalid_ID"
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(PresetValidationError, match="Invalid preset ID"):
PresetManifest(manifest_path)
def test_invalid_version(self, temp_dir, valid_pack_data):
"""Test invalid semantic version."""
valid_pack_data["preset"]["version"] = "not-a-version"
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(PresetValidationError, match="Invalid version"):
PresetManifest(manifest_path)
def test_missing_speckit_version(self, temp_dir, valid_pack_data):
"""Test missing requires.speckit_version."""
del valid_pack_data["requires"]["speckit_version"]
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(PresetValidationError, match="Missing requires.speckit_version"):
PresetManifest(manifest_path)
def test_no_templates_provided(self, temp_dir, valid_pack_data):
"""Test pack with no templates."""
valid_pack_data["provides"]["templates"] = []
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(PresetValidationError, match="must provide at least one template"):
PresetManifest(manifest_path)
def test_invalid_template_type(self, temp_dir, valid_pack_data):
"""Test template with invalid type."""
valid_pack_data["provides"]["templates"][0]["type"] = "invalid"
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(PresetValidationError, match="Invalid template type"):
PresetManifest(manifest_path)
def test_valid_template_types(self):
"""Test that all expected template types are valid."""
assert "template" in VALID_PRESET_TEMPLATE_TYPES
assert "command" in VALID_PRESET_TEMPLATE_TYPES
assert "script" in VALID_PRESET_TEMPLATE_TYPES
def test_template_missing_required_fields(self, temp_dir, valid_pack_data):
"""Test template missing required fields."""
valid_pack_data["provides"]["templates"] = [{"type": "template"}]
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(PresetValidationError, match="missing 'type', 'name', or 'file'"):
PresetManifest(manifest_path)
def test_invalid_template_name_format(self, temp_dir, valid_pack_data):
"""Test template with invalid name format."""
valid_pack_data["provides"]["templates"][0]["name"] = "Invalid Name"
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(PresetValidationError, match="Invalid template name"):
PresetManifest(manifest_path)
def test_get_hash(self, pack_dir):
"""Test manifest hash calculation."""
manifest = PresetManifest(pack_dir / "preset.yml")
hash_val = manifest.get_hash()
assert hash_val.startswith("sha256:")
assert len(hash_val) > 10
def test_multiple_templates(self, temp_dir, valid_pack_data):
"""Test pack with multiple templates of different types."""
valid_pack_data["provides"]["templates"] = [
{"type": "template", "name": "spec-template", "file": "templates/spec-template.md"},
{"type": "template", "name": "plan-template", "file": "templates/plan-template.md"},
{"type": "command", "name": "specify", "file": "commands/specify.md"},
{"type": "script", "name": "create-new-feature", "file": "scripts/create-new-feature.sh"},
]
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
manifest = PresetManifest(manifest_path)
assert len(manifest.templates) == 4
# ===== PresetRegistry Tests =====
class TestPresetRegistry:
"""Test PresetRegistry operations."""
def test_empty_registry(self, temp_dir):
"""Test empty registry initialization."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
assert registry.list() == {}
assert not registry.is_installed("test-pack")
def test_add_and_get(self, temp_dir):
"""Test adding and retrieving a pack."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
registry.add("test-pack", {"version": "1.0.0", "source": "local"})
assert registry.is_installed("test-pack")
metadata = registry.get("test-pack")
assert metadata is not None
assert metadata["version"] == "1.0.0"
assert "installed_at" in metadata
def test_remove(self, temp_dir):
"""Test removing a pack."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
registry.add("test-pack", {"version": "1.0.0"})
assert registry.is_installed("test-pack")
registry.remove("test-pack")
assert not registry.is_installed("test-pack")
def test_remove_nonexistent(self, temp_dir):
"""Test removing a pack that doesn't exist."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
registry.remove("nonexistent") # Should not raise
def test_list(self, temp_dir):
"""Test listing all packs."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
registry.add("pack-a", {"version": "1.0.0"})
registry.add("pack-b", {"version": "2.0.0"})
all_packs = registry.list()
assert len(all_packs) == 2
assert "pack-a" in all_packs
assert "pack-b" in all_packs
def test_persistence(self, temp_dir):
"""Test that registry data persists across instances."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
# Add with first instance
registry1 = PresetRegistry(packs_dir)
registry1.add("test-pack", {"version": "1.0.0"})
# Load with second instance
registry2 = PresetRegistry(packs_dir)
assert registry2.is_installed("test-pack")
def test_corrupted_registry(self, temp_dir):
"""Test recovery from corrupted registry file."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry_file = packs_dir / ".registry"
registry_file.write_text("not valid json{{{")
registry = PresetRegistry(packs_dir)
assert registry.list() == {}
def test_get_nonexistent(self, temp_dir):
"""Test getting a nonexistent pack."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
assert registry.get("nonexistent") is None
def test_restore(self, temp_dir):
"""Test restore() preserves timestamps exactly."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
# Create original entry with a specific timestamp
original_metadata = {
"version": "1.0.0",
"source": "local",
"installed_at": "2025-01-15T10:30:00+00:00",
"enabled": True,
}
registry.restore("test-pack", original_metadata)
# Verify exact restoration
restored = registry.get("test-pack")
assert restored["installed_at"] == "2025-01-15T10:30:00+00:00"
assert restored["version"] == "1.0.0"
assert restored["enabled"] is True
def test_restore_rejects_none_metadata(self, temp_dir):
"""Test restore() raises ValueError for None metadata."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
with pytest.raises(ValueError, match="metadata must be a dict"):
registry.restore("test-pack", None)
def test_restore_rejects_non_dict_metadata(self, temp_dir):
"""Test restore() raises ValueError for non-dict metadata."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
with pytest.raises(ValueError, match="metadata must be a dict"):
registry.restore("test-pack", "not-a-dict")
with pytest.raises(ValueError, match="metadata must be a dict"):
registry.restore("test-pack", ["list", "not", "dict"])
def test_restore_uses_deep_copy(self, temp_dir):
"""Test restore() deep copies metadata to prevent mutation."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
original_metadata = {
"version": "1.0.0",
"nested": {"key": "original"},
}
registry.restore("test-pack", original_metadata)
# Mutate the original metadata after restore
original_metadata["version"] = "MUTATED"
original_metadata["nested"]["key"] = "MUTATED"
# Registry should have the original values
stored = registry.get("test-pack")
assert stored["version"] == "1.0.0"
assert stored["nested"]["key"] == "original"
def test_get_returns_deep_copy(self, temp_dir):
"""Test that get() returns a deep copy to prevent mutation."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
registry.add("test-pack", {"version": "1.0.0", "nested": {"key": "original"}})
# Get and mutate the returned copy
metadata = registry.get("test-pack")
metadata["version"] = "MUTATED"
metadata["nested"]["key"] = "MUTATED"
# Original should be unchanged
fresh = registry.get("test-pack")
assert fresh["version"] == "1.0.0"
assert fresh["nested"]["key"] == "original"
def test_get_returns_none_for_corrupted_entry(self, temp_dir):
"""Test that get() returns None for corrupted (non-dict) entries."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
# Directly corrupt the registry with non-dict entries
registry.data["presets"]["corrupted-string"] = "not a dict"
registry.data["presets"]["corrupted-list"] = ["not", "a", "dict"]
registry.data["presets"]["corrupted-int"] = 42
registry._save()
# All corrupted entries should return None
assert registry.get("corrupted-string") is None
assert registry.get("corrupted-list") is None
assert registry.get("corrupted-int") is None
# Non-existent should also return None
assert registry.get("nonexistent") is None
def test_list_returns_deep_copy(self, temp_dir):
"""Test that list() returns deep copies to prevent mutation."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
registry.add("test-pack", {"version": "1.0.0", "nested": {"key": "original"}})
# Get list and mutate
all_packs = registry.list()
all_packs["test-pack"]["version"] = "MUTATED"
all_packs["test-pack"]["nested"]["key"] = "MUTATED"
# Original should be unchanged
fresh = registry.get("test-pack")
assert fresh["version"] == "1.0.0"
assert fresh["nested"]["key"] == "original"
def test_list_returns_empty_dict_for_corrupted_registry(self, temp_dir):
"""Test that list() returns empty dict when presets is not a dict."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
# Corrupt the registry - presets is a list instead of dict
registry.data["presets"] = ["not", "a", "dict"]
registry._save()
# list() should return empty dict, not crash
result = registry.list()
assert result == {}
def test_list_by_priority_excludes_disabled(self, temp_dir):
"""Test that list_by_priority excludes disabled presets by default."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
registry.add("pack-enabled", {"version": "1.0.0", "enabled": True, "priority": 5})
registry.add("pack-disabled", {"version": "1.0.0", "enabled": False, "priority": 1})
registry.add("pack-default", {"version": "1.0.0", "priority": 10}) # no enabled field = True
# Default: exclude disabled
by_priority = registry.list_by_priority()
pack_ids = [p[0] for p in by_priority]
assert "pack-enabled" in pack_ids
assert "pack-default" in pack_ids
assert "pack-disabled" not in pack_ids
def test_list_by_priority_includes_disabled_when_requested(self, temp_dir):
"""Test that list_by_priority includes disabled presets when requested."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
registry.add("pack-enabled", {"version": "1.0.0", "enabled": True, "priority": 5})
registry.add("pack-disabled", {"version": "1.0.0", "enabled": False, "priority": 1})
# Include disabled
by_priority = registry.list_by_priority(include_disabled=True)
pack_ids = [p[0] for p in by_priority]
assert "pack-enabled" in pack_ids
assert "pack-disabled" in pack_ids
# Disabled pack has lower priority number, so it comes first when included
assert pack_ids[0] == "pack-disabled"
# ===== PresetManager Tests =====
class TestPresetManager:
"""Test PresetManager installation and removal."""
def test_install_from_directory(self, project_dir, pack_dir):
"""Test installing a preset from a directory."""
manager = PresetManager(project_dir)
manifest = manager.install_from_directory(pack_dir, "0.1.5")
assert manifest.id == "test-pack"
assert manager.registry.is_installed("test-pack")
# Verify files are copied
installed_dir = project_dir / ".specify" / "presets" / "test-pack"
assert installed_dir.exists()
assert (installed_dir / "preset.yml").exists()
assert (installed_dir / "templates" / "spec-template.md").exists()
def test_install_already_installed(self, project_dir, pack_dir):
"""Test installing an already-installed pack raises error."""
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
with pytest.raises(PresetError, match="already installed"):
manager.install_from_directory(pack_dir, "0.1.5")
def test_install_incompatible(self, project_dir, temp_dir, valid_pack_data):
"""Test installing an incompatible pack raises error."""
valid_pack_data["requires"]["speckit_version"] = ">=99.0.0"
incompat_dir = temp_dir / "incompat-pack"
incompat_dir.mkdir()
manifest_path = incompat_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
(incompat_dir / "templates").mkdir()
(incompat_dir / "templates" / "spec-template.md").write_text("test")
manager = PresetManager(project_dir)
with pytest.raises(PresetCompatibilityError):
manager.install_from_directory(incompat_dir, "0.1.5")
def test_install_from_zip(self, project_dir, pack_dir, temp_dir):
"""Test installing from a ZIP file."""
zip_path = temp_dir / "test-pack.zip"
with zipfile.ZipFile(zip_path, 'w') as zf:
for file_path in pack_dir.rglob('*'):
if file_path.is_file():
arcname = file_path.relative_to(pack_dir)
zf.write(file_path, arcname)
manager = PresetManager(project_dir)
manifest = manager.install_from_zip(zip_path, "0.1.5")
assert manifest.id == "test-pack"
assert manager.registry.is_installed("test-pack")
def test_install_from_zip_nested(self, project_dir, pack_dir, temp_dir):
"""Test installing from ZIP with nested directory."""
zip_path = temp_dir / "test-pack.zip"
with zipfile.ZipFile(zip_path, 'w') as zf:
for file_path in pack_dir.rglob('*'):
if file_path.is_file():
arcname = Path("test-pack-v1.0.0") / file_path.relative_to(pack_dir)
zf.write(file_path, arcname)
manager = PresetManager(project_dir)
manifest = manager.install_from_zip(zip_path, "0.1.5")
assert manifest.id == "test-pack"
def test_install_from_zip_no_manifest(self, project_dir, temp_dir):
"""Test installing from ZIP without manifest raises error."""
zip_path = temp_dir / "bad.zip"
with zipfile.ZipFile(zip_path, 'w') as zf:
zf.writestr("readme.txt", "no manifest here")
manager = PresetManager(project_dir)
with pytest.raises(PresetValidationError, match="No preset.yml found"):
manager.install_from_zip(zip_path, "0.1.5")
def test_remove(self, project_dir, pack_dir):
"""Test removing a preset."""
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
assert manager.registry.is_installed("test-pack")
result = manager.remove("test-pack")
assert result is True
assert not manager.registry.is_installed("test-pack")
installed_dir = project_dir / ".specify" / "presets" / "test-pack"
assert not installed_dir.exists()
def test_remove_nonexistent(self, project_dir):
"""Test removing a pack that doesn't exist."""
manager = PresetManager(project_dir)
result = manager.remove("nonexistent")
assert result is False
def test_list_installed(self, project_dir, pack_dir):
"""Test listing installed packs."""
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
installed = manager.list_installed()
assert len(installed) == 1
assert installed[0]["id"] == "test-pack"
assert installed[0]["name"] == "Test Preset"
assert installed[0]["version"] == "1.0.0"
assert installed[0]["template_count"] == 1
def test_list_installed_empty(self, project_dir):
"""Test listing when no packs installed."""
manager = PresetManager(project_dir)
assert manager.list_installed() == []
def test_get_pack(self, project_dir, pack_dir):
"""Test getting a specific installed pack."""
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
pack = manager.get_pack("test-pack")
assert pack is not None
assert pack.id == "test-pack"
def test_get_pack_not_installed(self, project_dir):
"""Test getting a non-installed pack returns None."""
manager = PresetManager(project_dir)
assert manager.get_pack("nonexistent") is None
def test_check_compatibility_valid(self, pack_dir, temp_dir):
"""Test compatibility check with valid version."""
manager = PresetManager(temp_dir)
manifest = PresetManifest(pack_dir / "preset.yml")
assert manager.check_compatibility(manifest, "0.1.5") is True
def test_check_compatibility_invalid(self, pack_dir, temp_dir):
"""Test compatibility check with invalid specifier."""
manager = PresetManager(temp_dir)
manifest = PresetManifest(pack_dir / "preset.yml")
manifest.data["requires"]["speckit_version"] = "not-a-specifier"
with pytest.raises(PresetCompatibilityError, match="Invalid version specifier"):
manager.check_compatibility(manifest, "0.1.5")
def test_install_with_priority(self, project_dir, pack_dir):
"""Test installing a pack with custom priority."""
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5", priority=5)
metadata = manager.registry.get("test-pack")
assert metadata is not None
assert metadata["priority"] == 5
def test_install_default_priority(self, project_dir, pack_dir):
"""Test that default priority is 10."""
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
metadata = manager.registry.get("test-pack")
assert metadata is not None
assert metadata["priority"] == 10
def test_list_installed_includes_priority(self, project_dir, pack_dir):
"""Test that list_installed includes priority."""
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5", priority=3)
installed = manager.list_installed()
assert len(installed) == 1
assert installed[0]["priority"] == 3
class TestRegistryPriority:
"""Test registry priority sorting."""
def test_list_by_priority(self, temp_dir):
"""Test that list_by_priority sorts by priority number."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
registry.add("pack-high", {"version": "1.0.0", "priority": 1})
registry.add("pack-low", {"version": "1.0.0", "priority": 20})
registry.add("pack-mid", {"version": "1.0.0", "priority": 10})
sorted_packs = registry.list_by_priority()
assert len(sorted_packs) == 3
assert sorted_packs[0][0] == "pack-high"
assert sorted_packs[1][0] == "pack-mid"
assert sorted_packs[2][0] == "pack-low"
def test_list_by_priority_default(self, temp_dir):
"""Test that packs without priority default to 10."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
registry.add("pack-a", {"version": "1.0.0"}) # no priority, defaults to 10
registry.add("pack-b", {"version": "1.0.0", "priority": 5})
sorted_packs = registry.list_by_priority()
assert sorted_packs[0][0] == "pack-b"
assert sorted_packs[1][0] == "pack-a"
def test_list_by_priority_invalid_priority_defaults(self, temp_dir):
"""Malformed priority values fall back to the default priority."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
registry.add("pack-high", {"version": "1.0.0", "priority": 1})
registry.data["presets"]["pack-invalid"] = {
"version": "1.0.0",
"priority": "high",
}
registry._save()
sorted_packs = registry.list_by_priority()
assert [item[0] for item in sorted_packs] == ["pack-high", "pack-invalid"]
assert sorted_packs[1][1]["priority"] == 10
# ===== PresetResolver Tests =====
class TestPresetResolver:
"""Test PresetResolver priority stack."""
def test_resolve_core_template(self, project_dir):
"""Test resolving a core template."""
resolver = PresetResolver(project_dir)
result = resolver.resolve("spec-template")
assert result is not None
assert result.name == "spec-template.md"
assert "Core Spec Template" in result.read_text()
def test_resolve_nonexistent(self, project_dir):
"""Test resolving a nonexistent template returns None."""
resolver = PresetResolver(project_dir)
result = resolver.resolve("nonexistent-template")
assert result is None
def test_resolve_higher_priority_pack_wins(self, project_dir, temp_dir, valid_pack_data):
"""Test that a pack with lower priority number wins over higher number."""
manager = PresetManager(project_dir)
# Create pack A (priority 10 — lower precedence)
pack_a_dir = temp_dir / "pack-a"
pack_a_dir.mkdir()
data_a = {**valid_pack_data}
data_a["preset"] = {**valid_pack_data["preset"], "id": "pack-a", "name": "Pack A"}
with open(pack_a_dir / "preset.yml", 'w') as f:
yaml.dump(data_a, f)
(pack_a_dir / "templates").mkdir()
(pack_a_dir / "templates" / "spec-template.md").write_text("# From Pack A\n")
# Create pack B (priority 1 — higher precedence)
pack_b_dir = temp_dir / "pack-b"
pack_b_dir.mkdir()
data_b = {**valid_pack_data}
data_b["preset"] = {**valid_pack_data["preset"], "id": "pack-b", "name": "Pack B"}
with open(pack_b_dir / "preset.yml", 'w') as f:
yaml.dump(data_b, f)
(pack_b_dir / "templates").mkdir()
(pack_b_dir / "templates" / "spec-template.md").write_text("# From Pack B\n")
# Install A first (priority 10), B second (priority 1)
manager.install_from_directory(pack_a_dir, "0.1.5", priority=10)
manager.install_from_directory(pack_b_dir, "0.1.5", priority=1)
# Pack B should win because lower priority number
resolver = PresetResolver(project_dir)
result = resolver.resolve("spec-template")
assert result is not None
assert "From Pack B" in result.read_text()
def test_resolve_override_takes_priority(self, project_dir):
"""Test that project overrides take priority over core."""
# Create override
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True)
override = overrides_dir / "spec-template.md"
override.write_text("# Override Spec Template\n")
resolver = PresetResolver(project_dir)
result = resolver.resolve("spec-template")
assert result is not None
assert "Override Spec Template" in result.read_text()
def test_resolve_pack_takes_priority_over_core(self, project_dir, pack_dir):
"""Test that installed packs take priority over core templates."""
# Install the pack
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
resolver = PresetResolver(project_dir)
result = resolver.resolve("spec-template")
assert result is not None
assert "Custom Spec Template" in result.read_text()
def test_resolve_override_takes_priority_over_pack(self, project_dir, pack_dir):
"""Test that overrides take priority over installed packs."""
# Install the pack
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
# Create override
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True)
override = overrides_dir / "spec-template.md"
override.write_text("# Override Spec Template\n")
resolver = PresetResolver(project_dir)
result = resolver.resolve("spec-template")
assert result is not None
assert "Override Spec Template" in result.read_text()
def test_resolve_extension_provided_templates(self, project_dir):
"""Test resolving templates provided by extensions."""
# Create extension with templates
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
ext_templates_dir = ext_dir / "templates"
ext_templates_dir.mkdir(parents=True)
ext_template = ext_templates_dir / "custom-template.md"
ext_template.write_text("# Extension Custom Template\n")
# Register extension in registry
extensions_dir = project_dir / ".specify" / "extensions"
ext_registry = ExtensionRegistry(extensions_dir)
ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10})
resolver = PresetResolver(project_dir)
result = resolver.resolve("custom-template")
assert result is not None
assert "Extension Custom Template" in result.read_text()
def test_resolve_disabled_extension_templates_skipped(self, project_dir):
"""Test that disabled extension templates are not resolved."""
# Create extension with templates
ext_dir = project_dir / ".specify" / "extensions" / "disabled-ext"
ext_templates_dir = ext_dir / "templates"
ext_templates_dir.mkdir(parents=True)
ext_template = ext_templates_dir / "disabled-template.md"
ext_template.write_text("# Disabled Extension Template\n")
# Register extension as disabled
extensions_dir = project_dir / ".specify" / "extensions"
ext_registry = ExtensionRegistry(extensions_dir)
ext_registry.add("disabled-ext", {"version": "1.0.0", "priority": 1, "enabled": False})
# Template should NOT be resolved because extension is disabled
resolver = PresetResolver(project_dir)
result = resolver.resolve("disabled-template")
assert result is None, "Disabled extension template should not be resolved"
def test_resolve_disabled_extension_not_picked_up_as_unregistered(self, project_dir):
"""Test that disabled extensions are not picked up via unregistered dir scan."""
# Create extension directory with templates
ext_dir = project_dir / ".specify" / "extensions" / "test-disabled-ext"
ext_templates_dir = ext_dir / "templates"
ext_templates_dir.mkdir(parents=True)
ext_template = ext_templates_dir / "unique-disabled-template.md"
ext_template.write_text("# Should Not Resolve\n")
# Register the extension but disable it
extensions_dir = project_dir / ".specify" / "extensions"
ext_registry = ExtensionRegistry(extensions_dir)
ext_registry.add("test-disabled-ext", {"version": "1.0.0", "enabled": False})
# Verify the template is NOT resolved (even though the directory exists)
resolver = PresetResolver(project_dir)
result = resolver.resolve("unique-disabled-template")
assert result is None, "Disabled extension should not be picked up as unregistered"
def test_resolve_pack_over_extension(self, project_dir, pack_dir, temp_dir, valid_pack_data):
"""Test that pack templates take priority over extension templates."""
# Create extension with templates
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
ext_templates_dir = ext_dir / "templates"
ext_templates_dir.mkdir(parents=True)
ext_template = ext_templates_dir / "spec-template.md"
ext_template.write_text("# Extension Spec Template\n")
# Install a pack with the same template
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
resolver = PresetResolver(project_dir)
result = resolver.resolve("spec-template")
assert result is not None
# Pack should win over extension
assert "Custom Spec Template" in result.read_text()
def test_resolve_with_source_core(self, project_dir):
"""Test resolve_with_source for core template."""
resolver = PresetResolver(project_dir)
result = resolver.resolve_with_source("spec-template")
assert result is not None
assert result["source"] == "core"
assert "spec-template.md" in result["path"]
def test_resolve_with_source_override(self, project_dir):
"""Test resolve_with_source for override template."""
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True)
override = overrides_dir / "spec-template.md"
override.write_text("# Override\n")
resolver = PresetResolver(project_dir)
result = resolver.resolve_with_source("spec-template")
assert result is not None
assert result["source"] == "project override"
def test_resolve_with_source_pack(self, project_dir, pack_dir):
"""Test resolve_with_source for pack template."""
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
resolver = PresetResolver(project_dir)
result = resolver.resolve_with_source("spec-template")
assert result is not None
assert "test-pack" in result["source"]
assert "v1.0.0" in result["source"]
def test_resolve_with_source_extension(self, project_dir):
"""Test resolve_with_source for extension-provided template."""
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
ext_templates_dir = ext_dir / "templates"
ext_templates_dir.mkdir(parents=True)
ext_template = ext_templates_dir / "unique-template.md"
ext_template.write_text("# Unique\n")
# Register extension in registry
extensions_dir = project_dir / ".specify" / "extensions"
ext_registry = ExtensionRegistry(extensions_dir)
ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10})
resolver = PresetResolver(project_dir)
result = resolver.resolve_with_source("unique-template")
assert result is not None
assert result["source"] == "extension:my-ext v1.0.0"
def test_resolve_with_source_not_found(self, project_dir):
"""Test resolve_with_source for nonexistent template."""
resolver = PresetResolver(project_dir)
result = resolver.resolve_with_source("nonexistent")
assert result is None
def test_resolve_skips_hidden_extension_dirs(self, project_dir):
"""Test that hidden directories in extensions are skipped."""
ext_dir = project_dir / ".specify" / "extensions" / ".backup"
ext_templates_dir = ext_dir / "templates"
ext_templates_dir.mkdir(parents=True)
ext_template = ext_templates_dir / "hidden-template.md"
ext_template.write_text("# Hidden\n")
resolver = PresetResolver(project_dir)
result = resolver.resolve("hidden-template")
assert result is None
class TestResolveCore:
"""Test PresetResolver.resolve_core() skips the installed-presets tier."""
def test_resolve_core_does_not_return_preset_files(self, project_dir):
"""resolve_core must not return files from .specify/presets/."""
preset_cmd_dir = project_dir / ".specify" / "presets" / "my-preset" / "commands"
preset_cmd_dir.mkdir(parents=True)
(preset_cmd_dir / "specify.md").write_text("---\ndescription: preset wrap\n---\n\nwrap body\n")
resolver = PresetResolver(project_dir)
result = resolver.resolve_core("specify", "command")
# The preset file must never be returned — but the bundled core may be.
if result is not None:
assert "presets" not in result.parts
def test_resolve_core_returns_core_template(self, project_dir):
"""resolve_core falls through to core templates (tier 4)."""
core_cmd_dir = project_dir / ".specify" / "templates" / "commands"
core_cmd_dir.mkdir(parents=True, exist_ok=True)
(core_cmd_dir / "specify.md").write_text("---\ndescription: core\n---\n\ncore body\n")
# Also place a preset file — resolve_core must still return the core
preset_cmd_dir = project_dir / ".specify" / "presets" / "my-preset" / "commands"
preset_cmd_dir.mkdir(parents=True)
(preset_cmd_dir / "specify.md").write_text("---\ndescription: preset wrap\n---\n\nwrap body\n")
resolver = PresetResolver(project_dir)
result = resolver.resolve_core("specify", "command")
assert result is not None
assert "presets" not in result.parts
assert result.parts[-3:] == ("templates", "commands", "specify.md")
def test_resolve_core_returns_override(self, project_dir):
"""resolve_core returns tier-1 override if present."""
override_dir = project_dir / ".specify" / "templates" / "overrides"
override_dir.mkdir(parents=True)
(override_dir / "specify.md").write_text("---\ndescription: override\n---\n\noverride body\n")
resolver = PresetResolver(project_dir)
result = resolver.resolve_core("specify", "command")
assert result is not None
assert result.parts[-2:] == ("overrides", "specify.md")
def test_resolve_core_returns_extension_template(self, project_dir):
"""resolve_core returns extension templates (tier 3)."""
ext_cmd_dir = project_dir / ".specify" / "extensions" / "myext" / "commands"
ext_cmd_dir.mkdir(parents=True)
(ext_cmd_dir / "myext-cmd.md").write_text("---\ndescription: ext\n---\n\next body\n")
resolver = PresetResolver(project_dir)
result = resolver.resolve_core("myext-cmd", "command")
assert result is not None
assert result.parts[-4:-1] == ("extensions", "myext", "commands")
def test_resolve_core_returns_none_when_nothing_found(self, project_dir):
"""resolve_core returns None when no file found in tiers 1/3/4."""
resolver = PresetResolver(project_dir)
result = resolver.resolve_core("nonexistent", "command")
assert result is None
def test_resolve_extension_command_via_manifest_skips_oserror_manifests(self, project_dir):
"""resolve_extension_command_via_manifest skips extensions whose manifest raises OSError."""
import unittest.mock as mock
ext_dir = project_dir / ".specify" / "extensions" / "bad-ext"
cmd_dir = ext_dir / "commands"
cmd_dir.mkdir(parents=True)
(cmd_dir / "mycmd.md").write_text("---\ndescription: d\n---\n\nbody\n")
(ext_dir / "extension.yml").write_text(
"schema_version: '1.0'\n"
"extension:\n id: bad-ext\n name: Bad\n version: 1.0.0\n"
" description: d\n author: a\n repository: https://example.com\n"
" license: MIT\n"
"requires:\n speckit_version: '>=0.2.0'\n"
"provides:\n commands:\n"
" - name: speckit.bad-ext.mycmd\n"
" file: commands/mycmd.md\n"
" description: My command\n"
)
resolver = PresetResolver(project_dir)
# Simulate a permission error when opening the manifest file.
with mock.patch("builtins.open", side_effect=PermissionError("denied")):
result = resolver.resolve_extension_command_via_manifest("speckit.bad-ext.mycmd")
assert result is None, "OSError during manifest load must be silently skipped"
class TestExtensionPriorityResolution:
"""Test extension priority resolution with registered and unregistered extensions."""
def test_unregistered_beats_registered_with_lower_precedence(self, project_dir):
"""Unregistered extension (implicit priority 10) beats registered with priority 20."""
extensions_dir = project_dir / ".specify" / "extensions"
extensions_dir.mkdir(parents=True, exist_ok=True)
# Create registered extension with priority 20 (lower precedence than 10)
registered_dir = extensions_dir / "registered-ext"
(registered_dir / "templates").mkdir(parents=True)
(registered_dir / "templates" / "test-template.md").write_text("# From Registered\n")
ext_registry = ExtensionRegistry(extensions_dir)
ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20})
# Create unregistered extension directory (implicit priority 10)
unregistered_dir = extensions_dir / "unregistered-ext"
(unregistered_dir / "templates").mkdir(parents=True)
(unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n")
# Unregistered (priority 10) should beat registered (priority 20)
resolver = PresetResolver(project_dir)
result = resolver.resolve("test-template")
assert result is not None
assert "From Unregistered" in result.read_text()
def test_registered_with_higher_precedence_beats_unregistered(self, project_dir):
"""Registered extension with priority 5 beats unregistered (implicit priority 10)."""
extensions_dir = project_dir / ".specify" / "extensions"
extensions_dir.mkdir(parents=True, exist_ok=True)
# Create registered extension with priority 5 (higher precedence than 10)
registered_dir = extensions_dir / "registered-ext"
(registered_dir / "templates").mkdir(parents=True)
(registered_dir / "templates" / "test-template.md").write_text("# From Registered\n")
ext_registry = ExtensionRegistry(extensions_dir)
ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 5})
# Create unregistered extension directory (implicit priority 10)
unregistered_dir = extensions_dir / "unregistered-ext"
(unregistered_dir / "templates").mkdir(parents=True)
(unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n")
# Registered (priority 5) should beat unregistered (priority 10)
resolver = PresetResolver(project_dir)
result = resolver.resolve("test-template")
assert result is not None
assert "From Registered" in result.read_text()
def test_unregistered_attribution_with_priority_ordering(self, project_dir):
"""Test resolve_with_source correctly attributes unregistered extension."""
extensions_dir = project_dir / ".specify" / "extensions"
extensions_dir.mkdir(parents=True, exist_ok=True)
# Create registered extension with priority 20
registered_dir = extensions_dir / "registered-ext"
(registered_dir / "templates").mkdir(parents=True)
(registered_dir / "templates" / "test-template.md").write_text("# From Registered\n")
ext_registry = ExtensionRegistry(extensions_dir)
ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20})
# Create unregistered extension (implicit priority 10)
unregistered_dir = extensions_dir / "unregistered-ext"
(unregistered_dir / "templates").mkdir(parents=True)
(unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n")
# Attribution should show unregistered extension
resolver = PresetResolver(project_dir)
result = resolver.resolve_with_source("test-template")
assert result is not None
assert "unregistered-ext" in result["source"]
assert "(unregistered)" in result["source"]
def test_same_priority_sorted_alphabetically(self, project_dir):
"""Extensions with same priority are sorted alphabetically by ID."""
extensions_dir = project_dir / ".specify" / "extensions"
extensions_dir.mkdir(parents=True, exist_ok=True)
# Create two unregistered extensions (both implicit priority 10)
# "aaa-ext" should come before "zzz-ext" alphabetically
zzz_dir = extensions_dir / "zzz-ext"
(zzz_dir / "templates").mkdir(parents=True)
(zzz_dir / "templates" / "test-template.md").write_text("# From ZZZ\n")
aaa_dir = extensions_dir / "aaa-ext"
(aaa_dir / "templates").mkdir(parents=True)
(aaa_dir / "templates" / "test-template.md").write_text("# From AAA\n")
# AAA should win due to alphabetical ordering at same priority
resolver = PresetResolver(project_dir)
result = resolver.resolve("test-template")
assert result is not None
assert "From AAA" in result.read_text()
# ===== PresetCatalog Tests =====
class TestPresetCatalog:
"""Test template catalog functionality."""
def test_default_catalog_url(self, project_dir):
"""Test default catalog URL."""
catalog = PresetCatalog(project_dir)
assert catalog.DEFAULT_CATALOG_URL.startswith("https://")
assert catalog.DEFAULT_CATALOG_URL.endswith("/presets/catalog.json")
def test_community_catalog_url(self, project_dir):
"""Test community catalog URL."""
catalog = PresetCatalog(project_dir)
assert "presets/catalog.community.json" in catalog.COMMUNITY_CATALOG_URL
def test_cache_validation_no_cache(self, project_dir):
"""Test cache validation when no cache exists."""
catalog = PresetCatalog(project_dir)
assert catalog.is_cache_valid() is False
def test_cache_validation_valid(self, project_dir):
"""Test cache validation with valid cache."""
catalog = PresetCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps({
"schema_version": "1.0",
"presets": {},
}))
catalog.cache_metadata_file.write_text(json.dumps({
"cached_at": datetime.now(timezone.utc).isoformat(),
}))
assert catalog.is_cache_valid() is True
def test_cache_validation_expired(self, project_dir):
"""Test cache validation with expired cache."""
catalog = PresetCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps({
"schema_version": "1.0",
"presets": {},
}))
catalog.cache_metadata_file.write_text(json.dumps({
"cached_at": "2020-01-01T00:00:00+00:00",
}))
assert catalog.is_cache_valid() is False
def test_cache_validation_corrupted(self, project_dir):
"""Test cache validation with corrupted metadata."""
catalog = PresetCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text("not json")
catalog.cache_metadata_file.write_text("not json")
assert catalog.is_cache_valid() is False
def test_clear_cache(self, project_dir):
"""Test clearing the cache."""
catalog = PresetCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text("{}")
catalog.cache_metadata_file.write_text("{}")
catalog.clear_cache()
assert not catalog.cache_file.exists()
assert not catalog.cache_metadata_file.exists()
def test_search_with_cached_data(self, project_dir, monkeypatch):
"""Test search with cached catalog data."""
from unittest.mock import patch
monkeypatch.delenv("SPECKIT_PRESET_CATALOG_URL", raising=False)
catalog = PresetCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog_data = {
"schema_version": "1.0",
"presets": {
"safe-agile": {
"name": "SAFe Agile Templates",
"description": "SAFe-aligned templates",
"author": "agile-community",
"version": "1.0.0",
"tags": ["safe", "agile"],
},
"healthcare": {
"name": "Healthcare Compliance",
"description": "HIPAA-compliant templates",
"author": "healthcare-org",
"version": "1.0.0",
"tags": ["healthcare", "hipaa"],
},
}
}
catalog.cache_file.write_text(json.dumps(catalog_data))
catalog.cache_metadata_file.write_text(json.dumps({
"cached_at": datetime.now(timezone.utc).isoformat(),
}))
# Isolate from community catalog so results are deterministic
default_only = [PresetCatalogEntry(url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True)]
with patch.object(catalog, "get_active_catalogs", return_value=default_only):
# Search by query
results = catalog.search(query="agile")
assert len(results) == 1
assert results[0]["id"] == "safe-agile"
# Search by tag
results = catalog.search(tag="hipaa")
assert len(results) == 1
assert results[0]["id"] == "healthcare"
# Search by author
results = catalog.search(author="agile-community")
assert len(results) == 1
# Search all
results = catalog.search()
assert len(results) == 2
def test_get_pack_info(self, project_dir):
"""Test getting info for a specific pack."""
catalog = PresetCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog_data = {
"schema_version": "1.0",
"presets": {
"test-pack": {
"name": "Test Pack",
"version": "1.0.0",
},
}
}
catalog.cache_file.write_text(json.dumps(catalog_data))
catalog.cache_metadata_file.write_text(json.dumps({
"cached_at": datetime.now(timezone.utc).isoformat(),
}))
info = catalog.get_pack_info("test-pack")
assert info is not None
assert info["name"] == "Test Pack"
assert info["id"] == "test-pack"
assert catalog.get_pack_info("nonexistent") is None
def test_validate_catalog_url_https(self, project_dir):
"""Test that HTTPS URLs are accepted."""
catalog = PresetCatalog(project_dir)
catalog._validate_catalog_url("https://example.com/catalog.json")
def test_validate_catalog_url_http_rejected(self, project_dir):
"""Test that HTTP URLs are rejected."""
catalog = PresetCatalog(project_dir)
with pytest.raises(PresetValidationError, match="must use HTTPS"):
catalog._validate_catalog_url("http://example.com/catalog.json")
def test_validate_catalog_url_localhost_http_allowed(self, project_dir):
"""Test that HTTP is allowed for localhost."""
catalog = PresetCatalog(project_dir)
catalog._validate_catalog_url("http://localhost:8080/catalog.json")
catalog._validate_catalog_url("http://127.0.0.1:8080/catalog.json")
def test_env_var_catalog_url(self, project_dir, monkeypatch):
"""Test catalog URL from environment variable."""
monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", "https://custom.example.com/catalog.json")
catalog = PresetCatalog(project_dir)
assert catalog.get_catalog_url() == "https://custom.example.com/catalog.json"
# ===== Integration Tests =====
class TestIntegration:
"""Integration tests for complete preset workflows."""
def test_full_install_resolve_remove_cycle(self, project_dir, pack_dir):
"""Test complete lifecycle: install → resolve → remove."""
# Install
manager = PresetManager(project_dir)
manifest = manager.install_from_directory(pack_dir, "0.1.5")
assert manifest.id == "test-pack"
# Resolve — pack template should win over core
resolver = PresetResolver(project_dir)
result = resolver.resolve("spec-template")
assert result is not None
assert "Custom Spec Template" in result.read_text()
# Remove
manager.remove("test-pack")
# Resolve — should fall back to core
result = resolver.resolve("spec-template")
assert result is not None
assert "Core Spec Template" in result.read_text()
def test_override_beats_pack_beats_extension_beats_core(self, project_dir, pack_dir):
"""Test the full priority stack: override > pack > extension > core."""
resolver = PresetResolver(project_dir)
# Core should resolve
result = resolver.resolve_with_source("spec-template")
assert result["source"] == "core"
# Add extension template
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
ext_templates_dir = ext_dir / "templates"
ext_templates_dir.mkdir(parents=True)
(ext_templates_dir / "spec-template.md").write_text("# Extension\n")
# Register extension in registry
extensions_dir = project_dir / ".specify" / "extensions"
ext_registry = ExtensionRegistry(extensions_dir)
ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10})
result = resolver.resolve_with_source("spec-template")
assert result["source"] == "extension:my-ext v1.0.0"
# Install pack — should win over extension
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
result = resolver.resolve_with_source("spec-template")
assert "test-pack" in result["source"]
# Add override — should win over pack
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True)
(overrides_dir / "spec-template.md").write_text("# Override\n")
result = resolver.resolve_with_source("spec-template")
assert result["source"] == "project override"
def test_install_from_zip_then_resolve(self, project_dir, pack_dir, temp_dir):
"""Test installing from ZIP and then resolving."""
# Create ZIP
zip_path = temp_dir / "test-pack.zip"
with zipfile.ZipFile(zip_path, 'w') as zf:
for file_path in pack_dir.rglob('*'):
if file_path.is_file():
arcname = file_path.relative_to(pack_dir)
zf.write(file_path, arcname)
# Install
manager = PresetManager(project_dir)
manager.install_from_zip(zip_path, "0.1.5")
# Resolve
resolver = PresetResolver(project_dir)
result = resolver.resolve("spec-template")
assert result is not None
assert "Custom Spec Template" in result.read_text()
# ===== PresetCatalogEntry Tests =====
class TestPresetCatalogEntry:
"""Test PresetCatalogEntry dataclass."""
def test_create_entry(self):
"""Test creating a catalog entry."""
entry = PresetCatalogEntry(
url="https://example.com/catalog.json",
name="test",
priority=1,
install_allowed=True,
description="Test catalog",
)
assert entry.url == "https://example.com/catalog.json"
assert entry.name == "test"
assert entry.priority == 1
assert entry.install_allowed is True
assert entry.description == "Test catalog"
def test_default_description(self):
"""Test default empty description."""
entry = PresetCatalogEntry(
url="https://example.com/catalog.json",
name="test",
priority=1,
install_allowed=False,
)
assert entry.description == ""
# ===== Multi-Catalog Tests =====
class TestPresetCatalogMultiCatalog:
"""Test multi-catalog support in PresetCatalog."""
def test_default_active_catalogs(self, project_dir):
"""Test that default catalogs are returned when no config exists."""
catalog = PresetCatalog(project_dir)
active = catalog.get_active_catalogs()
assert len(active) == 2
assert active[0].name == "default"
assert active[0].priority == 1
assert active[0].install_allowed is True
assert active[1].name == "community"
assert active[1].priority == 2
assert active[1].install_allowed is False
def test_env_var_overrides_catalogs(self, project_dir, monkeypatch):
"""Test that SPECKIT_PRESET_CATALOG_URL env var overrides defaults."""
monkeypatch.setenv(
"SPECKIT_PRESET_CATALOG_URL",
"https://custom.example.com/catalog.json",
)
catalog = PresetCatalog(project_dir)
active = catalog.get_active_catalogs()
assert len(active) == 1
assert active[0].name == "custom"
assert active[0].url == "https://custom.example.com/catalog.json"
assert active[0].install_allowed is True
def test_project_config_overrides_defaults(self, project_dir):
"""Test that project-level config overrides built-in defaults."""
config_path = project_dir / ".specify" / "preset-catalogs.yml"
config_path.write_text(yaml.dump({
"catalogs": [
{
"name": "my-catalog",
"url": "https://my.example.com/catalog.json",
"priority": 1,
"install_allowed": True,
}
]
}))
catalog = PresetCatalog(project_dir)
active = catalog.get_active_catalogs()
assert len(active) == 1
assert active[0].name == "my-catalog"
assert active[0].url == "https://my.example.com/catalog.json"
def test_load_catalog_config_nonexistent(self, project_dir):
"""Test loading config from nonexistent file returns None."""
catalog = PresetCatalog(project_dir)
result = catalog._load_catalog_config(
project_dir / ".specify" / "nonexistent.yml"
)
assert result is None
def test_load_catalog_config_empty(self, project_dir):
"""Test loading empty config returns None."""
config_path = project_dir / ".specify" / "preset-catalogs.yml"
config_path.write_text("")
catalog = PresetCatalog(project_dir)
result = catalog._load_catalog_config(config_path)
assert result is None
def test_load_catalog_config_invalid_yaml(self, project_dir):
"""Test loading invalid YAML raises error."""
config_path = project_dir / ".specify" / "preset-catalogs.yml"
config_path.write_text(": invalid: {{{")
catalog = PresetCatalog(project_dir)
with pytest.raises(PresetValidationError, match="Failed to read"):
catalog._load_catalog_config(config_path)
def test_load_catalog_config_not_a_list(self, project_dir):
"""Test that non-list catalogs key raises error."""
config_path = project_dir / ".specify" / "preset-catalogs.yml"
config_path.write_text(yaml.dump({"catalogs": "not-a-list"}))
catalog = PresetCatalog(project_dir)
with pytest.raises(PresetValidationError, match="must be a list"):
catalog._load_catalog_config(config_path)
def test_load_catalog_config_invalid_entry(self, project_dir):
"""Test that non-dict entry raises error."""
config_path = project_dir / ".specify" / "preset-catalogs.yml"
config_path.write_text(yaml.dump({"catalogs": ["not-a-dict"]}))
catalog = PresetCatalog(project_dir)
with pytest.raises(PresetValidationError, match="expected a mapping"):
catalog._load_catalog_config(config_path)
def test_load_catalog_config_http_url_rejected(self, project_dir):
"""Test that HTTP URLs are rejected."""
config_path = project_dir / ".specify" / "preset-catalogs.yml"
config_path.write_text(yaml.dump({
"catalogs": [
{
"name": "bad",
"url": "http://insecure.example.com/catalog.json",
"priority": 1,
}
]
}))
catalog = PresetCatalog(project_dir)
with pytest.raises(PresetValidationError, match="must use HTTPS"):
catalog._load_catalog_config(config_path)
def test_load_catalog_config_priority_sorting(self, project_dir):
"""Test that catalogs are sorted by priority."""
config_path = project_dir / ".specify" / "preset-catalogs.yml"
config_path.write_text(yaml.dump({
"catalogs": [
{
"name": "low-priority",
"url": "https://low.example.com/catalog.json",
"priority": 10,
"install_allowed": False,
},
{
"name": "high-priority",
"url": "https://high.example.com/catalog.json",
"priority": 1,
"install_allowed": True,
},
]
}))
catalog = PresetCatalog(project_dir)
entries = catalog._load_catalog_config(config_path)
assert entries is not None
assert len(entries) == 2
assert entries[0].name == "high-priority"
assert entries[1].name == "low-priority"
def test_load_catalog_config_invalid_priority(self, project_dir):
"""Test that invalid priority raises error."""
config_path = project_dir / ".specify" / "preset-catalogs.yml"
config_path.write_text(yaml.dump({
"catalogs": [
{
"name": "bad",
"url": "https://example.com/catalog.json",
"priority": "not-a-number",
}
]
}))
catalog = PresetCatalog(project_dir)
with pytest.raises(PresetValidationError, match="Invalid priority"):
catalog._load_catalog_config(config_path)
def test_load_catalog_config_install_allowed_string(self, project_dir):
"""Test that install_allowed accepts string values."""
config_path = project_dir / ".specify" / "preset-catalogs.yml"
config_path.write_text(yaml.dump({
"catalogs": [
{
"name": "test",
"url": "https://example.com/catalog.json",
"priority": 1,
"install_allowed": "true",
}
]
}))
catalog = PresetCatalog(project_dir)
entries = catalog._load_catalog_config(config_path)
assert entries is not None
assert entries[0].install_allowed is True
def test_get_catalog_url_uses_highest_priority(self, project_dir):
"""Test that get_catalog_url returns URL of highest priority catalog."""
config_path = project_dir / ".specify" / "preset-catalogs.yml"
config_path.write_text(yaml.dump({
"catalogs": [
{
"name": "secondary",
"url": "https://secondary.example.com/catalog.json",
"priority": 5,
},
{
"name": "primary",
"url": "https://primary.example.com/catalog.json",
"priority": 1,
},
]
}))
catalog = PresetCatalog(project_dir)
assert catalog.get_catalog_url() == "https://primary.example.com/catalog.json"
def test_cache_paths_default_url(self, project_dir):
"""Test cache paths for default catalog URL use legacy locations."""
catalog = PresetCatalog(project_dir)
cache_file, metadata_file = catalog._get_cache_paths(
PresetCatalog.DEFAULT_CATALOG_URL
)
assert cache_file == catalog.cache_file
assert metadata_file == catalog.cache_metadata_file
def test_cache_paths_custom_url(self, project_dir):
"""Test cache paths for custom URLs use hash-based files."""
catalog = PresetCatalog(project_dir)
cache_file, metadata_file = catalog._get_cache_paths(
"https://custom.example.com/catalog.json"
)
assert cache_file != catalog.cache_file
assert "catalog-" in cache_file.name
assert cache_file.name.endswith(".json")
def test_url_cache_valid(self, project_dir):
"""Test URL-specific cache validation."""
catalog = PresetCatalog(project_dir)
url = "https://custom.example.com/catalog.json"
cache_file, metadata_file = catalog._get_cache_paths(url)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps({"schema_version": "1.0", "presets": {}}))
metadata_file.write_text(json.dumps({
"cached_at": datetime.now(timezone.utc).isoformat(),
}))
assert catalog._is_url_cache_valid(url) is True
def test_url_cache_expired(self, project_dir):
"""Test URL-specific cache expiration."""
catalog = PresetCatalog(project_dir)
url = "https://custom.example.com/catalog.json"
cache_file, metadata_file = catalog._get_cache_paths(url)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps({"schema_version": "1.0", "presets": {}}))
metadata_file.write_text(json.dumps({
"cached_at": "2020-01-01T00:00:00+00:00",
}))
assert catalog._is_url_cache_valid(url) is False
# ===== Self-Test Preset Tests =====
SELF_TEST_PRESET_DIR = Path(__file__).parent.parent / "presets" / "self-test"
CORE_TEMPLATE_NAMES = [
"spec-template",
"plan-template",
"tasks-template",
"checklist-template",
"constitution-template",
]
class TestSelfTestPreset:
"""Tests using the self-test preset that ships with the repo."""
def test_self_test_preset_exists(self):
"""Verify the self-test preset directory and manifest exist."""
assert SELF_TEST_PRESET_DIR.exists()
assert (SELF_TEST_PRESET_DIR / "preset.yml").exists()
def test_self_test_manifest_valid(self):
"""Verify the self-test preset manifest is valid."""
manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml")
assert manifest.id == "self-test"
assert manifest.name == "Self-Test Preset"
assert manifest.version == "1.0.0"
assert len(manifest.templates) == 8 # 6 templates + 2 commands
def test_self_test_provides_all_core_templates(self):
"""Verify the self-test preset provides an override for every core template."""
manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml")
provided_names = {t["name"] for t in manifest.templates}
for name in CORE_TEMPLATE_NAMES:
assert name in provided_names, f"Self-test preset missing template: {name}"
def test_self_test_template_files_exist(self):
"""Verify that all declared template files actually exist on disk."""
manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml")
for tmpl in manifest.templates:
tmpl_path = SELF_TEST_PRESET_DIR / tmpl["file"]
assert tmpl_path.exists(), f"Missing template file: {tmpl['file']}"
def test_self_test_templates_have_marker(self):
"""Verify each template contains the preset:self-test marker."""
for name in CORE_TEMPLATE_NAMES:
tmpl_path = SELF_TEST_PRESET_DIR / "templates" / f"{name}.md"
content = tmpl_path.read_text()
assert "preset:self-test" in content, f"{name}.md missing preset:self-test marker"
def test_install_self_test_preset(self, project_dir):
"""Test installing the self-test preset from its directory."""
manager = PresetManager(project_dir)
manifest = manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
assert manifest.id == "self-test"
assert manager.registry.is_installed("self-test")
def test_self_test_overrides_all_core_templates(self, project_dir):
"""Test that installing self-test overrides every core template."""
# Set up core templates in the project
templates_dir = project_dir / ".specify" / "templates"
for name in CORE_TEMPLATE_NAMES:
(templates_dir / f"{name}.md").write_text(f"# Core {name}\n")
# Install self-test preset
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
# Every core template should now resolve from the preset
resolver = PresetResolver(project_dir)
for name in CORE_TEMPLATE_NAMES:
result = resolver.resolve(name)
assert result is not None, f"{name} did not resolve"
content = result.read_text()
assert "preset:self-test" in content, (
f"{name} resolved but not from self-test preset"
)
def test_self_test_resolve_with_source(self, project_dir):
"""Test that resolve_with_source attributes templates to self-test."""
templates_dir = project_dir / ".specify" / "templates"
for name in CORE_TEMPLATE_NAMES:
(templates_dir / f"{name}.md").write_text(f"# Core {name}\n")
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
resolver = PresetResolver(project_dir)
for name in CORE_TEMPLATE_NAMES:
result = resolver.resolve_with_source(name)
assert result is not None, f"{name} did not resolve"
assert "self-test" in result["source"], (
f"{name} source is '{result['source']}', expected self-test"
)
def test_self_test_removal_restores_core(self, project_dir):
"""Test that removing self-test falls back to core templates."""
templates_dir = project_dir / ".specify" / "templates"
for name in CORE_TEMPLATE_NAMES:
(templates_dir / f"{name}.md").write_text(f"# Core {name}\n")
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
manager.remove("self-test")
resolver = PresetResolver(project_dir)
for name in CORE_TEMPLATE_NAMES:
result = resolver.resolve_with_source(name)
assert result is not None
assert result["source"] == "core"
def test_self_test_not_in_catalog(self):
"""Verify the self-test preset is NOT in the catalog (it's local-only)."""
catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json"
catalog_data = json.loads(catalog_path.read_text())
assert "self-test" not in catalog_data["presets"]
def test_self_test_has_command(self):
"""Verify the self-test preset includes a command override."""
manifest = PresetManifest(SELF_TEST_PRESET_DIR / "preset.yml")
commands = [t for t in manifest.templates if t["type"] == "command"]
assert len(commands) >= 1
assert commands[0]["name"] == "speckit.specify"
def test_self_test_command_file_exists(self):
"""Verify the self-test command file exists on disk."""
cmd_path = SELF_TEST_PRESET_DIR / "commands" / "speckit.specify.md"
assert cmd_path.exists()
content = cmd_path.read_text()
assert "preset:self-test" in content
def test_self_test_registers_commands_for_claude(self, project_dir):
"""Test that installing self-test registers skills in .claude/skills/."""
# Create Claude skills directory to simulate Claude being set up
claude_dir = project_dir / ".claude" / "skills"
claude_dir.mkdir(parents=True)
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
# Check the skill was registered
cmd_file = claude_dir / "speckit-specify" / "SKILL.md"
assert cmd_file.exists(), "Skill not registered in .claude/skills/"
content = cmd_file.read_text()
assert "self-test" in content
assert "source:" in content # skill frontmatter includes metadata.source
def test_self_test_registers_commands_for_gemini(self, project_dir):
"""Test that installing self-test registers commands in .gemini/commands/ as TOML."""
# Create Gemini agent directory
gemini_dir = project_dir / ".gemini" / "commands"
gemini_dir.mkdir(parents=True)
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
# Check the command was registered in TOML format
cmd_file = gemini_dir / "speckit.specify.toml"
assert cmd_file.exists(), "Command not registered in .gemini/commands/"
content = cmd_file.read_text()
assert "prompt" in content # TOML format has a prompt field
assert "{{args}}" in content # Gemini uses {{args}} placeholder
def test_self_test_unregisters_commands_on_remove(self, project_dir):
"""Test that removing self-test cleans up registered commands."""
claude_dir = project_dir / ".claude" / "skills"
claude_dir.mkdir(parents=True)
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
cmd_file = claude_dir / "speckit-specify" / "SKILL.md"
assert cmd_file.exists()
manager.remove("self-test")
assert not cmd_file.exists(), "Command not cleaned up after preset removal"
def test_self_test_no_commands_without_agent_dirs(self, project_dir):
"""Test that no commands are registered when no agent dirs exist."""
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
metadata = manager.registry.get("self-test")
assert metadata["registered_commands"] == {}
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"
claude_dir.mkdir(parents=True)
preset_dir = temp_dir / "ext-override-preset"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
"---\ndescription: Override fakeext cmd\n---\nOverridden content"
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "ext-override",
"name": "Ext Override",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.fakeext.cmd",
"file": "commands/speckit.fakeext.cmd.md",
"description": "Override fakeext cmd",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(manifest_data, f)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
# Extension not installed — command should NOT be registered
cmd_file = claude_dir / "speckit.fakeext.cmd.md"
assert not cmd_file.exists(), "Command registered for missing extension"
metadata = manager.registry.get("ext-override")
assert metadata["registered_commands"] == {}
def test_extension_command_registered_when_extension_present(self, project_dir, temp_dir):
"""Test that extension command overrides ARE registered when the extension is installed."""
claude_dir = project_dir / ".claude" / "skills"
claude_dir.mkdir(parents=True)
(project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True)
preset_dir = temp_dir / "ext-override-preset2"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
"---\ndescription: Override fakeext cmd\n---\nOverridden content"
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "ext-override2",
"name": "Ext Override",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.fakeext.cmd",
"file": "commands/speckit.fakeext.cmd.md",
"description": "Override fakeext cmd",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(manifest_data, f)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
cmd_file = claude_dir / "speckit-fakeext-cmd" / "SKILL.md"
assert cmd_file.exists(), "Skill not registered despite extension being present"
# ===== Init Options and Skills Tests =====
class TestInitOptions:
"""Tests for save_init_options / load_init_options helpers."""
def test_save_and_load_round_trip(self, project_dir):
from specify_cli import save_init_options, load_init_options
opts = {"ai": "claude", "ai_skills": True, "here": False}
save_init_options(project_dir, opts)
loaded = load_init_options(project_dir)
assert loaded["ai"] == "claude"
assert loaded["ai_skills"] is True
def test_load_returns_empty_when_missing(self, project_dir):
from specify_cli import load_init_options
assert load_init_options(project_dir) == {}
def test_load_returns_empty_on_invalid_json(self, project_dir):
from specify_cli import load_init_options
opts_file = project_dir / ".specify" / "init-options.json"
opts_file.parent.mkdir(parents=True, exist_ok=True)
opts_file.write_text("{bad json")
assert load_init_options(project_dir) == {}
class TestPresetSkills:
"""Tests for preset skill registration and unregistration."""
def _write_init_options(self, project_dir, ai="claude", ai_skills=True, script="sh"):
from specify_cli import save_init_options
save_init_options(project_dir, {"ai": ai, "ai_skills": ai_skills, "script": script})
def _create_skill(self, skills_dir, skill_name, body="original body"):
skill_dir = skills_dir / skill_name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {skill_name}\n---\n\n{body}\n"
)
return skill_dir
def test_skill_overridden_on_preset_install(self, project_dir, temp_dir):
"""When --ai-skills was used, a preset command override should update the skill."""
# Simulate --ai-skills having been used: write init-options + create skill
self._write_init_options(project_dir, ai="claude")
skills_dir = project_dir / ".claude" / "skills"
self._create_skill(skills_dir, "speckit-specify")
# Also create the claude commands dir so commands get registered
(project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True)
# Install self-test preset (has a command override for speckit.specify)
manager = PresetManager(project_dir)
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
assert "preset:self-test" in content, "Skill should reference preset source"
assert "disable-model-invocation: false" in content
# Verify it was recorded in registry
metadata = manager.registry.get("self-test")
assert "speckit-specify" in metadata.get("registered_skills", [])
def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir):
"""When --ai-skills was NOT used, preset install should not touch skills."""
self._write_init_options(project_dir, ai="qwen", ai_skills=False)
skills_dir = project_dir / ".qwen" / "skills"
self._create_skill(skills_dir, "speckit-specify", body="untouched")
manager = PresetManager(project_dir)
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
content = skill_file.read_text()
assert "untouched" in content, "Skill should not be modified when ai_skills=False"
def test_get_skills_dir_returns_none_for_non_string_ai(self, project_dir):
"""Corrupted init-options ai values should not crash preset skill resolution."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text('{"ai":["codex"],"ai_skills":true,"script":"sh"}')
manager = PresetManager(project_dir)
assert manager._get_skills_dir() is None
def test_get_skills_dir_returns_none_for_non_dict_init_options(self, project_dir):
"""Corrupted non-dict init-options payloads should fail closed."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text("[]")
manager = PresetManager(project_dir)
assert manager._get_skills_dir() is None
def test_skill_not_updated_without_init_options(self, project_dir, temp_dir):
"""When no init-options.json exists, preset install should not touch skills."""
skills_dir = project_dir / ".qwen" / "skills"
self._create_skill(skills_dir, "speckit-specify", body="untouched")
manager = PresetManager(project_dir)
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
file_content = skill_file.read_text()
assert "untouched" in file_content
def test_skill_restored_on_preset_remove(self, project_dir, temp_dir):
"""When a preset is removed, skills should be restored from core templates."""
self._write_init_options(project_dir, ai="claude")
skills_dir = project_dir / ".claude" / "skills"
self._create_skill(skills_dir, "speckit-specify")
(project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True)
# Set up core command template in the project so restoration works
core_cmds = project_dir / ".specify" / "templates" / "commands"
core_cmds.mkdir(parents=True, exist_ok=True)
(core_cmds / "specify.md").write_text("---\ndescription: Core specify command\n---\n\nCore specify body\n")
manager = PresetManager(project_dir)
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
# Verify preset content is in the skill
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
assert "preset:self-test" in skill_file.read_text()
# Remove the preset
manager.remove("self-test")
# Skill should be restored (core specify.md template exists)
assert skill_file.exists(), "Skill should still exist after preset removal"
content = skill_file.read_text()
assert "preset:self-test" not in content, "Preset content should be gone"
assert "templates/commands/specify.md" in content, "Should reference core template"
assert "disable-model-invocation: false" in content
def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir):
"""Core restore should resolve {SCRIPT}/{ARGS} placeholders like other skill paths."""
self._write_init_options(project_dir, ai="claude", ai_skills=True, script="sh")
skills_dir = project_dir / ".claude" / "skills"
self._create_skill(skills_dir, "speckit-specify", body="old")
(project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True)
core_cmds = project_dir / ".specify" / "templates" / "commands"
core_cmds.mkdir(parents=True, exist_ok=True)
(core_cmds / "specify.md").write_text(
"---\n"
"description: Core specify command\n"
"scripts:\n"
" sh: .specify/scripts/bash/create-new-feature.sh --json \"{ARGS}\"\n"
"---\n\n"
"Run:\n"
"{SCRIPT}\n"
)
manager = PresetManager(project_dir)
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
manager.remove("self-test")
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
assert "{SCRIPT}" not in content
assert "{ARGS}" not in content
assert ".specify/scripts/bash/create-new-feature.sh --json \"$ARGUMENTS\"" in content
def test_skill_not_overridden_when_skill_path_is_file(self, project_dir):
"""Preset install should skip non-directory skill targets."""
self._write_init_options(project_dir, ai="qwen")
skills_dir = project_dir / ".qwen" / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
(skills_dir / "speckit-specify").write_text("not-a-directory")
manager = PresetManager(project_dir)
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
assert (skills_dir / "speckit-specify").is_file()
metadata = manager.registry.get("self-test")
assert "speckit-specify" not in metadata.get("registered_skills", [])
def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_dir):
"""Skills should not be created when no existing skill dir is found."""
self._write_init_options(project_dir, ai="claude")
# Don't create skills dir — simulate --ai-skills never created them
manager = PresetManager(project_dir)
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
metadata = manager.registry.get("self-test")
assert metadata.get("registered_skills", []) == []
def test_extension_skill_override_matches_hyphenated_multisegment_name(self, project_dir, temp_dir):
"""Preset overrides for speckit.<ext>.<cmd> should target speckit-<ext>-<cmd> skills."""
self._write_init_options(project_dir, ai="codex")
skills_dir = project_dir / ".agents" / "skills"
self._create_skill(skills_dir, "speckit-fakeext-cmd", body="untouched")
(project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True, exist_ok=True)
preset_dir = temp_dir / "ext-skill-override"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
"---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-override\n"
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "ext-skill-override",
"name": "Ext Skill Override",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.fakeext.cmd",
"file": "commands/speckit.fakeext.cmd.md",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(manifest_data, f)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
assert "preset:ext-skill-override" in content
assert "name: speckit-fakeext-cmd" in content
assert "# Speckit Fakeext Cmd Skill" in content
metadata = manager.registry.get("ext-skill-override")
assert "speckit-fakeext-cmd" in metadata.get("registered_skills", [])
def test_extension_skill_restored_on_preset_remove(self, project_dir, temp_dir):
"""Preset removal should restore an extension-backed skill instead of deleting it."""
self._write_init_options(project_dir, ai="codex")
skills_dir = project_dir / ".agents" / "skills"
self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill")
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
(extension_dir / "commands" / "cmd.md").write_text(
"---\n"
"description: Extension fakeext cmd\n"
"scripts:\n"
" sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n"
"---\n\n"
"extension:fakeext\n"
"Run {SCRIPT}\n"
)
extension_manifest = {
"schema_version": "1.0",
"extension": {
"id": "fakeext",
"name": "Fake Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.fakeext.cmd",
"file": "commands/cmd.md",
"description": "Fake extension command",
}
]
},
}
with open(extension_dir / "extension.yml", "w") as f:
yaml.dump(extension_manifest, f)
preset_dir = temp_dir / "ext-skill-restore"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
"---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-restore\n"
)
preset_manifest = {
"schema_version": "1.0",
"preset": {
"id": "ext-skill-restore",
"name": "Ext Skill Restore",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.fakeext.cmd",
"file": "commands/speckit.fakeext.cmd.md",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(preset_manifest, f)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md"
assert "preset:ext-skill-restore" in skill_file.read_text()
manager.remove("ext-skill-restore")
assert skill_file.exists()
content = skill_file.read_text()
assert "preset:ext-skill-restore" not in content
assert "source: extension:fakeext" in content
assert "extension:fakeext" in content
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
assert "# Fakeext Cmd Skill" in content
def test_preset_remove_skips_skill_dir_without_skill_file(self, project_dir, temp_dir):
"""Preset removal should not delete arbitrary directories missing SKILL.md."""
self._write_init_options(project_dir, ai="codex")
skills_dir = project_dir / ".agents" / "skills"
stray_skill_dir = skills_dir / "speckit-fakeext-cmd"
stray_skill_dir.mkdir(parents=True, exist_ok=True)
note_file = stray_skill_dir / "notes.txt"
note_file.write_text("user content", encoding="utf-8")
preset_dir = temp_dir / "ext-skill-missing-file"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
"---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-missing-file\n"
)
preset_manifest = {
"schema_version": "1.0",
"preset": {
"id": "ext-skill-missing-file",
"name": "Ext Skill Missing File",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.fakeext.cmd",
"file": "commands/speckit.fakeext.cmd.md",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(preset_manifest, f)
manager = PresetManager(project_dir)
installed_preset_dir = manager.presets_dir / "ext-skill-missing-file"
shutil.copytree(preset_dir, installed_preset_dir)
manager.registry.add(
"ext-skill-missing-file",
{
"version": "1.0.0",
"source": str(preset_dir),
"provides_templates": ["speckit.fakeext.cmd"],
"registered_skills": ["speckit-fakeext-cmd"],
"priority": 10,
},
)
manager.remove("ext-skill-missing-file")
assert stray_skill_dir.is_dir()
assert note_file.read_text(encoding="utf-8") == "user content"
def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp_dir):
"""Preset overrides should still target legacy dotted Kimi skill directories."""
self._write_init_options(project_dir, ai="kimi")
skills_dir = project_dir / ".kimi" / "skills"
self._create_skill(skills_dir, "speckit.specify", body="untouched")
(project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)
manager = PresetManager(project_dir)
self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(self_test_dir, "0.1.5")
skill_file = skills_dir / "speckit.specify" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
assert "preset:self-test" in content
assert "name: speckit.specify" in content
metadata = manager.registry.get("self-test")
assert "speckit.specify" in metadata.get("registered_skills", [])
def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp_dir):
"""Kimi presets should still propagate command overrides to existing skills."""
self._write_init_options(project_dir, ai="kimi", ai_skills=False)
skills_dir = project_dir / ".kimi" / "skills"
self._create_skill(skills_dir, "speckit-specify", body="untouched")
(project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)
manager = PresetManager(project_dir)
self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(self_test_dir, "0.1.5")
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
assert "preset:self-test" in content
assert "name: speckit-specify" in content
metadata = manager.registry.get("self-test")
assert "speckit-specify" in metadata.get("registered_skills", [])
def test_kimi_new_skill_created_even_when_ai_skills_disabled(self, project_dir, temp_dir):
"""Kimi native skills should still receive brand-new preset commands."""
self._write_init_options(project_dir, ai="kimi", ai_skills=False)
skills_dir = project_dir / ".kimi" / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
preset_dir = temp_dir / "kimi-new-skill"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.research.md").write_text(
"---\n"
"description: Kimi research workflow\n"
"---\n\n"
"preset:kimi-new-skill\n"
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "kimi-new-skill",
"name": "Kimi New Skill",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.research",
"file": "commands/speckit.research.md",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(manifest_data, f)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
skill_file = skills_dir / "speckit-research" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
assert "preset:kimi-new-skill" in content
assert "name: speckit-research" in content
metadata = manager.registry.get("kimi-new-skill")
assert "speckit-research" in metadata.get("registered_skills", [])
def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_dir, temp_dir):
"""Kimi preset skill overrides should resolve placeholders and rewrite project paths."""
self._write_init_options(project_dir, ai="kimi", ai_skills=False, script="sh")
skills_dir = project_dir / ".kimi" / "skills"
self._create_skill(skills_dir, "speckit-specify", body="untouched")
(project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)
preset_dir = temp_dir / "kimi-placeholder-override"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.specify.md").write_text(
"---\n"
"description: Kimi placeholder override\n"
"scripts:\n"
" sh: scripts/bash/create-new-feature.sh --json \"{ARGS}\"\n"
"---\n\n"
"Execute `{SCRIPT}` for __AGENT__\n"
"Review templates/checklist.md and memory/constitution.md\n"
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "kimi-placeholder-override",
"name": "Kimi Placeholder Override",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.specify",
"file": "commands/speckit.specify.md",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(manifest_data, f)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
assert "{SCRIPT}" not in content
assert "__AGENT__" not in content
assert ".specify/scripts/bash/create-new-feature.sh --json \"$ARGUMENTS\"" in content
assert ".specify/templates/checklist.md" in content
assert ".specify/memory/constitution.md" in content
assert "for kimi" in content
def test_agy_skill_restored_on_preset_remove(self, project_dir, temp_dir):
"""Agy preset removal should restore native skills instead of deleting them."""
self._write_init_options(project_dir, ai="agy", ai_skills=True)
skills_dir = project_dir / ".agents" / "skills"
self._create_skill(skills_dir, "speckit-specify", body="before override")
core_command = project_dir / ".specify" / "templates" / "commands" / "specify.md"
core_command.write_text(
"---\n"
"description: Restored core specify workflow\n"
"---\n\n"
"restored core body\n"
)
preset_dir = temp_dir / "agy-override"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.specify.md").write_text(
"---\n"
"description: Agy override\n"
"---\n\n"
"preset agy body\n"
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "agy-override",
"name": "Agy Override",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.specify",
"file": "commands/speckit.specify.md",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(manifest_data, f)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
assert "preset agy body" in skill_file.read_text()
assert manager.remove("agy-override") is True
assert skill_file.exists()
restored = skill_file.read_text()
assert "restored core body" in restored
assert "name: speckit-specify" in restored
def test_preset_skill_registration_handles_non_dict_init_options(self, project_dir, temp_dir):
"""Non-dict init-options payloads should not crash preset install/remove flows."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text("[]")
skills_dir = project_dir / ".qwen" / "skills"
self._create_skill(skills_dir, "speckit-specify", body="untouched")
manager = PresetManager(project_dir)
self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(self_test_dir, "0.1.5")
skill_content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
assert "untouched" in skill_content
class TestPresetSetPriority:
"""Test preset set-priority CLI command."""
def test_set_priority_changes_priority(self, project_dir, pack_dir):
"""Test set-priority command changes preset priority."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install preset with default priority
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
# Verify default priority
assert manager.registry.get("test-pack")["priority"] == 10
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"])
assert result.exit_code == 0, result.output
plain = strip_ansi(result.output)
assert "priority changed: 10 → 5" in plain
# Reload registry to see updated value
manager2 = PresetManager(project_dir)
assert manager2.registry.get("test-pack")["priority"] == 5
def test_set_priority_same_value_no_change(self, project_dir, pack_dir):
"""Test set-priority with same value shows already set message."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install preset with priority 5
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5", priority=5)
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"])
assert result.exit_code == 0, result.output
plain = strip_ansi(result.output)
assert "already has priority 5" in plain
def test_set_priority_invalid_value(self, project_dir, pack_dir):
"""Test set-priority rejects invalid priority values."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install preset
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "0"])
assert result.exit_code == 1, result.output
assert "Priority must be a positive integer" in result.output
def test_set_priority_not_installed(self, project_dir):
"""Test set-priority fails for non-installed preset."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "set-priority", "nonexistent", "5"])
assert result.exit_code == 1, result.output
assert "not installed" in result.output.lower()
class TestPresetPriorityBackwardsCompatibility:
"""Test backwards compatibility for presets installed before priority feature."""
def test_legacy_preset_without_priority_field(self, temp_dir):
"""Presets installed before priority feature should default to 10."""
presets_dir = temp_dir / ".specify" / "presets"
presets_dir.mkdir(parents=True)
# Simulate legacy registry entry without priority field
registry = PresetRegistry(presets_dir)
registry.data["presets"]["legacy-pack"] = {
"version": "1.0.0",
"source": "local",
"enabled": True,
"installed_at": "2025-01-01T00:00:00Z",
# No "priority" field - simulates pre-feature preset
}
registry._save()
# Reload registry
registry2 = PresetRegistry(presets_dir)
# list_by_priority should use default of 10
result = registry2.list_by_priority()
assert len(result) == 1
assert result[0][0] == "legacy-pack"
# Priority defaults to 10 and is normalized in returned metadata
assert result[0][1]["priority"] == 10
def test_legacy_preset_in_list_installed(self, project_dir, pack_dir):
"""list_installed returns priority=10 for legacy presets without priority field."""
manager = PresetManager(project_dir)
# Install preset normally
manager.install_from_directory(pack_dir, "0.1.5")
# Manually remove priority to simulate legacy preset
pack_data = manager.registry.data["presets"]["test-pack"]
del pack_data["priority"]
manager.registry._save()
# list_installed should still return priority=10
installed = manager.list_installed()
assert len(installed) == 1
assert installed[0]["priority"] == 10
def test_mixed_legacy_and_new_presets_ordering(self, temp_dir):
"""Legacy presets (no priority) sort with default=10 among prioritized presets."""
presets_dir = temp_dir / ".specify" / "presets"
presets_dir.mkdir(parents=True)
registry = PresetRegistry(presets_dir)
# Add preset with explicit priority=5
registry.add("pack-with-priority", {"version": "1.0.0", "priority": 5})
# Add legacy preset without priority (manually)
registry.data["presets"]["legacy-pack"] = {
"version": "1.0.0",
"source": "local",
"enabled": True,
# No priority field
}
# Add another preset with priority=15
registry.add("low-priority-pack", {"version": "1.0.0", "priority": 15})
registry._save()
# Reload and check ordering
registry2 = PresetRegistry(presets_dir)
sorted_presets = registry2.list_by_priority()
# Should be: pack-with-priority (5), legacy-pack (default 10), low-priority-pack (15)
assert [p[0] for p in sorted_presets] == [
"pack-with-priority",
"legacy-pack",
"low-priority-pack",
]
class TestPresetEnableDisable:
"""Test preset enable/disable CLI commands."""
def test_disable_preset(self, project_dir, pack_dir):
"""Test disable command sets enabled=False."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install preset
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
# Verify initially enabled
assert manager.registry.get("test-pack").get("enabled", True) is True
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "disable", "test-pack"])
assert result.exit_code == 0, result.output
assert "disabled" in result.output.lower()
# Reload registry to see updated value
manager2 = PresetManager(project_dir)
assert manager2.registry.get("test-pack")["enabled"] is False
def test_enable_preset(self, project_dir, pack_dir):
"""Test enable command sets enabled=True."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install preset and disable it
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
manager.registry.update("test-pack", {"enabled": False})
# Verify disabled
assert manager.registry.get("test-pack")["enabled"] is False
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "enable", "test-pack"])
assert result.exit_code == 0, result.output
assert "enabled" in result.output.lower()
# Reload registry to see updated value
manager2 = PresetManager(project_dir)
assert manager2.registry.get("test-pack")["enabled"] is True
def test_disable_already_disabled(self, project_dir, pack_dir):
"""Test disable on already disabled preset shows warning."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install preset and disable it
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
manager.registry.update("test-pack", {"enabled": False})
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "disable", "test-pack"])
assert result.exit_code == 0, result.output
assert "already disabled" in result.output.lower()
def test_enable_already_enabled(self, project_dir, pack_dir):
"""Test enable on already enabled preset shows warning."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install preset (enabled by default)
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "enable", "test-pack"])
assert result.exit_code == 0, result.output
assert "already enabled" in result.output.lower()
def test_disable_not_installed(self, project_dir):
"""Test disable fails for non-installed preset."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "disable", "nonexistent"])
assert result.exit_code == 1, result.output
assert "not installed" in result.output.lower()
def test_enable_not_installed(self, project_dir):
"""Test enable fails for non-installed preset."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "enable", "nonexistent"])
assert result.exit_code == 1, result.output
assert "not installed" in result.output.lower()
def test_disabled_preset_excluded_from_resolution(self, project_dir, pack_dir):
"""Test that disabled presets are excluded from template resolution."""
# Install preset with a template
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
# Create a template in the preset directory
preset_template = project_dir / ".specify" / "presets" / "test-pack" / "templates" / "test-template.md"
preset_template.parent.mkdir(parents=True, exist_ok=True)
preset_template.write_text("# Template from test-pack")
resolver = PresetResolver(project_dir)
# Template should be found when enabled
result = resolver.resolve("test-template", "template")
assert result is not None
assert "test-pack" in str(result)
# Disable the preset
manager.registry.update("test-pack", {"enabled": False})
# Template should NOT be found when disabled
resolver2 = PresetResolver(project_dir)
result2 = resolver2.resolve("test-template", "template")
assert result2 is None
def test_enable_corrupted_registry_entry(self, project_dir, pack_dir):
"""Test enable fails gracefully for corrupted registry entry."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install preset then corrupt the registry entry
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
manager.registry.data["presets"]["test-pack"] = "corrupted-string"
manager.registry._save()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "enable", "test-pack"])
assert result.exit_code == 1
assert "corrupted state" in result.output.lower()
def test_disable_corrupted_registry_entry(self, project_dir, pack_dir):
"""Test disable fails gracefully for corrupted registry entry."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install preset then corrupt the registry entry
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
manager.registry.data["presets"]["test-pack"] = "corrupted-string"
manager.registry._save()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "disable", "test-pack"])
assert result.exit_code == 1
assert "corrupted state" in result.output.lower()
# ===== Lean Preset Tests =====
LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean"
LEAN_COMMAND_NAMES = [
"speckit.specify",
"speckit.plan",
"speckit.tasks",
"speckit.implement",
"speckit.constitution",
]
class TestLeanPreset:
"""Tests for the lean preset that ships with the repo."""
def test_lean_preset_exists(self):
"""Verify the lean preset directory and manifest exist."""
assert LEAN_PRESET_DIR.exists()
assert (LEAN_PRESET_DIR / "preset.yml").exists()
def test_lean_manifest_valid(self):
"""Verify the lean preset manifest is valid."""
manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml")
assert manifest.id == "lean"
assert manifest.name == "Lean Workflow"
assert manifest.version == "1.0.0"
assert len(manifest.templates) == 5 # 5 commands
def test_lean_provides_core_workflow_commands(self):
"""Verify the lean preset provides overrides for core workflow commands."""
manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml")
provided_names = {t["name"] for t in manifest.templates}
for name in LEAN_COMMAND_NAMES:
assert name in provided_names, f"Lean preset missing command: {name}"
def test_lean_command_files_exist(self):
"""Verify that all declared command files actually exist on disk."""
manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml")
for tmpl in manifest.templates:
tmpl_path = LEAN_PRESET_DIR / tmpl["file"]
assert tmpl_path.exists(), f"Missing command file: {tmpl['file']}"
def test_lean_commands_have_no_scripts(self):
"""Verify lean commands have no scripts in frontmatter."""
from specify_cli.agents import CommandRegistrar
for name in LEAN_COMMAND_NAMES:
cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md"
content = cmd_path.read_text()
frontmatter, _ = CommandRegistrar.parse_frontmatter(content)
assert "scripts" not in frontmatter, f"{name} should not have scripts in frontmatter"
def test_lean_commands_have_no_hooks(self):
"""Verify lean commands do not contain extension hook boilerplate."""
for name in LEAN_COMMAND_NAMES:
cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md"
content = cmd_path.read_text()
assert "hooks." not in content, f"{name} should not reference extension hooks"
assert "extensions.yml" not in content, f"{name} should not reference extensions.yml"
def test_install_lean_preset(self, project_dir):
"""Test installing the lean preset from its directory."""
manager = PresetManager(project_dir)
manifest = manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0")
assert manifest.id == "lean"
assert manager.registry.is_installed("lean")
def test_lean_overrides_commands(self, project_dir):
"""Test that lean preset overrides are resolved correctly."""
manager = PresetManager(project_dir)
manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0")
resolver = PresetResolver(project_dir)
for name in LEAN_COMMAND_NAMES:
result = resolver.resolve(name, template_type="command")
assert result is not None, f"Lean override for {name} not resolved"
# ===== Bundled Preset Locator Tests =====
class TestBundledPresetLocator:
"""Tests for _locate_bundled_preset discovery function."""
def test_locate_bundled_lean_preset(self):
"""_locate_bundled_preset finds the lean preset."""
from specify_cli import _locate_bundled_preset
path = _locate_bundled_preset("lean")
assert path is not None
assert (path / "preset.yml").is_file()
def test_locate_bundled_preset_not_found(self):
"""_locate_bundled_preset returns None for nonexistent preset."""
from specify_cli import _locate_bundled_preset
path = _locate_bundled_preset("nonexistent-preset")
assert path is None
def test_locate_bundled_preset_rejects_invalid_id(self):
"""_locate_bundled_preset rejects IDs with invalid characters."""
from specify_cli import _locate_bundled_preset
assert _locate_bundled_preset("../escape") is None
assert _locate_bundled_preset("UPPERCASE") is None
assert _locate_bundled_preset("has spaces") is None
def test_bundled_preset_add_via_cli(self, project_dir):
"""Test that 'specify preset add lean' installs the bundled preset."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("specify_cli.get_speckit_version", return_value="0.6.0"):
result = runner.invoke(app, ["preset", "add", "lean"])
assert result.exit_code == 0, result.output
assert "Lean Workflow" in result.output
assert "installed" in result.output.lower()
def test_bundled_preset_in_catalog(self):
"""Verify the lean preset is listed in catalog.json with bundled marker."""
catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json"
catalog = json.loads(catalog_path.read_text())
assert "lean" in catalog["presets"]
assert catalog["presets"]["lean"]["bundled"] is True
assert "download_url" not in catalog["presets"]["lean"]
def test_bundled_preset_download_raises_error(self, project_dir):
"""download_pack raises PresetError for bundled presets without download_url."""
catalog = PresetCatalog(project_dir)
catalog_data = {
"test-bundled": {
"name": "Test Bundled",
"version": "1.0.0",
"bundled": True,
}
}
from unittest.mock import patch
with patch.object(catalog, "_get_merged_packs", return_value=catalog_data):
with pytest.raises(PresetError, match="bundled with spec-kit"):
catalog.download_pack("test-bundled")
def test_bundled_preset_missing_locally_cli_error(self, project_dir):
"""CLI shows clear error when bundled preset cannot be found locally."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Patch _locate_bundled_preset to return None (simulating missing files)
# and mock the catalog to return a bundled entry for "lean"
fake_pack_info = {
"id": "lean",
"name": "Lean Workflow",
"version": "1.0.0",
"bundled": True,
"_install_allowed": True,
}
with patch.object(Path, "cwd", return_value=project_dir), \
patch("specify_cli._locate_bundled_preset", return_value=None), \
patch("specify_cli.presets.PresetCatalog") as MockCatalog:
MockCatalog.return_value.get_pack_info.return_value = fake_pack_info
result = runner.invoke(app, ["preset", "add", "lean"])
# Should fail with a helpful error explaining this is a bundled preset
# and suggesting how to recover.
assert result.exit_code == 1
output = strip_ansi(result.output).lower()
assert "bundled" in output, result.output
assert "reinstall" in output, result.output
class TestWrapStrategy:
"""Tests for strategy: wrap preset command substitution."""
def test_substitute_core_template_replaces_placeholder(self, project_dir):
"""Core template body replaces {CORE_TEMPLATE} in preset command body."""
from specify_cli.presets import _substitute_core_template
from specify_cli.agents import CommandRegistrar
# Set up a core command template
core_dir = project_dir / ".specify" / "templates" / "commands"
core_dir.mkdir(parents=True, exist_ok=True)
(core_dir / "specify.md").write_text(
"---\ndescription: core\n---\n\n# Core Specify\n\nDo the thing.\n"
)
registrar = CommandRegistrar()
body = "## Pre-Logic\n\nBefore stuff.\n\n{CORE_TEMPLATE}\n\n## Post-Logic\n\nAfter stuff.\n"
result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar)
assert "{CORE_TEMPLATE}" not in result
assert "# Core Specify" in result
assert "## Pre-Logic" in result
assert "## Post-Logic" in result
assert core_fm.get("description") == "core"
def test_substitute_core_template_no_op_when_placeholder_absent(self, project_dir):
"""Returns body unchanged when {CORE_TEMPLATE} is not present."""
from specify_cli.presets import _substitute_core_template
from specify_cli.agents import CommandRegistrar
core_dir = project_dir / ".specify" / "templates" / "commands"
core_dir.mkdir(parents=True, exist_ok=True)
(core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCore body.\n")
registrar = CommandRegistrar()
body = "## No placeholder here.\n"
result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar)
assert result == body
assert core_fm == {}
def test_substitute_core_template_no_op_when_core_missing(self, project_dir):
"""Returns body unchanged when core template file does not exist."""
from specify_cli.presets import _substitute_core_template
from specify_cli.agents import CommandRegistrar
registrar = CommandRegistrar()
body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n"
result, core_fm = _substitute_core_template(body, "nonexistent", project_dir, registrar)
assert result == body
assert "{CORE_TEMPLATE}" in result
assert core_fm == {}
def test_register_commands_substitutes_core_template_for_wrap_strategy(self, project_dir):
"""register_commands substitutes {CORE_TEMPLATE} when strategy: wrap."""
from specify_cli.agents import CommandRegistrar
# Set up core command template
core_dir = project_dir / ".specify" / "templates" / "commands"
core_dir.mkdir(parents=True, exist_ok=True)
(core_dir / "specify.md").write_text(
"---\ndescription: core\n---\n\n# Core Specify\n\nCore body here.\n"
)
# Create a preset command dir with a wrap-strategy command
cmd_dir = project_dir / "preset" / "commands"
cmd_dir.mkdir(parents=True, exist_ok=True)
(cmd_dir / "speckit.specify.md").write_text(
"---\ndescription: wrap test\nstrategy: wrap\n---\n\n"
"## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n"
)
commands = [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}]
registrar = CommandRegistrar()
# Use a generic agent that writes markdown to commands/
agent_dir = project_dir / ".claude" / "commands"
agent_dir.mkdir(parents=True, exist_ok=True)
# Patch AGENT_CONFIGS to use a simple markdown agent pointing at our dir
import copy
original = copy.deepcopy(registrar.AGENT_CONFIGS)
registrar.AGENT_CONFIGS["test-agent"] = {
"dir": str(agent_dir.relative_to(project_dir)),
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
"strip_frontmatter_keys": [],
}
try:
registrar.register_commands(
"test-agent", commands, "test-preset",
project_dir / "preset", project_dir
)
finally:
CommandRegistrar.AGENT_CONFIGS.clear()
CommandRegistrar.AGENT_CONFIGS.update(original)
written = (agent_dir / "speckit.specify.md").read_text()
assert "{CORE_TEMPLATE}" not in written
assert "# Core Specify" in written
assert "## Pre" in written
assert "## Post" in written
def test_end_to_end_wrap_via_self_test_preset(self, project_dir):
"""Installing self-test preset with a wrap command substitutes {CORE_TEMPLATE}."""
from specify_cli.presets import PresetManager
# Install a core template that wrap-test will wrap around
core_dir = project_dir / ".specify" / "templates" / "commands"
core_dir.mkdir(parents=True, exist_ok=True)
(core_dir / "wrap-test.md").write_text(
"---\ndescription: core wrap-test\n---\n\n# Core Wrap-Test Body\n"
)
# Set up skills dir (simulating --ai claude)
skills_dir = project_dir / ".claude" / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
skill_subdir = skills_dir / "speckit-wrap-test"
skill_subdir.mkdir()
(skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold content\n")
# Write init-options so _register_skills finds the claude skills dir
import json
(project_dir / ".specify" / "init-options.json").write_text(
json.dumps({"ai": "claude", "ai_skills": True})
)
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
written = (skill_subdir / "SKILL.md").read_text()
assert "{CORE_TEMPLATE}" not in written
assert "# Core Wrap-Test Body" in written
assert "preset:self-test wrap-pre" in written
assert "preset:self-test wrap-post" in written
def test_substitute_core_template_returns_core_scripts(self, project_dir):
"""core_frontmatter in the returned tuple includes scripts/agent_scripts."""
from specify_cli.presets import _substitute_core_template
from specify_cli.agents import CommandRegistrar
core_dir = project_dir / ".specify" / "templates" / "commands"
core_dir.mkdir(parents=True, exist_ok=True)
(core_dir / "specify.md").write_text(
"---\ndescription: core\nscripts:\n sh: run.sh\nagent_scripts:\n sh: agent-run.sh\n---\n\n# Body\n"
)
registrar = CommandRegistrar()
body = "## Wrapper\n\n{CORE_TEMPLATE}\n"
result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar)
assert "# Body" in result
assert core_fm.get("scripts") == {"sh": "run.sh"}
assert core_fm.get("agent_scripts") == {"sh": "agent-run.sh"}
def test_register_skills_inherits_scripts_from_core_when_preset_omits_them(self, project_dir):
"""_register_skills merges scripts/agent_scripts from core when preset lacks them."""
from specify_cli.presets import PresetManager
import json
# Core template with scripts
core_dir = project_dir / ".specify" / "templates" / "commands"
core_dir.mkdir(parents=True, exist_ok=True)
(core_dir / "wrap-test.md").write_text(
"---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh\n---\n\n"
"Run: {SCRIPT}\n"
)
# Skills dir for claude
skills_dir = project_dir / ".claude" / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
skill_subdir = skills_dir / "speckit-wrap-test"
skill_subdir.mkdir()
(skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold\n")
(project_dir / ".specify" / "init-options.json").write_text(
json.dumps({"ai": "claude", "ai_skills": True})
)
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
written = (skill_subdir / "SKILL.md").read_text()
# {SCRIPT} should have been resolved (not left as a literal placeholder)
assert "{SCRIPT}" not in written
def test_register_skills_preset_scripts_take_precedence_over_core(self, project_dir):
"""preset-defined scripts/agent_scripts are not overwritten by core frontmatter."""
from specify_cli.presets import _substitute_core_template
from specify_cli.agents import CommandRegistrar
core_dir = project_dir / ".specify" / "templates" / "commands"
core_dir.mkdir(parents=True, exist_ok=True)
(core_dir / "specify.md").write_text(
"---\ndescription: core\nscripts:\n sh: core-run.sh\n---\n\nCore body.\n"
)
registrar = CommandRegistrar()
body = "{CORE_TEMPLATE}"
_, core_fm = _substitute_core_template(body, "specify", project_dir, registrar)
# Simulate preset frontmatter that already defines scripts
preset_fm = {"description": "preset", "strategy": "wrap", "scripts": {"sh": "preset-run.sh"}}
for key in ("scripts", "agent_scripts"):
if key not in preset_fm and key in core_fm:
preset_fm[key] = core_fm[key]
# Preset's scripts must not be overwritten by core
assert preset_fm["scripts"] == {"sh": "preset-run.sh"}
def test_register_commands_inherits_scripts_from_core(self, project_dir):
"""register_commands merges scripts/agent_scripts from core and normalizes paths."""
from specify_cli.agents import CommandRegistrar
import copy
core_dir = project_dir / ".specify" / "templates" / "commands"
core_dir.mkdir(parents=True, exist_ok=True)
(core_dir / "specify.md").write_text(
"---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n"
"Run: {SCRIPT}\n"
)
cmd_dir = project_dir / "preset" / "commands"
cmd_dir.mkdir(parents=True, exist_ok=True)
# Preset has strategy: wrap but no scripts of its own
(cmd_dir / "speckit.specify.md").write_text(
"---\ndescription: wrap no scripts\nstrategy: wrap\n---\n\n"
"## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n"
)
agent_dir = project_dir / ".claude" / "commands"
agent_dir.mkdir(parents=True, exist_ok=True)
registrar = CommandRegistrar()
original = copy.deepcopy(registrar.AGENT_CONFIGS)
registrar.AGENT_CONFIGS["test-agent"] = {
"dir": str(agent_dir.relative_to(project_dir)),
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
"strip_frontmatter_keys": [],
}
try:
registrar.register_commands(
"test-agent",
[{"name": "speckit.specify", "file": "commands/speckit.specify.md"}],
"test-preset",
project_dir / "preset",
project_dir,
)
finally:
CommandRegistrar.AGENT_CONFIGS.clear()
CommandRegistrar.AGENT_CONFIGS.update(original)
written = (agent_dir / "speckit.specify.md").read_text()
assert "{CORE_TEMPLATE}" not in written
assert "Run:" in written
assert "scripts:" in written
assert "run.sh" in written
def test_register_commands_toml_resolves_inherited_scripts(self, project_dir):
"""TOML agents resolve {SCRIPT} from inherited core scripts when preset omits them."""
from specify_cli.agents import CommandRegistrar
import copy
core_dir = project_dir / ".specify" / "templates" / "commands"
core_dir.mkdir(parents=True, exist_ok=True)
(core_dir / "specify.md").write_text(
"---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n"
"Run: {SCRIPT}\n"
)
cmd_dir = project_dir / "preset" / "commands"
cmd_dir.mkdir(parents=True, exist_ok=True)
(cmd_dir / "speckit.specify.md").write_text(
"---\ndescription: toml wrap\nstrategy: wrap\n---\n\n"
"## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n"
)
toml_dir = project_dir / ".gemini" / "commands"
toml_dir.mkdir(parents=True, exist_ok=True)
registrar = CommandRegistrar()
original = copy.deepcopy(registrar.AGENT_CONFIGS)
registrar.AGENT_CONFIGS["test-toml-agent"] = {
"dir": str(toml_dir.relative_to(project_dir)),
"format": "toml",
"args": "{{args}}",
"extension": ".toml",
"strip_frontmatter_keys": [],
}
try:
registrar.register_commands(
"test-toml-agent",
[{"name": "speckit.specify", "file": "commands/speckit.specify.md"}],
"test-preset",
project_dir / "preset",
project_dir,
)
finally:
CommandRegistrar.AGENT_CONFIGS.clear()
CommandRegistrar.AGENT_CONFIGS.update(original)
written = (toml_dir / "speckit.specify.toml").read_text()
assert "{CORE_TEMPLATE}" not in written
assert "{SCRIPT}" not in written
assert "run.sh" in written
# args token must use TOML format, not the intermediate $ARGUMENTS
assert "$ARGUMENTS" not in written
assert "{{args}}" in written
def test_register_commands_markdown_resolves_inherited_scripts(self, project_dir):
"""Markdown agents resolve {SCRIPT} from inherited core scripts when preset omits them."""
from specify_cli.agents import CommandRegistrar
import copy
core_dir = project_dir / ".specify" / "templates" / "commands"
core_dir.mkdir(parents=True, exist_ok=True)
(core_dir / "specify.md").write_text(
"---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n"
"Run: {SCRIPT}\n"
)
cmd_dir = project_dir / "preset" / "commands"
cmd_dir.mkdir(parents=True, exist_ok=True)
(cmd_dir / "speckit.specify.md").write_text(
"---\ndescription: markdown wrap\nstrategy: wrap\n---\n\n"
"## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n"
)
agent_dir = project_dir / ".claude" / "commands"
agent_dir.mkdir(parents=True, exist_ok=True)
registrar = CommandRegistrar()
original = copy.deepcopy(registrar.AGENT_CONFIGS)
registrar.AGENT_CONFIGS["test-md-agent"] = {
"dir": str(agent_dir.relative_to(project_dir)),
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
"strip_frontmatter_keys": [],
}
try:
registrar.register_commands(
"test-md-agent",
[{"name": "speckit.specify", "file": "commands/speckit.specify.md"}],
"test-preset",
project_dir / "preset",
project_dir,
)
finally:
CommandRegistrar.AGENT_CONFIGS.clear()
CommandRegistrar.AGENT_CONFIGS.update(original)
written = (agent_dir / "speckit.specify.md").read_text()
assert "{CORE_TEMPLATE}" not in written
assert "{SCRIPT}" not in written
assert "run.sh" in written
assert "strategy" not in written
def test_register_commands_markdown_converts_args_after_script_resolution(self, project_dir):
"""Markdown agents re-run arg placeholder conversion after resolve_skill_placeholders.
resolve_skill_placeholders injects $ARGUMENTS (via {ARGS} expansion). A second
_convert_argument_placeholder call must convert those to the agent's native format.
"""
from specify_cli.agents import CommandRegistrar
import copy
core_dir = project_dir / ".specify" / "templates" / "commands"
core_dir.mkdir(parents=True, exist_ok=True)
(core_dir / "specify.md").write_text(
"---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n"
"Run: {SCRIPT}\n"
)
cmd_dir = project_dir / "preset" / "commands"
cmd_dir.mkdir(parents=True, exist_ok=True)
(cmd_dir / "speckit.specify.md").write_text(
"---\ndescription: forge wrap\nstrategy: wrap\n---\n\n"
"## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n"
)
agent_dir = project_dir / ".forge" / "commands"
agent_dir.mkdir(parents=True, exist_ok=True)
registrar = CommandRegistrar()
original = copy.deepcopy(registrar.AGENT_CONFIGS)
registrar.AGENT_CONFIGS["test-forge-agent"] = {
"dir": str(agent_dir.relative_to(project_dir)),
"format": "markdown",
"args": "{{parameters}}",
"extension": ".md",
"strip_frontmatter_keys": [],
}
try:
registrar.register_commands(
"test-forge-agent",
[{"name": "speckit.specify", "file": "commands/speckit.specify.md"}],
"test-preset",
project_dir / "preset",
project_dir,
)
finally:
CommandRegistrar.AGENT_CONFIGS.clear()
CommandRegistrar.AGENT_CONFIGS.update(original)
written = (agent_dir / "speckit.specify.md").read_text()
assert "{SCRIPT}" not in written
assert "run.sh" in written
# $ARGUMENTS injected by resolve_skill_placeholders must be re-converted
assert "$ARGUMENTS" not in written
assert "{{parameters}}" in written
def test_extension_command_resolves_via_extension_directory(self, project_dir):
"""Extension commands (e.g. speckit.git.feature) resolve from the extension directory.
Both _register_skills and register_commands pass the full cmd_name to
_substitute_core_template, which tries the full name first via PresetResolver
and finds speckit.git.feature.md in the extension commands directory.
"""
from specify_cli.presets import _substitute_core_template
from specify_cli.agents import CommandRegistrar
# Place the template where a real extension would install it
ext_cmd_dir = project_dir / ".specify" / "extensions" / "git" / "commands"
ext_cmd_dir.mkdir(parents=True, exist_ok=True)
(ext_cmd_dir / "speckit.git.feature.md").write_text(
"---\ndescription: git feature core\n---\n\n# Git Feature Core\n"
)
# Ensure a hyphenated or dot-separated fallback does NOT exist
assert not (project_dir / ".specify" / "templates" / "commands" / "git.feature.md").exists()
assert not (project_dir / ".specify" / "templates" / "commands" / "git-feature.md").exists()
registrar = CommandRegistrar()
body = "## Wrapper\n\n{CORE_TEMPLATE}\n"
# Both call sites now pass the full cmd_name
result, _ = _substitute_core_template(body, "speckit.git.feature", project_dir, registrar)
assert "# Git Feature Core" in result
assert "{CORE_TEMPLATE}" not in result
def test_extension_command_resolves_via_manifest_when_filename_differs(self, project_dir):
"""Extension commands whose filename differs from the command name resolve via extension.yml.
The selftest extension maps speckit.selftest.extension → commands/selftest.md.
Name-based lookup would look for commands/speckit.selftest.extension.md and fail;
manifest-based lookup must find the actual file declared in the manifest.
"""
from specify_cli.presets import _substitute_core_template
from specify_cli.agents import CommandRegistrar
ext_dir = project_dir / ".specify" / "extensions" / "selftest"
cmd_dir = ext_dir / "commands"
cmd_dir.mkdir(parents=True, exist_ok=True)
# File is named selftest.md, NOT speckit.selftest.extension.md
(cmd_dir / "selftest.md").write_text(
"---\ndescription: selftest core\n---\n\n# Selftest Core\n"
)
# Manifest maps the command name to the actual file
(ext_dir / "extension.yml").write_text(
"schema_version: '1.0'\n"
"extension:\n id: selftest\n name: Self-Test\n version: 1.0.0\n"
" description: test\n author: test\n repository: https://example.com\n"
" license: MIT\n"
"requires:\n speckit_version: '>=0.2.0'\n"
"provides:\n"
" commands:\n"
" - name: speckit.selftest.extension\n"
" file: commands/selftest.md\n"
" description: Selftest command\n"
)
registrar = CommandRegistrar()
body = "## Wrapper\n\n{CORE_TEMPLATE}\n"
result, _ = _substitute_core_template(body, "speckit.selftest.extension", project_dir, registrar)
assert "# Selftest Core" in result
assert "{CORE_TEMPLATE}" not in result
# ===== _replay_wraps_for_command Tests =====
def _make_wrap_preset_dir(
base: Path,
preset_id: str,
cmd_name: str,
pre: str,
post: str,
aliases: list[str] | None = None,
file_rel: str | None = None,
) -> Path:
"""Create a minimal wrap-strategy preset directory for testing."""
preset_dir = base / preset_id
cmd_dir = preset_dir / "commands"
cmd_dir.mkdir(parents=True)
file_rel = file_rel or f"commands/{cmd_name}.md"
template = {
"type": "command",
"name": cmd_name,
"file": file_rel,
"description": f"{preset_id} wrap",
}
if aliases is not None:
template["aliases"] = aliases
manifest = {
"schema_version": "1.0",
"preset": {
"id": preset_id,
"name": preset_id,
"version": "1.0.0",
"description": f"Preset {preset_id}",
"author": "test",
"repository": "https://example.com",
"license": "MIT",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [template]
},
"tags": [],
}
import yaml as _yaml
(preset_dir / "preset.yml").write_text(_yaml.dump(manifest))
command_path = preset_dir / file_rel
command_path.parent.mkdir(parents=True, exist_ok=True)
command_path.write_text(
f"---\ndescription: {preset_id} wrap\nstrategy: wrap\n---\n\n"
f"[{pre}]\n\n{{CORE_TEMPLATE}}\n\n[{post}]\n"
)
return preset_dir
class TestCompositionStrategyValidation:
"""Test strategy field validation in PresetManifest."""
def test_valid_replace_strategy(self, temp_dir, valid_pack_data):
"""Test that replace strategy is accepted."""
valid_pack_data["provides"]["templates"][0]["strategy"] = "replace"
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
(temp_dir / "templates").mkdir(exist_ok=True)
(temp_dir / "templates" / "spec-template.md").write_text("test")
manifest = PresetManifest(manifest_path)
assert manifest.templates[0]["strategy"] == "replace"
def test_valid_prepend_strategy(self, temp_dir, valid_pack_data):
"""Test that prepend strategy is accepted for templates."""
valid_pack_data["provides"]["templates"][0]["strategy"] = "prepend"
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
(temp_dir / "templates").mkdir(exist_ok=True)
(temp_dir / "templates" / "spec-template.md").write_text("test")
manifest = PresetManifest(manifest_path)
assert manifest.templates[0]["strategy"] == "prepend"
def test_valid_append_strategy(self, temp_dir, valid_pack_data):
"""Test that append strategy is accepted for templates."""
valid_pack_data["provides"]["templates"][0]["strategy"] = "append"
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
(temp_dir / "templates").mkdir(exist_ok=True)
(temp_dir / "templates" / "spec-template.md").write_text("test")
manifest = PresetManifest(manifest_path)
assert manifest.templates[0]["strategy"] == "append"
def test_valid_wrap_strategy(self, temp_dir, valid_pack_data):
"""Test that wrap strategy is accepted for templates."""
valid_pack_data["provides"]["templates"][0]["strategy"] = "wrap"
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
(temp_dir / "templates").mkdir(exist_ok=True)
(temp_dir / "templates" / "spec-template.md").write_text("test")
manifest = PresetManifest(manifest_path)
assert manifest.templates[0]["strategy"] == "wrap"
def test_default_strategy_is_replace(self, pack_dir):
"""Test that omitting strategy defaults to replace (key is absent)."""
manifest = PresetManifest(pack_dir / "preset.yml")
# Strategy key should not be present in the manifest data
assert "strategy" not in manifest.templates[0]
# But consumers should treat missing strategy as "replace"
assert manifest.templates[0].get("strategy", "replace") == "replace"
def test_invalid_strategy_rejected(self, temp_dir, valid_pack_data):
"""Test that invalid strategy values are rejected."""
valid_pack_data["provides"]["templates"][0]["strategy"] = "merge"
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(PresetValidationError, match="Invalid strategy"):
PresetManifest(manifest_path)
def test_prepend_rejected_for_scripts(self, temp_dir, valid_pack_data):
"""Test that prepend strategy is rejected for scripts."""
valid_pack_data["provides"]["templates"] = [{
"type": "script",
"name": "create-new-feature",
"file": "scripts/create-new-feature.sh",
"strategy": "prepend",
}]
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(PresetValidationError, match="Invalid strategy.*for script"):
PresetManifest(manifest_path)
def test_append_rejected_for_scripts(self, temp_dir, valid_pack_data):
"""Test that append strategy is rejected for scripts."""
valid_pack_data["provides"]["templates"] = [{
"type": "script",
"name": "create-new-feature",
"file": "scripts/create-new-feature.sh",
"strategy": "append",
}]
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
with pytest.raises(PresetValidationError, match="Invalid strategy.*for script"):
PresetManifest(manifest_path)
def test_wrap_accepted_for_scripts(self, temp_dir, valid_pack_data):
"""Test that wrap strategy is accepted for scripts."""
valid_pack_data["provides"]["templates"] = [{
"type": "script",
"name": "create-new-feature",
"file": "scripts/create-new-feature.sh",
"strategy": "wrap",
}]
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
manifest = PresetManifest(manifest_path)
assert manifest.templates[0]["strategy"] == "wrap"
def test_replace_accepted_for_scripts(self, temp_dir, valid_pack_data):
"""Test that replace strategy is accepted for scripts."""
valid_pack_data["provides"]["templates"] = [{
"type": "script",
"name": "create-new-feature",
"file": "scripts/create-new-feature.sh",
"strategy": "replace",
}]
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
manifest = PresetManifest(manifest_path)
assert manifest.templates[0]["strategy"] == "replace"
def test_prepend_accepted_for_commands(self, temp_dir, valid_pack_data):
"""Test that prepend strategy is accepted for commands."""
valid_pack_data["provides"]["templates"] = [{
"type": "command",
"name": "speckit.specify",
"file": "commands/speckit.specify.md",
"strategy": "prepend",
}]
manifest_path = temp_dir / "preset.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_pack_data, f)
manifest = PresetManifest(manifest_path)
assert manifest.templates[0]["strategy"] == "prepend"
class TestResolveContent:
"""Test PresetResolver.resolve_content() composition."""
def test_resolve_content_core_template(self, project_dir):
"""Test resolve_content returns core template when no composition."""
resolver = PresetResolver(project_dir)
content = resolver.resolve_content("spec-template")
assert content is not None
assert "Core Spec Template" in content
def test_resolve_content_nonexistent(self, project_dir):
"""Test resolve_content returns None for nonexistent template."""
resolver = PresetResolver(project_dir)
content = resolver.resolve_content("nonexistent")
assert content is None
def test_resolve_content_replace_strategy(self, project_dir, temp_dir, valid_pack_data):
"""Test resolve_content with default replace strategy."""
manager = PresetManager(project_dir)
manager.install_from_directory(
_create_pack(temp_dir, valid_pack_data, "replace-pack",
"# Replaced Content\n"),
"0.1.5"
)
resolver = PresetResolver(project_dir)
content = resolver.resolve_content("spec-template")
assert content is not None
assert "Replaced Content" in content
assert "Core Spec Template" not in content
def test_resolve_content_append_strategy(self, project_dir, temp_dir, valid_pack_data):
"""Test resolve_content with append strategy."""
pack_data = {**valid_pack_data}
pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"}
pack_data["provides"] = {
"templates": [{
"type": "template",
"name": "spec-template",
"file": "templates/spec-template.md",
"strategy": "append",
}]
}
pack_dir = temp_dir / "append-pack"
pack_dir.mkdir()
with open(pack_dir / "preset.yml", 'w') as f:
yaml.dump(pack_data, f)
(pack_dir / "templates").mkdir()
(pack_dir / "templates" / "spec-template.md").write_text("## Appended Section\n")
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
resolver = PresetResolver(project_dir)
content = resolver.resolve_content("spec-template")
assert content is not None
assert "Core Spec Template" in content
assert "Appended Section" in content
# Core should come first, appended after
assert content.index("Core Spec Template") < content.index("Appended Section")
def test_resolve_content_prepend_strategy(self, project_dir, temp_dir, valid_pack_data):
"""Test resolve_content with prepend strategy."""
pack_data = {**valid_pack_data}
pack_data["preset"] = {**valid_pack_data["preset"], "id": "prepend-pack", "name": "Prepend"}
pack_data["provides"] = {
"templates": [{
"type": "template",
"name": "spec-template",
"file": "templates/spec-template.md",
"strategy": "prepend",
}]
}
pack_dir = temp_dir / "prepend-pack"
pack_dir.mkdir()
with open(pack_dir / "preset.yml", 'w') as f:
yaml.dump(pack_data, f)
(pack_dir / "templates").mkdir()
(pack_dir / "templates" / "spec-template.md").write_text("## Security Header\n")
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
resolver = PresetResolver(project_dir)
content = resolver.resolve_content("spec-template")
assert content is not None
assert "Security Header" in content
assert "Core Spec Template" in content
# Prepended content should come first
assert content.index("Security Header") < content.index("Core Spec Template")
def test_resolve_content_wrap_strategy(self, project_dir, temp_dir, valid_pack_data):
"""Test resolve_content with wrap strategy for templates."""
pack_data = {**valid_pack_data}
pack_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-pack", "name": "Wrap"}
pack_data["provides"] = {
"templates": [{
"type": "template",
"name": "spec-template",
"file": "templates/spec-template.md",
"strategy": "wrap",
}]
}
pack_dir = temp_dir / "wrap-pack"
pack_dir.mkdir()
with open(pack_dir / "preset.yml", 'w') as f:
yaml.dump(pack_data, f)
(pack_dir / "templates").mkdir()
(pack_dir / "templates" / "spec-template.md").write_text(
"# Wrapper Start\n\n{CORE_TEMPLATE}\n\n# Wrapper End\n"
)
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
resolver = PresetResolver(project_dir)
content = resolver.resolve_content("spec-template")
assert content is not None
assert "Wrapper Start" in content
assert "Core Spec Template" in content
assert "Wrapper End" in content
# Wrapper should surround core
assert content.index("Wrapper Start") < content.index("Core Spec Template")
assert content.index("Core Spec Template") < content.index("Wrapper End")
def test_resolve_content_wrap_strategy_script(self, project_dir, temp_dir, valid_pack_data):
"""Test resolve_content with wrap strategy for scripts uses $CORE_SCRIPT."""
# Create core script
scripts_dir = project_dir / ".specify" / "templates" / "scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
(scripts_dir / "test-script.sh").write_text("echo 'core script'\n")
pack_data = {**valid_pack_data}
pack_data["preset"] = {**valid_pack_data["preset"], "id": "script-wrap", "name": "Script Wrap"}
pack_data["provides"] = {
"templates": [{
"type": "script",
"name": "test-script",
"file": "scripts/test-script.sh",
"strategy": "wrap",
}]
}
pack_dir = temp_dir / "script-wrap"
pack_dir.mkdir()
with open(pack_dir / "preset.yml", 'w') as f:
yaml.dump(pack_data, f)
(pack_dir / "scripts").mkdir()
(pack_dir / "scripts" / "test-script.sh").write_text(
"#!/bin/bash\necho 'before'\n$CORE_SCRIPT\necho 'after'\n"
)
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
resolver = PresetResolver(project_dir)
content = resolver.resolve_content("test-script", "script")
assert content is not None
assert "echo 'before'" in content
assert "echo 'core script'" in content
assert "echo 'after'" in content
def test_resolve_content_multi_preset_chain(self, project_dir, temp_dir, valid_pack_data):
"""Test multi-preset composition chain: prepend + append stacking."""
# Create preset A (priority 1): prepend security header
pack_a_data = {**valid_pack_data}
pack_a_data["preset"] = {**valid_pack_data["preset"], "id": "preset-a", "name": "A"}
pack_a_data["provides"] = {
"templates": [{
"type": "template",
"name": "spec-template",
"file": "templates/spec-template.md",
"strategy": "prepend",
}]
}
pack_a_dir = temp_dir / "preset-a"
pack_a_dir.mkdir()
with open(pack_a_dir / "preset.yml", 'w') as f:
yaml.dump(pack_a_data, f)
(pack_a_dir / "templates").mkdir()
(pack_a_dir / "templates" / "spec-template.md").write_text("## Security Header\n")
# Create preset B (priority 2): append compliance footer
pack_b_data = {**valid_pack_data}
pack_b_data["preset"] = {**valid_pack_data["preset"], "id": "preset-b", "name": "B"}
pack_b_data["provides"] = {
"templates": [{
"type": "template",
"name": "spec-template",
"file": "templates/spec-template.md",
"strategy": "append",
}]
}
pack_b_dir = temp_dir / "preset-b"
pack_b_dir.mkdir()
with open(pack_b_dir / "preset.yml", 'w') as f:
yaml.dump(pack_b_data, f)
(pack_b_dir / "templates").mkdir()
(pack_b_dir / "templates" / "spec-template.md").write_text("## Compliance Footer\n")
manager = PresetManager(project_dir)
manager.install_from_directory(pack_a_dir, "0.1.5", priority=1)
manager.install_from_directory(pack_b_dir, "0.1.5", priority=2)
resolver = PresetResolver(project_dir)
content = resolver.resolve_content("spec-template")
assert content is not None
# Result: <security header> + <core> + <compliance footer>
assert "Security Header" in content
assert "Core Spec Template" in content
assert "Compliance Footer" in content
assert content.index("Security Header") < content.index("Core Spec Template")
assert content.index("Core Spec Template") < content.index("Compliance Footer")
def test_resolve_content_override_trumps_composition(self, project_dir, temp_dir, valid_pack_data):
"""Test that project overrides trump composition (replace at top priority)."""
# Install a composing preset
pack_data = {**valid_pack_data}
pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"}
pack_data["provides"] = {
"templates": [{
"type": "template",
"name": "spec-template",
"file": "templates/spec-template.md",
"strategy": "append",
}]
}
pack_dir = temp_dir / "append-pack"
pack_dir.mkdir()
with open(pack_dir / "preset.yml", 'w') as f:
yaml.dump(pack_data, f)
(pack_dir / "templates").mkdir()
(pack_dir / "templates" / "spec-template.md").write_text("## Appended\n")
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
# Create project override (replaces everything)
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True)
(overrides_dir / "spec-template.md").write_text("# Override Only\n")
resolver = PresetResolver(project_dir)
content = resolver.resolve_content("spec-template")
assert content is not None
assert "Override Only" in content
# Override replaces, so appended content should not be visible
assert "Core Spec Template" not in content
def test_resolve_content_command_type(self, project_dir, temp_dir, valid_pack_data):
"""Test resolve_content with command template type."""
# Create core command using stem naming (matches real layout: plan.md, not speckit.plan.md)
commands_dir = project_dir / ".specify" / "templates" / "commands"
commands_dir.mkdir(parents=True, exist_ok=True)
(commands_dir / "plan.md").write_text("# Core Plan Command\n")
pack_data = {**valid_pack_data}
pack_data["preset"] = {**valid_pack_data["preset"], "id": "cmd-append", "name": "CmdAppend"}
pack_data["provides"] = {
"templates": [{
"type": "command",
"name": "speckit.plan",
"file": "commands/speckit.plan.md",
"strategy": "append",
}]
}
pack_dir = temp_dir / "cmd-append"
pack_dir.mkdir()
with open(pack_dir / "preset.yml", 'w') as f:
yaml.dump(pack_data, f)
(pack_dir / "commands").mkdir()
(pack_dir / "commands" / "speckit.plan.md").write_text("## Additional Instructions\n")
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
resolver = PresetResolver(project_dir)
content = resolver.resolve_content("speckit.plan", "command")
assert content is not None
assert "Core Plan Command" in content
assert "Additional Instructions" in content
def test_resolve_content_command_frontmatter_stripping(self, project_dir, temp_dir, valid_pack_data):
"""Test that command composition strips frontmatter from lower layers
and reattaches only the highest-priority frontmatter."""
# Create core command with frontmatter
commands_dir = project_dir / ".specify" / "templates" / "commands"
commands_dir.mkdir(parents=True, exist_ok=True)
(commands_dir / "check.md").write_text(
"---\ndescription: Core check command\n---\nCore body content\n"
)
pack_data = {**valid_pack_data}
pack_data["preset"] = {**valid_pack_data["preset"], "id": "fm-test", "name": "FmTest"}
pack_data["provides"] = {
"templates": [{
"type": "command",
"name": "speckit.check",
"file": "commands/speckit.check.md",
"strategy": "append",
}]
}
pack_dir = temp_dir / "fm-test"
pack_dir.mkdir()
with open(pack_dir / "preset.yml", 'w') as f:
yaml.dump(pack_data, f)
(pack_dir / "commands").mkdir()
(pack_dir / "commands" / "speckit.check.md").write_text(
"---\ndescription: Preset check override\n---\nPreset body content\n"
)
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
resolver = PresetResolver(project_dir)
content = resolver.resolve_content("speckit.check", "command")
assert content is not None
# Should have the preset (highest-priority) frontmatter
assert "Preset check override" in content
# Should have both bodies
assert "Core body content" in content
assert "Preset body content" in content
# Core frontmatter should NOT appear in the body
assert content.count("---") == 2 # only one frontmatter block (opening + closing)
def test_resolve_content_blank_line_separator(self, project_dir, temp_dir, valid_pack_data):
"""Test that prepend/append use blank line separator."""
pack_data = {**valid_pack_data}
pack_data["preset"] = {**valid_pack_data["preset"], "id": "sep-test", "name": "SepTest"}
pack_data["provides"] = {
"templates": [{
"type": "template",
"name": "spec-template",
"file": "templates/spec-template.md",
"strategy": "append",
}]
}
pack_dir = temp_dir / "sep-test"
pack_dir.mkdir()
with open(pack_dir / "preset.yml", 'w') as f:
yaml.dump(pack_data, f)
(pack_dir / "templates").mkdir()
(pack_dir / "templates" / "spec-template.md").write_text("appended")
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
resolver = PresetResolver(project_dir)
content = resolver.resolve_content("spec-template")
# Should have blank line separator
assert "\n\n" in content
def test_resolve_content_replace_over_wrap(self, project_dir, temp_dir, valid_pack_data):
"""Top-priority replace layer should win even if a lower layer uses wrap."""
# Install a low-priority wrap preset (with no placeholder — would fail if evaluated)
wrap_data = {**valid_pack_data}
wrap_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-lo", "name": "WrapLo"}
wrap_data["provides"] = {
"templates": [{
"type": "template",
"name": "spec-template",
"file": "templates/spec-template.md",
"strategy": "wrap",
}]
}
wrap_dir = temp_dir / "wrap-lo"
wrap_dir.mkdir()
with open(wrap_dir / "preset.yml", "w") as f:
yaml.dump(wrap_data, f)
(wrap_dir / "templates").mkdir()
# Intentionally missing {CORE_TEMPLATE} — would error if composition ran
(wrap_dir / "templates" / "spec-template.md").write_text("wrapper without placeholder")
manager = PresetManager(project_dir)
manager.install_from_directory(wrap_dir, "0.1.5", priority=10)
# Install a high-priority replace preset
rep_data = {**valid_pack_data}
rep_data["preset"] = {**valid_pack_data["preset"], "id": "rep-hi", "name": "RepHi"}
rep_data["provides"] = {
"templates": [{
"type": "template",
"name": "spec-template",
"file": "templates/spec-template.md",
}]
}
rep_dir = temp_dir / "rep-hi"
rep_dir.mkdir()
with open(rep_dir / "preset.yml", "w") as f:
yaml.dump(rep_data, f)
(rep_dir / "templates").mkdir()
(rep_dir / "templates" / "spec-template.md").write_text("# Replaced content\n")
manager.install_from_directory(rep_dir, "0.1.5", priority=1)
resolver = PresetResolver(project_dir)
content = resolver.resolve_content("spec-template")
assert content == "# Replaced content\n"
class TestCollectAllLayers:
"""Test PresetResolver.collect_all_layers() method."""
def test_single_core_layer(self, project_dir):
"""Test collecting layers with only core template."""
resolver = PresetResolver(project_dir)
layers = resolver.collect_all_layers("spec-template")
assert len(layers) == 1
assert layers[0]["source"] == "core"
assert layers[0]["strategy"] == "replace"
def test_layers_include_presets(self, project_dir, temp_dir, valid_pack_data):
"""Test that layers include installed preset."""
manager = PresetManager(project_dir)
pack_dir = _create_pack(temp_dir, valid_pack_data, "test-pack",
"# From Pack\n")
manager.install_from_directory(pack_dir, "0.1.5")
resolver = PresetResolver(project_dir)
layers = resolver.collect_all_layers("spec-template")
assert len(layers) == 2
# Highest priority first
assert "test-pack" in layers[0]["source"]
assert layers[1]["source"] == "core"
def test_layers_order_matches_priority(self, project_dir, temp_dir, valid_pack_data):
"""Test that layers are ordered by priority (highest first)."""
manager = PresetManager(project_dir)
for pid, prio in [("pack-lo", 10), ("pack-hi", 1)]:
d = {**valid_pack_data}
d["preset"] = {**valid_pack_data["preset"], "id": pid, "name": pid}
p = temp_dir / pid
p.mkdir()
with open(p / "preset.yml", 'w') as f:
yaml.dump(d, f)
(p / "templates").mkdir()
(p / "templates" / "spec-template.md").write_text(f"# {pid}\n")
manager.install_from_directory(p, "0.1.5", priority=prio)
resolver = PresetResolver(project_dir)
layers = resolver.collect_all_layers("spec-template")
assert len(layers) == 3 # pack-hi, pack-lo, core
assert "pack-hi" in layers[0]["source"]
assert "pack-lo" in layers[1]["source"]
assert layers[2]["source"] == "core"
def test_layers_read_strategy_from_manifest(self, project_dir, temp_dir, valid_pack_data):
"""Test that layers read strategy from preset manifest."""
pack_data = {**valid_pack_data}
pack_data["preset"] = {**valid_pack_data["preset"], "id": "strat-pack", "name": "Strat"}
pack_data["provides"] = {
"templates": [{
"type": "template",
"name": "spec-template",
"file": "templates/spec-template.md",
"strategy": "append",
}]
}
pack_dir = temp_dir / "strat-pack"
pack_dir.mkdir()
with open(pack_dir / "preset.yml", 'w') as f:
yaml.dump(pack_data, f)
(pack_dir / "templates").mkdir()
(pack_dir / "templates" / "spec-template.md").write_text("## Footer\n")
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
resolver = PresetResolver(project_dir)
layers = resolver.collect_all_layers("spec-template")
# Preset layer should have strategy=append
assert layers[0]["strategy"] == "append"
# Core layer should be replace
assert layers[1]["strategy"] == "replace"
class TestRemoveReconciliation:
"""Test that removing a preset re-registers the next layer's command."""
def test_remove_restores_lower_priority_command(
self, project_dir, temp_dir, valid_pack_data
):
"""After removing the top-priority preset, the next preset's command
should be re-registered in agent directories."""
manager = PresetManager(project_dir)
# Create a gemini commands dir so reconciliation writes there
gemini_dir = project_dir / ".gemini" / "commands"
gemini_dir.mkdir(parents=True)
# Install a low-priority preset with a command
lo_data = {**valid_pack_data}
lo_data["preset"] = {
**valid_pack_data["preset"],
"id": "lo-preset",
"name": "Lo",
}
lo_data["provides"] = {
"templates": [{
"type": "command",
"name": "speckit.specify",
"file": "commands/speckit.specify.md",
}]
}
lo_dir = temp_dir / "lo-preset"
lo_dir.mkdir()
with open(lo_dir / "preset.yml", "w") as f:
yaml.dump(lo_data, f)
(lo_dir / "commands").mkdir()
(lo_dir / "commands" / "speckit.specify.md").write_text(
"---\ndescription: lo\n---\nLo content\n"
)
manager.install_from_directory(lo_dir, "0.1.5", priority=10)
# Install a high-priority preset overriding the same command
hi_data = {**valid_pack_data}
hi_data["preset"] = {
**valid_pack_data["preset"],
"id": "hi-preset",
"name": "Hi",
}
hi_data["provides"] = {
"templates": [{
"type": "command",
"name": "speckit.specify",
"file": "commands/speckit.specify.md",
}]
}
hi_dir = temp_dir / "hi-preset"
hi_dir.mkdir()
with open(hi_dir / "preset.yml", "w") as f:
yaml.dump(hi_data, f)
(hi_dir / "commands").mkdir()
(hi_dir / "commands" / "speckit.specify.md").write_text(
"---\ndescription: hi\n---\nHi content\n"
)
manager.install_from_directory(hi_dir, "0.1.5", priority=1)
# Verify the hi-preset's content is active in agent dir
cmd_files = list(gemini_dir.glob("*specify*"))
assert cmd_files, "Command file should exist in gemini dir"
assert "Hi content" in cmd_files[0].read_text()
# Remove the high-priority preset
manager.remove("hi-preset")
# The low-priority preset's command should now be in the resolution stack
resolver = PresetResolver(project_dir)
layers = resolver.collect_all_layers("speckit.specify", "command")
assert len(layers) >= 1
assert "lo-preset" in layers[0]["source"]
# Verify on-disk agent command file switched to lo-preset content
cmd_files = list(gemini_dir.glob("*specify*"))
assert cmd_files, "Command file should still exist after removal"
assert "Lo content" in cmd_files[0].read_text()
def _create_pack(temp_dir, valid_pack_data, pack_id, content,
strategy="replace", template_type="template",
template_name="spec-template"):
"""Helper to create a preset pack directory."""
pack_data = {**valid_pack_data}
pack_data["preset"] = {**valid_pack_data["preset"], "id": pack_id, "name": pack_id}
tmpl_entry = {
"type": template_type,
"name": template_name,
}
if template_type == "script":
tmpl_entry["file"] = f"scripts/{template_name}.sh"
elif template_type == "command":
tmpl_entry["file"] = f"commands/{template_name}.md"
else:
tmpl_entry["file"] = f"templates/{template_name}.md"
if strategy != "replace":
tmpl_entry["strategy"] = strategy
pack_data["provides"] = {"templates": [tmpl_entry]}
pack_dir = temp_dir / pack_id
pack_dir.mkdir(exist_ok=True)
with open(pack_dir / "preset.yml", 'w') as f:
yaml.dump(pack_data, f)
if template_type == "script":
subdir = pack_dir / "scripts"
subdir.mkdir(exist_ok=True)
(subdir / f"{template_name}.sh").write_text(content)
elif template_type == "command":
subdir = pack_dir / "commands"
subdir.mkdir(exist_ok=True)
(subdir / f"{template_name}.md").write_text(content)
else:
subdir = pack_dir / "templates"
subdir.mkdir(exist_ok=True)
(subdir / f"{template_name}.md").write_text(content)
return pack_dir