Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 21 additions & 17 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -523,10 +523,11 @@ def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, st
"""Collect command and alias names declared by a manifest.

Performs install-time validation for extension-specific constraints:
- commands and aliases must use the canonical `speckit.{extension}.{command}` shape
- commands and aliases must use this extension's namespace
- primary commands must use the canonical `speckit.{extension}.{command}` shape
- primary commands must use this extension's namespace
- command namespaces must not shadow core commands
- duplicate command/alias names inside one manifest are rejected
- aliases are validated for type and uniqueness only (no pattern enforcement)

Args:
manifest: Parsed extension manifest
Expand Down Expand Up @@ -563,23 +564,26 @@ def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, st
f"{kind.capitalize()} for command '{primary_name}' must be a string"
)

match = EXTENSION_COMMAND_NAME_PATTERN.match(name)
if match is None:
raise ValidationError(
f"Invalid {kind} '{name}': "
"must follow pattern 'speckit.{extension}.{command}'"
)
# Enforce canonical pattern only for primary command names;
# aliases are free-form to preserve community extension compat.
if kind == "command":
match = EXTENSION_COMMAND_NAME_PATTERN.match(name)
if match is None:
raise ValidationError(
f"Invalid {kind} '{name}': "
"must follow pattern 'speckit.{extension}.{command}'"
)

namespace = match.group(1)
if namespace != manifest.id:
raise ValidationError(
f"{kind.capitalize()} '{name}' must use extension namespace '{manifest.id}'"
)
namespace = match.group(1)
if namespace != manifest.id:
raise ValidationError(
f"{kind.capitalize()} '{name}' must use extension namespace '{manifest.id}'"
)

if namespace in CORE_COMMAND_NAMES:
raise ValidationError(
f"{kind.capitalize()} '{name}' conflicts with core command namespace '{namespace}'"
)
if namespace in CORE_COMMAND_NAMES:
raise ValidationError(
f"{kind.capitalize()} '{name}' conflicts with core command namespace '{namespace}'"
)

if name in declared_names:
raise ValidationError(
Expand Down
8 changes: 4 additions & 4 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -686,8 +686,8 @@ def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_
with pytest.raises(ValidationError, match="conflicts with core command namespace"):
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)

def test_install_rejects_alias_without_extension_namespace(self, temp_dir, project_dir):
"""Install should reject legacy short aliases that can shadow core commands."""
def test_install_accepts_short_alias(self, temp_dir, project_dir):
"""Install should accept legacy short aliases for community extension compat."""
import yaml

ext_dir = temp_dir / "alias-shortcut"
Expand Down Expand Up @@ -718,8 +718,8 @@ def test_install_rejects_alias_without_extension_namespace(self, temp_dir, proje
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")

manager = ExtensionManager(project_dir)
with pytest.raises(ValidationError, match="Invalid alias 'speckit.shortcut'"):
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
# Should not raise — short aliases are allowed
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)

def test_install_rejects_namespace_squatting(self, temp_dir, project_dir):
"""Install should reject commands and aliases outside the extension namespace."""
Expand Down
Loading