Skip to content
Open
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
26 changes: 20 additions & 6 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1185,17 +1185,31 @@ def _get_installed_command_name_map(
return installed_names

def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None:
"""Reject installs that would shadow core or installed extension commands."""
"""Reject installs that would shadow core or installed extension commands.

Primary command names are already namespace-checked against
``CORE_COMMAND_NAMES`` in ``_collect_manifest_command_names``, but
aliases are intentionally free-form (see the comment there) and so
can only be caught here, by comparing declared names directly
against the fully-qualified core command names (``speckit.<name>``)
rather than relying on ``_get_installed_command_name_map``, which
only knows about installed extensions.
"""
declared_names = self._collect_manifest_command_names(manifest)
installed_names = self._get_installed_command_name_map(
exclude_extension_id=manifest.id
)
core_command_names = {f"speckit.{name}" for name in CORE_COMMAND_NAMES}

collisions = []
for name in sorted(declared_names):
if name in installed_names:
collisions.append(
f"{name} (already provided by extension '{installed_names[name]}')"
)
elif name in core_command_names:
collisions.append(f"{name} (conflicts with core command)")

collisions = [
f"{name} (already provided by extension '{installed_names[name]}')"
for name in sorted(declared_names)
if name in installed_names
]
if collisions:
raise ValidationError(
"Extension commands conflict with installed extensions:\n- "
Expand Down
42 changes: 42 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3126,6 +3126,48 @@ def test_install_rejects_command_collision_with_installed_extension(self, temp_d
with pytest.raises(ValidationError, match="already provided by extension 'ext-one'"):
manager.install_from_directory(second_dir, "0.1.0", register_commands=False)

def test_install_rejects_alias_shadowing_core_command(self, temp_dir, project_dir):
"""An alias equal to a core command's qualified name must not install.

Regression test for #4555: a primary name is namespace-checked
against CORE_COMMAND_NAMES, but aliases are intentionally free-form
and previously went unchecked against core commands entirely, so an
extension could claim e.g. 'speckit.taskstoissues' as an alias and
shadow the core command of the same name.
"""
import yaml

ext_dir = temp_dir / "probe-ext"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()

manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "probe",
"name": "Probe",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.probe.taskstoissues",
"file": "commands/cmd.md",
"aliases": ["speckit.taskstoissues"],
}
]
},
}

(ext_dir / "extension.yml").write_text(yaml.dump(manifest_data))
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")

manager = ExtensionManager(project_dir)
with pytest.raises(ValidationError, match="conflicts with core command"):
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)

def test_remove_extension(self, extension_dir, project_dir):
"""Test removing an installed extension."""
manager = ExtensionManager(project_dir)
Expand Down