Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion extensions/EXTENSION-DEVELOPMENT-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,12 +177,17 @@ Compatibility requirements.

What the extension provides.

**Optional sub-fields** (at least one of `commands`, `templates`, `scripts`, `hooks`, or `events` is required):
**Optional sub-fields:**

- `commands`: Array of command objects
- `templates`: Array of template objects
- `scripts`: Array of script objects

`hooks` and `events` are separate top-level manifest fields (siblings of
`provides`, not nested under it — see [`hooks`](#hooks) below). At least one
of `provides.commands`, `provides.templates`, `provides.scripts`, `hooks`, or
`events` is required.

**Command object**:

- `name`: Command name (must match `speckit.{ext-id}.{command}`)
Expand Down
12 changes: 11 additions & 1 deletion src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -577,8 +577,13 @@ def _validate_provided_artifacts(entries: List[Any], section: str, singular: str
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.
silently-dropped field. Duplicate names within a section are also
rejected: the resolver returns the first matching entry by name
(``PresetResolver._extension_manifest_declared_template``), so a
later duplicate would be silently unreachable while still being
exposed by ``ExtensionManifest.templates``/``.scripts``.
"""
seen_names: set[str] = set()
for entry in entries:
if not isinstance(entry, dict):
raise ValidationError(
Expand All @@ -597,6 +602,11 @@ def _validate_provided_artifacts(entries: List[Any], section: str, singular: str
f"Invalid {singular} name '{name}': "
"must be lowercase alphanumeric with hyphens only"
)
if name in seen_names:
raise ValidationError(
f"Duplicate {singular} name '{name}' in 'provides.{section}'"
)
seen_names.add(name)

file_value = entry["file"]
reason = relative_extension_path_violation(file_value)
Expand Down
23 changes: 23 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,29 @@ def test_provides_entry_invalid_name_format(self, temp_dir, valid_manifest_data,
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_duplicate_name_rejected(self, temp_dir, valid_manifest_data, section):
"""Two entries in the same section sharing a name are rejected.

The resolver (PresetResolver._extension_manifest_declared_template)
returns the first entry matching a name, so a later duplicate would
be silently unreachable while still counted by ExtensionManifest
properties -- reject it up front instead.
"""
import yaml

valid_manifest_data["provides"][section] = [
{"name": "dup", "file": f"{section}/a.txt"},
{"name": "dup", "file": f"{section}/b.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=f"Duplicate .* name 'dup' in 'provides.{section}'"):
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."""
Expand Down