Skip to content

Commit b0019c5

Browse files
iamaeroplaneclaude
andcommitted
fix(extensions): raise ValidationError for non-dict hook entries; fix wording
Raise ValidationError instead of silently skipping hook entries that are not mappings. The silent skip allowed malformed manifests to pass validation and crash later in HookExecutor.register_hooks() with an AttributeError. Added test_hook_entry_non_dict_value_raises. Change 'from AI agent' to 'across AI agents' in the extension remove confirmation message — cmd_count is summed across all registered agents, so the singular 'agent' was misleading. Updated CLI tests accordingly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 426d28e commit b0019c5

File tree

3 files changed

+24
-7
lines changed

3 files changed

+24
-7
lines changed

src/specify_cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3140,7 +3140,7 @@ def extension_remove(
31403140
if not force:
31413141
console.print("\n[yellow]⚠ This will remove:[/yellow]")
31423142
cmd_label = "command" if cmd_count == 1 else "commands"
3143-
console.print(f" • {cmd_count} {cmd_label} from AI agent")
3143+
console.print(f" • {cmd_count} {cmd_label} across AI agents")
31443144
if skill_count:
31453145
console.print(f" • {skill_count} agent skill(s)")
31463146
console.print(f" • Extension directory: .specify/extensions/{extension_id}/")

src/specify_cli/extensions.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,9 @@ def _validate(self):
299299
)
300300
for hook_name, hook_data in hooks.items():
301301
if not isinstance(hook_data, dict):
302-
continue
302+
raise ValidationError(
303+
f"'hooks.{hook_name}' must be a mapping, got {type(hook_data).__name__}"
304+
)
303305
command_ref = hook_data.get("command")
304306
if not isinstance(command_ref, str):
305307
continue

tests/test_extensions.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,21 @@ def test_hook_non_dict_value_raises(self, temp_dir, valid_manifest_data):
448448
with pytest.raises(ValidationError, match="'hooks' must be a mapping"):
449449
ExtensionManifest(manifest_path)
450450

451+
def test_hook_entry_non_dict_value_raises(self, temp_dir, valid_manifest_data):
452+
"""A hook entry that is not a mapping raises ValidationError instead of silently skipping."""
453+
import yaml
454+
455+
# hooks.after_tasks is a list instead of a dict — should fail validation,
456+
# not silently pass and crash later in HookExecutor.
457+
valid_manifest_data["hooks"]["after_tasks"] = ["x", "y"]
458+
459+
manifest_path = temp_dir / "extension.yml"
460+
with open(manifest_path, "w") as f:
461+
yaml.dump(valid_manifest_data, f)
462+
463+
with pytest.raises(ValidationError, match="'hooks.after_tasks' must be a mapping"):
464+
ExtensionManifest(manifest_path)
465+
451466
def test_valid_command_name_has_no_warnings(self, temp_dir, valid_manifest_data):
452467
"""Test that a correctly-named command produces no warnings."""
453468
import yaml
@@ -3185,8 +3200,8 @@ def test_remove_confirmation_counts_primary_command_only(self, extension_dir, pr
31853200
result = runner.invoke(app, ["extension", "remove", "test-ext"], input="n\n")
31863201

31873202
plain = strip_ansi(result.output)
3188-
# The fixture has 1 command and 0 aliases → "1 command from AI agent"
3189-
assert "1 command from AI agent" in plain
3203+
# The fixture has 1 command and 0 aliases → "1 command across AI agents"
3204+
assert "1 command across AI agents" in plain
31903205

31913206
def test_remove_confirmation_counts_aliases(self, temp_dir, project_dir):
31923207
"""Removal confirmation shows primary + alias count, not just primary."""
@@ -3239,9 +3254,9 @@ def test_remove_confirmation_counts_aliases(self, temp_dir, project_dir):
32393254
result = runner.invoke(app, ["extension", "remove", "ext-with-alias"], input="n\n")
32403255

32413256
plain = strip_ansi(result.output)
3242-
# 1 primary + 1 alias = "2 commands from AI agent" (not "1 command")
3243-
assert "2 commands from AI agent" in plain
3244-
assert "1 command from AI agent" not in plain
3257+
# 1 primary + 1 alias = "2 commands across AI agents" (not "1 command")
3258+
assert "2 commands across AI agents" in plain
3259+
assert "1 command across AI agents" not in plain
32453260

32463261

32473262
class TestExtensionUpdateCLI:

0 commit comments

Comments
 (0)