Skip to content

Commit 9ab59cd

Browse files
iamaeroplaneclaude
andcommitted
fix(extensions): address final reviewer comments (#2017)
- Reject aliases whose SKILL output name would collide with the primary command (e.g. primary 'speckit.myext.run' + alias 'myext.run' both map to skill 'speckit-myext-run') - Fix hook rewrite warning to show the final canonical value instead of the intermediate rename_map value; compute final_ref (rename + lift) first, then write to hook_data and emit warning in a single step - Guard registered_commands against non-dict in extension_remove CLI (corrupted/legacy registry entries no longer crash with AttributeError) - Update alias tests to use non-colliding alias suffix ('greet' not 'hello') - Add test_alias_collision_with_primary_skill_name_rejected Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b80ce0d commit 9ab59cd

File tree

3 files changed

+69
-29
lines changed

3 files changed

+69
-29
lines changed

src/specify_cli/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3124,8 +3124,11 @@ def extension_remove(
31243124

31253125
# Get extension info for command and skill counts
31263126
reg_meta = manager.registry.get(extension_id)
3127-
# Count total registered commands across all agents (includes aliases)
3128-
registered_commands = reg_meta.get("registered_commands", {}) if reg_meta else {}
3127+
# Count total registered commands across all agents (includes aliases).
3128+
# Normalize to dict in case registry entry is corrupted or from an older version.
3129+
registered_commands = reg_meta.get("registered_commands") if reg_meta else None
3130+
if not isinstance(registered_commands, dict):
3131+
registered_commands = {}
31293132
cmd_count = sum(
31303133
len(names) for names in registered_commands.values()
31313134
if isinstance(names, list)

src/specify_cli/extensions.py

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,21 @@ def _validate(self):
229229
)
230230
alias_match = EXTENSION_ALIAS_PATTERN.match(alias)
231231
if alias_match and alias_match.group(1) != 'speckit':
232-
pass # already valid: 'myext.command' form
232+
# Valid 'ext.cmd' form — check it won't collide with the primary
233+
# command's SKILL output name. For SKILL.md agents,
234+
# _compute_output_name strips 'speckit.' so e.g. primary
235+
# 'speckit.myext.run' and alias 'myext.run' both map to
236+
# 'speckit-myext-run', causing the alias write to overwrite
237+
# the primary skill file.
238+
primary = cmd["name"]
239+
if primary.startswith("speckit.") and primary[len("speckit."):] == alias:
240+
skill_name = "speckit-" + alias.replace(".", "-")
241+
raise ValidationError(
242+
f"Alias '{alias}' would collide with primary command "
243+
f"'{primary}' on SKILL.md-based agents (both map to "
244+
f"'{skill_name}'). "
245+
f"Choose a distinct alias name."
246+
)
233247
else:
234248
corrected = self._try_correct_alias_name(alias, ext["id"])
235249
if corrected:
@@ -263,23 +277,24 @@ def _validate(self):
263277
continue
264278

265279
# Step 1: apply rename_map (command was auto-corrected during validation).
266-
rewritten = rename_map.get(command_ref, command_ref)
267-
if rewritten != command_ref:
268-
hook_data["command"] = rewritten
269-
self.warnings.append(
270-
f"Hook '{hook_name}' referenced renamed command '{command_ref}'; "
271-
f"updated to '{rewritten}'. "
272-
f"The extension author should update the manifest."
273-
)
274-
command_ref = rewritten
280+
final_ref = rename_map.get(command_ref, command_ref)
275281

276282
# Step 2: if the ref is in alias form '{ext_id}.cmd', lift it to
277283
# 'speckit.{ext_id}.cmd' so skill-mode hook invocation renders correctly.
278-
parts = command_ref.split('.')
284+
parts = final_ref.split('.')
279285
if len(parts) == 2 and parts[0] == ext["id"]:
280286
canonical = f"speckit.{ext['id']}.{parts[1]}"
281287
if EXTENSION_COMMAND_NAME_PATTERN.match(canonical):
282-
hook_data["command"] = canonical
288+
final_ref = canonical
289+
290+
if final_ref != command_ref:
291+
hook_data["command"] = final_ref
292+
if command_ref in rename_map:
293+
self.warnings.append(
294+
f"Hook '{hook_name}' referenced renamed command '{command_ref}'; "
295+
f"updated to '{final_ref}'. "
296+
f"The extension author should update the manifest."
297+
)
283298

284299
@staticmethod
285300
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:

tests/test_extensions.py

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -305,63 +305,85 @@ def test_command_name_mismatched_namespace_not_corrected(self, temp_dir, valid_m
305305
ExtensionManifest(manifest_path)
306306

307307
def test_alias_valid_two_part_no_prefix(self, temp_dir, valid_manifest_data):
308-
"""Test that a 'test-ext.hello' alias is accepted as-is with no warning."""
308+
"""Test that a 'test-ext.greet' alias is accepted as-is with no warning."""
309309
import yaml
310310

311-
valid_manifest_data["provides"]["commands"][0]["aliases"] = ["test-ext.hello"]
311+
# Use a name distinct from the primary command's suffix ('hello') to avoid
312+
# the SKILL.md output-name collision (both would map to speckit-test-ext-hello).
313+
valid_manifest_data["provides"]["commands"][0]["aliases"] = ["test-ext.greet"]
312314

313315
manifest_path = temp_dir / "extension.yml"
314316
with open(manifest_path, 'w') as f:
315317
yaml.dump(valid_manifest_data, f)
316318

317319
manifest = ExtensionManifest(manifest_path)
318320

319-
assert manifest.commands[0]["aliases"] == ["test-ext.hello"]
321+
assert manifest.commands[0]["aliases"] == ["test-ext.greet"]
320322
assert manifest.warnings == []
321323

322324
def test_alias_autocorrect_speckit_two_part(self, temp_dir, valid_manifest_data):
323325
"""Test that legacy 'speckit.command' alias is corrected to '{ext_id}.command'."""
324326
import yaml
325327

326-
valid_manifest_data["provides"]["commands"][0]["aliases"] = ["speckit.hello"]
328+
# Use 'speckit.greet' → 'test-ext.greet' to avoid colliding with the primary
329+
# command's SKILL output name (speckit.test-ext.hello → speckit-test-ext-hello).
330+
valid_manifest_data["provides"]["commands"][0]["aliases"] = ["speckit.greet"]
327331

328332
manifest_path = temp_dir / "extension.yml"
329333
with open(manifest_path, 'w') as f:
330334
yaml.dump(valid_manifest_data, f)
331335

332336
manifest = ExtensionManifest(manifest_path)
333337

334-
assert manifest.commands[0]["aliases"] == ["test-ext.hello"]
338+
assert manifest.commands[0]["aliases"] == ["test-ext.greet"]
335339
assert len(manifest.warnings) == 1
336-
assert "speckit.hello" in manifest.warnings[0]
337-
assert "test-ext.hello" in manifest.warnings[0]
340+
assert "speckit.greet" in manifest.warnings[0]
341+
assert "test-ext.greet" in manifest.warnings[0]
338342

339343
def test_alias_autocorrect_speckit_three_part(self, temp_dir, valid_manifest_data):
340344
"""Test that a 3-part 'speckit.ext.command' alias is corrected to 'ext.command'."""
341345
import yaml
342346

343347
# Clear hooks so the alias rename doesn't also trigger a hook-reference warning.
348+
# Use 'speckit.test-ext.greet' → 'test-ext.greet' to avoid colliding with the
349+
# primary command's SKILL output name (speckit.test-ext.hello → speckit-test-ext-hello).
344350
valid_manifest_data.pop("hooks", None)
345-
valid_manifest_data["provides"]["commands"][0]["aliases"] = ["speckit.test-ext.hello"]
351+
valid_manifest_data["provides"]["commands"][0]["aliases"] = ["speckit.test-ext.greet"]
346352

347353
manifest_path = temp_dir / "extension.yml"
348354
with open(manifest_path, 'w') as f:
349355
yaml.dump(valid_manifest_data, f)
350356

351357
manifest = ExtensionManifest(manifest_path)
352358

353-
assert manifest.commands[0]["aliases"] == ["test-ext.hello"]
359+
assert manifest.commands[0]["aliases"] == ["test-ext.greet"]
354360
assert len(manifest.warnings) == 1
355-
assert "speckit.test-ext.hello" in manifest.warnings[0]
356-
assert "test-ext.hello" in manifest.warnings[0]
361+
assert "speckit.test-ext.greet" in manifest.warnings[0]
362+
assert "test-ext.greet" in manifest.warnings[0]
363+
364+
def test_alias_collision_with_primary_skill_name_rejected(self, temp_dir, valid_manifest_data):
365+
"""Alias whose SKILL output name matches the primary command's is rejected."""
366+
import yaml
367+
368+
# Primary 'speckit.test-ext.hello' maps to skill 'speckit-test-ext-hello'.
369+
# Alias 'test-ext.hello' would map to the same skill name — collision.
370+
valid_manifest_data["provides"]["commands"][0]["aliases"] = ["test-ext.hello"]
371+
372+
manifest_path = temp_dir / "extension.yml"
373+
with open(manifest_path, "w") as f:
374+
yaml.dump(valid_manifest_data, f)
375+
376+
with pytest.raises(ValidationError, match="would collide with primary command"):
377+
ExtensionManifest(manifest_path)
357378

358379
def test_hook_alias_ref_canonicalized_to_speckit_form(self, temp_dir, valid_manifest_data):
359380
"""Hook command refs in alias form are lifted to canonical speckit.ext.cmd form."""
360381
import yaml
361382

362-
# Manifest uses a valid alias but the hook mistakenly references the alias name.
363-
valid_manifest_data["provides"]["commands"][0]["aliases"] = ["test-ext.hello"]
364-
valid_manifest_data["hooks"]["after_tasks"]["command"] = "test-ext.hello"
383+
# Manifest uses a valid alias (distinct suffix to avoid SKILL name collision)
384+
# but the hook mistakenly references the alias name instead of the primary.
385+
valid_manifest_data["provides"]["commands"][0]["aliases"] = ["test-ext.greet"]
386+
valid_manifest_data["hooks"]["after_tasks"]["command"] = "test-ext.greet"
365387

366388
manifest_path = temp_dir / "extension.yml"
367389
with open(manifest_path, "w") as f:
@@ -370,7 +392,7 @@ def test_hook_alias_ref_canonicalized_to_speckit_form(self, temp_dir, valid_mani
370392
manifest = ExtensionManifest(manifest_path)
371393

372394
# Hook ref should be lifted to canonical form for skill-mode invocation.
373-
assert manifest.hooks["after_tasks"]["command"] == "speckit.test-ext.hello"
395+
assert manifest.hooks["after_tasks"]["command"] == "speckit.test-ext.greet"
374396

375397
def test_hook_non_dict_value_raises(self, temp_dir, valid_manifest_data):
376398
"""A non-mapping hooks value raises ValidationError."""

0 commit comments

Comments
 (0)