From fa565874da39803e36c6d8312f0d6547b3c5dcab Mon Sep 17 00:00:00 2001 From: chelsealong Date: Fri, 7 Aug 2026 18:06:16 +0000 Subject: [PATCH] fix(extensions): reject duplicate provides.templates/scripts names 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. --- extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 7 ++++++- src/specify_cli/extensions/__init__.py | 12 +++++++++++- tests/test_extensions.py | 23 +++++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index 5030565b14..ac78029f2a 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -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}`) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 2becae9bc1..95c0eeddd4 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -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( @@ -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) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 6508826dc9..36e7d67aab 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -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."""