|
25 | 25 | from packaging import version as pkg_version |
26 | 26 | from packaging.specifiers import SpecifierSet, InvalidSpecifier |
27 | 27 |
|
| 28 | +_FALLBACK_CORE_COMMAND_NAMES = frozenset({ |
| 29 | + "analyze", |
| 30 | + "checklist", |
| 31 | + "clarify", |
| 32 | + "constitution", |
| 33 | + "implement", |
| 34 | + "plan", |
| 35 | + "specify", |
| 36 | + "tasks", |
| 37 | + "taskstoissues", |
| 38 | +}) |
| 39 | +EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$") |
| 40 | + |
| 41 | + |
| 42 | +def _load_core_command_names() -> frozenset[str]: |
| 43 | + """Discover bundled core command names from the packaged templates. |
| 44 | +
|
| 45 | + Prefer the wheel-time ``core_pack`` bundle when present, and fall back to |
| 46 | + the source checkout when running from the repository. If neither is |
| 47 | + available, use the baked-in fallback set so validation still works. |
| 48 | + """ |
| 49 | + candidate_dirs = [ |
| 50 | + Path(__file__).parent / "core_pack" / "commands", |
| 51 | + Path(__file__).resolve().parent.parent.parent / "templates" / "commands", |
| 52 | + ] |
| 53 | + |
| 54 | + for commands_dir in candidate_dirs: |
| 55 | + if not commands_dir.is_dir(): |
| 56 | + continue |
| 57 | + |
| 58 | + command_names = { |
| 59 | + command_file.stem |
| 60 | + for command_file in commands_dir.iterdir() |
| 61 | + if command_file.is_file() and command_file.suffix == ".md" |
| 62 | + } |
| 63 | + if command_names: |
| 64 | + return frozenset(command_names) |
| 65 | + |
| 66 | + return _FALLBACK_CORE_COMMAND_NAMES |
| 67 | + |
| 68 | + |
| 69 | +CORE_COMMAND_NAMES = _load_core_command_names() |
| 70 | + |
28 | 71 |
|
29 | 72 | class ExtensionError(Exception): |
30 | 73 | """Base exception for extension-related errors.""" |
@@ -149,7 +192,7 @@ def _validate(self): |
149 | 192 | raise ValidationError("Command missing 'name' or 'file'") |
150 | 193 |
|
151 | 194 | # Validate command name format |
152 | | - if not re.match(r'^speckit\.[a-z0-9-]+\.[a-z0-9-]+$', cmd["name"]): |
| 195 | + if EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]) is None: |
153 | 196 | raise ValidationError( |
154 | 197 | f"Invalid command name '{cmd['name']}': " |
155 | 198 | "must follow pattern 'speckit.{extension}.{command}'" |
@@ -446,6 +489,126 @@ def __init__(self, project_root: Path): |
446 | 489 | self.extensions_dir = project_root / ".specify" / "extensions" |
447 | 490 | self.registry = ExtensionRegistry(self.extensions_dir) |
448 | 491 |
|
| 492 | + @staticmethod |
| 493 | + def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, str]: |
| 494 | + """Collect command and alias names declared by a manifest. |
| 495 | +
|
| 496 | + Performs install-time validation for extension-specific constraints: |
| 497 | + - commands and aliases must use the canonical `speckit.{extension}.{command}` shape |
| 498 | + - commands and aliases must use this extension's namespace |
| 499 | + - command namespaces must not shadow core commands |
| 500 | + - duplicate command/alias names inside one manifest are rejected |
| 501 | +
|
| 502 | + Args: |
| 503 | + manifest: Parsed extension manifest |
| 504 | +
|
| 505 | + Returns: |
| 506 | + Mapping of declared command/alias name -> kind ("command"/"alias") |
| 507 | +
|
| 508 | + Raises: |
| 509 | + ValidationError: If any declared name is invalid |
| 510 | + """ |
| 511 | + if manifest.id in CORE_COMMAND_NAMES: |
| 512 | + raise ValidationError( |
| 513 | + f"Extension ID '{manifest.id}' conflicts with core command namespace '{manifest.id}'" |
| 514 | + ) |
| 515 | + |
| 516 | + declared_names: Dict[str, str] = {} |
| 517 | + |
| 518 | + for cmd in manifest.commands: |
| 519 | + primary_name = cmd["name"] |
| 520 | + aliases = cmd.get("aliases", []) |
| 521 | + |
| 522 | + if aliases is None: |
| 523 | + aliases = [] |
| 524 | + if not isinstance(aliases, list): |
| 525 | + raise ValidationError( |
| 526 | + f"Aliases for command '{primary_name}' must be a list" |
| 527 | + ) |
| 528 | + |
| 529 | + for kind, name in [("command", primary_name)] + [ |
| 530 | + ("alias", alias) for alias in aliases |
| 531 | + ]: |
| 532 | + if not isinstance(name, str): |
| 533 | + raise ValidationError( |
| 534 | + f"{kind.capitalize()} for command '{primary_name}' must be a string" |
| 535 | + ) |
| 536 | + |
| 537 | + match = EXTENSION_COMMAND_NAME_PATTERN.match(name) |
| 538 | + if match is None: |
| 539 | + raise ValidationError( |
| 540 | + f"Invalid {kind} '{name}': " |
| 541 | + "must follow pattern 'speckit.{extension}.{command}'" |
| 542 | + ) |
| 543 | + |
| 544 | + namespace = match.group(1) |
| 545 | + if namespace != manifest.id: |
| 546 | + raise ValidationError( |
| 547 | + f"{kind.capitalize()} '{name}' must use extension namespace '{manifest.id}'" |
| 548 | + ) |
| 549 | + |
| 550 | + if namespace in CORE_COMMAND_NAMES: |
| 551 | + raise ValidationError( |
| 552 | + f"{kind.capitalize()} '{name}' conflicts with core command namespace '{namespace}'" |
| 553 | + ) |
| 554 | + |
| 555 | + if name in declared_names: |
| 556 | + raise ValidationError( |
| 557 | + f"Duplicate command or alias '{name}' in extension manifest" |
| 558 | + ) |
| 559 | + |
| 560 | + declared_names[name] = kind |
| 561 | + |
| 562 | + return declared_names |
| 563 | + |
| 564 | + def _get_installed_command_name_map( |
| 565 | + self, |
| 566 | + exclude_extension_id: Optional[str] = None, |
| 567 | + ) -> Dict[str, str]: |
| 568 | + """Return registered command and alias names for installed extensions.""" |
| 569 | + installed_names: Dict[str, str] = {} |
| 570 | + |
| 571 | + for ext_id in self.registry.keys(): |
| 572 | + if ext_id == exclude_extension_id: |
| 573 | + continue |
| 574 | + |
| 575 | + manifest = self.get_extension(ext_id) |
| 576 | + if manifest is None: |
| 577 | + continue |
| 578 | + |
| 579 | + for cmd in manifest.commands: |
| 580 | + cmd_name = cmd.get("name") |
| 581 | + if isinstance(cmd_name, str): |
| 582 | + installed_names.setdefault(cmd_name, ext_id) |
| 583 | + |
| 584 | + aliases = cmd.get("aliases", []) |
| 585 | + if not isinstance(aliases, list): |
| 586 | + continue |
| 587 | + |
| 588 | + for alias in aliases: |
| 589 | + if isinstance(alias, str): |
| 590 | + installed_names.setdefault(alias, ext_id) |
| 591 | + |
| 592 | + return installed_names |
| 593 | + |
| 594 | + def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None: |
| 595 | + """Reject installs that would shadow core or installed extension commands.""" |
| 596 | + declared_names = self._collect_manifest_command_names(manifest) |
| 597 | + installed_names = self._get_installed_command_name_map( |
| 598 | + exclude_extension_id=manifest.id |
| 599 | + ) |
| 600 | + |
| 601 | + collisions = [ |
| 602 | + f"{name} (already provided by extension '{installed_names[name]}')" |
| 603 | + for name in sorted(declared_names) |
| 604 | + if name in installed_names |
| 605 | + ] |
| 606 | + if collisions: |
| 607 | + raise ValidationError( |
| 608 | + "Extension commands conflict with installed extensions:\n- " |
| 609 | + + "\n- ".join(collisions) |
| 610 | + ) |
| 611 | + |
449 | 612 | @staticmethod |
450 | 613 | def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]: |
451 | 614 | """Load .extensionignore and return an ignore function for shutil.copytree. |
@@ -861,6 +1024,9 @@ def install_from_directory( |
861 | 1024 | f"Use 'specify extension remove {manifest.id}' first." |
862 | 1025 | ) |
863 | 1026 |
|
| 1027 | + # Reject manifests that would shadow core commands or installed extensions. |
| 1028 | + self._validate_install_conflicts(manifest) |
| 1029 | + |
864 | 1030 | # Install extension |
865 | 1031 | dest_dir = self.extensions_dir / manifest.id |
866 | 1032 | if dest_dir.exists(): |
|
0 commit comments