Skip to content

Commit 9a48e78

Browse files
iamaeroplaneclaude
andcommitted
fix(extensions): detect cross-command SKILL name collisions in manifest validation
Extend the SKILL output name collision check in ExtensionManifest._validate() to cover cross-command scenarios: a primary on command A and an alias on command B that both map to the same SKILL.md directory (e.g. 'speckit.myext.build' and alias 'myext.build' on a different entry both produce 'speckit-myext-build/SKILL.md'). Previously only same-entry collisions were caught. Now a shared dict of seen SKILL output names is maintained across all commands and aliases, and any duplicate raises ValidationError with a clear message indicating the claimant. Adds _skill_output_name() static helper to mirror _compute_output_name logic for SKILL.md agents without importing the agents module. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ecd9c0a commit 9a48e78

2 files changed

Lines changed: 62 additions & 13 deletions

File tree

src/specify_cli/extensions.py

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,12 @@ def _validate(self):
192192

193193
# Validate commands; track renames so hook references can be rewritten.
194194
rename_map: Dict[str, str] = {}
195+
# Track SKILL output names across all commands+aliases to catch cross-command
196+
# collisions. For SKILL.md agents, _compute_output_name strips the 'speckit.'
197+
# prefix and hyphenates: 'speckit.myext.build' and alias 'myext.build' on a
198+
# *different* command entry both map to 'speckit-myext-build/SKILL.md'.
199+
seen_skill_names: Dict[str, str] = {} # skill_output_name -> description of claimant
200+
195201
for cmd in provides["commands"]:
196202
if "name" not in cmd or "file" not in cmd:
197203
raise ValidationError("Command missing 'name' or 'file'")
@@ -213,6 +219,17 @@ def _validate(self):
213219
"must follow pattern 'speckit.{extension}.{command}'"
214220
)
215221

222+
# Register the primary command's SKILL output name.
223+
primary_skill = self._skill_output_name(cmd["name"])
224+
if primary_skill in seen_skill_names:
225+
raise ValidationError(
226+
f"Command '{cmd['name']}' would produce SKILL output name "
227+
f"'{primary_skill}' which is already claimed by "
228+
f"{seen_skill_names[primary_skill]}. "
229+
f"Choose a distinct command name."
230+
)
231+
seen_skill_names[primary_skill] = f"primary command '{cmd['name']}'"
232+
216233
# Validate and auto-correct alias name formats
217234
aliases = cmd.get("aliases")
218235
if aliases is None:
@@ -229,21 +246,22 @@ def _validate(self):
229246
)
230247
alias_match = EXTENSION_ALIAS_PATTERN.match(alias)
231248
if alias_match and alias_match.group(1) != 'speckit':
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(".", "-")
249+
# Valid 'ext.cmd' form — check it won't collide with any previously
250+
# seen primary or alias SKILL output name (including its own command's
251+
# primary). For SKILL.md agents, _compute_output_name strips 'speckit.'
252+
# so e.g. primary 'speckit.myext.run' and alias 'myext.run' both map
253+
# to 'speckit-myext-run', causing the alias write to overwrite the
254+
# primary skill file.
255+
alias_skill = self._skill_output_name(alias)
256+
if alias_skill in seen_skill_names:
257+
skill_name = alias_skill
241258
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}'). "
259+
f"Alias '{alias}' on command '{cmd['name']}' would produce "
260+
f"SKILL output name '{skill_name}' which is already claimed by "
261+
f"{seen_skill_names[alias_skill]}. "
245262
f"Choose a distinct alias name."
246263
)
264+
seen_skill_names[alias_skill] = f"alias '{alias}' on command '{cmd['name']}'"
247265
else:
248266
corrected = self._try_correct_alias_name(alias, ext["id"])
249267
if corrected:
@@ -304,6 +322,16 @@ def _validate(self):
304322
f"the canonical command name directly."
305323
)
306324

325+
@staticmethod
326+
def _skill_output_name(cmd_name: str) -> str:
327+
"""Compute the SKILL.md directory name for a command or alias.
328+
329+
Mirrors the logic in CommandRegistrar._compute_output_name for SKILL.md
330+
agents: strip the 'speckit.' prefix (if present) then hyphenate.
331+
"""
332+
short = cmd_name[len("speckit."):] if cmd_name.startswith("speckit.") else cmd_name
333+
return "speckit-" + short.replace(".", "-")
334+
307335
@staticmethod
308336
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
309337
"""Try to auto-correct a non-conforming primary command name to 'speckit.{ext_id}.command'.

tests/test_extensions.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,28 @@ def test_alias_collision_with_primary_skill_name_rejected(self, temp_dir, valid_
373373
with open(manifest_path, "w") as f:
374374
yaml.dump(valid_manifest_data, f)
375375

376-
with pytest.raises(ValidationError, match="would collide with primary command"):
376+
with pytest.raises(ValidationError, match="would produce SKILL output name"):
377+
ExtensionManifest(manifest_path)
378+
379+
def test_cross_command_skill_name_collision_rejected(self, temp_dir, valid_manifest_data):
380+
"""Alias on one command that collides with a primary on a different command is rejected."""
381+
import yaml
382+
383+
# Command A: primary 'speckit.test-ext.hello' → skill 'speckit-test-ext-hello'
384+
# Command B: primary 'speckit.test-ext.build' with alias 'test-ext.hello'
385+
# → alias also maps to 'speckit-test-ext-hello' — collision with cmd A.
386+
valid_manifest_data["provides"]["commands"][0]["aliases"] = []
387+
valid_manifest_data["provides"]["commands"].append({
388+
"name": "speckit.test-ext.build",
389+
"file": "commands/build.md",
390+
"aliases": ["test-ext.hello"],
391+
})
392+
393+
manifest_path = temp_dir / "extension.yml"
394+
with open(manifest_path, "w") as f:
395+
yaml.dump(valid_manifest_data, f)
396+
397+
with pytest.raises(ValidationError, match="would produce SKILL output name"):
377398
ExtensionManifest(manifest_path)
378399

379400
def test_hook_alias_ref_canonicalized_to_speckit_form(self, temp_dir, valid_manifest_data):

0 commit comments

Comments
 (0)