github/spec-kit · error · ValidationError

Hook '{hook_name}' must be a mapping or list of mappings, go

Error message

Hook '{hook_name}' must be a mapping or list of mappings, got {type(entry).__name__}

What it means

While rewriting hook command references, the loader found a hooks.<name> entry (after coerce_hook_entries() normalization) that is not a mapping. Each hook entry must be a dict so the rewriter can read its 'command' key; scalars, lists-of-scalars, or nested lists produce this ValidationError. The hook pass exists to rename refs pointed at auto-corrected command names and to lift alias-form '{ext_id}.cmd' refs to canonical 'speckit.{ext_id}.cmd'.

Source

Thrown at src/specify_cli/extensions/__init__.py:519

            for alias in aliases:
                if not isinstance(alias, str):
                    raise ValidationError(
                        f"Aliases for command '{cmd['name']}' must be strings"
                    )
                alias_reason = relative_extension_path_violation(alias)
                if alias_reason:
                    raise ValidationError(
                        f"Invalid alias {alias!r} for command "
                        f"'{cmd['name']}': {alias_reason}"
                    )

        # Rewrite any hook command references that pointed at a renamed command or
        # an alias-form ref (ext.cmd → speckit.ext.cmd).  Always emit a warning when
        # the reference is changed so extension authors know to update the manifest.
        for hook_name, hook_data in self.data.get("hooks", {}).items():
            for entry in coerce_hook_entries(hook_data):
                if not isinstance(entry, dict):
                    raise ValidationError(
                        f"Hook '{hook_name}' must be a mapping or list of mappings, "
                        f"got {type(entry).__name__}"
                    )
                command_ref = entry.get("command")
                if not isinstance(command_ref, str):
                    continue
                # Step 1: apply any rename from the auto-correction pass.
                after_rename = rename_map.get(command_ref, command_ref)
                # Step 2: lift alias-form '{ext_id}.cmd' to canonical 'speckit.{ext_id}.cmd'.
                parts = after_rename.split(".")
                if len(parts) == 2 and parts[0] == ext["id"]:
                    final_ref = f"speckit.{ext['id']}.{parts[1]}"
                else:
                    final_ref = after_rename
                if final_ref != command_ref:
                    entry["command"] = final_ref
                    self.warnings.append(
                        f"Hook '{hook_name}' referenced command '{command_ref}'; "

View on GitHub (pinned to bf88c9f9a8)

Solutions

  1. Wrap each hook entry as a mapping: "hooks": {"post_install": {"command": "speckit.myext.setup"}}.
  2. If a hook holds multiple entries, use a list of mappings only — no bare strings inside.
  3. Verify the hooks section against a known-good bundled extension (e.g. extensions/agent-context) after editing.

Example fix

// before
"hooks": { "post_install": "speckit.myext.setup" }
// after
"hooks": { "post_install": { "command": "speckit.myext.setup" } }
Defensive patterns

Strategy: validation

Validate before calling

for hook_name, hook_data in manifest.get("hooks", {}).items():
    entries = hook_data if isinstance(hook_data, list) else [hook_data]
    for e in entries:
        if not isinstance(e, dict):
            raise SystemExit(f"hook '{hook_name}' entry is not a mapping: {e!r}")

Type guard

def hooks_are_mappings(manifest: dict) -> bool:
    for hook in manifest.get("hooks", {}).values():
        for e in (hook if isinstance(hook, list) else [hook]):
            if not isinstance(e, dict):
                return False
    return True

Try / catch

try:
    ExtensionManifest.load(path)
except ValidationError as e:
    if "must be a mapping or list of mappings" in str(e):
        # wrap the hook value as {"command": ...} and retry
        ...

Prevention

When it happens

Trigger: "hooks": {"post_install": "run.sh"} (bare string) or a hook list containing a scalar like ["run.sh", {"command": "..."}]. Raised while iterating coerce_hook_entries() output in ExtensionManifest._validate().

Common situations: Author shorthand: writing a hook as a plain command string instead of an object; copy-pasting hook YAML from older extension examples with a different schema; malformed nesting after YAML edit.

Related errors


AI-assisted analysis of github/spec-kit@bf88c9f9a8 (2026-08-14). Data as JSON: /api/errors/5736c77224679a80. Report an issue: GitHub.