Skip to content

Commit 11e3176

Browse files
authored
fix(extensions): reject duplicate provides.templates/scripts names (#4016)
The resolver returns the first entry matching a declared name, so a later duplicate within provides.templates or provides.scripts was silently unreachable while still counted by ExtensionManifest properties. Reject duplicates at manifest-validation time instead. Also clarify EXTENSION-DEVELOPMENT-GUIDE.md's provides section: hooks and events are top-level manifest fields, not provides sub-fields, so the "at least one of ..." wording doesn't imply they can be nested under provides.
1 parent 16cfab7 commit 11e3176

3 files changed

Lines changed: 40 additions & 2 deletions

File tree

extensions/EXTENSION-DEVELOPMENT-GUIDE.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,12 +177,17 @@ Compatibility requirements.
177177

178178
What the extension provides.
179179

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

182182
- `commands`: Array of command objects
183183
- `templates`: Array of template objects
184184
- `scripts`: Array of script objects
185185

186+
`hooks` and `events` are separate top-level manifest fields (siblings of
187+
`provides`, not nested under it — see [`hooks`](#hooks) below). At least one
188+
of `provides.commands`, `provides.templates`, `provides.scripts`, `hooks`, or
189+
`events` is required.
190+
186191
**Command object**:
187192

188193
- `name`: Command name (must match `speckit.{ext-id}.{command}`)

src/specify_cli/extensions/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -577,8 +577,13 @@ def _validate_provided_artifacts(entries: List[Any], section: str, singular: str
577577
behavior for extension layers in presets/__init__.py). A present
578578
'strategy' key is rejected rather than silently ignored, so an author
579579
who copies a preset-style entry gets a clear error instead of a
580-
silently-dropped field.
580+
silently-dropped field. Duplicate names within a section are also
581+
rejected: the resolver returns the first matching entry by name
582+
(``PresetResolver._extension_manifest_declared_template``), so a
583+
later duplicate would be silently unreachable while still being
584+
exposed by ``ExtensionManifest.templates``/``.scripts``.
581585
"""
586+
seen_names: set[str] = set()
582587
for entry in entries:
583588
if not isinstance(entry, dict):
584589
raise ValidationError(
@@ -597,6 +602,11 @@ def _validate_provided_artifacts(entries: List[Any], section: str, singular: str
597602
f"Invalid {singular} name '{name}': "
598603
"must be lowercase alphanumeric with hyphens only"
599604
)
605+
if name in seen_names:
606+
raise ValidationError(
607+
f"Duplicate {singular} name '{name}' in 'provides.{section}'"
608+
)
609+
seen_names.add(name)
600610

601611
file_value = entry["file"]
602612
reason = relative_extension_path_violation(file_value)

tests/test_extensions.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,6 +1162,29 @@ def test_provides_entry_invalid_name_format(self, temp_dir, valid_manifest_data,
11621162
with pytest.raises(ValidationError, match="must be lowercase alphanumeric with hyphens only"):
11631163
ExtensionManifest(manifest_path)
11641164

1165+
@pytest.mark.parametrize("section", ["templates", "scripts"])
1166+
def test_provides_entry_duplicate_name_rejected(self, temp_dir, valid_manifest_data, section):
1167+
"""Two entries in the same section sharing a name are rejected.
1168+
1169+
The resolver (PresetResolver._extension_manifest_declared_template)
1170+
returns the first entry matching a name, so a later duplicate would
1171+
be silently unreachable while still counted by ExtensionManifest
1172+
properties -- reject it up front instead.
1173+
"""
1174+
import yaml
1175+
1176+
valid_manifest_data["provides"][section] = [
1177+
{"name": "dup", "file": f"{section}/a.txt"},
1178+
{"name": "dup", "file": f"{section}/b.txt"},
1179+
]
1180+
1181+
manifest_path = temp_dir / "extension.yml"
1182+
with open(manifest_path, 'w', encoding="utf-8") as f:
1183+
yaml.dump(valid_manifest_data, f)
1184+
1185+
with pytest.raises(ValidationError, match=f"Duplicate .* name 'dup' in 'provides.{section}'"):
1186+
ExtensionManifest(manifest_path)
1187+
11651188
@pytest.mark.parametrize("section", ["templates", "scripts"])
11661189
def test_provides_entry_path_traversal_rejected(self, temp_dir, valid_manifest_data, section):
11671190
"""The 'file' field is checked with the same path-safety policy as commands."""

0 commit comments

Comments
 (0)