mirror of
https://github.com/github/spec-kit.git
synced 2026-03-29 08:43:08 +00:00
fix: prevent extension command shadowing (#1994)
* fix: prevent extension command shadowing * Validate extension command namespaces * Reuse extension command name pattern
This commit is contained in:
@@ -25,6 +25,49 @@ import yaml
|
||||
from packaging import version as pkg_version
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
|
||||
_FALLBACK_CORE_COMMAND_NAMES = frozenset({
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
"constitution",
|
||||
"implement",
|
||||
"plan",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
})
|
||||
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
|
||||
|
||||
|
||||
def _load_core_command_names() -> frozenset[str]:
|
||||
"""Discover bundled core command names from the packaged templates.
|
||||
|
||||
Prefer the wheel-time ``core_pack`` bundle when present, and fall back to
|
||||
the source checkout when running from the repository. If neither is
|
||||
available, use the baked-in fallback set so validation still works.
|
||||
"""
|
||||
candidate_dirs = [
|
||||
Path(__file__).parent / "core_pack" / "commands",
|
||||
Path(__file__).resolve().parent.parent.parent / "templates" / "commands",
|
||||
]
|
||||
|
||||
for commands_dir in candidate_dirs:
|
||||
if not commands_dir.is_dir():
|
||||
continue
|
||||
|
||||
command_names = {
|
||||
command_file.stem
|
||||
for command_file in commands_dir.iterdir()
|
||||
if command_file.is_file() and command_file.suffix == ".md"
|
||||
}
|
||||
if command_names:
|
||||
return frozenset(command_names)
|
||||
|
||||
return _FALLBACK_CORE_COMMAND_NAMES
|
||||
|
||||
|
||||
CORE_COMMAND_NAMES = _load_core_command_names()
|
||||
|
||||
|
||||
class ExtensionError(Exception):
|
||||
"""Base exception for extension-related errors."""
|
||||
@@ -149,7 +192,7 @@ class ExtensionManifest:
|
||||
raise ValidationError("Command missing 'name' or 'file'")
|
||||
|
||||
# Validate command name format
|
||||
if not re.match(r'^speckit\.[a-z0-9-]+\.[a-z0-9-]+$', cmd["name"]):
|
||||
if EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]) is None:
|
||||
raise ValidationError(
|
||||
f"Invalid command name '{cmd['name']}': "
|
||||
"must follow pattern 'speckit.{extension}.{command}'"
|
||||
@@ -446,6 +489,126 @@ class ExtensionManager:
|
||||
self.extensions_dir = project_root / ".specify" / "extensions"
|
||||
self.registry = ExtensionRegistry(self.extensions_dir)
|
||||
|
||||
@staticmethod
|
||||
def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, str]:
|
||||
"""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
|
||||
- command namespaces must not shadow core commands
|
||||
- duplicate command/alias names inside one manifest are rejected
|
||||
|
||||
Args:
|
||||
manifest: Parsed extension manifest
|
||||
|
||||
Returns:
|
||||
Mapping of declared command/alias name -> kind ("command"/"alias")
|
||||
|
||||
Raises:
|
||||
ValidationError: If any declared name is invalid
|
||||
"""
|
||||
if manifest.id in CORE_COMMAND_NAMES:
|
||||
raise ValidationError(
|
||||
f"Extension ID '{manifest.id}' conflicts with core command namespace '{manifest.id}'"
|
||||
)
|
||||
|
||||
declared_names: Dict[str, str] = {}
|
||||
|
||||
for cmd in manifest.commands:
|
||||
primary_name = cmd["name"]
|
||||
aliases = cmd.get("aliases", [])
|
||||
|
||||
if aliases is None:
|
||||
aliases = []
|
||||
if not isinstance(aliases, list):
|
||||
raise ValidationError(
|
||||
f"Aliases for command '{primary_name}' must be a list"
|
||||
)
|
||||
|
||||
for kind, name in [("command", primary_name)] + [
|
||||
("alias", alias) for alias in aliases
|
||||
]:
|
||||
if not isinstance(name, str):
|
||||
raise ValidationError(
|
||||
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}'"
|
||||
)
|
||||
|
||||
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 name in declared_names:
|
||||
raise ValidationError(
|
||||
f"Duplicate command or alias '{name}' in extension manifest"
|
||||
)
|
||||
|
||||
declared_names[name] = kind
|
||||
|
||||
return declared_names
|
||||
|
||||
def _get_installed_command_name_map(
|
||||
self,
|
||||
exclude_extension_id: Optional[str] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""Return registered command and alias names for installed extensions."""
|
||||
installed_names: Dict[str, str] = {}
|
||||
|
||||
for ext_id in self.registry.keys():
|
||||
if ext_id == exclude_extension_id:
|
||||
continue
|
||||
|
||||
manifest = self.get_extension(ext_id)
|
||||
if manifest is None:
|
||||
continue
|
||||
|
||||
for cmd in manifest.commands:
|
||||
cmd_name = cmd.get("name")
|
||||
if isinstance(cmd_name, str):
|
||||
installed_names.setdefault(cmd_name, ext_id)
|
||||
|
||||
aliases = cmd.get("aliases", [])
|
||||
if not isinstance(aliases, list):
|
||||
continue
|
||||
|
||||
for alias in aliases:
|
||||
if isinstance(alias, str):
|
||||
installed_names.setdefault(alias, ext_id)
|
||||
|
||||
return installed_names
|
||||
|
||||
def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None:
|
||||
"""Reject installs that would shadow core or installed extension commands."""
|
||||
declared_names = self._collect_manifest_command_names(manifest)
|
||||
installed_names = self._get_installed_command_name_map(
|
||||
exclude_extension_id=manifest.id
|
||||
)
|
||||
|
||||
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- "
|
||||
+ "\n- ".join(collisions)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]:
|
||||
"""Load .extensionignore and return an ignore function for shutil.copytree.
|
||||
@@ -861,6 +1024,9 @@ class ExtensionManager:
|
||||
f"Use 'specify extension remove {manifest.id}' first."
|
||||
)
|
||||
|
||||
# Reject manifests that would shadow core commands or installed extensions.
|
||||
self._validate_install_conflicts(manifest)
|
||||
|
||||
# Install extension
|
||||
dest_dir = self.extensions_dir / manifest.id
|
||||
if dest_dir.exists():
|
||||
|
||||
Reference in New Issue
Block a user