diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md index fba74efa81..0cf11932fc 100644 --- a/docs/reference/artifacts.md +++ b/docs/reference/artifacts.md @@ -1,6 +1,6 @@ # Artifacts -An **artifact** is any command, template, or script Spec Kit exposes in a project, regardless of which layer contributes it — built-in assets, an installed preset, an installed extension, or a project-local override in `.specify/templates/overrides/`. +An **artifact** is any command, template, script, or hook Spec Kit exposes in a project, regardless of which layer contributes it — built-in assets, an installed preset, an installed extension, or a project-local override in `.specify/templates/overrides/`. The `specify artifact` command group is the read-only introspection surface for that inventory. `specify preset resolve ` answers "which file wins for this preset-managed name?"; `specify artifact` answers "what exists at all, and what is the full composition stack behind it?" — including built-in artifacts that no preset touches. @@ -16,7 +16,7 @@ specify artifact list --json | -------- | -------------------------------------------------------- | | `--json` | Required. Emit the inventory as a JSON array on stdout. | -Prints the full inventory of every visible artifact — one row per `(kind, name)` pair, including its composition `stack` — sorted by kind (`command`, then `template`, then `script`) and then by name. +Prints the full inventory of every visible artifact — one row per `(kind, name)` pair, including its composition `stack` — sorted by kind (`command`, then `template`, then `script`, then `hook`) and then by name. ```json [ @@ -65,12 +65,14 @@ Prints the full inventory of every visible artifact — one row per `(kind, name | Field | Description | | ------------- | ------------------------------------------------------------------------- | -| `id` | `{kind}:{name}` — the shorthand `artifact info` accepts as its argument | -| `name` | Logical artifact name (commands use the `speckit.` namespace) | -| `kind` | One of `command`, `template`, `script` | +| `id` | `{kind}:{name}` — the shorthand `artifact info` accepts as its argument (for hooks, `hook:{eventName}:{targetCommand}`) | +| `name` | Logical artifact name (commands use the `speckit.` namespace; hooks use `{eventName}:{targetCommand}`) | +| `kind` | One of `command`, `template`, `script`, `hook` | | `description` | Description from the highest-precedence layer that declares one, else `""` | | `stack` | Composition stack for this artifact, using the same row shape as `artifact info` | +Hook rows carry additional top-level scalar fields that mirror the priority-sorted winner of the composition stack — see [Hook artifacts](#hook-artifacts) below. + Built-in artifacts always appear, even when nothing overrides them. Descriptions come from the highest-priority layer that has one — a preset or project override that hides a built-in command reports its own description, not the hidden built-in text. Skills (`.github/skills/**/SKILL.md`) are excluded: they are integration-specific output, not a shipped asset family. ## Artifact Info @@ -82,9 +84,9 @@ specify artifact info --json | Option | Description | | ---------------- | ------------------------------------------------------------------- | | `--json` | Required. Emit the composition stack as a JSON object on stdout. | -| `--kind ` | Narrow the lookup to `command`, `template`, or `script` | +| `--kind ` | Narrow the lookup to `command`, `template`, `script`, or `hook` | -`` accepts either a bare name (`speckit.specify`) or the `kind:name` shorthand (`command:speckit.specify`). When both the shorthand and `--kind` are supplied they must agree. +`` accepts either a bare name (`speckit.specify`) or the `kind:name` shorthand (`command:speckit.specify`). For hooks, the shorthand is `hook:{eventName}:{targetCommand}` — the bare hook name (`{eventName}:{targetCommand}`) is also accepted. When both the shorthand and `--kind` are supplied they must agree. ```json { @@ -144,19 +146,92 @@ The top-level `id`, `name`, `kind`, `description`, and `stack` fields match the Lookup IDs use the same grammar as [preset contribution identifiers](presets.md#contribution-identifiers), so a `lookupId` from this command joins directly to `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` for manifest-declared layers. Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID that intentionally matches no manifest contribution. `lookupId` is manifest-backed layer provenance, not the round-trip key — use `id` for that. +## Hook artifacts + +Hook rows extend the shape above with a few fields that only apply to hooks. A hook row's public identifier is `hook:{eventName}:{targetCommand}` — that string is the round-trip key that `artifact info` accepts, and `name` is the same value with the `hook:` prefix stripped. + +```json +{ + "id": "hook:before_specify:speckit.compliance.pre-check", + "name": "before_specify:speckit.compliance.pre-check", + "kind": "hook", + "description": "Compliance pre-check guard", + "eventName": "before_specify", + "targetCommand": "speckit.compliance.pre-check", + "optional": false, + "priority": 5, + "registered": true, + "stack": [ + { + "id": "hook:before_specify:speckit.compliance.pre-check", + "layer": "extension", + "sourceId": "compliance", + "strategy": "replace", + "active": true, + "lookupId": "extension:compliance:hook:before_specify:speckit.compliance.pre-check", + "priority": 5, + "optional": false + } + ] +} +``` + +### Top-level fields + +| Field | Description | +| --------------- | ----------------------------------------------------------------------------------------------- | +| `eventName` | The event whose fires trigger this hook (`before_specify`, `after_plan`, …) | +| `targetCommand` | The command the hook proposes to run when the event fires | +| `optional` | Mirrors the active winner's `optional` scalar — the value the runtime will actually see | +| `priority` | Mirrors the active winner's `priority` scalar | +| `registered` | `true` when a matching `.specify/extensions.yml` binding exists and is not `enabled: false` | + +`optional` and `priority` on the row always agree with the entry marked `active: true` on the stack — they are the values the runtime will actually execute for this `(eventName, targetCommand)` pair. Per-contributor `priority` / `optional` remain visible on every stack entry so callers can audit why one contributor won. + +### Hook stack entries + +Hook stack entries drop `presetId`, `presetName`, `hidden`, and `manifestPath` (all of which are meaningless for hooks) and add per-contributor `priority` and `optional`. `strategy` is always `"replace"` — the runtime has no composable hook-strategy vocabulary today, so the field is present for shape parity but carries no semantics beyond "this hook overrides earlier hooks in the same slot". + +| Field | Description | +| ----------- | -------------------------------------------------------------------------------------------- | +| `id` | The row-shorthand `hook:{eventName}:{targetCommand}`, identical on every entry | +| `layer` | Always `preset` or `extension` (never `null`, never `project`, never the built-in tier) | +| `sourceId` | The contributing pack's manifest id | +| `strategy` | Always `"replace"` — see note above | +| `active` | `true` only on the priority-sorted winner (index `0`) | +| `lookupId` | The manifest identifier: `{layer}:{sourceId}:hook:{eventName}:{targetCommand}` | +| `priority` | Per-contributor priority (ascending = higher precedence; falls back to the runtime default) | +| `optional` | Per-contributor optional flag | + +### `registered` semantics + +`registered` reflects the project's runtime binding state under `.specify/extensions.yml` and MUST match the runtime's own execution decision. It is `true` when at least one entry in the event's binding array (a) names one of the row's contributing sources via `extension` and (b) is not explicitly `enabled: false`. A binding entry with a matching `extension` but no `command` field counts as a wildcard — same rule the runtime's `enable_hooks` / `disable_hooks` apply. + +A declared hook whose contributors have **no** matching binding entry still appears in the inventory with `registered: false`. This is intentional: `artifact list --json` describes what an extension declares, and `registered` tells you whether the runtime will actually invoke it. A structurally invalid `.specify/extensions.yml` (parse error, wrong top-level type, missing `hooks:` key) is silently normalized to an empty bindings map — every declared hook then reports `registered: false` and no error is raised to callers. + +### Layer invariant + +Hooks only appear on `preset` or `extension` stack entries. There is no built-in ("core") hook tier — the identifier grammar itself refuses to build hook IDs on any other layer, and `derive_hook_id` will raise `IdentifierComponentError` for a `layer` outside `{preset, extension}`. In practice, no built-in preset today emits hooks; extensions are the only source. Even so, the grammar reserves the preset layer for forward compatibility. + +Runtime bindings under `.specify/extensions.yml` that name an extension or command no installed extension has declared do **not** synthesize a phantom row — the inventory is manifest-driven, and orphan bindings only influence the `registered` flag on rows that already exist. + +### Sort order + +Hooks appear after all `command` / `template` / `script` rows in `artifact list --json`. Within the hook block, rows are sorted primarily by `eventName` (alphabetical) and secondarily by the winner's `priority` (ascending). Two rows in the same event at the same priority preserve their original insertion order — matching the runtime's stable-sort tiebreak in `HookExecutor.get_hooks_for_event`. + ## JSON Errors On failure, nothing is written to stdout. A single-key JSON envelope is written to stderr and the process exits with code `1`: ```json -{ "error": "unknown artifact command:nope" } +{ "error": "unknown artifact hook:before_specify:absent.cmd" } ``` | Message | Cause | | --------------------------------------------------- | ---------------------------------------------------------------- | | `not a Spec Kit project: no .specify/ directory found` | Run outside an initialized project | -| `unknown artifact ` | No artifact matches the requested name (and kind, when given) | +| `unknown artifact ` | No artifact matches the requested name (and kind, when given) — same envelope for unknown hooks (`hook:{event}:{command}`) | | `ambiguous artifact : matches kinds [...]` | The bare name matches more than one kind — re-run with `--kind` | | `artifact resolution failed` | The preset/extension registries could not be read, or artifact content could not be composed | -Exit code `2` is reserved for usage errors — a missing `--json` flag or an invalid `--kind` value — and emits a plain-text message on stderr rather than a JSON envelope. +Exit code `2` is reserved for usage errors — a missing `--json` flag or an invalid `--kind` value (accepted: `command`, `template`, `script`, `hook`) — and emits a plain-text message on stderr rather than a JSON envelope. diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index f32bdb854e..8b1ea84869 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -22,6 +22,7 @@ from .._identifier import ( PROJECT_OVERRIDE_LAYER, IdentifierComponentError, + derive_hook_id, derive_public_id, is_dotted_command_name, layer_kind_from_lookup_id, @@ -33,10 +34,17 @@ # Public data classes # --------------------------------------------------------------------------- -ArtifactKind = Literal["command", "template", "script"] +ArtifactKind = Literal["command", "template", "script", "hook"] LayerName = Literal["project", "preset", "extension"] Strategy = Literal["replace", "wrap", "prepend", "append"] +# Kinds whose logical name fits the ``(kind, name)`` candidate tuple grammar +# used by ``_iter_pack_candidates``. Hooks use ``{event}:{command}`` as their +# logical name — the embedded ``:`` breaks that grammar — so they flow +# through a separate iterator/stack builder pipeline. See +# ``_iter_hook_contributions`` and ``_build_hook_stack``. +_NAMED_ARTIFACT_KINDS: frozenset[str] = frozenset({"command", "template", "script"}) + @dataclass(frozen=True) class Artifact: @@ -96,6 +104,80 @@ def to_json_dict(self) -> dict[str, Any]: } +@dataclass(frozen=True) +class HookArtifact: + """One row in the flat inventory for a hook contribution. + + A hook row is keyed by the ``(eventName, targetCommand)`` pair. The + top-level ``optional`` and ``priority`` scalars reflect the contributor + marked ``active: true`` on the composition stack — the priority-sorted + winner the runtime will actually execute. ``registered`` reflects the + project's ``.specify/extensions.yml`` binding state, matching the + runtime's own execution decision. + """ + + id: str + name: str + kind: Literal["hook"] + description: str + eventName: str + targetCommand: str + optional: bool + priority: int + registered: bool + + def to_json_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "kind": self.kind, + "description": self.description, + "eventName": self.eventName, + "targetCommand": self.targetCommand, + "optional": self.optional, + "priority": self.priority, + "registered": self.registered, + } + + +@dataclass(frozen=True) +class HookStackEntry: + """One entry inside the ``stack`` array on a hook row. + + Hook stack entries mirror the shape of :class:`StackLayer` for the fields + common to every artifact kind (``id``, ``layer``, ``sourceId``, + ``strategy``, ``active``, ``lookupId``) and add ``priority`` and + ``optional`` — the two per-contributor scalars that vary across the stack + and drive the runtime's active-winner selection. ``strategy`` is fixed + to ``"replace"`` because the runtime does not implement a composable hook + strategy vocabulary; the field is present for shape parity with the other + kinds. Hooks are always attributed to a manifest-declared contributor + (``preset`` or ``extension``), so ``layer``, ``sourceId``, and ``lookupId`` + are never ``None``. + """ + + id: str + layer: LayerName + sourceId: str + strategy: Literal["replace"] + active: bool + lookupId: str + priority: int + optional: bool + + def to_json_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "layer": self.layer, + "sourceId": self.sourceId, + "strategy": self.strategy, + "active": self.active, + "lookupId": self.lookupId, + "priority": self.priority, + "optional": self.optional, + } + + # --------------------------------------------------------------------------- # Exceptions — pinned error strings (see artifact-error contract regex) # --------------------------------------------------------------------------- @@ -328,6 +410,138 @@ class ``PresetManager.list_installed()`` and ``specify preset list`` use — return pack_id +def _iter_hook_contributions( + project_root: Path, +) -> Iterable[tuple[int, dict[str, Any]]]: + """Yield ``(insertion_index, contribution)`` pairs for every declared hook. + + Walks every installed extension (and, forward-compatibly, every installed + preset — though :class:`PresetManifest` today does not emit ``kind:"hook"`` + entries) in the same order used by + :meth:`PresetResolver.iter_extensions_by_priority` / + :meth:`PresetResolver.iter_presets_by_priority`, and yields each + ``ExtensionManifest.iter_contributions()`` entry whose ``kind`` is + ``"hook"``. The insertion index is a running counter across the whole + walk; two contributors that share the same explicit ``priority`` on the + same ``(event, command)`` pair are ordered by this index — matching the + runtime's stable-sort tiebreak (see + ``HookExecutor.get_hooks_for_event``). + + Contributions with missing/empty ``eventName`` or ``command`` fields are + silently skipped — the manifest validator has already surfaced those. + """ + from ..extensions import ExtensionManager, ExtensionManifest, ValidationError + from ..presets import PresetManager, PresetResolver # lazy: avoids circular import + + try: + resolver = PresetResolver(project_root) + except OSError: + return + + counter = 0 + + # Presets are walked first for forward compatibility with a future + # ``PresetManifest.iter_contributions()`` that emits hooks. Today none + # do, so this loop yields nothing — but the ordering ensures that if a + # preset ever declares a hook it participates in the same insertion-index + # tiebreak as extensions. + preset_manager = PresetManager(project_root) + for pack_id, _metadata in resolver.iter_presets_by_priority(): + manifest = preset_manager.get_pack(pack_id) + if manifest is None: + continue + for contribution in manifest.iter_contributions(): + if contribution.get("kind") != "hook": + continue + if not contribution.get("eventName") or not contribution.get("command"): + continue + counter += 1 + yield counter, contribution + + ext_manager = ExtensionManager(project_root) + for _priority, ext_id, metadata in resolver.iter_extensions_by_priority(): + ext_dir = resolver.extensions_dir / ext_id + if metadata is not None: + manifest = ext_manager.get_extension(ext_id) + else: + manifest_path = ext_dir / "extension.yml" + manifest = None + if manifest_path.is_file(): + try: + manifest = ExtensionManifest(manifest_path) + except (ValidationError, OSError, TypeError, AttributeError): + manifest = None + if manifest is None: + continue + for contribution in manifest.iter_contributions(): + if contribution.get("kind") != "hook": + continue + if not contribution.get("eventName") or not contribution.get("command"): + continue + counter += 1 + yield counter, contribution + + +def _hook_logical_name(event_name: str, command: str) -> str: + """Return the ``{event}:{command}`` logical name used for hook rows.""" + return f"{event_name}:{command}" + + +def _hook_public_id(event_name: str, command: str) -> str: + """Return the shorthand ``hook:{event}:{command}`` public identifier.""" + return f"hook:{event_name}:{command}" + + +def _build_hook_stack( + grouped: list[tuple[int, dict[str, Any]]], +) -> list[HookStackEntry]: + """Build the composition stack for a single ``(event, command)`` group. + + ``grouped`` is the subset of ``_iter_hook_contributions`` output that + shares one ``(eventName, command)`` pair, in original insertion order. + Contributors are re-sorted by ``(priority, insertion_index)`` — Python's + stable sort combined with the ascending secondary key preserves the same + "priority ascending, ties break by insertion order" behavior the runtime + uses (see ``HookExecutor.get_hooks_for_event``). The first entry after + the sort is marked ``active: true``. + """ + from ..extensions import DEFAULT_HOOK_PRIORITY, normalize_priority + + def _sort_key(item: tuple[int, dict[str, Any]]) -> tuple[int, int]: + idx, contribution = item + priority = normalize_priority( + contribution.get("priority"), DEFAULT_HOOK_PRIORITY + ) + return (priority, idx) + + ordered = sorted(grouped, key=_sort_key) + entries: list[HookStackEntry] = [] + for position, (_idx, contribution) in enumerate(ordered): + layer = contribution.get("layer", "extension") + source_id = contribution.get("sourceId", "") + lookup_id = contribution.get("id", "") + priority = normalize_priority( + contribution.get("priority"), DEFAULT_HOOK_PRIORITY + ) + optional = bool(contribution.get("optional", True)) + entries.append( + HookStackEntry( + id=_hook_public_id( + str(contribution.get("eventName", "")), + str(contribution.get("command", "")), + ), + layer=layer, # type: ignore[arg-type] + sourceId=str(source_id), + strategy="replace", + active=(position == 0), + lookupId=str(lookup_id), + priority=priority, + optional=optional, + ) + ) + return entries + + def _build_stack( project_root: Path, kind: ArtifactKind, @@ -514,19 +728,43 @@ def _resolve_kind_hint(name: str, kind: ArtifactKind | None) -> tuple[str, Artif Returns ``(bare_name, resolved_kind)``. When ``name`` uses the ``kind:name`` grammar and ``kind`` is also set explicitly, the two must agree — a mismatch is treated as an unknown artifact. + + Hook shorthand is ``hook:{eventName}:{command}``; the "bare name" after + the leading ``hook:`` still contains the ``event:command`` colon and is + the logical row name used by :class:`HookArtifact`. That name is + intentionally exempt from :func:`validate_component` — the colon-free + grammar the other three kinds enforce does not apply to hooks (see + :func:`_validate_artifact_name`). """ if ":" in name: prefix, _, bare = name.partition(":") - if prefix in ("command", "template", "script"): + if prefix in _NAMED_ARTIFACT_KINDS: resolved: ArtifactKind = prefix # type: ignore[assignment] if kind is not None and kind != resolved: raise ArtifactNotFoundError(name) return bare, resolved + if prefix == "hook": + if kind is not None and kind != "hook": + raise ArtifactNotFoundError(name) + return bare, "hook" return name, kind def _validate_artifact_name(name: str, kind: ArtifactKind) -> str: - """Validate the structural identifier component constraints for ``name``.""" + """Validate the structural identifier component constraints for ``name``. + + Hook logical names are ``{eventName}:{command}`` — the embedded ``:`` is + intentional and part of the round-trip key, so + :func:`validate_component` is bypassed for hook kind. Empty hook names + still raise :class:`ArtifactNotFoundError`. + """ + if kind == "hook": + if not isinstance(name, str) or not name or ":" not in name: + raise ArtifactNotFoundError(name) + event, _, command = name.partition(":") + if not event or not command: + raise ArtifactNotFoundError(name) + return name try: return validate_component(name, f"{kind} name") except IdentifierComponentError as exc: @@ -572,7 +810,14 @@ def list_artifacts(self) -> list[Artifact]: return artifacts def list_artifacts_with_stack(self) -> list[dict[str, Any]]: - """Return list rows enriched with each artifact's full composition stack.""" + """Return list rows enriched with each artifact's full composition stack. + + Ordered so all command/template/script rows appear first (sorted by + the existing ``kind`` order and then by name), followed by hook rows + sorted primarily by ``eventName`` alphabetical and secondarily by the + winner's ``priority`` — matching the runtime's execution order for + hooks that share an event (see FR-016). + """ artifacts, layers_cache = self._collect_inventory() rows: list[dict[str, Any]] = [] for artifact in artifacts: @@ -585,6 +830,15 @@ def list_artifacts_with_stack(self) -> list[dict[str, Any]]: row = artifact.to_json_dict() row["stack"] = [layer.to_json_dict() for layer in stack] rows.append(row) + + # Validate project + registries once. The hook pipeline reuses the + # same failure envelopes as the command/template/script pipeline. + hook_rows, hook_stack_cache = self._collect_hook_inventory() + for hook in hook_rows: + stack_entries = hook_stack_cache.get((hook.eventName, hook.targetCommand), []) + row = hook.to_json_dict() + row["stack"] = [entry.to_json_dict() for entry in stack_entries] + rows.append(row) return rows # ------------------------------------------------------------------ info @@ -598,7 +852,9 @@ def get_artifact_info( Argument resolution: * ``name`` accepts the ``kind:name`` grammar as shorthand; when both - the shorthand and ``kind`` are supplied they must agree. + the shorthand and ``kind`` are supplied they must agree. Hook + shorthand is ``hook:{eventName}:{command}`` — the embedded + ``event:command`` colon is part of the hook logical name. * When neither the shorthand nor ``kind`` narrows the search and more than one kind matches ``name``, raises :class:`AmbiguousArtifactError`. @@ -606,6 +862,9 @@ def get_artifact_info( """ bare, resolved_kind = _resolve_kind_hint(name, kind) + if resolved_kind == "hook": + return self._get_hook_info(bare, original_argument=name) + # Project and registry validation happens once, inside # ``_collect_inventory`` below — the same chokepoint ``list_artifacts`` # uses — so both public methods fail closed identically instead of @@ -617,11 +876,21 @@ def get_artifact_info( for artifact in inventory if artifact.name == bare ] + # Also probe the hook inventory so a bare name that unambiguously + # matches only a hook logical name (``event:command``) still + # resolves — and so a name that matches BOTH a named kind and a + # hook surfaces the ambiguity envelope. + hook_rows, _hook_stack_cache = self._collect_hook_inventory() + hook_match = any(row.name == bare for row in hook_rows) + if hook_match: + matches.append(("hook", bare)) if not matches: raise ArtifactNotFoundError(name) - if len(matches) > 1: + if len({m[0] for m in matches}) > 1: raise AmbiguousArtifactError(bare, [k for k, _ in matches]) resolved_kind = matches[0][0] + if resolved_kind == "hook": + return self._get_hook_info(bare, original_argument=name) validated_name = _validate_artifact_name(bare, resolved_kind) artifact = next( @@ -651,6 +920,27 @@ def get_artifact_info( "stack": [layer.to_json_dict() for layer in stack], } + def _get_hook_info(self, bare_name: str, original_argument: str) -> dict[str, Any]: + """Return the full JSON-ready dict for a hook artifact info lookup. + + ``bare_name`` is the logical hook name (``{eventName}:{command}``) + after any ``hook:`` prefix has been stripped by + :func:`_resolve_kind_hint`. ``original_argument`` is preserved so the + error message on an unknown hook mirrors what the caller passed in. + """ + _validate_artifact_name(bare_name, "hook") + event_name, _, command = bare_name.partition(":") + hook_rows, stack_cache = self._collect_hook_inventory() + for row in hook_rows: + if row.eventName == event_name and row.targetCommand == command: + stack_entries = stack_cache.get((event_name, command), []) + if not stack_entries: # pragma: no cover — invariant + raise ArtifactNotFoundError(original_argument) + payload = row.to_json_dict() + payload["stack"] = [entry.to_json_dict() for entry in stack_entries] + return payload + raise ArtifactNotFoundError(original_argument) + # -------------------------------------------------------------- internals def _collect_inventory( self, @@ -715,6 +1005,105 @@ def _has_any_replace_layer(layers: list[dict[str, Any]]) -> bool: kind_order = {"command": 0, "template": 1, "script": 2} return sorted(artifacts, key=lambda a: (kind_order[a.kind], a.name)), layers_cache + def _collect_hook_inventory( + self, + ) -> tuple[ + list[HookArtifact], + dict[tuple[str, str], list[HookStackEntry]], + ]: + """Return the hook inventory plus the per-pair composition stacks. + + The list is sorted primarily by ``eventName`` alphabetical and + secondarily by the winner's ``priority`` — matching FR-016. Ties + between winners at the same event and priority preserve first-yield + insertion order via a stable sort. + + The second return value maps each ``(eventName, targetCommand)`` pair + to its full :class:`HookStackEntry` list so callers do not need to + rebuild the stack for a subsequent info lookup. + + Registry validation raises :class:`ArtifactResolutionError` if the + extension registry is corrupt; a structurally-invalid + ``.specify/extensions.yml`` is normalized to an empty bindings map + by :meth:`HookExecutor.get_project_config` and produces + ``registered: false`` for every declared hook without raising. + """ + _validate_project(self.project_root) + _validate_extension_registry(self.project_root) + _validate_preset_registry(self.project_root) + + from ..extensions import DEFAULT_HOOK_PRIORITY, HookExecutor, normalize_priority + + grouped: dict[tuple[str, str], list[tuple[int, dict[str, Any]]]] = {} + for idx, contribution in _iter_hook_contributions(self.project_root): + event_name = str(contribution.get("eventName", "")) + command = str(contribution.get("command", "")) + grouped.setdefault((event_name, command), []).append((idx, contribution)) + + hook_executor = HookExecutor(self.project_root) + + rows: list[HookArtifact] = [] + stack_cache: dict[tuple[str, str], list[HookStackEntry]] = {} + + for (event_name, command), contributions in grouped.items(): + stack_entries = _build_hook_stack(contributions) + stack_cache[(event_name, command)] = stack_entries + if not stack_entries: # pragma: no cover — invariant + continue + + # The description precedence follows the same "highest-priority + # non-empty" rule the other kinds use (FR-015): walk contributors + # in stack order (already priority-sorted) and take the first + # non-empty description. + description = "" + ordered_contributions = [ + contribution + for _idx, contribution in sorted( + contributions, + key=lambda item: ( + normalize_priority( + item[1].get("priority"), DEFAULT_HOOK_PRIORITY + ), + item[0], + ), + ) + ] + for contribution in ordered_contributions: + candidate = contribution.get("description", "") + if isinstance(candidate, str) and candidate: + description = candidate + break + + # Top-level ``optional`` / ``priority`` mirror the active winner + # (FR-017); ``registered`` is true when ANY contributor in the + # stack has a matching, non-disabled binding entry (Q1 answer B). + winner = stack_entries[0] + registered = any( + hook_executor.is_hook_registered( + event_name=event_name, + extension_id=entry.sourceId, + command=command, + ) + for entry in stack_entries + ) + + rows.append( + HookArtifact( + id=_hook_public_id(event_name, command), + name=_hook_logical_name(event_name, command), + kind="hook", + description=description, + eventName=event_name, + targetCommand=command, + optional=winner.optional, + priority=winner.priority, + registered=registered, + ) + ) + + rows.sort(key=lambda row: (row.eventName, row.priority)) + return rows, stack_cache + def _iter_candidate_artifacts( self, resolver: Any, @@ -781,13 +1170,20 @@ def _iter_pack_candidates( manifest: Any, pack_dir: Path, ) -> Iterable[tuple[ArtifactKind, str]]: - """Yield manifest-declared and convention-based candidate names.""" + """Yield manifest-declared and convention-based candidate names. + + Hook contributions are intentionally not yielded here: their logical + name (``{event}:{command}``) contains a ``:`` that would break the + ``(kind, name)`` candidate tuple grammar shared with the resolver. + Hooks are surfaced through the parallel :func:`_iter_hook_contributions` + pipeline instead — see :meth:`ArtifactCatalog._collect_hook_inventory`. + """ if manifest is not None: for contribution in manifest.iter_contributions(): kind = contribution.get("kind") name = contribution.get("name") if ( - kind in ("command", "template", "script") + kind in _NAMED_ARTIFACT_KINDS and isinstance(name, str) and name and ":" not in name @@ -1046,6 +1442,8 @@ def _iter_convention_contributions( "ArtifactKind", "ArtifactNotFoundError", "ArtifactResolutionError", + "HookArtifact", + "HookStackEntry", "LayerName", "NotASpecKitProjectError", "StackLayer", diff --git a/src/specify_cli/artifacts/_commands.py b/src/specify_cli/artifacts/_commands.py index 2c19b6166b..0e90368c07 100644 --- a/src/specify_cli/artifacts/_commands.py +++ b/src/specify_cli/artifacts/_commands.py @@ -35,7 +35,7 @@ artifact_app = typer.Typer( name="artifact", - help="Introspect commands, templates, and scripts SpecKit exposes.", + help="Introspect commands, templates, scripts, and hooks SpecKit exposes.", no_args_is_help=True, ) @@ -100,7 +100,7 @@ def artifact_list( help="Emit the inventory as a JSON array on stdout.", ), ) -> None: - """List every command, template, and script SpecKit exposes.""" + """List every command, template, script, and hook SpecKit exposes.""" _require_json_flag(json_flag) try: root = _resolve_project_root() @@ -128,7 +128,7 @@ def artifact_info( kind: Optional[str] = typer.Option( None, "--kind", - help="Narrow the lookup to one artifact family (command/template/script).", + help="Narrow the lookup to one artifact family (command/template/script/hook).", ), ) -> None: """Show one artifact and its full composition stack.""" @@ -136,9 +136,9 @@ def artifact_info( resolved_kind: Optional[ArtifactKind] = None if kind is not None: - if kind not in ("command", "template", "script"): + if kind not in ("command", "template", "script", "hook"): print( - f"invalid --kind {kind!r}: expected one of command, template, script", + f"invalid --kind {kind!r}: expected one of command, template, script, hook", file=sys.stderr, ) raise typer.Exit(code=2) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index af370b05f7..8ffacca717 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -5247,6 +5247,43 @@ def get_hooks_for_event(self, event_name: str) -> List[Dict[str, Any]]: key=lambda h: normalize_priority(h.get("priority"), DEFAULT_HOOK_PRIORITY), ) + def is_hook_registered( + self, + event_name: str, + extension_id: str, + command: str, + ) -> bool: + """Return whether a declared hook is currently registered to run. + + A hook contribution is "registered" when the project's + ``.specify/extensions.yml`` binding array for ``event_name`` contains + an entry that (a) names the owning contributor via ``extension`` and + (b) is not explicitly disabled (``enabled: false``). The entry's + ``command`` must either match the declared target ``command`` or be + missing/empty — matching the runtime's own execution decision (see + :meth:`enable_hooks` / :meth:`disable_hooks`, which do not + distinguish per-command entries). + + A structurally invalid ``.specify/extensions.yml`` is normalized to + an empty ``hooks`` map by :meth:`get_project_config` — this method + never raises for a malformed registry; it simply returns ``False``. + """ + config = self.get_project_config() + bindings = config.get("hooks", {}).get(event_name, []) + if not isinstance(bindings, list): + return False + for entry in bindings: + if not isinstance(entry, dict): + continue + if entry.get("extension") != extension_id: + continue + if entry.get("enabled", True) is False: + continue + binding_command = entry.get("command") + if not binding_command or binding_command == command: + return True + return False + def should_execute_hook(self, hook: Dict[str, Any]) -> bool: """Determine if a hook should be executed based on its condition. diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 44b5d24ba9..e6d335128f 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -1295,5 +1295,597 @@ def test_falls_back_to_pack_id_without_manifest_file(self, tmp_path: Path): # --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# Hook artifact tests — spec #4343 (see specs/001-hook-artifacts/) +# --------------------------------------------------------------------------- + + +def _install_extension_with_hooks( + project_root: Path, + extension_id: str, + hooks: dict, + *, + description: str = "Test extension", + priority: int = 10, + enabled: bool = True, +) -> Path: + """Create a registered extension whose manifest declares the given hook events.""" + ext_dir = project_root / ".specify" / "extensions" / extension_id + ext_dir.mkdir(parents=True, exist_ok=True) + manifest = { + "schema_version": "1.0", + "extension": { + "id": extension_id, + "name": extension_id, + "version": "1.0.0", + "description": description, + "author": "test", + "repository": "https://example.com", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.2.0"}, + "provides": {}, + "hooks": hooks, + } + (ext_dir / "extension.yml").write_text( + yaml.safe_dump(manifest), encoding="utf-8" + ) + ExtensionRegistry(project_root / ".specify" / "extensions").add( + extension_id, + {"version": "1.0.0", "enabled": enabled, "priority": priority}, + ) + return ext_dir + + +def _write_hook_binding( + project_root: Path, + event_name: str, + entries: list[dict], +) -> Path: + """Write ``.specify/extensions.yml`` binding a hook to the given extensions. + + Each ``entries`` dict must at minimum contain ``extension`` and + ``command`` — the schema :meth:`HookExecutor.register_hooks` writes. + """ + config_path = project_root / ".specify" / "extensions.yml" + payload = { + "installed": [], + "settings": {"auto_execute_hooks": True}, + "hooks": {event_name: entries}, + } + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(yaml.safe_dump(payload), encoding="utf-8") + return config_path + + +class TestHookInventorySurfacing: + """US1 — hooks appear in ``list_artifacts_with_stack`` alongside other kinds.""" + + def test_no_hooks_when_no_extensions_installed(self, spec_kit_project: Path): + rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() + assert all(row.get("kind") != "hook" for row in rows) + + def test_declared_hook_appears_as_row(self, spec_kit_project: Path): + _install_extension_with_hooks( + spec_kit_project, + "compliance", + hooks={ + "before_specify": [ + { + "command": "speckit.compliance.pre-check", + "description": "Compliance pre-check hook", + } + ] + }, + ) + rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() + hook_rows = [row for row in rows if row["kind"] == "hook"] + assert len(hook_rows) == 1 + row = hook_rows[0] + assert row["id"] == "hook:before_specify:speckit.compliance.pre-check" + assert row["name"] == "before_specify:speckit.compliance.pre-check" + assert row["eventName"] == "before_specify" + assert row["targetCommand"] == "speckit.compliance.pre-check" + assert row["description"] == "Compliance pre-check hook" + + def test_stack_entry_has_hook_shape(self, spec_kit_project: Path): + _install_extension_with_hooks( + spec_kit_project, + "compliance", + hooks={ + "before_specify": [ + {"command": "speckit.compliance.pre-check", "priority": 5, "optional": False} + ] + }, + ) + rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() + hook_rows = [row for row in rows if row["kind"] == "hook"] + assert len(hook_rows[0]["stack"]) == 1 + entry = hook_rows[0]["stack"][0] + assert entry["layer"] == "extension" + assert entry["sourceId"] == "compliance" + assert entry["strategy"] == "replace" + assert entry["active"] is True + assert entry["priority"] == 5 + assert entry["optional"] is False + assert entry["lookupId"] == ( + "extension:compliance:hook:before_specify:speckit.compliance.pre-check" + ) + + def test_hook_lookupid_matches_derive_hook_id(self, spec_kit_project: Path): + from specify_cli._identifier import derive_hook_id + + _install_extension_with_hooks( + spec_kit_project, + "compliance", + hooks={ + "before_specify": [ + {"command": "speckit.compliance.pre-check"} + ] + }, + ) + rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() + entry = [r for r in rows if r["kind"] == "hook"][0]["stack"][0] + expected = derive_hook_id( + "extension", + "compliance", + "before_specify", + "speckit.compliance.pre-check", + ) + assert entry["lookupId"] == expected + + def test_multiple_extensions_same_event_same_command_produce_one_row( + self, spec_kit_project: Path + ): + _install_extension_with_hooks( + spec_kit_project, + "ext-a", + hooks={"before_specify": [{"command": "shared.cmd", "priority": 10}]}, + ) + _install_extension_with_hooks( + spec_kit_project, + "ext-b", + hooks={"before_specify": [{"command": "shared.cmd", "priority": 5}]}, + ) + rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() + hook_rows = [row for row in rows if row["kind"] == "hook"] + assert len(hook_rows) == 1 + assert len(hook_rows[0]["stack"]) == 2 + + def test_higher_precedence_winner_selected_by_priority( + self, spec_kit_project: Path + ): + _install_extension_with_hooks( + spec_kit_project, + "ext-a", + hooks={"before_specify": [{"command": "shared.cmd", "priority": 10, "optional": True}]}, + ) + _install_extension_with_hooks( + spec_kit_project, + "ext-b", + hooks={"before_specify": [{"command": "shared.cmd", "priority": 3, "optional": False}]}, + ) + rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() + hook_row = [row for row in rows if row["kind"] == "hook"][0] + # Winner is ext-b (priority 3, lower = higher precedence). + assert hook_row["priority"] == 3 + assert hook_row["optional"] is False + assert hook_row["stack"][0]["sourceId"] == "ext-b" + assert hook_row["stack"][0]["active"] is True + assert hook_row["stack"][1]["sourceId"] == "ext-a" + assert hook_row["stack"][1]["active"] is False + + def test_stable_insertion_tiebreak_on_equal_priorities( + self, spec_kit_project: Path + ): + _install_extension_with_hooks( + spec_kit_project, + "ext-a", + hooks={"before_specify": [{"command": "shared.cmd", "priority": 5}]}, + priority=5, + ) + _install_extension_with_hooks( + spec_kit_project, + "ext-b", + hooks={"before_specify": [{"command": "shared.cmd", "priority": 5}]}, + priority=10, + ) + rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() + hook_row = [row for row in rows if row["kind"] == "hook"][0] + # Higher-precedence extension (lower priority in resolver ordering) + # emits first — that becomes the insertion-order winner on tie. + assert hook_row["stack"][0]["sourceId"] == "ext-a" + + +class TestHookInfoShorthand: + """US2 — ``hook:{event}:{command}`` round-trips through artifact info.""" + + def test_shorthand_round_trip(self, spec_kit_project: Path): + _install_extension_with_hooks( + spec_kit_project, + "compliance", + hooks={ + "before_specify": [ + {"command": "speckit.compliance.pre-check", "description": "Guard"} + ] + }, + ) + payload = ArtifactCatalog(spec_kit_project).get_artifact_info( + "hook:before_specify:speckit.compliance.pre-check" + ) + assert payload["kind"] == "hook" + assert payload["id"] == "hook:before_specify:speckit.compliance.pre-check" + assert payload["eventName"] == "before_specify" + assert payload["targetCommand"] == "speckit.compliance.pre-check" + assert payload["description"] == "Guard" + + def test_unknown_hook_shorthand_raises_not_found(self, spec_kit_project: Path): + catalog = ArtifactCatalog(spec_kit_project) + with pytest.raises(ArtifactNotFoundError) as excinfo: + catalog.get_artifact_info("hook:nope:speckit.absent") + assert "unknown artifact" in excinfo.value.message + + def test_explicit_kind_flag_agrees(self, spec_kit_project: Path): + _install_extension_with_hooks( + spec_kit_project, + "ext", + hooks={"after_plan": [{"command": "cmd.x"}]}, + ) + payload = ArtifactCatalog(spec_kit_project).get_artifact_info( + "after_plan:cmd.x", kind="hook" + ) + assert payload["eventName"] == "after_plan" + assert payload["targetCommand"] == "cmd.x" + + def test_bare_hook_name_resolves(self, spec_kit_project: Path): + _install_extension_with_hooks( + spec_kit_project, + "ext", + hooks={"before_specify": [{"command": "unique.hook.only"}]}, + ) + payload = ArtifactCatalog(spec_kit_project).get_artifact_info( + "before_specify:unique.hook.only" + ) + assert payload["kind"] == "hook" + + +class TestHookRegisteredFlag: + """US3 — ``registered`` reflects ``.specify/extensions.yml`` bindings.""" + + def test_declared_but_not_bound_is_false(self, spec_kit_project: Path): + _install_extension_with_hooks( + spec_kit_project, + "compliance", + hooks={"before_specify": [{"command": "speckit.compliance.pre-check"}]}, + ) + rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() + hook_row = [row for row in rows if row["kind"] == "hook"][0] + assert hook_row["registered"] is False + + def test_bound_and_enabled_is_true(self, spec_kit_project: Path): + _install_extension_with_hooks( + spec_kit_project, + "compliance", + hooks={"before_specify": [{"command": "speckit.compliance.pre-check"}]}, + ) + _write_hook_binding( + spec_kit_project, + "before_specify", + entries=[ + { + "extension": "compliance", + "command": "speckit.compliance.pre-check", + "enabled": True, + } + ], + ) + rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() + hook_row = [row for row in rows if row["kind"] == "hook"][0] + assert hook_row["registered"] is True + + def test_bound_but_disabled_is_false(self, spec_kit_project: Path): + _install_extension_with_hooks( + spec_kit_project, + "compliance", + hooks={"before_specify": [{"command": "speckit.compliance.pre-check"}]}, + ) + _write_hook_binding( + spec_kit_project, + "before_specify", + entries=[ + { + "extension": "compliance", + "command": "speckit.compliance.pre-check", + "enabled": False, + } + ], + ) + rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() + hook_row = [row for row in rows if row["kind"] == "hook"][0] + assert hook_row["registered"] is False + + def test_binding_without_command_matches(self, spec_kit_project: Path): + _install_extension_with_hooks( + spec_kit_project, + "compliance", + hooks={"before_specify": [{"command": "speckit.compliance.pre-check"}]}, + ) + _write_hook_binding( + spec_kit_project, + "before_specify", + entries=[{"extension": "compliance", "enabled": True}], + ) + rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() + hook_row = [row for row in rows if row["kind"] == "hook"][0] + assert hook_row["registered"] is True + + def test_orphan_binding_does_not_create_row(self, spec_kit_project: Path): + """A binding naming an uninstalled extension MUST NOT synthesize a row.""" + _write_hook_binding( + spec_kit_project, + "before_specify", + entries=[{"extension": "ghost", "command": "ghost.cmd", "enabled": True}], + ) + rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() + hook_rows = [row for row in rows if row["kind"] == "hook"] + assert hook_rows == [] + + def test_malformed_extensions_yml_degrades_to_registered_false( + self, spec_kit_project: Path + ): + _install_extension_with_hooks( + spec_kit_project, + "compliance", + hooks={"before_specify": [{"command": "speckit.compliance.pre-check"}]}, + ) + (spec_kit_project / ".specify" / "extensions.yml").write_text( + "this is not: valid: yaml: [\n", encoding="utf-8" + ) + rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() + hook_rows = [row for row in rows if row["kind"] == "hook"] + assert hook_rows[0]["registered"] is False + + +class TestHookPerContributorFields: + """US4 — per-contributor priority and optional visible on each stack entry.""" + + def test_per_entry_priority_and_optional(self, spec_kit_project: Path): + _install_extension_with_hooks( + spec_kit_project, + "ext-a", + hooks={"before_specify": [{"command": "shared.cmd", "priority": 10, "optional": True}]}, + ) + _install_extension_with_hooks( + spec_kit_project, + "ext-b", + hooks={"before_specify": [{"command": "shared.cmd", "priority": 3, "optional": False}]}, + ) + rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() + stack = [row for row in rows if row["kind"] == "hook"][0]["stack"] + by_source = {entry["sourceId"]: entry for entry in stack} + assert by_source["ext-a"]["priority"] == 10 + assert by_source["ext-a"]["optional"] is True + assert by_source["ext-b"]["priority"] == 3 + assert by_source["ext-b"]["optional"] is False + + def test_every_stack_entry_uses_replace_strategy(self, spec_kit_project: Path): + _install_extension_with_hooks( + spec_kit_project, + "ext-a", + hooks={"before_specify": [{"command": "shared.cmd", "priority": 10}]}, + ) + _install_extension_with_hooks( + spec_kit_project, + "ext-b", + hooks={"before_specify": [{"command": "shared.cmd", "priority": 5}]}, + ) + rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() + stack = [row for row in rows if row["kind"] == "hook"][0]["stack"] + assert all(entry["strategy"] == "replace" for entry in stack) + + +class TestHookLayerInvariants: + """US5 — hooks never appear on a built-in ``core`` layer.""" + + def test_no_hooks_from_core_tier(self, spec_kit_project: Path): + _install_extension_with_hooks( + spec_kit_project, + "ext", + hooks={"before_specify": [{"command": "cmd.x"}]}, + ) + rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() + for row in rows: + if row["kind"] != "hook": + continue + for entry in row["stack"]: + assert entry["layer"] in ("preset", "extension"), ( + "hook stack entries must never carry a built-in layer" + ) + assert entry["sourceId"], "hook stack entries must have a sourceId" + assert entry["lookupId"], "hook stack entries must have a lookupId" + + +class TestHookSortOrder: + """FR-016 — sort order matches runtime execution ordering.""" + + def test_hooks_sorted_by_event_then_priority(self, spec_kit_project: Path): + _install_extension_with_hooks( + spec_kit_project, + "ext", + hooks={ + "before_specify": [ + {"command": "cmd.z", "priority": 20}, + {"command": "cmd.a", "priority": 5}, + ], + "after_plan": [{"command": "cmd.x", "priority": 15}], + }, + ) + rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() + hook_rows = [row for row in rows if row["kind"] == "hook"] + # after_plan sorts before before_specify alphabetically. + assert hook_rows[0]["eventName"] == "after_plan" + # Within before_specify, cmd.a (priority 5) sorts before cmd.z (priority 20). + assert hook_rows[1]["targetCommand"] == "cmd.a" + assert hook_rows[2]["targetCommand"] == "cmd.z" + + +class TestHookCliIntegration: + """CLI wrapper — ``specify artifact list --json`` and ``info --json`` for hooks.""" + + def test_list_includes_hook_via_cli(self, spec_kit_project: Path, monkeypatch): + _install_extension_with_hooks( + spec_kit_project, + "compliance", + hooks={ + "before_specify": [ + {"command": "speckit.compliance.pre-check", "description": "Guard"} + ] + }, + ) + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + hook_rows = [row for row in payload if row.get("kind") == "hook"] + assert len(hook_rows) == 1 + assert hook_rows[0]["id"] == "hook:before_specify:speckit.compliance.pre-check" + + def test_info_shorthand_via_cli(self, spec_kit_project: Path, monkeypatch): + _install_extension_with_hooks( + spec_kit_project, + "compliance", + hooks={"before_specify": [{"command": "speckit.compliance.pre-check"}]}, + ) + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "artifact", + "info", + "hook:before_specify:speckit.compliance.pre-check", + "--json", + ], + ) + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert payload["kind"] == "hook" + assert payload["eventName"] == "before_specify" + assert payload["targetCommand"] == "speckit.compliance.pre-check" + + def test_kind_hook_accepted_by_cli(self, spec_kit_project: Path, monkeypatch): + _install_extension_with_hooks( + spec_kit_project, + "ext", + hooks={"before_specify": [{"command": "cmd.x"}]}, + ) + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "artifact", + "info", + "before_specify:cmd.x", + "--kind", + "hook", + "--json", + ], + ) + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert payload["kind"] == "hook" + + def test_unknown_hook_via_cli_returns_error_envelope( + self, spec_kit_project: Path, monkeypatch + ): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke( + app, + ["artifact", "info", "hook:nope:absent.cmd", "--json"], + catch_exceptions=False, + ) + assert result.exit_code == 1 + assert result.stdout == "" + assert ERROR_REGEX.match(json.loads(result.stderr)["error"]) + + +class TestNoRegressionExistingKinds: + """Confirm hook rollout is additive-only — command/template/script rows are unchanged.""" + + def test_existing_kind_row_shape_unchanged(self, spec_kit_project: Path): + rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() + for row in rows: + if row.get("kind") == "hook": + continue + # Existing kinds must NOT gain hook-only fields. + assert "eventName" not in row + assert "targetCommand" not in row + assert "registered" not in row + # Stack entries for existing kinds must retain their original shape. + for entry in row.get("stack", []): + assert "presetId" in entry + assert "presetName" in entry + assert "hidden" in entry + assert "manifestPath" in entry + + +class TestIsHookRegisteredHelper: + """HookExecutor.is_hook_registered — behavioral truth table.""" + + def test_no_config_returns_false(self, spec_kit_project: Path): + from specify_cli.extensions import HookExecutor + + assert ( + HookExecutor(spec_kit_project).is_hook_registered( + event_name="before_specify", + extension_id="whatever", + command="cmd.x", + ) + is False + ) + + def test_matching_binding_returns_true(self, spec_kit_project: Path): + from specify_cli.extensions import HookExecutor + + _write_hook_binding( + spec_kit_project, + "before_specify", + entries=[ + {"extension": "ext-a", "command": "cmd.x", "enabled": True} + ], + ) + executor = HookExecutor(spec_kit_project) + assert executor.is_hook_registered("before_specify", "ext-a", "cmd.x") is True + + def test_disabled_binding_returns_false(self, spec_kit_project: Path): + from specify_cli.extensions import HookExecutor + + _write_hook_binding( + spec_kit_project, + "before_specify", + entries=[ + {"extension": "ext-a", "command": "cmd.x", "enabled": False} + ], + ) + executor = HookExecutor(spec_kit_project) + assert executor.is_hook_registered("before_specify", "ext-a", "cmd.x") is False + + def test_command_mismatch_returns_false(self, spec_kit_project: Path): + from specify_cli.extensions import HookExecutor + + _write_hook_binding( + spec_kit_project, + "before_specify", + entries=[ + {"extension": "ext-a", "command": "cmd.y", "enabled": True} + ], + ) + executor = HookExecutor(spec_kit_project) + assert executor.is_hook_registered("before_specify", "ext-a", "cmd.x") is False + + def test_module_imports(): assert ArtifactCatalog is not None