Skip to content

Commit 16f03ff

Browse files
iamaeroplaneclaude
andcommitted
fix(extensions): address PR review feedback (#2017)
- _try_correct_command_name: only correct 'X.Y' to 'speckit.ext_id.Y' when X matches ext_id, preventing misleading warnings followed by install failure due to namespace mismatch - _validate: add aliases type/string guards matching _collect_manifest _command_names defensive checks - _validate: track command renames and rewrite any hook.*.command references that pointed at a renamed command, emitting a warning - test: fix test_command_name_autocorrect_no_speckit_prefix to use ext_id matching the legacy namespace; add namespace-mismatch test - test: replace redundant preset-catalogs.yml isolation with monkeypatch.delenv("SPECKIT_PRESET_CATALOG_URL") so the env var cannot bypass catalog restriction in CI environments Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fc99452 commit 16f03ff

File tree

3 files changed

+55
-24
lines changed

3 files changed

+55
-24
lines changed

src/specify_cli/extensions.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,8 @@ def _validate(self):
187187
if "commands" not in provides or not provides["commands"]:
188188
raise ValidationError("Extension must provide at least one command")
189189

190-
# Validate commands
190+
# Validate commands; track renames so hook references can be rewritten.
191+
rename_map: Dict[str, str] = {}
191192
for cmd in provides["commands"]:
192193
if "name" not in cmd or "file" not in cmd:
193194
raise ValidationError("Command missing 'name' or 'file'")
@@ -201,6 +202,7 @@ def _validate(self):
201202
f"'speckit.{{extension}}.{{command}}'. Registering as '{corrected}'. "
202203
f"The extension author should update the manifest to use this name."
203204
)
205+
rename_map[cmd["name"]] = corrected
204206
cmd["name"] = corrected
205207
else:
206208
raise ValidationError(
@@ -209,8 +211,18 @@ def _validate(self):
209211
)
210212

211213
# Validate and auto-correct alias name formats
212-
aliases = cmd.get("aliases") or []
214+
aliases = cmd.get("aliases")
215+
if aliases is None:
216+
aliases = []
217+
if not isinstance(aliases, list):
218+
raise ValidationError(
219+
f"Aliases for command '{cmd['name']}' must be a list"
220+
)
213221
for i, alias in enumerate(aliases):
222+
if not isinstance(alias, str):
223+
raise ValidationError(
224+
f"Aliases for command '{cmd['name']}' must be strings"
225+
)
214226
if not EXTENSION_COMMAND_NAME_PATTERN.match(alias):
215227
corrected = self._try_correct_command_name(alias, ext["id"])
216228
if corrected:
@@ -219,31 +231,45 @@ def _validate(self):
219231
f"'speckit.{{extension}}.{{command}}'. Registering as '{corrected}'. "
220232
f"The extension author should update the manifest to use this name."
221233
)
234+
rename_map[alias] = corrected
222235
aliases[i] = corrected
223236
else:
224237
raise ValidationError(
225238
f"Invalid alias '{alias}': "
226239
"must follow pattern 'speckit.{extension}.{command}'"
227240
)
228241

242+
# Rewrite any hook command references that pointed at a renamed command.
243+
for hook_name, hook_data in self.data.get("hooks", {}).items():
244+
if isinstance(hook_data, dict) and hook_data.get("command") in rename_map:
245+
old_ref = hook_data["command"]
246+
hook_data["command"] = rename_map[old_ref]
247+
self.warnings.append(
248+
f"Hook '{hook_name}' referenced renamed command '{old_ref}'; "
249+
f"updated to '{rename_map[old_ref]}'. "
250+
f"The extension author should update the manifest."
251+
)
252+
229253
@staticmethod
230254
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
231255
"""Try to auto-correct a non-conforming command name to the required pattern.
232256
233-
Handles the two most common legacy formats used by community extensions:
234-
- 'speckit.command' → 'speckit.{ext_id}.command'
235-
- 'extension.command' → 'speckit.extension.command'
257+
Handles the two legacy formats used by community extensions:
258+
- 'speckit.command' → 'speckit.{ext_id}.command'
259+
- '{ext_id}.command' → 'speckit.{ext_id}.command'
260+
261+
The 'X.Y' form is only corrected when X matches ext_id to ensure the
262+
result passes the install-time namespace check. Any other prefix is
263+
uncorrectable and will produce a ValidationError at the call site.
236264
237265
Returns the corrected name, or None if no safe correction is possible.
238266
"""
239267
parts = name.split('.')
240268
if len(parts) == 2:
241-
if parts[0] == 'speckit':
269+
if parts[0] == 'speckit' or parts[0] == ext_id:
242270
candidate = f"speckit.{ext_id}.{parts[1]}"
243-
else:
244-
candidate = f"speckit.{parts[0]}.{parts[1]}"
245-
if EXTENSION_COMMAND_NAME_PATTERN.match(candidate):
246-
return candidate
271+
if EXTENSION_COMMAND_NAME_PATTERN.match(candidate):
272+
return candidate
247273
return None
248274

