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:
chelsealong
2026-08-07 12:55:55 -05:00
committed by GitHub
parent 247abbf5e4
commit 684b3d8e05
6 changed files with 646 additions and 26 deletions
+239
View File
@@ -1020,6 +1020,245 @@ provides:
assert len(hash_value) > 10
class TestExtensionManifestTemplatesAndScripts:
"""Tests for the optional provides.templates / provides.scripts sections."""
def test_templates_and_scripts_declared(self, temp_dir, valid_manifest_data):
"""A manifest declaring templates and scripts exposes them via properties."""
import yaml
valid_manifest_data["provides"]["templates"] = [
{
"name": "myext-template",
"file": "templates/myext-template.md",
"description": "Report scaffold contributed by myext",
}
]
valid_manifest_data["provides"]["scripts"] = [
{
"name": "myext-collect",
"file": "scripts/bash/myext-collect.sh",
"description": "Data-collection helper",
"runtimes": ["bash", "python"],
}
]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.templates == valid_manifest_data["provides"]["templates"]
assert manifest.scripts == valid_manifest_data["provides"]["scripts"]
assert manifest.warnings == []
def test_templates_only_extension_is_valid(self, temp_dir, valid_manifest_data):
"""An extension with only a declared template (no commands/hooks/events) is valid."""
import yaml
valid_manifest_data["provides"]["commands"] = []
valid_manifest_data.pop("hooks", None)
valid_manifest_data["provides"]["templates"] = [
{"name": "myext-template", "file": "templates/myext-template.md"}
]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert len(manifest.templates) == 1
assert len(manifest.commands) == 0
def test_scripts_only_extension_is_valid(self, temp_dir, valid_manifest_data):
"""An extension with only a declared script (no commands/hooks/events) is valid."""
import yaml
valid_manifest_data["provides"]["commands"] = []
valid_manifest_data.pop("hooks", None)
valid_manifest_data["provides"]["scripts"] = [
{"name": "myext-collect", "file": "scripts/bash/myext-collect.sh"}
]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert len(manifest.scripts) == 1
def test_no_provides_at_all_still_rejected(self, temp_dir, valid_manifest_data):
"""Without commands, hooks, events, templates, or scripts the manifest is
still rejected — the relaxed rule only widens what counts, it doesn't
drop the requirement that an extension provide *something*."""
import yaml
valid_manifest_data["provides"]["commands"] = []
valid_manifest_data.pop("hooks", None)
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="must provide at least one command, hook, or event"):
ExtensionManifest(manifest_path)
@pytest.mark.parametrize("section", ["templates", "scripts"])
def test_provides_section_must_be_a_list(self, temp_dir, valid_manifest_data, section):
"""provides.templates / provides.scripts must be a list, not e.g. a mapping."""
import yaml
valid_manifest_data["provides"][section] = {"not": "a list"}
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match=f"Invalid provides.{section}: expected a list"):
ExtensionManifest(manifest_path)
@pytest.mark.parametrize("section", ["templates", "scripts"])
def test_provides_entry_must_be_a_mapping(self, temp_dir, valid_manifest_data, section):
"""Each provides.templates / provides.scripts entry must be a mapping."""
import yaml
valid_manifest_data["provides"][section] = ["not-a-mapping"]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match=f"Each entry in 'provides.{section}' must be a mapping"):
ExtensionManifest(manifest_path)
@pytest.mark.parametrize("section", ["templates", "scripts"])
def test_provides_entry_missing_name_or_file(self, temp_dir, valid_manifest_data, section):
"""Each entry requires both 'name' and 'file'."""
import yaml
valid_manifest_data["provides"][section] = [{"name": "only-a-name"}]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="missing 'name' or 'file'"):
ExtensionManifest(manifest_path)
@pytest.mark.parametrize("section", ["templates", "scripts"])
def test_provides_entry_invalid_name_format(self, temp_dir, valid_manifest_data, section):
"""Names must be lowercase alphanumeric with hyphens only."""
import yaml
valid_manifest_data["provides"][section] = [
{"name": "Bad_Name", "file": f"{section}/bad.txt"}
]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="must be lowercase alphanumeric with hyphens only"):
ExtensionManifest(manifest_path)
@pytest.mark.parametrize("section", ["templates", "scripts"])
def test_provides_entry_path_traversal_rejected(self, temp_dir, valid_manifest_data, section):
"""The 'file' field is checked with the same path-safety policy as commands."""
import yaml
valid_manifest_data["provides"][section] = [
{"name": "escape", "file": "../evil"}
]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="relative path within the extension directory"):
ExtensionManifest(manifest_path)
@pytest.mark.parametrize("section", ["templates", "scripts"])
def test_provides_entry_strategy_rejected(self, temp_dir, valid_manifest_data, section):
"""'strategy' is preset-only; extension-provided artifacts are always 'replace'."""
import yaml
valid_manifest_data["provides"][section] = [
{"name": "has-strategy", "file": f"{section}/x.txt", "strategy": "replace"}
]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="'strategy' is not authorable"):
ExtensionManifest(manifest_path)
def test_script_runtimes_accepted(self, temp_dir, valid_manifest_data):
"""A valid 'runtimes' list on a script entry is accepted as-is."""
import yaml
valid_manifest_data["provides"]["scripts"] = [
{
"name": "myext-collect",
"file": "scripts/bash/myext-collect.sh",
"runtimes": ["bash", "powershell", "python"],
}
]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.scripts[0]["runtimes"] == ["bash", "powershell", "python"]
def test_script_runtimes_must_be_a_list_of_strings(self, temp_dir, valid_manifest_data):
"""A non-list 'runtimes' value is rejected."""
import yaml
valid_manifest_data["provides"]["scripts"] = [
{"name": "myext-collect", "file": "scripts/bash/myext-collect.sh", "runtimes": "bash"}
]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="expected a list of strings"):
ExtensionManifest(manifest_path)
def test_script_runtimes_rejects_unknown_runtime(self, temp_dir, valid_manifest_data):
"""An unrecognized runtime name is rejected with the valid set in the message."""
import yaml
valid_manifest_data["provides"]["scripts"] = [
{"name": "myext-collect", "file": "scripts/bash/myext-collect.sh", "runtimes": ["ruby"]}
]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid runtimes.*must be one of"):
ExtensionManifest(manifest_path)
def test_provides_entry_description_must_be_a_string(self, temp_dir, valid_manifest_data):
"""An optional 'description' field must be a string when present."""
import yaml
valid_manifest_data["provides"]["templates"] = [
{"name": "myext-template", "file": "templates/myext-template.md", "description": 123}
]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="expected a string"):
ExtensionManifest(manifest_path)
# ===== ExtensionRegistry Tests =====
class TestExtensionRegistry:
+175
View File
@@ -11249,6 +11249,181 @@ class TestWrapStrategy:
assert "# Selftest Core" in result
assert "{CORE_TEMPLATE}" not in result
def test_extension_template_resolves_via_manifest_when_filename_differs(self, project_dir):
"""provides.templates entries resolve via extension.yml when the file
doesn't sit at the conventional path.
Regression coverage for #4010: manifest-declared templates/scripts
must actually be consulted by the resolver, not just accepted by
manifest validation.
"""
ext_dir = project_dir / ".specify" / "extensions" / "reportext"
tmpl_dir = ext_dir / "templates" / "nested"
tmpl_dir.mkdir(parents=True, exist_ok=True)
# File lives at a path convention-based lookup (templates/<name>.md)
# would never find.
(tmpl_dir / "actual.md").write_text("# Report Scaffold\n")
(ext_dir / "extension.yml").write_text(
"schema_version: '1.0'\n"
"extension:\n id: reportext\n name: Report Ext\n version: 1.0.0\n"
" description: test\n author: test\n repository: https://example.com\n"
" license: MIT\n"
"requires:\n speckit_version: '>=0.2.0'\n"
"provides:\n"
" templates:\n"
" - name: report-scaffold\n"
" file: templates/nested/actual.md\n"
" description: Report scaffold\n"
)
resolver = PresetResolver(project_dir)
layers = resolver.collect_all_layers("report-scaffold", "template")
assert layers, "expected the manifest-declared template to resolve"
assert layers[0]["path"] == tmpl_dir / "actual.md"
assert layers[0]["strategy"] == "replace"
def test_extension_script_resolves_via_manifest_when_filename_differs(self, project_dir):
"""provides.scripts entries resolve via extension.yml when the file
doesn't sit at the conventional path."""
ext_dir = project_dir / ".specify" / "extensions" / "collectext"
script_dir = ext_dir / "scripts" / "bash"
script_dir.mkdir(parents=True, exist_ok=True)
# File is under scripts/bash/, not directly under scripts/, so
# convention-based lookup (scripts/<name>.sh) would never find it.
(script_dir / "collect.sh").write_text("#!/usr/bin/env bash\necho collect\n")
(ext_dir / "extension.yml").write_text(
"schema_version: '1.0'\n"
"extension:\n id: collectext\n name: Collect Ext\n version: 1.0.0\n"
" description: test\n author: test\n repository: https://example.com\n"
" license: MIT\n"
"requires:\n speckit_version: '>=0.2.0'\n"
"provides:\n"
" scripts:\n"
" - name: myext-collect\n"
" file: scripts/bash/collect.sh\n"
" description: Data-collection helper\n"
" runtimes: [bash]\n"
)
resolver = PresetResolver(project_dir)
layers = resolver.collect_all_layers("myext-collect", "script")
assert layers, "expected the manifest-declared script to resolve"
assert layers[0]["path"] == script_dir / "collect.sh"
assert layers[0]["strategy"] == "replace"
def test_extension_template_convention_lookup_unaffected_when_undeclared(self, project_dir):
"""An extension template with no manifest entry still resolves via
the pre-existing filename convention (no regression)."""
ext_dir = project_dir / ".specify" / "extensions" / "conventionext"
tmpl_dir = ext_dir / "templates"
tmpl_dir.mkdir(parents=True, exist_ok=True)
(tmpl_dir / "legacy-template.md").write_text("# Legacy Template\n")
# No extension.yml at all -- purely convention-based, unregistered extension.
resolver = PresetResolver(project_dir)
layers = resolver.collect_all_layers("legacy-template", "template")
assert layers, "expected convention-based lookup to still find the template"
assert layers[0]["path"] == tmpl_dir / "legacy-template.md"
def test_extension_manifest_wins_over_stale_conventional_file(self, project_dir):
"""A declared entry is authoritative even when a stale file also sits at
the conventional path (templates/<name>.md) — the manifest must win,
not the convention lookup, per #4010's acceptance criteria."""
ext_dir = project_dir / ".specify" / "extensions" / "bothpathsext"
(ext_dir / "templates").mkdir(parents=True, exist_ok=True)
(ext_dir / "custom").mkdir(parents=True, exist_ok=True)
# Stale file at the conventional path -- must NOT win.
(ext_dir / "templates" / "report-scaffold.md").write_text("# Stale\n")
# Declared file at a non-conventional path -- must win.
(ext_dir / "custom" / "bar.md").write_text("# Actual\n")
(ext_dir / "extension.yml").write_text(
"schema_version: '1.0'\n"
"extension:\n id: bothpathsext\n name: Both Paths Ext\n version: 1.0.0\n"
" description: test\n author: test\n repository: https://example.com\n"
" license: MIT\n"
"requires:\n speckit_version: '>=0.2.0'\n"
"provides:\n"
" templates:\n"
" - name: report-scaffold\n"
" file: custom/bar.md\n"
" description: Report scaffold\n"
)
resolver = PresetResolver(project_dir)
layers = resolver.collect_all_layers("report-scaffold", "template")
assert layers, "expected the manifest-declared template to resolve"
assert layers[0]["path"] == ext_dir / "custom" / "bar.md"
resolved = resolver.resolve("report-scaffold", "template")
assert resolved == ext_dir / "custom" / "bar.md"
with_source = resolver.resolve_with_source("report-scaffold", "template")
assert with_source["path"] == str(ext_dir / "custom" / "bar.md")
def test_extension_manifest_declared_but_missing_file_does_not_fall_back(self, project_dir):
"""A declared entry whose file is missing is authoritative -- the
resolver must not silently mask the typo by falling back to a
conventional file that happens to also exist."""
ext_dir = project_dir / ".specify" / "extensions" / "missingfileext"
(ext_dir / "scripts").mkdir(parents=True, exist_ok=True)
# A conventional file exists, but the manifest declares a different,
# non-existent file for the same name.
(ext_dir / "scripts" / "myext-collect.sh").write_text("#!/usr/bin/env bash\necho legacy\n")
(ext_dir / "extension.yml").write_text(
"schema_version: '1.0'\n"
"extension:\n id: missingfileext\n name: Missing File Ext\n version: 1.0.0\n"
" description: test\n author: test\n repository: https://example.com\n"
" license: MIT\n"
"requires:\n speckit_version: '>=0.2.0'\n"
"provides:\n"
" scripts:\n"
" - name: myext-collect\n"
" file: scripts/does-not-exist.sh\n"
" description: Data-collection helper\n"
)
resolver = PresetResolver(project_dir)
assert resolver.collect_all_layers("myext-collect", "script") == []
assert resolver.resolve("myext-collect", "script") is None
def test_extension_script_resolve_and_resolve_with_source_parity(self, project_dir):
"""resolve() and resolve_with_source() must find a manifest-declared
script at a non-conventional path, matching collect_all_layers()."""
ext_dir = project_dir / ".specify" / "extensions" / "collectext2"
script_dir = ext_dir / "scripts" / "bash"
script_dir.mkdir(parents=True, exist_ok=True)
(script_dir / "collect.sh").write_text("#!/usr/bin/env bash\necho collect\n")
(ext_dir / "extension.yml").write_text(
"schema_version: '1.0'\n"
"extension:\n id: collectext2\n name: Collect Ext 2\n version: 1.0.0\n"
" description: test\n author: test\n repository: https://example.com\n"
" license: MIT\n"
"requires:\n speckit_version: '>=0.2.0'\n"
"provides:\n"
" scripts:\n"
" - name: myext-collect2\n"
" file: scripts/bash/collect.sh\n"
" description: Data-collection helper\n"
" runtimes: [bash]\n"
)
resolver = PresetResolver(project_dir)
resolved = resolver.resolve("myext-collect2", "script")
assert resolved == script_dir / "collect.sh"
with_source = resolver.resolve_with_source("myext-collect2", "script")
assert with_source is not None
assert with_source["path"] == str(script_dir / "collect.sh")
assert with_source["source"] == "extension:collectext2 (unregistered)"
# ===== _replay_wraps_for_command Tests =====