mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
feat(extensions): accept provides.templates and provides.scripts in manifest (#4012)
* feat(extensions): accept provides.templates and provides.scripts in manifest Extensions could only formally declare commands under `provides` (plus config/hooks/events); templates and scripts shipped by an extension were picked up purely by filename convention, with no id, description, or metadata. Add optional `provides.templates` and `provides.scripts` sections to the extension manifest schema, mirroring the preset template shape minus an authorable `strategy` (extension artifacts always resolve as replace, so a present `strategy` key is now a validation error rather than a silently accepted no-op). ExtensionManifest gains `templates`/`scripts` properties so tooling can enumerate an extension's declared artifacts directly from the manifest. An extension may now satisfy the "must provide something" rule with only a template or script, not just a command/hook/event. Addresses the manifest-schema portion of #4010; resolver authoritative-vs-convention precedence for these new sections is left for a follow-up. * fix(presets): wire extension-declared templates/scripts into resolver collect_all_layers only consulted ExtensionManifest for command resolution, leaving provides.templates/.scripts purely decorative -- a declared entry whose file didn't sit at the conventional path was validated but never resolved. Extend the existing manifest-fallback branch to cover template_type "template" and "script" the same way it already does "command": convention lookup first, manifest lookup as fallback so undeclared on-disk files keep resolving unchanged. * fix(presets): make extension manifest lookup authoritative over convention Copilot review on #4012 found the manifest-declared template/script lookup was gated on convention lookup missing first, so a stale conventional file could shadow a declared entry at a non-conventional path, and resolve() never consulted the manifest at all (only collect_all_layers() did). Add a shared _extension_manifest_declared_template() helper and check it before convention-based lookup in both resolve() and collect_all_layers(), mirroring the preset manifest precedence. Also update EXTENSION-DEVELOPMENT-GUIDE.md, which still claimed provides only supports commands and required a command or hook. * fix(presets): stop resolving symlinks in extension manifest candidate path _extension_manifest_declared_template() resolved ext_dir/rel_path before returning it, which follows symlinks in ext_dir's ancestors (e.g. macOS's symlinked tmp dir) and diverges from the unresolved paths convention-based lookup returns for the same directory. Resolve only for the traversal containment check; return the unresolved candidate. Fixes the 4 CI test failures across all OS/Python matrix jobs on #4012.
This commit is contained in:
@@ -61,6 +61,13 @@ _FALLBACK_CORE_COMMAND_NAMES = frozenset(
|
||||
)
|
||||
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
|
||||
|
||||
# Naming pattern for provides.templates / provides.scripts entries. Unlike
|
||||
# commands, these are not namespaced (they aren't invoked via a command
|
||||
# name), so they follow the same plain slug pattern as extension.id.
|
||||
VALID_EXTENSION_ARTIFACT_NAME_PATTERN = re.compile(r"^[a-z0-9-]+$")
|
||||
|
||||
VALID_SCRIPT_RUNTIMES = frozenset({"bash", "powershell", "python"})
|
||||
|
||||
VALID_EFFECTS = frozenset({"read-only", "read-write"})
|
||||
|
||||
DEFAULT_HOOK_PRIORITY = 10
|
||||
@@ -368,11 +375,17 @@ class ExtensionManifest:
|
||||
f"Invalid provides: expected a mapping, got {type(provides).__name__}"
|
||||
)
|
||||
commands = provides.get("commands", [])
|
||||
templates = provides.get("templates", [])
|
||||
scripts = provides.get("scripts", [])
|
||||
hooks = self.data.get("hooks")
|
||||
events = self.data.get("events")
|
||||
|
||||
if "commands" in provides and not isinstance(commands, list):
|
||||
raise ValidationError("Invalid provides.commands: expected a list")
|
||||
if "templates" in provides and not isinstance(templates, list):
|
||||
raise ValidationError("Invalid provides.templates: expected a list")
|
||||
if "scripts" in provides and not isinstance(scripts, list):
|
||||
raise ValidationError("Invalid provides.scripts: expected a list")
|
||||
if "hooks" in self.data and not isinstance(hooks, dict):
|
||||
raise ValidationError("Invalid hooks: expected a mapping")
|
||||
if "events" in self.data:
|
||||
@@ -382,9 +395,17 @@ class ExtensionManifest:
|
||||
has_commands = bool(commands)
|
||||
has_hooks = bool(hooks)
|
||||
has_events = bool(events)
|
||||
has_templates = bool(templates)
|
||||
has_scripts = bool(scripts)
|
||||
|
||||
if not has_commands and not has_hooks and not has_events:
|
||||
raise ValidationError("Extension must provide at least one command, hook, or event")
|
||||
if not has_commands and not has_hooks and not has_events and not has_templates and not has_scripts:
|
||||
raise ValidationError(
|
||||
"Extension must provide at least one command, hook, or event "
|
||||
"(or a declared template/script)"
|
||||
)
|
||||
|
||||
self._validate_provided_artifacts(templates, section="templates", singular="template")
|
||||
self._validate_provided_artifacts(scripts, section="scripts", singular="script")
|
||||
|
||||
# Validate hook values (if present).
|
||||
# Each event is a single mapping or a list of mappings.
|
||||
@@ -545,6 +566,70 @@ class ExtensionManifest:
|
||||
f"The extension author should update the manifest."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_provided_artifacts(entries: List[Any], section: str, singular: str) -> None:
|
||||
"""Validate provides.templates / provides.scripts entries.
|
||||
|
||||
Mirrors the shape/path-safety checks PresetManifest applies to its
|
||||
non-command templates, minus 'type' (the section name already
|
||||
distinguishes template vs script) and 'strategy' (extension-provided
|
||||
artifacts are always 'replace' -- see the forced-replace resolver
|
||||
behavior for extension layers in presets/__init__.py). A present
|
||||
'strategy' key is rejected rather than silently ignored, so an author
|
||||
who copies a preset-style entry gets a clear error instead of a
|
||||
silently-dropped field.
|
||||
"""
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
raise ValidationError(
|
||||
f"Each entry in 'provides.{section}' must be a mapping"
|
||||
)
|
||||
if "name" not in entry or "file" not in entry:
|
||||
raise ValidationError(f"{singular.capitalize()} missing 'name' or 'file'")
|
||||
|
||||
name = entry["name"]
|
||||
if not isinstance(name, str):
|
||||
raise ValidationError(
|
||||
f"Invalid {singular} name: expected a string, got {type(name).__name__}"
|
||||
)
|
||||
if not VALID_EXTENSION_ARTIFACT_NAME_PATTERN.match(name):
|
||||
raise ValidationError(
|
||||
f"Invalid {singular} name '{name}': "
|
||||
"must be lowercase alphanumeric with hyphens only"
|
||||
)
|
||||
|
||||
file_value = entry["file"]
|
||||
reason = relative_extension_path_violation(file_value)
|
||||
if reason:
|
||||
label = repr(file_value) if isinstance(file_value, str) else f"for {singular} '{name}'"
|
||||
raise ValidationError(f"Invalid {singular} 'file' {label}: {reason}")
|
||||
|
||||
if "description" in entry and not isinstance(entry["description"], str):
|
||||
raise ValidationError(
|
||||
f"Invalid {singular} description for '{name}': expected a string"
|
||||
)
|
||||
|
||||
if "strategy" in entry:
|
||||
raise ValidationError(
|
||||
f"Invalid {singular} entry '{name}': 'strategy' is not authorable for "
|
||||
"extension-provided artifacts, which always use 'replace' semantics"
|
||||
)
|
||||
|
||||
if section == "scripts" and "runtimes" in entry:
|
||||
runtimes = entry["runtimes"]
|
||||
if not isinstance(runtimes, list) or not all(
|
||||
isinstance(r, str) for r in runtimes
|
||||
):
|
||||
raise ValidationError(
|
||||
f"Invalid runtimes for script '{name}': expected a list of strings"
|
||||
)
|
||||
invalid = sorted(set(runtimes) - VALID_SCRIPT_RUNTIMES)
|
||||
if invalid:
|
||||
raise ValidationError(
|
||||
f"Invalid runtimes {invalid} for script '{name}': "
|
||||
f"must be one of {sorted(VALID_SCRIPT_RUNTIMES)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
|
||||
"""Try to auto-correct a non-conforming command name to the required pattern.
|
||||
@@ -615,6 +700,16 @@ class ExtensionManifest:
|
||||
return []
|
||||
return raw
|
||||
|
||||
@property
|
||||
def templates(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of declared templates (provides.templates)."""
|
||||
return self.data.get("provides", {}).get("templates", [])
|
||||
|
||||
@property
|
||||
def scripts(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of declared scripts (provides.scripts)."""
|
||||
return self.data.get("provides", {}).get("scripts", [])
|
||||
|
||||
@property
|
||||
def hooks(self) -> Dict[str, Any]:
|
||||
"""Get hook definitions."""
|
||||
|
||||
Reference in New Issue
Block a user