249275
@property

tests/test_extensions.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,10 +270,12 @@ def test_command_name_autocorrect_speckit_prefix(self, temp_dir, valid_manifest_
270270
assert "speckit.hello" in manifest.warnings[0]
271271
assert "speckit.test-ext.hello" in manifest.warnings[0]
272272

273-
def test_command_name_autocorrect_no_speckit_prefix(self, temp_dir, valid_manifest_data):
274-
"""Test that 'extension.command' is auto-corrected to 'speckit.extension.command'."""
273+
def test_command_name_autocorrect_matching_ext_id_prefix(self, temp_dir, valid_manifest_data):
274+
"""Test that '{ext_id}.command' is auto-corrected to 'speckit.{ext_id}.command'."""
275275
import yaml
276276

277+
# Set ext_id to match the legacy namespace so correction is valid
278+
valid_manifest_data["extension"]["id"] = "docguard"
277279
valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard"
278280

279281
manifest_path = temp_dir / "extension.yml"
@@ -287,6 +289,20 @@ def test_command_name_autocorrect_no_speckit_prefix(self, temp_dir, valid_manife
287289
assert "docguard.guard" in manifest.warnings[0]
288290
assert "speckit.docguard.guard" in manifest.warnings[0]
289291

292+
def test_command_name_mismatched_namespace_not_corrected(self, temp_dir, valid_manifest_data):
293+
"""Test that 'X.command' is NOT corrected when X doesn't match ext_id."""
294+
import yaml
295+
296+
# ext_id is "test-ext" but command uses a different namespace
297+
valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard"
298+
299+
manifest_path = temp_dir / "extension.yml"
300+
with open(manifest_path, 'w') as f:
301+
yaml.dump(valid_manifest_data, f)
302+
303+
with pytest.raises(ValidationError, match="Invalid command name"):
304+
ExtensionManifest(manifest_path)
305+
290306
def test_alias_autocorrect_speckit_prefix(self, temp_dir, valid_manifest_data):
291307
"""Test that a legacy 'speckit.command' alias is auto-corrected."""
292308
import yaml

tests/test_presets.py

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,21 +1174,10 @@ def test_search_with_cached_data(self, project_dir, monkeypatch):
11741174
"""Test search with cached catalog data."""
11751175
from unittest.mock import patch
11761176

1177-
# Only use the default catalog to prevent fetching the community catalog from the network
1178-
monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", PresetCatalog.DEFAULT_CATALOG_URL)
1177+
monkeypatch.delenv("SPECKIT_PRESET_CATALOG_URL", raising=False)
11791178
catalog = PresetCatalog(project_dir)
11801179
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
11811180

1182-
# Restrict to default catalog only — prevents community catalog network calls
1183-
# which would add extra results and make the count assertions flaky.
1184-
(project_dir / ".specify" / "preset-catalogs.yml").write_text(
1185-
f"catalogs:\n"
1186-
f" - url: \"{PresetCatalog.DEFAULT_CATALOG_URL}\"\n"
1187-
f" name: default\n"
1188-
f" priority: 1\n"
1189-
f" install_allowed: true\n"
1190-
)
1191-
11921181
catalog_data = {
11931182
"schema_version": "1.0",
11941183
"presets": {

0 commit comments

Comments
 (0)