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."""
|
||||
|
||||
@@ -4995,6 +4995,64 @@ class PresetResolver:
|
||||
return tmpl, None
|
||||
return None, None
|
||||
|
||||
def _extension_manifest_declared_template(
|
||||
self, ext_dir: Path, template_name: str, template_type: str
|
||||
) -> tuple[dict | None, Path | None]:
|
||||
"""Resolve an extension's manifest-declared command/template/script entry and usable file.
|
||||
|
||||
Mirrors ``_manifest_declared_template`` (for presets): returns ``(entry, candidate)``
|
||||
where ``entry`` is the matching ``provides.<type>`` mapping, or ``None`` if the
|
||||
extension has no (valid) manifest or doesn't declare this ``(name, type)``.
|
||||
``candidate`` is the declared ``file:`` resolved under ``ext_dir`` IFF it is a
|
||||
regular file that stays within ``ext_dir`` (guards against path traversal via a
|
||||
malformed manifest, mirroring ``resolve_extension_command_via_manifest``);
|
||||
``None`` otherwise.
|
||||
|
||||
The manifest is authoritative: when ``entry`` is not ``None`` but ``candidate`` is
|
||||
``None``, callers must NOT fall back to convention-based lookup — that would mask
|
||||
a typo or pick up an undeclared file. Shared by ``resolve()`` and
|
||||
``collect_all_layers()`` so their manifest-first resolution cannot silently
|
||||
diverge (the divergence flagged in review on #4012).
|
||||
"""
|
||||
if template_type not in ("command", "template", "script"):
|
||||
return None, None
|
||||
ext_manifest_path = ext_dir / "extension.yml"
|
||||
if not ext_manifest_path.exists():
|
||||
return None, None
|
||||
from ..extensions import ExtensionManifest, ValidationError as ExtValidationError
|
||||
|
||||
try:
|
||||
ext_manifest = ExtensionManifest(ext_manifest_path)
|
||||
except (ExtValidationError, yaml.YAMLError, OSError, TypeError, AttributeError):
|
||||
return None, None
|
||||
if template_type == "command":
|
||||
entries = ext_manifest.commands
|
||||
elif template_type == "template":
|
||||
entries = ext_manifest.templates
|
||||
else:
|
||||
entries = ext_manifest.scripts
|
||||
for entry in entries:
|
||||
if entry.get("name") != template_name:
|
||||
continue
|
||||
file_rel = entry.get("file")
|
||||
if not file_rel:
|
||||
return entry, None
|
||||
rel_path = Path(file_rel)
|
||||
if rel_path.is_absolute():
|
||||
return entry, None
|
||||
candidate = ext_dir / rel_path
|
||||
try:
|
||||
# Resolve only for the containment check, not for the
|
||||
# returned path -- resolving the returned path would follow
|
||||
# symlinks in ext_dir's ancestors (e.g. a symlinked tmp dir
|
||||
# on macOS) and diverge from the unresolved paths convention
|
||||
# lookup returns for the same directory.
|
||||
candidate.resolve().relative_to(ext_dir.resolve()) # raises ValueError if outside
|
||||
except (OSError, ValueError):
|
||||
return entry, None
|
||||
return entry, (candidate if candidate.is_file() else None)
|
||||
return None, None
|
||||
|
||||
def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]:
|
||||
"""Build unified list of registered and unregistered extensions sorted by priority.
|
||||
|
||||
@@ -5131,6 +5189,16 @@ class PresetResolver:
|
||||
ext_dir = self.extensions_dir / ext_id
|
||||
if not ext_dir.is_dir():
|
||||
continue
|
||||
# The extension manifest is authoritative, same as preset manifests
|
||||
# above: check it before convention-based lookup so a declared entry
|
||||
# at a non-conventional path wins over a stale conventional file.
|
||||
entry, manifest_candidate = self._extension_manifest_declared_template(
|
||||
ext_dir, template_name, template_type
|
||||
)
|
||||
if manifest_candidate is not None:
|
||||
return manifest_candidate
|
||||
if entry is not None:
|
||||
continue
|
||||
for subdir in subdirs:
|
||||
if subdir:
|
||||
candidate = ext_dir / subdir / f"{template_name}{ext}"
|
||||
@@ -5440,27 +5508,15 @@ class PresetResolver:
|
||||
ext_dir = self.extensions_dir / ext_id
|
||||
if not ext_dir.is_dir():
|
||||
continue
|
||||
# Try convention-based lookup first
|
||||
candidate = _find_in_subdirs(ext_dir)
|
||||
# If not found and this is a command, check extension manifest
|
||||
if candidate is None and template_type == "command":
|
||||
ext_manifest_path = ext_dir / "extension.yml"
|
||||
if ext_manifest_path.exists():
|
||||
try:
|
||||
from ..extensions import ExtensionManifest, ValidationError as ExtValidationError
|
||||
ext_manifest = ExtensionManifest(ext_manifest_path)
|
||||
for cmd in ext_manifest.commands:
|
||||
if cmd.get("name") == template_name:
|
||||
cmd_file = cmd.get("file")
|
||||
if cmd_file:
|
||||
c = ext_dir / cmd_file
|
||||
if c.exists():
|
||||
candidate = c
|
||||
break
|
||||
except (ExtValidationError, yaml.YAMLError):
|
||||
# Invalid extension manifest — fall back to
|
||||
# convention-based lookup (already attempted above).
|
||||
pass
|
||||
# The extension manifest is authoritative, same as preset manifests
|
||||
# above: check it before convention-based lookup so a declared entry
|
||||
# at a non-conventional path wins over a stale conventional file, and
|
||||
# a declared-but-missing file isn't silently masked by convention.
|
||||
entry, candidate = self._extension_manifest_declared_template(
|
||||
ext_dir, template_name, template_type
|
||||
)
|
||||
if entry is None:
|
||||
candidate = _find_in_subdirs(ext_dir)
|
||||
if candidate:
|
||||
if ext_meta:
|
||||
version = ext_meta.get("version", "?")
|
||||
|
||||
Reference in New Issue
Block a user