diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md new file mode 100644 index 0000000000..7fa8f428cf --- /dev/null +++ b/docs/reference/artifacts.md @@ -0,0 +1,167 @@ +# 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/`. + +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. + +Both subcommands currently require `--json`. Omitting it exits with code `2` and prints a usage message on stderr; no stdout is produced. Text rendering is deliberately deferred so the JSON shapes below are the only contract, and adding a default text renderer later stays a non-breaking, additive change. + +## List Artifacts + +```bash +specify artifact list --json +``` + +| Option | Description | +| -------- | -------------------------------------------------------- | +| `--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. + +```json +[ + { + "id": "command:speckit.specify", + "name": "speckit.specify", + "kind": "command", + "description": "Create or update the feature specification.", + "stack": [ + { + "id": "command:speckit.specify", + "layer": null, + "sourceId": null, + "presetId": null, + "presetName": null, + "strategy": "replace", + "active": true, + "hidden": false, + "manifestPath": null, + "lookupId": null, + "sourcePath": null + } + ] + }, + { + "id": "script:setup-plan", + "name": "setup-plan", + "kind": "script", + "description": "Setup implementation plan for a feature.", + "stack": [ + { + "id": "script:setup-plan", + "layer": null, + "sourceId": null, + "presetId": null, + "presetName": null, + "strategy": "replace", + "active": true, + "hidden": false, + "manifestPath": null, + "lookupId": null, + "sourcePath": null + } + ] + } +] +``` + +| 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` | +| `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` | + +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 + +```bash +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` | + +`` 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. + +```json +{ + "id": "command:speckit.specify", + "name": "speckit.specify", + "kind": "command", + "description": "Create or update the feature specification.", + "stack": [ + { + "id": "command:speckit.specify", + "layer": "preset", + "sourceId": "compliance", + "presetId": "compliance", + "presetName": "Compliance Preset", + "strategy": "replace", + "active": true, + "hidden": false, + "manifestPath": ".specify/presets/compliance/preset.yml", + "lookupId": "preset:compliance:command:speckit.specify", + "sourcePath": ".github/skills/speckit-specify/SKILL.md" + }, + { + "id": "command:speckit.specify", + "layer": null, + "sourceId": null, + "presetId": null, + "presetName": null, + "strategy": "replace", + "active": false, + "hidden": true, + "manifestPath": null, + "lookupId": null, + "sourcePath": null + } + ] +} +``` + +The top-level `id`, `name`, `kind`, `description`, and `stack` fields match the corresponding row on `artifact list --json`. + +### Stack semantics + +`stack` is ordered by resolution precedence: index `0` is the layer that wins. Each row describes one contributing layer: + +| Field | Description | +| -------------- | -------------------------------------------------------------------------------- | +| `id` | `{kind}:{name}` — the source-agnostic round-trip key, identical on every row of the same artifact's stack | +| `layer` | `project`, `preset`, or `extension`; `null` for built-in layers | +| `sourceId` | Source component of `lookupId`, or `null` when the layer has no provenance | +| `presetId` | Preset pack directory id; `null` on built-in, `project`, and `extension` rows | +| `presetName` | Preset display name when its manifest declares one, else the pack id; `null` when `presetId` is `null` | +| `strategy` | `replace`, `wrap`, `prepend`, or `append` | +| `active` | `true` only for index `0` — the layer whose content is served | +| `hidden` | `true` when a lower-index `replace` layer cuts this layer out of the composition | +| `manifestPath` | Project-relative path to the declaring manifest, or `null` when none applies | +| `lookupId` | Deterministic `{layer}:{sourceId}:{kind}:{name}` identifier, or `null` for built-in layers | +| `sourcePath` | Project-relative POSIX path to the concrete file backing the layer, or `null` for built-in/synthetic layers | + +`active` and `hidden` are independent labels, not opposites. `active` identifies the highest-precedence layer selected by the existing Spec Kit layer-resolution order; it does not validate that the layer content can be read or composed. This preserves the diagnostic behavior of `specify preset resolve`, which reports the discovered layer chain even when content composition later produces a warning. Composing strategies (`wrap`, `prepend`, `append`) keep lower layers in the composed output, so an inactive layer is not necessarily hidden: only layers below the first `replace` layer are marked `hidden`. Built-in rows have no provenance: `layer`, `sourceId`, and `lookupId` are `null` — but `id` is always populated, even on built-in rows. `id` is the round-trip key: `specify artifact info` accepts it as input (for example, `specify artifact info command:speckit.specify --json`), and it resolves the same artifact whether the caller passes the bare name or the `id`. + +Lookup IDs are derived by the artifact command from the resolved layer and its existing preset or extension manifest. Manifest-declared layers use the manifest's `id`; convention-only layers use the installed preset or extension directory id. Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID, while built-in layers have no `lookupId`. These values are artifact-stack provenance, not the round-trip key — use `id` for that. `sourcePath` is populated only when the layer maps to a concrete installed preset/extension file or a tracked agent materialization; core, project-override, and other synthetic rows report `null`. + +## 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" } +``` + +| 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) | +| `ambiguous artifact : matches kinds [...]` | The bare name matches more than one kind — re-run with `--kind` | +| `artifact resolution failed` | The extension registry could not be read, or an error prevented the artifact layer stack from being collected | + +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. diff --git a/docs/reference/overview.md b/docs/reference/overview.md index 183ce84756..077eeb1d31 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -26,6 +26,12 @@ Presets customize how Spec Kit works — overriding command files, template file [Presets reference →](presets.md) +## Artifacts + +Artifacts are the commands, templates, and scripts a project exposes, whichever layer contributes them. The `specify artifact` command group is the read-only introspection surface over that inventory — a flat list of everything visible, plus the full composition stack behind any single entry, including which layer wins and which layers are hidden. + +[Artifacts reference →](artifacts.md) + ## Workflows Workflows automate multi-step Spec-Driven Development processes into repeatable sequences. They chain commands, prompts, shell steps, and human checkpoints together, with support for conditional logic, loops, fan-out/fan-in, and the ability to pause and resume from the exact point of interruption. diff --git a/docs/toc.yml b/docs/toc.yml index d2f1b2bd21..c0a4264547 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -41,6 +41,8 @@ href: reference/extensions.md - name: Presets href: reference/presets.md + - name: Artifacts + href: reference/artifacts.md - name: Workflows href: reference/workflows.md - name: Bundles diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f8afcf4f55..93f10a1950 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -560,6 +560,13 @@ def _require_specify_project() -> Path: _register_preset_cmds(app) +# ===== Artifact Commands ===== + +# Read-only introspection over the composed inventory (commands/templates/scripts). +from .artifacts._commands import register as _register_artifact_cmds # noqa: E402 +_register_artifact_cmds(app) + + # ===== Bundle Commands ===== # Bundler subcommand group (specify bundle ...) — see commands/bundle/. diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py new file mode 100644 index 0000000000..778b17a1e8 --- /dev/null +++ b/src/specify_cli/artifacts/__init__.py @@ -0,0 +1,29 @@ +"""Public API for artifact inventory and resolution.""" + +from .catalog import ArtifactCatalog +from .models import ( + AmbiguousArtifactError, + Artifact, + ArtifactError, + ArtifactKind, + ArtifactNotFoundError, + ArtifactResolutionError, + LayerName, + NotASpecKitProjectError, + StackLayer, + Strategy, +) + +__all__ = [ + "AmbiguousArtifactError", + "Artifact", + "ArtifactCatalog", + "ArtifactError", + "ArtifactKind", + "ArtifactNotFoundError", + "ArtifactResolutionError", + "LayerName", + "NotASpecKitProjectError", + "StackLayer", + "Strategy", +] diff --git a/src/specify_cli/artifacts/_commands.py b/src/specify_cli/artifacts/_commands.py new file mode 100644 index 0000000000..11e78bbe70 --- /dev/null +++ b/src/specify_cli/artifacts/_commands.py @@ -0,0 +1,164 @@ +"""Typer sub-app for the `specify artifact` command group. + +Kept intentionally thin: the pure logic lives in ``specify_cli.artifacts``. +This module is only responsible for CLI wiring — argument parsing, JSON +serialization, exit-code selection, and error-envelope emission on stderr. + +Mirrors the shape used by ``src/specify_cli/presets/_commands.py`` and +``src/specify_cli/extensions/_commands.py``: a module-level Typer app plus a +``register(app)`` entry point invoked from ``src/specify_cli/__init__.py``. + +The user-facing contract for both subcommands — the ``list``/``info`` JSON +shapes, stack semantics (``active``/``hidden``, built-in rows, lookup IDs), and +the JSON error envelope — is documented in ``docs/reference/artifacts.md``. +""" + +from __future__ import annotations + +import contextlib +import io +import json +import sys +from pathlib import Path +from typing import Optional + +import typer + +from . import ( + ArtifactCatalog, + ArtifactError, + ArtifactKind, + ArtifactResolutionError, + NotASpecKitProjectError, +) +from ..presets import PresetError + +artifact_app = typer.Typer( + name="artifact", + help="Introspect commands, templates, and scripts Spec Kit exposes.", + no_args_is_help=True, +) + + +def _resolve_project_root() -> Path: + """Return the project root without emitting Rich output on failure. + + Delegates to :func:`specify_cli._require_specify_project` — the same + resolution chokepoint every other project-scoped subcommand (``preset``, + ``extension``, ``workflow``, ...) uses, including its ``SPECIFY_INIT_DIR`` + override handling. That helper prints Rich error output and raises + ``typer.Exit`` on failure, which would corrupt the strict JSON envelope + ``specify artifact list --json`` and ``specify artifact info --json`` + emit on stdout/stderr. The Rich output is suppressed here and the + failure is re-raised as the module-local :class:`NotASpecKitProjectError` + for the shared error handler to serialize instead. + """ + from .. import _require_specify_project # lazy: avoids circular import + + with contextlib.redirect_stderr(io.StringIO()): + try: + return _require_specify_project() + except typer.Exit: + raise NotASpecKitProjectError() from None + + +def _emit_error_and_exit(exc: ArtifactError) -> None: + """Write ``{"error": "..."}`` to stderr and exit with code 1. + + The stdout stream is left completely untouched — the contract is that + machine consumers can rely on an empty stdout when the exit code is + non-zero, so no partial JSON payload leaks even on a late-stage failure. + """ + payload = json.dumps({"error": exc.message}, ensure_ascii=False) + print(payload, file=sys.stderr) + raise typer.Exit(code=1) + + +def _require_json_flag(json_flag: bool) -> None: + """Enforce the opt-in ``--json`` contract shared by both subcommands. + + A text-mode formatter is intentionally deferred so the initial release + can commit to exactly one output shape. Callers that omit ``--json`` + get a usage error (exit 2) with no stdout output — this makes future + addition of a default text renderer a purely additive, non-breaking + change. + """ + if json_flag: + return + print( + "specify artifact requires --json for now; text output is not yet implemented.", + file=sys.stderr, + ) + raise typer.Exit(code=2) + + +@artifact_app.command("list") +def artifact_list( + json_flag: bool = typer.Option( + False, + "--json", + help="Emit the inventory as a JSON array on stdout.", + ), +) -> None: + """List every command, template, and script Spec Kit exposes.""" + _require_json_flag(json_flag) + try: + root = _resolve_project_root() + catalog = ArtifactCatalog(root) + rows = catalog.list_artifacts_with_stack() + except ArtifactError as exc: + _emit_error_and_exit(exc) + return # pragma: no cover — _emit_error_and_exit raises + except (OSError, PresetError): + _emit_error_and_exit(ArtifactResolutionError()) + return # pragma: no cover — _emit_error_and_exit raises + + sys.stdout.write(json.dumps(rows, indent=2, sort_keys=True, ensure_ascii=False)) + sys.stdout.write("\n") + + +@artifact_app.command("info") +def artifact_info( + name: str = typer.Argument(..., help="Artifact name, optionally 'kind:name'."), + json_flag: bool = typer.Option( + False, + "--json", + help="Emit the composition stack as a JSON object on stdout.", + ), + kind: Optional[str] = typer.Option( + None, + "--kind", + help="Narrow the lookup to one artifact family (command/template/script).", + ), +) -> None: + """Show one artifact and its full composition stack.""" + _require_json_flag(json_flag) + + resolved_kind: Optional[ArtifactKind] = None + if kind is not None: + if kind not in ("command", "template", "script"): + print( + f"invalid --kind {kind!r}: expected one of command, template, script", + file=sys.stderr, + ) + raise typer.Exit(code=2) + resolved_kind = kind # type: ignore[assignment] + + try: + root = _resolve_project_root() + catalog = ArtifactCatalog(root) + payload = catalog.get_artifact_info(name, kind=resolved_kind) + except ArtifactError as exc: + _emit_error_and_exit(exc) + return # pragma: no cover + except (OSError, PresetError): + _emit_error_and_exit(ArtifactResolutionError()) + return # pragma: no cover + + sys.stdout.write(json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False)) + sys.stdout.write("\n") + + +def register(app: typer.Typer) -> None: + """Attach the artifact command group to the root Typer app.""" + app.add_typer(artifact_app, name="artifact") diff --git a/src/specify_cli/artifacts/_identifiers.py b/src/specify_cli/artifacts/_identifiers.py new file mode 100644 index 0000000000..3035a407a8 --- /dev/null +++ b/src/specify_cli/artifacts/_identifiers.py @@ -0,0 +1,61 @@ +"""Identifier helpers private to the artifact JSON surface.""" + +from __future__ import annotations + +from typing import Any + + +PROJECT_OVERRIDE_LAYER = "project" +_ARTIFACT_KINDS = frozenset({"command", "template", "script"}) +_LAYER_KINDS = frozenset({PROJECT_OVERRIDE_LAYER, "preset", "extension"}) + + +class IdentifierComponentError(ValueError): + """Raised when a value cannot be represented in an artifact identifier.""" + + +def validate_component(value: Any, field_label: str) -> str: + """Return a non-empty string that does not contain the ID delimiter.""" + if not isinstance(value, str): + raise IdentifierComponentError( + f"Invalid {field_label}: expected a string, got {type(value).__name__}" + ) + if not value: + raise IdentifierComponentError( + f"Invalid {field_label}: value must not be empty" + ) + if ":" in value: + raise IdentifierComponentError( + f"Invalid {field_label} '{value}': ':' is reserved as an identifier delimiter" + ) + return value + + +def derive_public_id(kind: str, name: str) -> str: + """Build the source-agnostic identifier exposed by ``specify artifact``.""" + validate_component(kind, "kind") + if kind not in _ARTIFACT_KINDS: + raise IdentifierComponentError(f"Invalid public artifact kind '{kind}'") + validate_component(name, "name") + return f"{kind}:{name}" + + +def derive_lookup_id(layer: str, source_id: str, kind: str, name: str) -> str: + """Build an artifact-stack lookup identifier.""" + validate_component(layer, "layer") + validate_component(source_id, "sourceId") + validate_component(kind, "kind") + validate_component(name, "name") + if layer not in _LAYER_KINDS: + raise IdentifierComponentError(f"Invalid layer '{layer}'") + if kind not in _ARTIFACT_KINDS: + raise IdentifierComponentError(f"Invalid artifact kind '{kind}'") + if layer == PROJECT_OVERRIDE_LAYER and source_id != "_": + raise IdentifierComponentError( + f"Invalid sourceId '{source_id}': project layer requires '_'" + ) + if layer != PROJECT_OVERRIDE_LAYER and source_id == "_": + raise IdentifierComponentError( + "Invalid sourceId '_': reserved for project layer" + ) + return f"{layer}:{source_id}:{kind}:{name}" diff --git a/src/specify_cli/artifacts/catalog.py b/src/specify_cli/artifacts/catalog.py new file mode 100644 index 0000000000..c27f083eb6 --- /dev/null +++ b/src/specify_cli/artifacts/catalog.py @@ -0,0 +1,765 @@ +"""Artifact inventory and catalog logic. No Typer decorators. + +Two public entry points: + +* :meth:`ArtifactCatalog.list_artifacts` — flat inventory (id, name, kind, description). +* :meth:`ArtifactCatalog.get_artifact_info` — one row plus its full ordered stack. + +Everything else in this module is internal machinery. Callers outside +:mod:`specify_cli.artifacts._commands` should not import the private helpers. +""" + +from __future__ import annotations + +import re +import shlex +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any, Iterable, Literal + +import yaml + +from ._identifiers import ( + IdentifierComponentError, + derive_public_id, + validate_component, +) +from .models import ( + AmbiguousArtifactError, + Artifact, + ArtifactKind, + ArtifactNotFoundError, + ArtifactResolutionError, + NotASpecKitProjectError, +) +from .resolution import _build_stack, _layer_provenance + + +_TEMPLATE_SUFFIX = ".md" +_SCRIPT_SUFFIX = ".sh" + + +def _resolve_script_reference(script_root: Path, token: str) -> Path | None: + """Resolve a command script reference that remains inside *script_root*.""" + posix_path = PurePosixPath(token) + windows_path = PureWindowsPath(token) + if posix_path.anchor or windows_path.anchor: + return None + if ".." in posix_path.parts or ".." in windows_path.parts: + return None + + relative = Path(token) + if relative.parts and relative.parts[0] == "scripts": + relative = Path(*relative.parts[1:]) + if not relative.parts: + return None + + try: + resolved_root = script_root.resolve() + candidate = (resolved_root / relative).resolve() + candidate.relative_to(resolved_root) + except (OSError, ValueError): + return None + return candidate if candidate.is_file() else None + + +def _locate_shared_asset_dir(subdir: str) -> Path | None: + """Locate a core asset directory without changing shared asset behavior.""" + if subdir not in {"commands", "scripts", "templates"}: + return None + + from .._assets import _locate_core_pack, _repo_root + + core_pack = _locate_core_pack() + bundled = core_pack / subdir if core_pack is not None else None + source = ( + _repo_root() / "templates" / "commands" + if subdir == "commands" + else _repo_root() / subdir + ) + for candidate in (bundled, source): + if candidate is not None and candidate.is_dir(): + return candidate + return None + + +def _project_core_asset_root(project_root: Path | None, subdir: str) -> Path | None: + """Return the project-local built-in-tier directory for an asset family, if present.""" + if project_root is None: + return None + if subdir not in {"commands", "scripts", "templates"}: + return None # pragma: no cover — internal misuse + from ..presets import PresetResolver # lazy: avoids circular import + + candidate = PresetResolver(project_root).templates_dir + if subdir != "templates": + candidate = candidate / subdir + return candidate if candidate.is_dir() else None + + +def _core_command_logical_name(stem: str) -> str: + return stem if stem.startswith("speckit.") else f"speckit.{stem}" + + +def _extract_frontmatter_description(text: str) -> str: + """Return the ``description`` value from YAML frontmatter, else ``""``. + + Matches the frontmatter shape used by every core command/template on disk: + a ``---`` fence pair at the top of the file with a YAML mapping between + them. Anything malformed silently yields the empty string — the contract + forbids omission but permits ``""``. + """ + lines = text.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return "" + fence_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + fence_end = i + break + if fence_end == -1: + return "" + try: + data = yaml.safe_load("".join(lines[1:fence_end])) + except yaml.YAMLError: + return "" + if not isinstance(data, dict): + return "" + value = data.get("description", "") + return value if isinstance(value, str) else "" + + +def _extract_script_description(text: str) -> str: + """Return the first docstring/comment line of a script, else ``""``. + + Supports the three script runtimes Spec Kit ships: + + * Python (``.py``): the first line of the module docstring. + * Bash (``.sh``): the first ``#``-prefixed comment line following the + shebang. + * PowerShell (``.ps1``): either the first line of a ``<# ... #>`` block + comment or the first ``#``-prefixed line. + + Anything unrecognized yields the empty string. + """ + py_match = re.match(r'^(?:#![^\n]*\n)?\s*(?:"""|\'\'\')(.*?)(?:"""|\'\'\')', text, re.DOTALL) + if py_match: + first = py_match.group(1).strip().splitlines() + if first: + return first[0].strip() + + ps_block = re.match(r'^(?:<#\s*(.*?)#>)', text, re.DOTALL) + if ps_block: + first = ps_block.group(1).strip().splitlines() + if first: + return first[0].strip().lstrip(".").strip() + + for raw in text.splitlines(): + stripped = raw.strip() + if not stripped or stripped.startswith("#!"): + continue + if stripped.startswith("#"): + return stripped.lstrip("#").strip() + break + return "" + + +def _describe_artifact_file(path: Path, kind: ArtifactKind) -> str: + """Return the on-disk description for an artifact file, else ``""``. + + Routes to the same extractors the inventory uses so a project + override reports its own metadata instead of inheriting the description + of the core/preset layer it hides. + """ + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return "" + if kind == "script": + return _extract_script_description(text) + return _extract_frontmatter_description(text) + + +# --------------------------------------------------------------------------- +# ArtifactCatalog — public façade +# --------------------------------------------------------------------------- + + +def _validate_project(project_root: Path) -> None: + """Raise NotASpecKitProjectError when ``project_root`` isn't a Spec Kit project. + + The two invariants the rest of the module relies on are that + ``project_root`` exists and that a ``.specify/`` subdirectory sits under + it. Anything else — missing presets/, missing extensions/, missing + templates/ — is a valid empty-inventory scenario and is not treated as + an error. + """ + if not (project_root / ".specify").is_dir(): + raise NotASpecKitProjectError() + + +def _validate_extension_registry(project_root: Path) -> None: + extensions_dir = project_root / ".specify" / "extensions" + if not extensions_dir.exists(): + return + + from ..extensions import ExtensionRegistry + + if ExtensionRegistry(extensions_dir).is_corrupt(): + raise ArtifactResolutionError() + + +def _resolve_kind_hint(name: str, kind: ArtifactKind | None) -> tuple[str, ArtifactKind | None]: + """Parse ``kind:name`` shorthand and reconcile it with an explicit ``--kind`` flag. + + 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. + """ + if ":" in name: + prefix, _, bare = name.partition(":") + if prefix in ("command", "template", "script"): + resolved: ArtifactKind = prefix # type: ignore[assignment] + if kind is not None and kind != resolved: + raise ArtifactNotFoundError(name) + return bare, resolved + return name, kind + + +def _validate_artifact_name(name: str, kind: ArtifactKind) -> str: + """Validate the structural identifier component constraints for ``name``.""" + try: + return validate_component(name, f"{kind} name") + except IdentifierComponentError as exc: + raise ArtifactNotFoundError(name) from exc + + +def _is_valid_artifact_name_component(name: Any, kind: ArtifactKind) -> bool: + """Return ``True`` when ``name`` can appear in an artifact identifier.""" + try: + validate_component(name, f"{kind} name") + except IdentifierComponentError: + return False + return True + + +class ArtifactCatalog: + """Read-only view over one Spec Kit project's artifact inventory.""" + + def __init__(self, project_root: Path) -> None: + self.project_root = project_root + + # ------------------------------------------------------------------ list + def list_artifacts(self) -> list[Artifact]: + """Return every artifact Spec Kit exposes for this project, deduped. + + Sort order is deterministic — first by ``kind`` in the fixed + ``["command", "template", "script"]`` order, then by ``name``. + Returns an empty list when no artifacts are found rather than raising; + a fresh install with no presets, no extensions, and no built-in assets is + still a valid Spec Kit project. + + Skills (``.github/skills/**/SKILL.md``) are intentionally excluded — + they are integration-specific output, not a shipped asset family. + + Descriptions are picked from the highest-priority layer that has one, + not the first layer discovered — a built-in command that an active + preset overrides must report the preset's description, and two + competing packs must report the higher-precedence one's. Precedence + is decided by :meth:`PresetResolver.collect_all_layers`'s own + ordering (index 0 = winner), not by enumeration order here. + """ + artifacts, _layers_cache, _resolver, _manifest_cache = self._collect_inventory() + return artifacts + + def list_artifacts_with_stack(self) -> list[dict[str, Any]]: + """Return list rows enriched with each artifact's full composition stack.""" + artifacts, layers_cache, resolver, manifest_cache = self._collect_inventory() + rows: list[dict[str, Any]] = [] + for artifact in artifacts: + stack = _build_stack( + self.project_root, + artifact.kind, + artifact.name, + raw_layers=layers_cache.get((artifact.kind, artifact.name)), + resolver=resolver, + manifest_cache=manifest_cache, + ) + row = artifact.to_json_dict() + row["stack"] = [layer.to_json_dict() for layer in stack] + rows.append(row) + return rows + + # ------------------------------------------------------------------ info + def get_artifact_info( + self, + name: str, + kind: ArtifactKind | None = None, + ) -> dict[str, Any]: + """Return the full JSON-ready dict for ``specify artifact info``. + + Argument resolution: + + * ``name`` accepts the ``kind:name`` grammar as shorthand; when both + the shorthand and ``kind`` are supplied they must agree. + * When neither the shorthand nor ``kind`` narrows the search and + more than one kind matches ``name``, raises + :class:`AmbiguousArtifactError`. + * When no artifact matches, raises :class:`ArtifactNotFoundError`. + """ + bare, resolved_kind = _resolve_kind_hint(name, kind) + + # 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 + # each re-implementing the checks. + inventory, layers_cache, resolver, manifest_cache = self._collect_inventory() + if resolved_kind is None: + matches = [ + (artifact.kind, artifact.name) + for artifact in inventory + if artifact.name == bare + ] + if not matches: + raise ArtifactNotFoundError(name) + if len(matches) > 1: + raise AmbiguousArtifactError(bare, [k for k, _ in matches]) + resolved_kind = matches[0][0] + + validated_name = _validate_artifact_name(bare, resolved_kind) + artifact = next( + ( + item + for item in inventory + if item.kind == resolved_kind and item.name == validated_name + ), + None, + ) + if artifact is None: + raise ArtifactNotFoundError(name) + stack = _build_stack( + self.project_root, + resolved_kind, + validated_name, + raw_layers=layers_cache.get((resolved_kind, validated_name)), + resolver=resolver, + manifest_cache=manifest_cache, + ) + if not stack: + raise ArtifactNotFoundError(name) + + return { + "id": derive_public_id(resolved_kind, validated_name), + "name": validated_name, + "kind": resolved_kind, + "description": artifact.description, + "stack": [layer.to_json_dict() for layer in stack], + } + + # -------------------------------------------------------------- internals + def _collect_inventory( + self, + ) -> tuple[ + list[Artifact], + dict[tuple[ArtifactKind, str], list[dict[str, Any]]], + Any, + dict[Path, Any | None], + ]: + _validate_project(self.project_root) + _validate_extension_registry(self.project_root) + + from ..presets import PresetError, PresetResolver # lazy: avoids circular import + + resolver = PresetResolver(self.project_root) + layers_cache: dict[tuple[ArtifactKind, str], list[dict[str, Any]]] = {} + core_script_paths = self._selected_core_script_paths() + + def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: + key = (kind, name) + if key not in layers_cache: + try: + layers = resolver.collect_all_layers(name, kind) + except (OSError, PresetError) as exc: + raise ArtifactResolutionError() from exc + core_script = core_script_paths.get(name) if kind == "script" else None + if core_script is not None and not any( + layer.get("source") in {"core", "core (bundled)"} + for layer in layers + ): + layers.append( + { + "path": core_script, + "source": "core", + "strategy": "replace", + } + ) + layers_cache[key] = layers + return layers_cache[key] + + def _has_any_replace_layer(layers: list[dict[str, Any]]) -> bool: + return any(layer.get("strategy") == "replace" for layer in layers) + + names: set[tuple[ArtifactKind, str]] = set() + try: + for kind, name in self._iter_candidate_artifacts( + resolver, core_script_paths + ): + key = (kind, name) + if not _is_valid_artifact_name_component(name, kind): + continue + # Resolve each candidate through Spec Kit's existing single-artifact + # path for behavioral parity; optimize shared manifest reads only if + # typical small extension sets show a measurable inventory cost. + layers = _layers_for(kind, name) + if layers and _has_any_replace_layer(layers): + names.add(key) + except (OSError, PresetError) as exc: + raise ArtifactResolutionError() from exc + + artifacts: list[Artifact] = [] + manifest_cache: dict[Path, Any | None] = {} + for kind, name in names: + description = "" + for layer in _layers_for(kind, name): + candidate = self._describe_layer( + resolver, layer, kind, name, manifest_cache + ) + if candidate: + description = candidate + break + artifacts.append( + Artifact( + id=derive_public_id(kind, name), + name=name, + kind=kind, + description=description, + ) + ) + + kind_order = {"command": 0, "template": 1, "script": 2} + return ( + sorted(artifacts, key=lambda a: (kind_order[a.kind], a.name)), + layers_cache, + resolver, + manifest_cache, + ) + + def _iter_candidate_artifacts( + self, + resolver: Any, + core_script_paths: dict[str, Path], + ) -> Iterable[tuple[ArtifactKind, str]]: + """Yield candidate ``(kind, name)`` pairs from every resolver tier. + + Covers the ways a pack can contribute an artifact: + + * manifest-declared entries (``preset.yml`` / ``extension.yml``), read + through each manifest class's existing normalized properties, and + * convention-placed extension files (``commands/``, ``templates/``, + ``scripts/``) that the resolver picks up even without a manifest. + + Presets and extensions are enumerated through the resolver's existing + priority helpers, so the candidate set follows the same install, + enable, and priority rules as resolution. Project overrides and + resolver-compatible core asset paths are included only as candidate + names; :meth:`PresetResolver.collect_all_layers` remains the source of + truth for which candidates are actually present and which layer wins. + + Project-local overrides under ``.specify/templates/overrides`` are + included too, so an artifact that exists only as an override is still + listed. + + Silent on any manifest that fails to parse — that would already be + surfaced by ``specify preset list`` or ``specify extension list``, and + this command's job is to describe the composed inventory, not to be + the second validation surface. + """ + from ..extensions import ExtensionManager, ExtensionManifest, ValidationError + from ..presets import PresetManager # lazy: avoids circular import + + # -- Presets: the registry is authoritative, no unregistered fallback. + preset_manager = PresetManager(self.project_root) + for pack_id, _metadata in resolver._get_all_presets_by_priority(): + pack_dir = preset_manager.presets_dir / pack_id + manifest = preset_manager.get_pack(pack_id) + yield from self._iter_pack_candidates(manifest, pack_dir, "preset") + + # -- Extensions: use the resolver's own extension enumeration order. + ext_manager = ExtensionManager(self.project_root) + for _priority, ext_id, metadata in resolver._get_all_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 + yield from self._iter_pack_candidates(manifest, ext_dir, "extension") + + yield from self._iter_project_override_candidates(resolver) + yield from self._iter_core_candidates(core_script_paths) + + @staticmethod + def _iter_pack_candidates( + manifest: Any, + pack_dir: Path, + layer: Literal["preset", "extension"], + ) -> Iterable[tuple[ArtifactKind, str]]: + """Yield manifest-declared and convention-based candidate names.""" + if manifest is not None: + if layer == "preset": + declarations = ( + (entry.get("type"), entry) + for entry in manifest.templates + if isinstance(entry, dict) + ) + else: + declarations = ( + (kind, entry) + for kind, entries in ( + ("command", manifest.commands), + ("template", manifest.templates), + ("script", manifest.scripts), + ) + for entry in entries + if isinstance(entry, dict) + ) + for kind, contribution in declarations: + name = contribution.get("name") + if ( + kind in ("command", "template", "script") + and isinstance(name, str) + and name + and ":" not in name + ): + yield kind, name + + yield from ((kind, name) for kind, name, _path in _iter_convention_contributions(pack_dir)) + + def _iter_project_override_candidates( + self, + resolver: Any, + ) -> Iterable[tuple[ArtifactKind, str]]: + """Yield candidate ``(kind, name)`` pairs for project overrides. + + A root ``overrides/.md`` file is the override for both the + ``template`` and the ``command`` lookup of ````. Both candidates + are emitted and the normal inventory resolution path decides whether + each is present. + """ + overrides_dir = resolver.overrides_dir + if not overrides_dir.is_dir(): + return + for entry in sorted(overrides_dir.iterdir(), key=lambda p: p.name): + if not entry.is_file() or entry.suffix != _TEMPLATE_SUFFIX: + continue + name = entry.stem + if not _is_valid_artifact_name_component(name, "command"): + continue + for kind in ("command", "template"): + yield kind, name + scripts_dir = overrides_dir / "scripts" + if not scripts_dir.is_dir(): + return + for entry in sorted(scripts_dir.iterdir(), key=lambda p: p.name): + if entry.is_file() and entry.suffix == _SCRIPT_SUFFIX: + if not _is_valid_artifact_name_component(entry.stem, "script"): + continue + yield "script", entry.stem + + def _iter_core_candidates( + self, core_script_paths: dict[str, Path] + ) -> Iterable[tuple[ArtifactKind, str]]: + """Yield candidate names from resolver-compatible core asset paths.""" + from ..extensions import CORE_COMMAND_NAMES # lazy: avoids circular import + from ..presets import PresetResolver + + project_commands_dir = _project_core_asset_root(self.project_root, "commands") + bundled_commands_dir = _locate_shared_asset_dir("commands") + command_dirs = tuple( + directory + for directory in (project_commands_dir, bundled_commands_dir) + if directory is not None + ) + command_names = {_core_command_logical_name(name) for name in CORE_COMMAND_NAMES} + for directory in command_dirs: + for entry in sorted(directory.iterdir(), key=lambda p: p.name): + if entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX: + command_names.add(_core_command_logical_name(entry.stem)) + for name in sorted(command_names): + if any( + (directory / f"{candidate}.md").is_file() + for directory in command_dirs + for candidate in ( + name, + *( + (PresetResolver._core_stem(name),) + if PresetResolver._core_stem(name) + else () + ), + ) + if candidate is not None + ): + yield "command", name + + seen_templates: set[str] = set() + for directory in ( + _project_core_asset_root(self.project_root, "templates"), + _locate_shared_asset_dir("templates"), + ): + if directory is None: + continue + for entry in sorted(directory.iterdir(), key=lambda p: p.name): + if ( + entry.is_file() + and entry.suffix == _TEMPLATE_SUFFIX + and entry.stem not in seen_templates + ): + seen_templates.add(entry.stem) + yield "template", entry.stem + + for directory in ( + _project_core_asset_root(self.project_root, "scripts"), + _locate_shared_asset_dir("scripts"), + ): + if directory is None: + continue + for entry in sorted(directory.glob(f"*{_SCRIPT_SUFFIX}"), key=lambda p: p.name): + yield "script", entry.stem + yield from (("script", name) for name in sorted(core_script_paths)) + + def _selected_core_script_paths(self) -> dict[str, Path]: + """Return built-in scripts selected by the project's existing runtime policy.""" + from .._init_options import load_init_options + from ..agents import CommandRegistrar + from ..integrations.base import IntegrationBase + + command_dirs = tuple( + directory + for directory in ( + _project_core_asset_root(self.project_root, "commands"), + _locate_shared_asset_dir("commands"), + ) + if directory is not None + ) + script_dirs = tuple( + directory + for directory in ( + _project_core_asset_root(self.project_root, "scripts"), + _locate_shared_asset_dir("scripts"), + ) + if directory is not None + ) + requested = load_init_options(self.project_root).get("script") + selected: dict[str, Path] = {} + + for command_dir in command_dirs: + for template_path in sorted(command_dir.glob("*.md"), key=lambda p: p.name): + try: + content = template_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + frontmatter, _body = CommandRegistrar.parse_frontmatter(content) + scripts = frontmatter.get("scripts", {}) + if not isinstance(scripts, dict): + continue + script_commands = { + key: value + for key, value in scripts.items() + if isinstance(key, str) and isinstance(value, str) and value.strip() + } + if not script_commands: + continue + try: + variant = IntegrationBase.select_script_variant( + requested, script_commands + ) + tokens = shlex.split(script_commands[variant], posix=True) + except (KeyError, ValueError): + continue + if not tokens: + continue + + path = None + for script_dir in script_dirs: + path = _resolve_script_reference(script_dir, tokens[0]) + if path is not None: + break + if path is None: + continue + name = path.stem.replace("_", "-") if variant == "py" else path.stem + selected.setdefault(name, path) + + return selected + + def _describe_layer( + self, + resolver: Any, + layer: dict[str, Any], + kind: ArtifactKind, + name: str, + manifest_cache: dict[Path, Any | None], + ) -> str: + """Return manifest metadata or on-disk metadata for one resolver layer.""" + manifest_description = self._manifest_description_for_layer( + resolver, layer, kind, name, manifest_cache + ) + if manifest_description: + return manifest_description + path = layer.get("path") + if isinstance(path, Path): + return _describe_artifact_file(path, kind) + return "" + + def _manifest_description_for_layer( + self, + resolver: Any, + layer: dict[str, Any], + kind: ArtifactKind, + name: str, + manifest_cache: dict[Path, Any | None], + ) -> str: + provenance = _layer_provenance( + resolver, layer, kind, name, manifest_cache + ) + entry = provenance.manifest_entry + if entry is None: + return "" + description = entry.get("description", "") + return description if isinstance(description, str) else "" + + +_CONVENTION_SUBDIRS: tuple[tuple[str, ArtifactKind, str], ...] = ( + ("commands", "command", _TEMPLATE_SUFFIX), + ("templates", "template", _TEMPLATE_SUFFIX), + ("scripts", "script", _SCRIPT_SUFFIX), +) + + +def _iter_convention_contributions( + pack_dir: Path, +) -> Iterable[tuple[ArtifactKind, str, Path]]: + """Yield ``(kind, name, path)`` for files exposed by convention. + + Templates are also accepted at the pack root for legacy compatibility, + matching the resolver's ``templates/``-then-root lookup order. + """ + for subdir, kind, suffix in _CONVENTION_SUBDIRS: + candidate_dir = pack_dir / subdir + if not candidate_dir.is_dir(): + continue + for entry in sorted(candidate_dir.iterdir(), key=lambda p: p.name): + if entry.is_file() and entry.suffix == suffix and ":" not in entry.stem: + yield kind, entry.stem, entry + if not pack_dir.is_dir(): + return + for entry in sorted(pack_dir.iterdir(), key=lambda p: p.name): + if ( + entry.is_file() + and entry.suffix == _TEMPLATE_SUFFIX + and ":" not in entry.stem + ): + yield "template", entry.stem, entry diff --git a/src/specify_cli/artifacts/models.py b/src/specify_cli/artifacts/models.py new file mode 100644 index 0000000000..8def90db66 --- /dev/null +++ b/src/specify_cli/artifacts/models.py @@ -0,0 +1,110 @@ +"""Public data contracts and errors for artifact inspection.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable, Literal + +ArtifactKind = Literal["command", "template", "script"] +LayerName = Literal["project", "preset", "extension"] +Strategy = Literal["replace", "wrap", "prepend", "append"] + + +@dataclass(frozen=True) +class Artifact: + """One row in the flat artifact inventory.""" + + id: str + name: str + kind: ArtifactKind + description: str + + def to_json_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "kind": self.kind, + "description": self.description, + } + + +@dataclass(frozen=True) +class StackLayer: + """One row in an artifact's ordered composition stack. + + ``id`` is the source-agnostic round-trip key for the artifact this stack + row belongs to. ``lookupId`` identifies a specific non-core contribution + when one exists. + """ + + id: str + layer: LayerName | None + sourceId: str | None + presetId: str | None + presetName: str | None + strategy: Strategy + active: bool + hidden: bool + manifestPath: str | None + lookupId: str | None + sourcePath: str | None + + def to_json_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "layer": self.layer, + "sourceId": self.sourceId, + "presetId": self.presetId, + "presetName": self.presetName, + "strategy": self.strategy, + "active": self.active, + "hidden": self.hidden, + "manifestPath": self.manifestPath, + "lookupId": self.lookupId, + "sourcePath": self.sourcePath, + } + + +class ArtifactError(Exception): + """Base class for artifact command errors with stable messages.""" + + message: str + + +class ArtifactNotFoundError(ArtifactError): + def __init__(self, name: str) -> None: + self.message = f"unknown artifact {name}" + super().__init__(self.message) + + +class AmbiguousArtifactError(ArtifactError): + def __init__(self, name: str, kinds: Iterable[str]) -> None: + kinds_list = sorted(kinds) + self.message = f"ambiguous artifact {name}: matches kinds {kinds_list}" + super().__init__(self.message) + + +class NotASpecKitProjectError(ArtifactError): + def __init__(self) -> None: + self.message = "not a Spec Kit project: no .specify/ directory found" + super().__init__(self.message) + + +class ArtifactResolutionError(ArtifactError): + def __init__(self) -> None: + self.message = "artifact resolution failed" + super().__init__(self.message) + + +__all__ = [ + "AmbiguousArtifactError", + "Artifact", + "ArtifactError", + "ArtifactKind", + "ArtifactNotFoundError", + "ArtifactResolutionError", + "LayerName", + "NotASpecKitProjectError", + "StackLayer", + "Strategy", +] diff --git a/src/specify_cli/artifacts/resolution.py b/src/specify_cli/artifacts/resolution.py new file mode 100644 index 0000000000..73625a630f --- /dev/null +++ b/src/specify_cli/artifacts/resolution.py @@ -0,0 +1,514 @@ +"""Artifact stack projection over the existing preset resolver.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +import yaml + +from ._identifiers import ( + PROJECT_OVERRIDE_LAYER, + IdentifierComponentError, + derive_lookup_id, + derive_public_id, +) +from .models import ArtifactKind, ArtifactResolutionError, LayerName, StackLayer + + +@dataclass(frozen=True) +class _LayerProvenance: + """Artifact-only metadata derived from an unchanged resolver layer.""" + + layer: LayerName | None + source_id: str | None + disk_id: str | None + pack_dir: Path | None + manifest: Any | None + manifest_entry: dict[str, Any] | None + + def lookup_id(self, kind: ArtifactKind, name: str) -> str | None: + if self.layer is None or self.source_id is None: + return None + try: + return derive_lookup_id(self.layer, self.source_id, kind, name) + except IdentifierComponentError: + return None + + +def _same_file(left: Path | None, right: Any) -> bool: + """Compare paths without requiring either path to exist at comparison time.""" + return isinstance(right, Path) and left is not None and left.resolve() == right.resolve() + + +def _manifest_entry_for_path( + manifest: Any, + layer: Literal["preset", "extension"], + pack_dir: Path, + kind: ArtifactKind, + name: str, + path: Path, +) -> dict[str, Any] | None: + """Return the existing manifest declaration that resolved to *path*.""" + if manifest is None: + return None + if layer == "preset": + entries = ( + entry + for entry in manifest.templates + if isinstance(entry, dict) and entry.get("type") == kind + ) + else: + entries = { + "command": manifest.commands, + "template": manifest.templates, + "script": manifest.scripts, + }[kind] + for entry in entries: + if not isinstance(entry, dict) or entry.get("name") != name: + continue + relative_file = entry.get("file") + if isinstance(relative_file, str) and _same_file( + pack_dir / relative_file, path + ): + return entry + return None + + +def _layer_provenance( + resolver: Any, + resolver_layer: dict[str, Any], + kind: ArtifactKind, + name: str, + manifest_cache: dict[Path, Any | None], +) -> _LayerProvenance: + """Derive artifact provenance from the resolver's established layer shape.""" + source = resolver_layer.get("source") + path = resolver_layer.get("path") + + if source == "project override": + return _LayerProvenance("project", "_", None, None, None, None) + if source in {"core", "core (bundled)"}: + return _LayerProvenance(None, None, None, None, None, None) + if not isinstance(path, Path) or not isinstance(source, str): + raise ArtifactResolutionError() + + if source.startswith("extension:"): + extension_id = resolver_layer.get("extension_id") + extension_dir = resolver_layer.get("extension_dir") + if not isinstance(extension_id, str) or not isinstance(extension_dir, Path): + raise ArtifactResolutionError() + manifest_path = extension_dir / "extension.yml" + if manifest_path not in manifest_cache: + try: + from ..extensions import ExtensionManifest, ValidationError + + manifest_cache[manifest_path] = ( + ExtensionManifest(manifest_path) if manifest_path.is_file() else None + ) + except ( + ValidationError, + yaml.YAMLError, + OSError, + TypeError, + AttributeError, + ): + manifest_cache[manifest_path] = None + manifest = manifest_cache[manifest_path] + declared = _manifest_entry_for_path( + manifest, "extension", extension_dir, kind, name, path + ) + source_id = ( + manifest.id + if declared is not None + and manifest is not None + and isinstance(manifest.id, str) + and manifest.id + else extension_id + ) + return _LayerProvenance( + "extension", + source_id, + extension_id, + extension_dir, + manifest, + declared, + ) + + try: + relative = path.relative_to(resolver.presets_dir) + except ValueError as exc: + raise ArtifactResolutionError() from exc + if not relative.parts: + raise ArtifactResolutionError() + pack_id = relative.parts[0] + pack_dir = resolver.presets_dir / pack_id + manifest = resolver._get_manifest(pack_dir) + declared = _manifest_entry_for_path( + manifest, "preset", pack_dir, kind, name, path + ) + source_id = ( + manifest.id + if declared is not None + and manifest is not None + and isinstance(manifest.id, str) + and manifest.id + else pack_id + ) + return _LayerProvenance( + "preset", + source_id, + pack_id, + pack_dir, + manifest, + declared, + ) + + +def _derive_manifest_path( + provenance: _LayerProvenance, project_root: Path +) -> str | None: + """Return the declaring manifest path for an artifact layer.""" + if provenance.manifest_entry is None or provenance.pack_dir is None: + return None + manifest_name = ( + "preset.yml" if provenance.layer == "preset" else "extension.yml" + ) + manifest_path = provenance.pack_dir / manifest_name + if not manifest_path.is_file(): + return None + try: + return manifest_path.relative_to(project_root).as_posix() + except ValueError: + return None + + +def _repo_relative_existing_file(project_root: Path, path: Path) -> str | None: + """Return *path* relative to the project root when it is an existing file.""" + if not path.is_file(): + return None + try: + return path.relative_to(project_root).as_posix() + except ValueError: + return None + + +def _is_safe_path_component(value: str) -> bool: + """Return true when *value* is a single non-traversing path component.""" + if not value or value in (".", ".."): + return False + path = Path(value) + return not path.is_absolute() and len(path.parts) == 1 and path.name == value + + +def _materialized_command_source_path( + project_root: Path, + metadata: dict[str, Any] | None, + name: str, + *, + source: Literal["preset", "extension"], +) -> str | None: + """Return the tracked agent output path for an installed command layer.""" + if not isinstance(metadata, dict): + return None + + try: + from ..agents import CommandRegistrar + except ImportError: + return None + + registrar = CommandRegistrar() + registrar._ensure_configs() + + registered_commands = metadata.get("registered_commands") + if isinstance(registered_commands, dict): + for agent_name in sorted(registered_commands): + cmd_names = registered_commands.get(agent_name) + if not isinstance(cmd_names, list): + continue + if name not in cmd_names: + continue + agent_config = registrar.AGENT_CONFIGS.get(agent_name) + if agent_config is None: + continue + output_name = registrar._compute_output_name( + agent_name, name, agent_config + ) + command_path = ( + registrar._resolve_agent_dir(agent_name, agent_config, project_root) + / f"{output_name}{agent_config['extension']}" + ) + rel = _repo_relative_existing_file(project_root, command_path) + if rel is not None: + return rel + + registered_skills = metadata.get("registered_skills") + if source == "preset": + skill_names_by_agent = registered_skills if isinstance(registered_skills, dict) else {} + elif isinstance(registered_skills, list): + # Extension registries store skills as a flat list, unlike presets' + # per-agent map. Probe every known agent's project-local skills + # directory and return the first extant tracked file. + skill_names_by_agent = { + agent_name: registered_skills for agent_name in sorted(registrar.AGENT_CONFIGS) + } + else: + skill_names_by_agent = {} + + expected_skill_names: set[str] | None = None + if source == "extension": + try: + from ..extensions import ExtensionManager + + expected_skill_names = {ExtensionManager._skill_name_for_command(name)} + except ImportError: + expected_skill_names = None + else: + try: + from ..presets import PresetManager + + expected_skill_names = set(PresetManager._skill_names_for_command(name)) + except ImportError: + expected_skill_names = None + + if isinstance(skill_names_by_agent, dict): + from .. import _get_skills_dir as _project_skills_dir + + for agent_name in sorted(skill_names_by_agent): + skill_names = skill_names_by_agent.get(agent_name) + if not isinstance(agent_name, str) or not isinstance(skill_names, list): + continue + agent_config = registrar.AGENT_CONFIGS.get(agent_name) + if agent_config is None: + continue + if agent_config.get("extension") == "/SKILL.md": + skills_dir = registrar._resolve_agent_dir( + agent_name, agent_config, project_root + ) + else: + skills_dir = _project_skills_dir(project_root, agent_name) + for skill_name in sorted( + n for n in skill_names if isinstance(n, str) and _is_safe_path_component(n) + ): + if expected_skill_names is not None and skill_name not in expected_skill_names: + continue + skill_path = skills_dir / skill_name / "SKILL.md" + rel = _repo_relative_existing_file(project_root, skill_path) + if rel is not None: + return rel + + return None + + +def _derive_source_path( + provenance: _LayerProvenance, + layer: dict[str, Any], + project_root: Path, + kind: ArtifactKind, + name: str, + *, + active: bool, +) -> str | None: + """Return the repo-relative concrete file backing a preset/extension layer. + + The tracked materialized agent output is shared by every stack row that + contributed the same command name, so it only reflects the winning + (``active``) row's content. Lower ``replace``/``merge`` rows must report + their own installed pack file instead of that shared output. + """ + if provenance.layer == "preset": + if provenance.disk_id is None: + return None + from ..presets import PresetRegistry + + metadata = PresetRegistry(project_root / ".specify" / "presets").get( + provenance.disk_id + ) + if kind == "command" and active: + materialized = _materialized_command_source_path( + project_root, metadata, name, source="preset" + ) + if materialized is not None: + return materialized + elif provenance.layer == "extension": + if provenance.disk_id is None: + return None + from ..extensions import ExtensionRegistry + + metadata = ExtensionRegistry(project_root / ".specify" / "extensions").get( + provenance.disk_id + ) + if kind == "command" and active: + materialized = _materialized_command_source_path( + project_root, metadata, name, source="extension" + ) + if materialized is not None: + return materialized + else: + return None + + # Non-active command layers, non-command preset/extension layers, and + # active command layers without a tracked materialized agent output all + # report the installed pack file from the raw + # PresetResolver.collect_all_layers() row's concrete ``path`` key. + path = layer.get("path") + if isinstance(path, Path): + return _repo_relative_existing_file(project_root, path) + return None + + +def _preset_display_name(pack_dir: Path, pack_id: str) -> str: + """Return the preset's human-friendly name from ``preset.yml``, or ``pack_id``. + + Delegates parsing and validation to :class:`PresetManifest` — the same + class ``PresetManager.list_installed()`` and ``specify preset list`` use — + instead of re-parsing the YAML by hand. Falls back to ``pack_id`` when the + manifest file is missing or fails manifest validation (for example, an + older flat-layout manifest with no ``preset:`` section at all). + """ + from ..presets import PresetManifest, PresetValidationError # lazy: avoids circular import + + manifest_path = pack_dir / "preset.yml" + if not manifest_path.is_file(): + return pack_id + try: + return PresetManifest(manifest_path).name + except PresetValidationError: + return pack_id + + +def _build_stack( + project_root: Path, + kind: ArtifactKind, + name: str, + raw_layers: list[dict[str, Any]] | None = None, + resolver: Any | None = None, + manifest_cache: dict[Path, Any | None] | None = None, +) -> list[StackLayer]: + """Build the ordered stack for a single artifact. + + Delegates the actual composition math to + :meth:`PresetResolver.collect_all_layers`; this function only reshapes + each raw layer dict into a :class:`StackLayer` and computes the + ``active`` / ``hidden`` labels documented on the data model. + + Returns an empty list when the artifact is not visible from any tier + (no preset, no extension, no built-in asset). + """ + from ..presets import PresetError, PresetResolver # lazy: avoids circular import + + template_type = kind + resolver = resolver or PresetResolver(project_root) + if raw_layers is None: + try: + raw = resolver.collect_all_layers(name, template_type) + except (OSError, PresetError) as exc: + raise ArtifactResolutionError() from exc + else: + raw = raw_layers + if not raw: + return [] + manifest_cache = manifest_cache if manifest_cache is not None else {} + + first_replace_idx = next( + (i for i, layer in enumerate(raw) if layer["strategy"] == "replace"), + None, + ) + + public_id = derive_public_id(kind, name) + rows: list[StackLayer] = [] + for idx, layer in enumerate(raw): + strategy = layer["strategy"] + active = idx == 0 + + if first_replace_idx is None: + hidden = False + else: + hidden = idx > first_replace_idx + + provenance = _layer_provenance( + resolver, layer, kind, name, manifest_cache + ) + lookup_id = provenance.lookup_id(kind, name) + source_path = _derive_source_path( + provenance, layer, project_root, kind, name, active=active + ) + + if provenance.layer == PROJECT_OVERRIDE_LAYER: + rows.append( + StackLayer( + id=public_id, + layer="project", + sourceId=provenance.source_id, + presetId=None, + presetName=None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=None, + lookupId=lookup_id, + sourcePath=source_path, + ) + ) + continue + + if provenance.layer == "extension": + manifest_path = _derive_manifest_path(provenance, project_root) + rows.append( + StackLayer( + id=public_id, + layer="extension", + sourceId=provenance.source_id, + presetId=None, + presetName=None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=manifest_path, + lookupId=lookup_id, + sourcePath=source_path, + ) + ) + continue + + if provenance.layer is None: + rows.append( + StackLayer( + id=public_id, + layer=None, + sourceId=None, + presetId=None, + presetName=None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=None, + lookupId=None, + sourcePath=source_path, + ) + ) + continue + + pack_id = provenance.disk_id or "" + pack_dir = provenance.pack_dir or ( + project_root / ".specify" / "presets" / pack_id + ) + display = _preset_display_name(pack_dir, pack_id) if pack_id else pack_id + manifest_path = _derive_manifest_path(provenance, project_root) + rows.append( + StackLayer( + id=public_id, + layer="preset", + sourceId=provenance.source_id, + presetId=pack_id or None, + presetName=display or None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=manifest_path, + lookupId=lookup_id, + sourcePath=source_path, + ) + ) + return rows diff --git a/tests/conftest.py b/tests/conftest.py index 94fb8c31b0..28fbfffc71 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,8 +5,12 @@ import shutil import subprocess import sys +from pathlib import Path import pytest +import yaml + +from specify_cli.presets import PresetRegistry _ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") @@ -63,6 +67,69 @@ def _has_working_bash() -> bool: ) +def install_preset( + project_root: Path, pack_id: str, provides: dict, priority: int = 10 +) -> Path: + """Create a registered preset with a validated modern manifest.""" + pack_dir = project_root / ".specify" / "presets" / pack_id + pack_dir.mkdir(parents=True) + templates: list[dict[str, str]] = [] + + def _default_file(kind: str, name: str) -> str: + if kind == "command": + return f"commands/{name}.md" + if kind == "script": + return f"scripts/{name}.sh" + return f"templates/{name}.md" + + for entry in provides.get("templates", []): + if not isinstance(entry, dict): + continue + entry_type = entry.get("type", "template") + if not isinstance(entry_type, str) or entry_type not in ( + "command", + "template", + "script", + ): + continue + name = entry.get("name") + if not isinstance(name, str): + continue + normalized = dict(entry) + normalized["type"] = entry_type + normalized.setdefault("file", _default_file(entry_type, name)) + templates.append(normalized) + + for kind_key, entry_type in (("commands", "command"), ("scripts", "script")): + for entry in provides.get(kind_key, []): + if not isinstance(entry, dict): + continue + name = entry.get("name") + if not isinstance(name, str): + continue + normalized = dict(entry) + normalized["type"] = entry_type + normalized.setdefault("file", _default_file(entry_type, name)) + templates.append(normalized) + + manifest = { + "schema_version": "1.0", + "preset": { + "id": pack_id, + "name": f"Test preset {pack_id}", + "version": "1.0.0", + "description": f"Test preset {pack_id}", + }, + "requires": {"speckit_version": ">=1.0.0"}, + "provides": {"templates": templates}, + } + (pack_dir / "preset.yml").write_text(yaml.safe_dump(manifest), encoding="utf-8") + PresetRegistry(project_root / ".specify" / "presets").add( + pack_id, {"priority": priority, "version": "1.0.0"} + ) + return pack_dir + + def strip_ansi(text: str) -> str: """Remove ANSI escape codes from Rich-formatted CLI output.""" return _ANSI_ESCAPE_RE.sub("", text) diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py new file mode 100644 index 0000000000..7571874d95 --- /dev/null +++ b/tests/test_artifact_command.py @@ -0,0 +1,1638 @@ +"""Unit and contract tests for the `specify artifact` command group. + +Covers the pure-logic layer (:class:`ArtifactCatalog`) plus the CLI wiring +(``specify artifact list``, ``specify artifact info``) exercised through +Typer's ``CliRunner``. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +from pathlib import Path + +import pytest +import yaml +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.artifacts import ( + AmbiguousArtifactError, + Artifact, + ArtifactCatalog, + ArtifactKind, + ArtifactNotFoundError, + ArtifactResolutionError, + NotASpecKitProjectError, +) +from specify_cli.artifacts.resolution import _preset_display_name +from specify_cli.extensions import CORE_COMMAND_NAMES, ExtensionRegistry +from specify_cli.presets import PresetRegistry, PresetResolver +from tests.conftest import install_preset + + +ERROR_REGEX = re.compile( + r"^(unknown artifact |ambiguous artifact |artifact resolution failed|not a Spec Kit project)" +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def spec_kit_project(tmp_path: Path) -> Path: + """Create a minimal but valid Spec Kit project layout.""" + root = tmp_path / "proj" + root.mkdir() + (root / ".specify").mkdir() + (root / ".specify" / "presets").mkdir() + (root / ".specify" / "extensions").mkdir() + (root / ".specify" / "templates").mkdir() + return root + + +@pytest.fixture +def non_project(tmp_path: Path) -> Path: + """A directory that intentionally lacks ``.specify/``.""" + root = tmp_path / "not-proj" + root.mkdir() + return root + + +# --------------------------------------------------------------------------- +# Contract tests — matching artifact-list.schema.json +# --------------------------------------------------------------------------- + + +class TestListArtifactsContract: + def test_returns_list_of_artifact(self, spec_kit_project: Path): + rows = ArtifactCatalog(spec_kit_project).list_artifacts() + assert all(isinstance(r, Artifact) for r in rows) + + def test_every_row_has_required_fields(self, spec_kit_project: Path): + for row in ArtifactCatalog(spec_kit_project).list_artifacts(): + d = row.to_json_dict() + assert set(d.keys()) == {"id", "name", "kind", "description"} + assert isinstance(d["description"], str) # never None; empty string OK + + def test_id_grammar(self, spec_kit_project: Path): + pattern = re.compile(r"^(command|template|script):[^:]+$") + for row in ArtifactCatalog(spec_kit_project).list_artifacts(): + assert pattern.match(row.id), f"bad id: {row.id!r}" + + def test_name_never_contains_colon(self, spec_kit_project: Path): + for row in ArtifactCatalog(spec_kit_project).list_artifacts(): + assert ":" not in row.name + + def test_kind_is_from_fixed_enum(self, spec_kit_project: Path): + for row in ArtifactCatalog(spec_kit_project).list_artifacts(): + assert row.kind in ("command", "template", "script") + + def test_rows_are_unique(self, spec_kit_project: Path): + rows = ArtifactCatalog(spec_kit_project).list_artifacts() + ids = [r.id for r in rows] + assert len(ids) == len(set(ids)) + + def test_every_core_command_is_listed_and_resolvable( + self, spec_kit_project: Path + ): + catalog = ArtifactCatalog(spec_kit_project) + listed = { + row.name for row in catalog.list_artifacts() if row.kind == "command" + } + expected = {f"speckit.{name}" for name in CORE_COMMAND_NAMES} + + assert expected <= listed + for name in expected: + info = catalog.get_artifact_info(f"command:{name}") + assert info["id"] == f"command:{name}" + assert info["kind"] == "command" + assert info["stack"] + + @pytest.mark.parametrize( + ("requested", "runtime_dir"), + [("sh", "bash"), ("ps", "powershell"), ("py", "python")], + ) + def test_core_scripts_follow_existing_project_runtime_selection( + self, spec_kit_project: Path, requested: str, runtime_dir: str + ): + (spec_kit_project / ".specify" / "init-options.json").write_text( + json.dumps({"script": requested}), + encoding="utf-8", + ) + catalog = ArtifactCatalog(spec_kit_project) + scripts = [row for row in catalog.list_artifacts() if row.kind == "script"] + + assert {row.name for row in scripts} == { + "check-prerequisites", + "resolve-template", + "setup-plan", + "setup-tasks", + } + selected_paths = catalog._selected_core_script_paths() + assert set(selected_paths) == {row.name for row in scripts} + assert all(path.parent.name == runtime_dir for path in selected_paths.values()) + for script in scripts: + info = catalog.get_artifact_info(script.id) + assert info["stack"][-1]["layer"] is None + assert info["stack"][-1]["sourceId"] is None + assert info["stack"][-1]["lookupId"] is None + assert info["stack"][-1]["sourcePath"] is None + + def test_core_scripts_reuse_existing_runtime_fallback( + self, spec_kit_project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + commands_dir = tmp_path / "commands" + scripts_dir = tmp_path / "scripts" + commands_dir.mkdir() + (scripts_dir / "bash").mkdir(parents=True) + (commands_dir / "demo.md").write_text( + "---\n" + "scripts:\n" + " sh: scripts/bash/demo.sh\n" + "---\n", + encoding="utf-8", + ) + script = scripts_dir / "bash" / "demo.sh" + script.write_text("#!/bin/sh\n", encoding="utf-8") + (spec_kit_project / ".specify" / "init-options.json").write_text( + json.dumps({"script": "ps"}), + encoding="utf-8", + ) + + monkeypatch.setattr( + "specify_cli.artifacts.catalog._locate_shared_asset_dir", + lambda subdir: { + "commands": commands_dir, + "scripts": scripts_dir, + "templates": None, + }[subdir], + ) + + assert ArtifactCatalog(spec_kit_project)._selected_core_script_paths() == { + "demo": script + } + + @pytest.mark.parametrize( + "reference_kind", + [ + "absolute", + "windows-drive", + "unc", + "traversal", + ], + ) + def test_core_scripts_reject_unsafe_references( + self, + spec_kit_project: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + reference_kind: str, + ): + commands_dir = tmp_path / "commands" + scripts_dir = tmp_path / "scripts" + commands_dir.mkdir() + (scripts_dir / "bash").mkdir(parents=True) + outside = tmp_path / "outside.sh" + outside.write_text( + "#!/bin/sh\n# Must not be read\n", encoding="utf-8" + ) + script_reference = { + "absolute": outside.resolve().as_posix(), + "windows-drive": "C:/outside/demo.sh", + "unc": "//server/share/demo.sh", + "traversal": "scripts/bash/../../outside.sh", + }[reference_kind] + (commands_dir / "demo.md").write_text( + "---\n" + "scripts:\n" + f" sh: {script_reference}\n" + "---\n", + encoding="utf-8", + ) + monkeypatch.setattr( + "specify_cli.artifacts.catalog._locate_shared_asset_dir", + lambda subdir: { + "commands": commands_dir, + "scripts": scripts_dir, + "templates": None, + }[subdir], + ) + + catalog = ArtifactCatalog(spec_kit_project) + assert catalog._selected_core_script_paths() == {} + assert all(row.id != "script:outside" for row in catalog.list_artifacts()) + + def test_core_scripts_reject_symlinks_escaping_script_root( + self, spec_kit_project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + commands_dir = tmp_path / "commands" + scripts_dir = tmp_path / "scripts" + commands_dir.mkdir() + (scripts_dir / "bash").mkdir(parents=True) + outside = tmp_path / "outside.sh" + outside.write_text("#!/bin/sh\n# Must not be read\n", encoding="utf-8") + link = scripts_dir / "bash" / "demo.sh" + try: + link.symlink_to(outside) + except OSError: + pytest.skip("symlink creation is not available") + (commands_dir / "demo.md").write_text( + "---\n" + "scripts:\n" + " sh: scripts/bash/demo.sh\n" + "---\n", + encoding="utf-8", + ) + monkeypatch.setattr( + "specify_cli.artifacts.catalog._locate_shared_asset_dir", + lambda subdir: { + "commands": commands_dir, + "scripts": scripts_dir, + "templates": None, + }[subdir], + ) + + assert ArtifactCatalog(spec_kit_project)._selected_core_script_paths() == {} + + def test_excludes_disabled_and_unusable_manifest_contributions( + self, spec_kit_project: Path + ): + extensions_dir = spec_kit_project / ".specify" / "extensions" + for extension_id, artifact_name, enabled, file_name in ( + ( + "disabled-ext", + "disabled-template", + False, + "templates/disabled-template.md", + ), + ( + "missing-file-ext", + "missing-template", + True, + "templates/missing-template.md", + ), + ): + extension_dir = extensions_dir / extension_id + extension_dir.mkdir() + (extension_dir / "extension.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "extension": { + "id": extension_id, + "name": extension_id, + "version": "1.0.0", + "description": "test", + "author": "test", + "repository": "https://example.com", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.2.0"}, + "provides": { + "templates": [ + { + "name": artifact_name, + "file": file_name, + "description": "Should not be listed", + } + ] + }, + } + ), + encoding="utf-8", + ) + if not enabled: + template = extension_dir / file_name + template.parent.mkdir() + template.write_text("# Disabled\n", encoding="utf-8") + ExtensionRegistry(extensions_dir).add( + extension_id, {"version": "1.0.0", "enabled": enabled} + ) + + names = {row.name for row in ArtifactCatalog(spec_kit_project).list_artifacts()} + assert "disabled-template" not in names + assert "missing-template" not in names + + def test_unregistered_extension_manifest_id_wins_for_lookup(self, spec_kit_project: Path): + ext_dir = spec_kit_project / ".specify" / "extensions" / "renamed" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + (ext_dir / "commands" / "actual.md").write_text( + "---\ndescription: Manifest identity wins\n---\nbody\n", + encoding="utf-8", + ) + (ext_dir / "commands" / "speckit.renamed.convention.md").write_text( + "---\ndescription: Convention identity uses directory\n---\nbody\n", + encoding="utf-8", + ) + (ext_dir / "extension.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "extension": { + "id": "original", + "name": "Original Id", + "version": "1.0.0", + "description": "test", + "author": "test", + "repository": "https://example.com", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.2.0"}, + "provides": { + "commands": [ + { + "name": "speckit.original.hello", + "file": "commands/actual.md", + "description": "manifest declared command", + } + ] + }, + } + ), + encoding="utf-8", + ) + + catalog = ArtifactCatalog(spec_kit_project) + assert "command:speckit.original.hello" in { + row.id for row in catalog.list_artifacts() + } + info = catalog.get_artifact_info("speckit.original.hello") + # Artifact projection uses the manifest id without changing the + # resolver's established layer shape. + assert info["stack"][0]["lookupId"] == "extension:original:command:speckit.original.hello" + resolver_layer = PresetResolver(spec_kit_project).collect_all_layers( + "speckit.original.hello", "command" + )[0] + assert "lookupId" not in resolver_layer + # The stack row's manifestPath must still reflect the actual on-disk + # extension directory (``renamed``), not the manifest id embedded in + # ``lookupId``. + assert ( + info["stack"][0]["manifestPath"] + == ".specify/extensions/renamed/extension.yml" + ) + convention = catalog.get_artifact_info("speckit.renamed.convention")[ + "stack" + ][0] + assert convention["sourceId"] == "renamed" + assert convention["lookupId"] == ( + "extension:renamed:command:speckit.renamed.convention" + ) + assert convention["manifestPath"] is None + + def test_includes_project_local_core_assets(self, spec_kit_project: Path): + templates_dir = spec_kit_project / ".specify" / "templates" + (templates_dir / "legacy-template.md").write_text( + "---\ndescription: Local template\n---\n", encoding="utf-8" + ) + commands_dir = templates_dir / "commands" + commands_dir.mkdir() + (commands_dir / "local-command.md").write_text( + "---\ndescription: Local command\n---\n", encoding="utf-8" + ) + scripts_dir = templates_dir / "scripts" + scripts_dir.mkdir() + (scripts_dir / "legacy-script.sh").write_text( + "# Local script\n", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + artifacts = {artifact.id: artifact for artifact in catalog.list_artifacts()} + + assert artifacts["template:legacy-template"].description == "Local template" + assert artifacts["command:speckit.local-command"].description == "Local command" + assert artifacts["script:legacy-script"].description == "Local script" + for name in ("speckit.local-command", "legacy-template", "legacy-script"): + layer = catalog.get_artifact_info(name)["stack"][0] + assert layer["layer"] is None + assert layer["sourceId"] is None + assert layer["lookupId"] is None + assert layer["sourcePath"] is None + + def test_includes_root_level_pack_templates( + self, spec_kit_project: Path + ): + extension_dir = spec_kit_project / ".specify" / "extensions" / "legacy" + extension_dir.mkdir() + (extension_dir / "legacy-root.md").write_text( + "---\ndescription: Legacy root template\n---\n", + encoding="utf-8", + ) + (extension_dir / "README.md").write_text("# Packaging notes\n", encoding="utf-8") + + catalog = ArtifactCatalog(spec_kit_project) + names = {row.name for row in catalog.list_artifacts()} + + assert "legacy-root" in names + assert "README" in names + assert next( + row for row in catalog.list_artifacts() if row.name == "legacy-root" + ).description == "Legacy root template" + + def test_extension_registry_missing_collection_key_uses_existing_normalization( + self, spec_kit_project: Path + ): + registry_path = spec_kit_project / ".specify" / "extensions" / ".registry" + registry_path.write_text('{"schema_version": "1.0"}', encoding="utf-8") + + assert ArtifactCatalog(spec_kit_project).list_artifacts() + + @pytest.mark.skipif(os.name == "nt", reason="':' filenames are unsupported on Windows") + def test_skips_invalid_colon_names_in_project_local_inventory(self, spec_kit_project: Path): + templates_dir = spec_kit_project / ".specify" / "templates" + commands_dir = templates_dir / "commands" + scripts_dir = templates_dir / "scripts" + overrides_dir = templates_dir / "overrides" + override_scripts_dir = overrides_dir / "scripts" + commands_dir.mkdir(parents=True) + scripts_dir.mkdir(parents=True) + overrides_dir.mkdir(parents=True) + override_scripts_dir.mkdir(parents=True) + + (templates_dir / "bad:template.md").write_text("---\ndescription: bad\n---\n", encoding="utf-8") + (commands_dir / "bad:command.md").write_text("---\ndescription: bad\n---\n", encoding="utf-8") + (scripts_dir / "bad:script.sh").write_text("# bad\n", encoding="utf-8") + (overrides_dir / "bad:override.md").write_text("override", encoding="utf-8") + (override_scripts_dir / "bad:override-script.sh").write_text("# bad\n", encoding="utf-8") + + artifacts = ArtifactCatalog(spec_kit_project).list_artifacts() + assert all(":" not in artifact.name for artifact in artifacts) + + def test_preserves_prefixed_project_local_command_names(self, spec_kit_project: Path): + commands_dir = spec_kit_project / ".specify" / "templates" / "commands" + commands_dir.mkdir() + (commands_dir / "speckit.local-prefixed.md").write_text( + "---\ndescription: Local prefixed command\n---\n", encoding="utf-8" + ) + + artifacts = {artifact.id: artifact for artifact in ArtifactCatalog(spec_kit_project).list_artifacts()} + assert "command:speckit.local-prefixed" in artifacts + assert "command:speckit.speckit.local-prefixed" not in artifacts + + def test_prefers_exact_core_command_name(self, spec_kit_project: Path): + commands_dir = spec_kit_project / ".specify" / "templates" / "commands" + commands_dir.mkdir() + (commands_dir / "foo.md").write_text( + "---\ndescription: Stripped fallback\n---\n", encoding="utf-8" + ) + exact_path = commands_dir / "speckit.foo.md" + exact_path.write_text( + "---\ndescription: Exact logical name\n---\n", encoding="utf-8" + ) + + assert PresetResolver(spec_kit_project).resolve("speckit.foo", "command") == exact_path + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.foo") + assert info["description"] == "Exact logical name" + + def test_active_preset_description_overrides_hidden_core_description( + self, spec_kit_project: Path + ): + """A preset that overrides a core command must win the description too. + + Regression test: descriptions used to be merged "first non-empty + wins", and core rows were inserted before contributions — so an + active preset's replacement of a core command still reported the + (now-inactive) core description. + """ + commands_dir = spec_kit_project / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True) + (commands_dir / "speckit.constitution.md").write_text( + "---\ndescription: Core description\n---\n", encoding="utf-8" + ) + + pack = install_preset( + spec_kit_project, + "override-preset", + { + "commands": [ + {"name": "speckit.constitution", "description": "Preset description"} + ] + }, + ) + (pack / "commands").mkdir() + (pack / "commands" / "speckit.constitution.md").write_text( + "# Preset\n", encoding="utf-8" + ) + + artifacts = { + artifact.id: artifact + for artifact in ArtifactCatalog(spec_kit_project).list_artifacts() + } + assert artifacts["command:speckit.constitution"].description == "Preset description" + + def test_higher_precedence_preset_description_wins(self, spec_kit_project: Path): + """When two presets both provide an artifact, the winner's description wins. + + Lower ``priority`` number means higher precedence (see + ``PresetResolver.collect_all_layers``); the loser's description must + not leak through just because it happens to be enumerated first + alphabetically. + """ + pack_low = install_preset( + spec_kit_project, + "aaa-low-priority-preset", + {"templates": [{"name": "shared-artifact", "description": "Loser description"}]}, + priority=20, + ) + (pack_low / "templates").mkdir() + (pack_low / "templates" / "shared-artifact.md").write_text( + "# Loser\n", encoding="utf-8" + ) + + pack_high = install_preset( + spec_kit_project, + "zzz-high-priority-preset", + {"templates": [{"name": "shared-artifact", "description": "Winner description"}]}, + priority=5, + ) + (pack_high / "templates").mkdir() + (pack_high / "templates" / "shared-artifact.md").write_text( + "# Winner\n", encoding="utf-8" + ) + + artifacts = { + artifact.id: artifact + for artifact in ArtifactCatalog(spec_kit_project).list_artifacts() + } + assert artifacts["template:shared-artifact"].description == "Winner description" + + +class TestListSorting: + """Deterministic ordering: kind first (command/template/script), then name.""" + + def test_kind_grouping(self, spec_kit_project: Path): + rows = ArtifactCatalog(spec_kit_project).list_artifacts() + kinds_seen = [r.kind for r in rows] + # kinds must appear as contiguous groups in the fixed order + first_idx = {k: next((i for i, x in enumerate(kinds_seen) if x == k), None) for k in ("command", "template", "script")} + indices = [v for v in first_idx.values() if v is not None] + assert indices == sorted(indices) + + def test_name_sorted_within_kind(self, spec_kit_project: Path): + rows = ArtifactCatalog(spec_kit_project).list_artifacts() + by_kind: dict[str, list[str]] = {} + for r in rows: + by_kind.setdefault(r.kind, []).append(r.name) + for _, names in by_kind.items(): + assert names == sorted(names) + + +class TestEmptyProject: + def test_empty_stack_returns_empty_list(self, tmp_path: Path): + # A .specify/ dir with no presets/extensions and no accessible core. + # We can't easily wipe the core baseline in this process, so instead + # verify list_artifacts is at least callable and returns a list. + root = tmp_path / "empty" + root.mkdir() + (root / ".specify").mkdir() + rows = ArtifactCatalog(root).list_artifacts() + assert isinstance(rows, list) + + +# --------------------------------------------------------------------------- +# get_artifact_info contract +# --------------------------------------------------------------------------- + + +class TestInfoContract: + def test_stack_ordered_highest_first(self, spec_kit_project: Path): + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + assert info["stack"], "expected at least one stack layer" + + def test_exactly_one_active_row(self, spec_kit_project: Path): + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + actives = [layer for layer in info["stack"] if layer["active"]] + assert len(actives) == 1 + + def test_active_is_index_zero(self, spec_kit_project: Path): + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + assert info["stack"][0]["active"] is True + for layer in info["stack"][1:]: + assert layer["active"] is False + + def test_builtin_row_shape(self, spec_kit_project: Path): + resolver_layer = PresetResolver(spec_kit_project).collect_all_layers( + "speckit.constitution", "command" + )[-1] + assert resolver_layer["source"] == "core (bundled)" + assert "lookupId" not in resolver_layer + + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + assert info["id"] == "command:speckit.constitution" + builtin = next(layer for layer in info["stack"] if layer["layer"] is None) + assert builtin["sourceId"] is None + assert builtin["presetId"] is None + assert builtin["presetName"] is None + assert builtin["manifestPath"] is None + assert builtin["strategy"] == "replace" + assert builtin["lookupId"] is None + assert builtin["sourcePath"] is None + + def test_project_override_row_shape(self, spec_kit_project: Path): + overrides = spec_kit_project / ".specify" / "templates" / "overrides" + overrides.mkdir() + (overrides / "speckit.constitution.md").write_text("override", encoding="utf-8") + + info = ArtifactCatalog(spec_kit_project).get_artifact_info( + "command:speckit.constitution" + ) + + project = next(layer for layer in info["stack"] if layer["layer"] == "project") + assert project["presetId"] is None + assert project["presetName"] is None + assert project["manifestPath"] is None + assert project["sourcePath"] is None + assert project["strategy"] == "replace" + assert project["sourceId"] == "_" + assert re.match(r"^project:_:(command|template|script):[^:]+$", project["lookupId"]) + + def test_lookup_id_grammar(self, spec_kit_project: Path): + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + for layer in info["stack"]: + if layer["lookupId"] is None: + assert layer["layer"] is None + assert layer["sourceId"] is None + continue + assert re.match( + r"^(project|preset|extension):[^:]+:(command|template|script):[^:]+(:[0-9a-f]{12})?$", + layer["lookupId"], + ) + + def test_id_matches_list(self, spec_kit_project: Path): + cat = ArtifactCatalog(spec_kit_project) + info = cat.get_artifact_info("speckit.constitution") + assert info["id"] == "command:speckit.constitution" + + def test_every_stack_row_carries_id(self, spec_kit_project: Path): + """Every stack row carries a non-null ``id``, including built-in rows. + + ``id`` is the source-agnostic round-trip key; it does not depend on + the row having a ``lookupId`` (manifest-backed layer provenance). + """ + overrides = spec_kit_project / ".specify" / "templates" / "overrides" + overrides.mkdir() + (overrides / "speckit.constitution.md").write_text("override", encoding="utf-8") + + info = ArtifactCatalog(spec_kit_project).get_artifact_info( + "command:speckit.constitution" + ) + assert len(info["stack"]) >= 2 + for layer in info["stack"]: + assert layer["id"] == "command:speckit.constitution" + + +# --------------------------------------------------------------------------- +# Error conditions — pinned strings for the artifact-error contract +# --------------------------------------------------------------------------- + + +class TestErrors: + def test_unknown_artifact_message(self, spec_kit_project: Path): + with pytest.raises(ArtifactNotFoundError) as excinfo: + ArtifactCatalog(spec_kit_project).get_artifact_info("no.such.thing") + assert excinfo.value.message == "unknown artifact no.such.thing" + assert ERROR_REGEX.match(excinfo.value.message) + + def test_not_a_project(self, non_project: Path): + with pytest.raises(NotASpecKitProjectError) as excinfo: + ArtifactCatalog(non_project).list_artifacts() + assert excinfo.value.message == "not a Spec Kit project: no .specify/ directory found" + assert ERROR_REGEX.match(excinfo.value.message) + + def test_ambiguous_artifact_message(self, spec_kit_project: Path): + """When both a command and a template share the same bare name.""" + # Register a preset that contributes 'shared-name' as both a + # template and a script — the info lookup with no kind hint should + # then be ambiguous. + pack = install_preset( + spec_kit_project, + "test-ambig", + { + "templates": [ + {"type": "template", "name": "shared-name", "description": "t"}, + {"type": "script", "name": "shared-name", "description": "s"}, + ], + }, + ) + (pack / "templates").mkdir() + (pack / "templates" / "shared-name.md").write_text("# Template\n") + (pack / "scripts").mkdir() + (pack / "scripts" / "shared-name.sh").write_text("#!/usr/bin/env bash\n") + with pytest.raises(AmbiguousArtifactError) as excinfo: + ArtifactCatalog(spec_kit_project).get_artifact_info("shared-name") + assert excinfo.value.message.startswith("ambiguous artifact shared-name: matches kinds") + assert ERROR_REGEX.match(excinfo.value.message) + + def test_resolution_error_message(self): + assert ArtifactResolutionError().message == "artifact resolution failed" + + def test_info_rejects_corrupt_extension_registry(self, spec_kit_project: Path): + registry = spec_kit_project / ".specify" / "extensions" / ".registry" + registry.write_text("{invalid", encoding="utf-8") + + with pytest.raises(ArtifactResolutionError): + ArtifactCatalog(spec_kit_project).get_artifact_info("command:speckit.constitution") + +class TestKindHint: + def test_kind_flag_disambiguates(self, spec_kit_project: Path): + install_preset( + spec_kit_project, + "test-kind", + {"templates": [{"name": "dup", "description": "t"}], + "scripts": [{"name": "dup", "description": "s"}]}, + ) + # No stack file backs these contributions on disk so the info call + # will raise unknown after resolving kind — either way it should + # not raise ambiguous when a kind is supplied. + try: + ArtifactCatalog(spec_kit_project).get_artifact_info("dup", kind="template") + except ArtifactNotFoundError: + pass # expected: manifest declared it but no file to compose + + def test_shorthand_grammar(self, spec_kit_project: Path): + # Even with core commands, the shorthand should route correctly. + info = ArtifactCatalog(spec_kit_project).get_artifact_info("command:speckit.constitution") + assert info["kind"] == "command" + + def test_conflicting_shorthand_and_flag(self, spec_kit_project: Path): + with pytest.raises(ArtifactNotFoundError): + ArtifactCatalog(spec_kit_project).get_artifact_info( + "template:speckit.constitution", kind="command" + ) + + @pytest.mark.parametrize( + ("kind", "name"), + ( + ("template", "../../outside"), + ("command", "template:foo"), + ("script", "script:name"), + ), + ) + def test_kind_hint_rejects_invalid_name_components( + self, spec_kit_project: Path, kind: ArtifactKind, name: str + ): + with pytest.raises(ArtifactNotFoundError): + ArtifactCatalog(spec_kit_project).get_artifact_info(name, kind=kind) + + def test_id_form_round_trips_to_same_artifact(self, spec_kit_project: Path): + """``artifact info`` accepts the public ``id`` form (``kind:name``). + + Given either the bare name or its ``id``, the resolved artifact is + the same — ``id`` is the source-agnostic round-trip key. + """ + cat = ArtifactCatalog(spec_kit_project) + by_bare = cat.get_artifact_info("speckit.plan") + by_id = cat.get_artifact_info("command:speckit.plan") + assert by_id == by_bare + + def test_id_form_resolves_template_despite_same_named_command( + self, spec_kit_project: Path + ): + """``kind:name`` disambiguates when a command shares a template's name.""" + pack_dir = install_preset( + spec_kit_project, + "collide-pack", + {"commands": [{"name": "spec-template", "description": "cmd"}]}, + ) + (pack_dir / "commands").mkdir(parents=True, exist_ok=True) + (pack_dir / "commands" / "spec-template.md").write_text( + "colliding command body", encoding="utf-8" + ) + + # Sanity check: without a kind hint, the bare name is ambiguous + # because both a command and a template named "spec-template" exist. + with pytest.raises(AmbiguousArtifactError): + ArtifactCatalog(spec_kit_project).get_artifact_info("spec-template") + + info = ArtifactCatalog(spec_kit_project).get_artifact_info("template:spec-template") + assert info["kind"] == "template" + assert info["id"] == "template:spec-template" + + +# --------------------------------------------------------------------------- +# Skills exclusion +# --------------------------------------------------------------------------- + + +class TestSkillsExcluded: + def test_no_skills_in_list(self, spec_kit_project: Path): + skills_dir = spec_kit_project / ".github" / "skills" / "speckit-my-skill" + skills_dir.mkdir(parents=True) + (skills_dir / "SKILL.md").write_text("---\nname: my-skill\n---\nbody", encoding="utf-8") + rows = ArtifactCatalog(spec_kit_project).list_artifacts() + assert not any("skill" in r.name.lower() for r in rows) + + +# --------------------------------------------------------------------------- +# CLI wiring — Typer CliRunner +# --------------------------------------------------------------------------- + + +class TestCLI: + def test_list_requires_json_flag(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list"]) + assert result.exit_code == 2 + assert result.stdout == "" + + def test_list_json_emits_array(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert isinstance(payload, list) + assert result.stdout.endswith("\n") + + def test_list_json_rows_include_stack(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert payload, "expected at least one artifact" + + row = payload[0] + assert set(row.keys()) == {"id", "name", "kind", "description", "stack"} + assert isinstance(row["stack"], list) + + info_result = runner.invoke(app, ["artifact", "info", row["id"], "--json"]) + assert info_result.exit_code == 0, info_result.stderr + info = json.loads(info_result.stdout) + assert row["stack"] == info["stack"] + + def test_hidden_command_layer_source_path_is_own_pack_file( + self, spec_kit_project: Path + ): + """A hidden (non-active) command row must not report the winner's + shared materialized agent output as its ``sourcePath``. + + Both presets below register the same command name and the same + agent skill name, so the tracked materialized output is a single + shared file. Only the active (winning) row may report that shared + file; the hidden loser row must report its own installed pack file. + """ + pack_low = install_preset( + spec_kit_project, + "aaa-low-priority-preset", + { + "commands": [ + { + "name": "speckit.compliance.plan", + "file": "commands/speckit.compliance.plan.md", + "description": "Loser", + } + ] + }, + priority=20, + ) + (pack_low / "commands").mkdir() + (pack_low / "commands" / "speckit.compliance.plan.md").write_text( + "---\ndescription: Loser\n---\nloser body\n", encoding="utf-8" + ) + PresetRegistry(spec_kit_project / ".specify" / "presets").update( + "aaa-low-priority-preset", + {"registered_skills": {"copilot": ["speckit-compliance-plan"]}}, + ) + + pack_high = install_preset( + spec_kit_project, + "zzz-high-priority-preset", + { + "commands": [ + { + "name": "speckit.compliance.plan", + "file": "commands/speckit.compliance.plan.md", + "description": "Winner", + } + ] + }, + priority=5, + ) + (pack_high / "commands").mkdir() + (pack_high / "commands" / "speckit.compliance.plan.md").write_text( + "---\ndescription: Winner\n---\nwinner body\n", encoding="utf-8" + ) + PresetRegistry(spec_kit_project / ".specify" / "presets").update( + "zzz-high-priority-preset", + {"registered_skills": {"copilot": ["speckit-compliance-plan"]}}, + ) + + skill_file = ( + spec_kit_project + / ".github" + / "skills" + / "speckit-compliance-plan" + / "SKILL.md" + ) + skill_file.parent.mkdir(parents=True) + skill_file.write_text("---\nname: speckit-compliance-plan\n---\n", encoding="utf-8") + + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.compliance.plan") + stack = info["stack"] + assert stack[0]["active"] is True + assert stack[0]["sourcePath"] == ".github/skills/speckit-compliance-plan/SKILL.md" + + hidden_rows = [layer for layer in stack if layer["active"] is False] + assert hidden_rows + for row in hidden_rows: + assert row["sourcePath"] != stack[0]["sourcePath"] + assert row["sourcePath"] == ( + ".specify/presets/aaa-low-priority-preset/commands/speckit.compliance.plan.md" + ) + + def test_list_json_stack_source_path_contract( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + preset_pack = install_preset( + spec_kit_project, + "compliance", + { + "commands": [ + { + "name": "speckit.compliance.plan", + "file": "commands/speckit.compliance.plan.md", + "description": "Compliance plan", + } + ] + }, + ) + (preset_pack / "commands").mkdir() + (preset_pack / "commands" / "speckit.compliance.plan.md").write_text( + "---\ndescription: Compliance plan\n---\nbody\n", encoding="utf-8" + ) + PresetRegistry(spec_kit_project / ".specify" / "presets").update( + "compliance", + { + "registered_skills": { + "copilot": ["speckit-compliance-plan"], + } + }, + ) + skill_file = ( + spec_kit_project + / ".github" + / "skills" + / "speckit-compliance-plan" + / "SKILL.md" + ) + skill_file.parent.mkdir(parents=True) + skill_file.write_text("---\nname: speckit-compliance-plan\n---\n", encoding="utf-8") + + extension_dir = spec_kit_project / ".specify" / "extensions" / "quality" + (extension_dir / "templates").mkdir(parents=True) + (extension_dir / "templates" / "checklist.md").write_text( + "---\ndescription: Extension checklist\n---\n", encoding="utf-8" + ) + (extension_dir / "extension.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "extension": { + "id": "quality", + "name": "Quality", + "version": "1.0.0", + "description": "test", + "author": "test", + "repository": "https://example.com", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.2.0"}, + "provides": { + "templates": [ + { + "name": "checklist", + "file": "templates/checklist.md", + "description": "Extension checklist", + } + ] + }, + } + ), + encoding="utf-8", + ) + ExtensionRegistry(spec_kit_project / ".specify" / "extensions").add( + "quality", {"version": "1.0.0", "enabled": True} + ) + + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + + non_null_source_paths: set[str] = set() + for row in payload: + for layer in row["stack"]: + assert "sourcePath" in layer + source_path = layer["sourcePath"] + if source_path is None: + continue + assert isinstance(source_path, str) + assert (spec_kit_project / source_path).is_file() + non_null_source_paths.add(source_path) + + assert ".github/skills/speckit-compliance-plan/SKILL.md" in non_null_source_paths + assert ".specify/extensions/quality/templates/checklist.md" in non_null_source_paths + + def test_list_json_is_pretty_printed(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert ' "id"' in result.stdout # 2-space indent visible + + def test_info_json_shape(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "info", "speckit.constitution", "--json"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert set(payload.keys()) == {"id", "name", "kind", "description", "stack"} + + def test_info_accepts_id_form_on_cli( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + by_bare = runner.invoke(app, ["artifact", "info", "speckit.plan", "--json"]) + by_id = runner.invoke(app, ["artifact", "info", "command:speckit.plan", "--json"]) + assert by_bare.exit_code == 0, by_bare.stderr + assert by_id.exit_code == 0, by_id.stderr + assert json.loads(by_id.stdout) == json.loads(by_bare.stdout) + + def test_info_unknown_error_envelope(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "info", "no.such.thing", "--json"]) + assert result.exit_code == 1 + assert result.stdout == "" + err = json.loads(result.stderr) + assert set(err.keys()) == {"error"} + assert ERROR_REGEX.match(err["error"]) + + def test_info_corrupt_extension_registry_uses_json_error_envelope( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + extensions_dir = spec_kit_project / ".specify" / "extensions" + (extensions_dir / ".registry").write_text("{invalid", encoding="utf-8") + monkeypatch.chdir(spec_kit_project) + result = CliRunner().invoke( + app, ["artifact", "info", "speckit.constitution", "--json"] + ) + assert result.exit_code == 1 + assert result.stdout == "" + assert json.loads(result.stderr) == {"error": "artifact resolution failed"} + + def test_list_corrupt_extension_registry_uses_json_error_envelope( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + extensions_dir = spec_kit_project / ".specify" / "extensions" + (extensions_dir / ".registry").write_text("{invalid", encoding="utf-8") + monkeypatch.chdir(spec_kit_project) + result = CliRunner().invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 1 + assert result.stdout == "" + assert json.loads(result.stderr) == {"error": "artifact resolution failed"} + + def test_not_a_project_error_envelope(self, non_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(non_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 1 + assert result.stdout == "" + err = json.loads(result.stderr) + assert err["error"] == "not a Spec Kit project: no .specify/ directory found" + + def test_stdout_empty_on_error(self, non_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(non_project) + runner = CliRunner() + for argv in ( + ["artifact", "list", "--json"], + ["artifact", "info", "x", "--json"], + ): + result = runner.invoke(app, argv) + assert result.stdout == "", f"stdout leak for {argv}: {result.stdout!r}" + + @pytest.mark.parametrize( + "override", + ("missing-project", "."), + ) + def test_invalid_init_dir_override_uses_json_error_envelope( + self, + non_project: Path, + monkeypatch: pytest.MonkeyPatch, + override: str, + ): + monkeypatch.chdir(non_project) + monkeypatch.setenv("SPECIFY_INIT_DIR", override) + runner = CliRunner() + for argv in ( + ["artifact", "list", "--json"], + ["artifact", "info", "x", "--json"], + ): + result = runner.invoke(app, argv) + assert result.exit_code == 1 + assert result.stdout == "" + assert json.loads(result.stderr) == { + "error": "not a Spec Kit project: no .specify/ directory found" + } + + +class TestUTF8NoBOM: + def test_output_is_utf8_without_bom(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 0 + # No BOM at start + assert not result.stdout.startswith("\ufeff") + + +# --------------------------------------------------------------------------- +# Preset composition integration — active/hidden semantics +# --------------------------------------------------------------------------- + + +class TestStackComposition: + @pytest.mark.parametrize( + ("strategy", "top_content"), + [ + ("prepend", "top contribution"), + ("append", "top contribution"), + ("wrap", "before top\n{CORE_TEMPLATE}\nafter top"), + ], + ) + def test_composing_stack_keeps_all_contributing_layers_visible( + self, + spec_kit_project: Path, + strategy: str, + top_content: str, + ): + base_content = PresetResolver(spec_kit_project).resolve_content("spec-template") + assert base_content is not None + + lower_pack = install_preset( + spec_kit_project, + "lower-composer", + { + "templates": [ + { + "name": "spec-template", + "strategy": "prepend", + } + ] + }, + priority=10, + ) + (lower_pack / "templates").mkdir() + (lower_pack / "templates" / "spec-template.md").write_text( + "lower contribution", encoding="utf-8" + ) + + top_pack = install_preset( + spec_kit_project, + "top-composer", + { + "templates": [ + { + "name": "spec-template", + "strategy": strategy, + } + ] + }, + priority=5, + ) + (top_pack / "templates").mkdir() + (top_pack / "templates" / "spec-template.md").write_text( + top_content, encoding="utf-8" + ) + + lower_composed = f"lower contribution\n\n{base_content}" + if strategy == "prepend": + expected = f"{top_content}\n\n{lower_composed}" + elif strategy == "append": + expected = f"{lower_composed}\n\n{top_content}" + else: + expected = top_content.replace("{CORE_TEMPLATE}", lower_composed) + + resolved = PresetResolver(spec_kit_project).resolve_content("spec-template") + stack = ArtifactCatalog(spec_kit_project).get_artifact_info( + "template:spec-template" + )["stack"] + + assert resolved == expected + assert [row["strategy"] for row in stack] == [ + strategy, + "prepend", + "replace", + ] + assert [row["active"] for row in stack] == [True, False, False] + assert [row["hidden"] for row in stack] == [False, False, False] + + def test_preset_command_uses_entry_type(self, spec_kit_project: Path): + pack = install_preset( + spec_kit_project, + "test-command", + { + "templates": [ + { + "type": "command", + "name": "speckit.constitution", + "description": "override", + } + ] + }, + ) + (pack / "commands").mkdir() + (pack / "commands" / "speckit.constitution.md").write_text( + "---\ndescription: override\n---\nbody", encoding="utf-8" + ) + + rows = ArtifactCatalog(spec_kit_project).list_artifacts() + assert any(row.id == "command:speckit.constitution" for row in rows) + assert ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution")["kind"] == "command" + + def test_preset_single_segment_command_id_from_list_is_resolvable( + self, spec_kit_project: Path + ): + pack = install_preset( + spec_kit_project, + "test-single-command", + {"commands": [{"name": "specify", "description": "single segment"}]}, + ) + (pack / "commands").mkdir() + (pack / "commands" / "specify.md").write_text( + "---\ndescription: single segment\n---\nbody", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + ids = {row.id for row in catalog.list_artifacts()} + + assert "command:specify" in ids + info = catalog.get_artifact_info("command:specify") + assert info["id"] == "command:specify" + assert catalog.get_artifact_info("specify", kind="command")["id"] == "command:specify" + + def test_append_only_candidate_without_base_is_not_listed( + self, spec_kit_project: Path + ): + pack = install_preset( + spec_kit_project, + "append-only", + { + "templates": [ + { + "type": "template", + "name": "append-only-template", + "strategy": "append", + } + ] + }, + ) + (pack / "templates").mkdir() + (pack / "templates" / "append-only-template.md").write_text( + "append", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + + assert "template:append-only-template" not in { + row.id for row in catalog.list_artifacts() + } + with pytest.raises(ArtifactNotFoundError): + catalog.get_artifact_info("append-only-template", kind="template") + + def test_preset_replace_hides_core(self, spec_kit_project: Path): + # Install a preset that replaces the constitution command. + pack = install_preset( + spec_kit_project, + "test-replace", + {"commands": [{"name": "speckit.constitution", "description": "override"}]}, + ) + (pack / "commands").mkdir() + (pack / "commands" / "speckit.constitution.md").write_text( + "---\ndescription: override\n---\nbody", encoding="utf-8" + ) + + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + stack = info["stack"] + assert stack[0]["active"] is True + assert stack[0]["hidden"] is False + # If a lower built-in layer exists it must be hidden. + built_in_rows = [layer for layer in stack if layer["layer"] is None] + for row in built_in_rows: + assert row["hidden"] is True + + +# --------------------------------------------------------------------------- +# Convention-based discovery — extensions without a manifest, project overrides +# --------------------------------------------------------------------------- + + +class TestConventionDiscovery: + def test_unregistered_extension_template_without_manifest(self, spec_kit_project: Path): + ext_dir = spec_kit_project / ".specify" / "extensions" / "legacy" / "templates" + ext_dir.mkdir(parents=True) + (ext_dir / "legacy-template.md").write_text("body", encoding="utf-8") + + catalog = ArtifactCatalog(spec_kit_project) + assert any(row.id == "template:legacy-template" for row in catalog.list_artifacts()) + info = catalog.get_artifact_info("legacy-template") + assert info["stack"][0]["lookupId"] == "extension:legacy:template:legacy-template" + assert info["stack"][0]["manifestPath"] is None + + def test_convention_only_extension_does_not_claim_manifest( + self, spec_kit_project: Path + ): + ext_dir = spec_kit_project / ".specify" / "extensions" / "legacy" + (ext_dir / "templates").mkdir(parents=True) + (ext_dir / "templates" / "legacy-template.md").write_text( + "body", encoding="utf-8" + ) + (ext_dir / "commands").mkdir() + (ext_dir / "commands" / "other.md").write_text("body", encoding="utf-8") + (ext_dir / "extension.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "extension": { + "id": "legacy", + "name": "Legacy", + "version": "1.0.0", + "description": "test", + "author": "test", + "repository": "https://example.com", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.2.0"}, + "provides": { + "commands": [ + { + "name": "speckit.legacy.other", + "file": "commands/other.md", + } + ] + }, + } + ), + encoding="utf-8", + ) + + layer = ArtifactCatalog(spec_kit_project).get_artifact_info( + "legacy-template" + )["stack"][0] + + assert layer["lookupId"] == "extension:legacy:template:legacy-template" + assert layer["manifestPath"] is None + + def test_convention_command_and_script_are_listed(self, spec_kit_project: Path): + ext_dir = spec_kit_project / ".specify" / "extensions" / "legacy" + (ext_dir / "commands").mkdir(parents=True) + (ext_dir / "commands" / "speckit.legacy.md").write_text("body", encoding="utf-8") + (ext_dir / "scripts").mkdir() + (ext_dir / "scripts" / "legacy-script.sh").write_text("#!/bin/sh\n", encoding="utf-8") + + ids = {row.id for row in ArtifactCatalog(spec_kit_project).list_artifacts()} + assert "command:speckit.legacy" in ids + assert "script:legacy-script" in ids + + def test_extension_readme_matches_resolver_inventory(self, spec_kit_project: Path): + ext_dir = spec_kit_project / ".specify" / "extensions" / "legacy" + ext_dir.mkdir(parents=True) + (ext_dir / "README.md").write_text("docs", encoding="utf-8") + + catalog = ArtifactCatalog(spec_kit_project) + artifacts = {row.id: row for row in catalog.list_artifacts()} + + assert "template:README" in artifacts + assert catalog.get_artifact_info("README")["stack"][0]["sourcePath"] == ( + ".specify/extensions/legacy/README.md" + ) + + def test_disabled_extension_convention_file_is_excluded(self, spec_kit_project: Path): + extensions_dir = spec_kit_project / ".specify" / "extensions" + ext_dir = extensions_dir / "legacy" / "templates" + ext_dir.mkdir(parents=True) + (ext_dir / "legacy-template.md").write_text("body", encoding="utf-8") + (extensions_dir / ".registry").write_text( + json.dumps( + { + "schema_version": "1.0.0", + "extensions": {"legacy": {"priority": 10, "enabled": False}}, + } + ), + encoding="utf-8", + ) + + ids = {row.id for row in ArtifactCatalog(spec_kit_project).list_artifacts()} + assert "template:legacy-template" not in ids + + def test_project_override_only_artifact_is_listed(self, spec_kit_project: Path): + overrides = spec_kit_project / ".specify" / "templates" / "overrides" + (overrides / "scripts").mkdir(parents=True) + (overrides / "local-template.md").write_text("body", encoding="utf-8") + (overrides / "scripts" / "local-script.sh").write_text("#!/bin/sh\n", encoding="utf-8") + + catalog = ArtifactCatalog(spec_kit_project) + ids = {row.id for row in catalog.list_artifacts()} + assert "command:local-template" in ids + assert "template:local-template" in ids + assert "script:local-script" in ids + for kind in ("command", "template"): + info = catalog.get_artifact_info(f"{kind}:local-template") + assert info["stack"][0]["layer"] == "project" + with pytest.raises(AmbiguousArtifactError): + catalog.get_artifact_info("local-template") + + def test_project_override_reports_its_own_description(self, spec_kit_project: Path): + """An override's frontmatter/comment metadata wins over the hidden layer.""" + commands_dir = spec_kit_project / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True) + (commands_dir / "speckit.constitution.md").write_text( + "---\ndescription: Core description\n---\n", encoding="utf-8" + ) + overrides = spec_kit_project / ".specify" / "templates" / "overrides" + (overrides / "scripts").mkdir(parents=True) + (overrides / "speckit.constitution.md").write_text( + "---\ndescription: Override description\n---\n", encoding="utf-8" + ) + (overrides / "scripts" / "local-script.sh").write_text( + "#!/bin/sh\n# Override script description\n", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + rows = {row.id: row.description for row in catalog.list_artifacts()} + assert rows["command:speckit.constitution"] == "Override description" + assert rows["script:local-script"] == "Override script description" + + def test_project_override_without_metadata_falls_back(self, spec_kit_project: Path): + """A metadata-free override still reports the hidden layer's description.""" + commands_dir = spec_kit_project / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True) + (commands_dir / "speckit.constitution.md").write_text( + "---\ndescription: Core description\n---\n", encoding="utf-8" + ) + overrides = spec_kit_project / ".specify" / "templates" / "overrides" + overrides.mkdir(parents=True) + (overrides / "speckit.constitution.md").write_text("body\n", encoding="utf-8") + + catalog = ArtifactCatalog(spec_kit_project) + rows = {row.id: row.description for row in catalog.list_artifacts()} + assert rows["command:speckit.constitution"] == "Core description" + + def test_project_override_describes_both_backed_kinds(self, spec_kit_project: Path): + commands_dir = spec_kit_project / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True) + (commands_dir / "shared.md").write_text("command\n", encoding="utf-8") + templates_dir = spec_kit_project / ".specify" / "templates" + (templates_dir / "shared.md").write_text("template\n", encoding="utf-8") + overrides = templates_dir / "overrides" + overrides.mkdir(parents=True) + (overrides / "shared.md").write_text( + "---\ndescription: Shared override\n---\n", encoding="utf-8" + ) + + rows = {row.id: row.description for row in ArtifactCatalog(spec_kit_project).list_artifacts()} + + assert rows["command:shared"] == "Shared override" + assert rows["template:shared"] == "Shared override" + + @pytest.mark.parametrize("name", ["local", "speckit.local"]) + def test_override_only_artifact_exposes_both_resolvable_kinds( + self, spec_kit_project: Path, name: str + ): + overrides = spec_kit_project / ".specify" / "templates" / "overrides" + overrides.mkdir(parents=True) + override = overrides / f"{name}.md" + override.write_text("body", encoding="utf-8") + + catalog = ArtifactCatalog(spec_kit_project) + ids = {row.id for row in catalog.list_artifacts()} + assert f"command:{name}" in ids + assert f"template:{name}" in ids + + resolver = PresetResolver(spec_kit_project) + for kind in ("command", "template"): + layers = resolver.collect_all_layers(name, kind) + assert layers[0]["path"] == override + + info = catalog.get_artifact_info(f"{kind}:{name}") + assert info["kind"] == kind + assert info["stack"][0]["layer"] == "project" + + with pytest.raises(AmbiguousArtifactError): + catalog.get_artifact_info(name) + + def test_unregistered_preset_template_without_manifest(self, spec_kit_project: Path): + pack_dir = spec_kit_project / ".specify" / "presets" / "legacy-preset" + pack_dir.mkdir() + PresetRegistry(pack_dir.parent).add( + "legacy-preset", {"priority": 10, "version": "1.0.0"} + ) + preset_templates_dir = pack_dir / "templates" + preset_templates_dir.mkdir() + (preset_templates_dir / "legacy-preset-template.md").write_text( + "body", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + assert any( + row.id == "template:legacy-preset-template" for row in catalog.list_artifacts() + ) + info = catalog.get_artifact_info("legacy-preset-template") + assert info["stack"][0]["lookupId"] == ( + "preset:legacy-preset:template:legacy-preset-template" + ) + + def test_stale_registry_entry_with_missing_pack_dir_is_skipped( + self, spec_kit_project: Path + ): + pack_dir = spec_kit_project / ".specify" / "presets" / "removed-preset" + pack_dir.mkdir() + PresetRegistry(pack_dir.parent).add( + "removed-preset", {"priority": 10, "version": "1.0.0"} + ) + shutil.rmtree(pack_dir) + + catalog = ArtifactCatalog(spec_kit_project) + # Should not raise FileNotFoundError despite the registry entry + # pointing at a directory that no longer exists on disk; the stale + # preset contributes no artifacts. + ids = {row.id for row in catalog.list_artifacts()} + assert not any("removed-preset" in artifact_id for artifact_id in ids) + + def test_command_backed_override_also_exposes_resolvable_template( + self, spec_kit_project: Path + ): + ext_dir = spec_kit_project / ".specify" / "extensions" / "legacy" / "commands" + ext_dir.mkdir(parents=True) + (ext_dir / "speckit.legacy.md").write_text("body", encoding="utf-8") + overrides = spec_kit_project / ".specify" / "templates" / "overrides" + overrides.mkdir(parents=True) + (overrides / "speckit.legacy.md").write_text("override", encoding="utf-8") + + catalog = ArtifactCatalog(spec_kit_project) + ids = {row.id for row in catalog.list_artifacts()} + assert "command:speckit.legacy" in ids + assert "template:speckit.legacy" in ids + assert catalog.get_artifact_info("command:speckit.legacy")["kind"] == "command" + assert catalog.get_artifact_info("template:speckit.legacy")["kind"] == "template" + with pytest.raises(AmbiguousArtifactError): + catalog.get_artifact_info("speckit.legacy") + + +class TestPresetDisplayName: + """`_preset_display_name` delegates to the validated `PresetManifest.name`.""" + + _VALID_MANIFEST = """\ +schema_version: "1.0" +preset: + id: pack + name: Nested Name + version: "1.0.0" + description: A test preset +requires: + speckit_version: ">=1.0.0" +provides: + templates: + - type: template + name: spec-template + file: spec-template.md +""" + + def test_reads_validated_preset_name(self, tmp_path: Path): + pack_dir = tmp_path / "pack" + pack_dir.mkdir() + (pack_dir / "preset.yml").write_text(self._VALID_MANIFEST, encoding="utf-8") + + assert _preset_display_name(pack_dir, "pack") == "Nested Name" + + def test_falls_back_to_pack_id_when_manifest_fails_validation(self, tmp_path: Path): + """A legacy flat manifest with no ``preset:`` section fails validation.""" + pack_dir = tmp_path / "pack" + pack_dir.mkdir() + (pack_dir / "preset.yml").write_text("id: pack\nname: Flat Name\n", encoding="utf-8") + + assert _preset_display_name(pack_dir, "pack") == "pack" + + def test_falls_back_to_pack_id_without_manifest_file(self, tmp_path: Path): + pack_dir = tmp_path / "pack" + pack_dir.mkdir() + + assert _preset_display_name(pack_dir, "pack") == "pack" + + +# --------------------------------------------------------------------------- +# Existing module-import placeholder retained for import safety. +# --------------------------------------------------------------------------- + + +def test_module_imports(): + assert ArtifactCatalog is not None diff --git a/tests/test_artifact_command_parity.py b/tests/test_artifact_command_parity.py new file mode 100644 index 0000000000..00eeb85619 --- /dev/null +++ b/tests/test_artifact_command_parity.py @@ -0,0 +1,117 @@ +"""Resolver-parity tests for the `specify artifact` command group. + +Verifies that the artifact output stays consistent with the underlying +:class:`~specify_cli.presets.PresetResolver`, including for contributions +that only a manifest can surface. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from specify_cli.artifacts import ArtifactCatalog +from specify_cli.presets import PresetResolver +from tests.conftest import install_preset + + +@pytest.fixture +def spec_kit_project(tmp_path: Path) -> Path: + root = tmp_path / "proj" + root.mkdir() + (root / ".specify").mkdir() + (root / ".specify" / "presets").mkdir() + (root / ".specify" / "extensions").mkdir() + (root / ".specify" / "templates").mkdir() + return root + + +class TestResolverParity: + """The ``active: true`` row must be what :meth:`resolve_content` would pick.""" + + def test_manifest_declared_artifact_matches_resolver(self, spec_kit_project: Path): + pack = install_preset( + spec_kit_project, + "test-manifest-parity", + { + "templates": [ + { + "type": "command", + "name": "speckit.manifest-declared", + "file": "commands/differently-named.md", + "description": "manifest contribution", + } + ] + }, + ) + (pack / "commands").mkdir() + (pack / "commands" / "differently-named.md").write_text( + "body-from-manifest", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + info = catalog.get_artifact_info("speckit.manifest-declared") + active = next(layer for layer in info["stack"] if layer["active"]) + winner = PresetResolver(spec_kit_project).resolve_content( + "speckit.manifest-declared", template_type="command" + ) + + assert winner == "body-from-manifest" + assert active["layer"] == "preset" + assert active["lookupId"] == "preset:test-manifest-parity:command:speckit.manifest-declared" + + def test_preset_manifest_id_mismatch_uses_manifest_id(self, spec_kit_project: Path): + pack = install_preset( + spec_kit_project, + "renamed-preset", + { + "commands": [ + { + "name": "speckit.preset-renamed.hello", + "file": "commands/actual.md", + "description": "manifest contribution", + } + ] + }, + ) + manifest_path = pack / "preset.yml" + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + manifest["preset"]["id"] = "original-preset" + manifest_path.write_text(yaml.safe_dump(manifest), encoding="utf-8") + (pack / "commands").mkdir() + (pack / "commands" / "actual.md").write_text( + "body-from-renamed-preset", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + assert "command:speckit.preset-renamed.hello" in { + row.id for row in catalog.list_artifacts() + } + info = catalog.get_artifact_info("speckit.preset-renamed.hello") + active = next(layer for layer in info["stack"] if layer["active"]) + winner = PresetResolver(spec_kit_project).resolve_content( + "speckit.preset-renamed.hello", template_type="command" + ) + + assert winner == "body-from-renamed-preset" + # Artifact projection uses the manifest's validated id regardless of + # the installed directory name. + assert active["lookupId"] == ( + "preset:original-preset:command:speckit.preset-renamed.hello" + ) + resolver_layer = PresetResolver(spec_kit_project).collect_all_layers( + "speckit.preset-renamed.hello", "command" + )[0] + assert "lookupId" not in resolver_layer + # The stack row's presetId / manifestPath must still reflect the + # actual on-disk directory (``renamed-preset``), not the manifest id + # embedded in ``lookupId`` — otherwise the display and manifest path + # would point to a non-existent location. + assert active["presetId"] == "renamed-preset" + assert active["manifestPath"] == ".specify/presets/renamed-preset/preset.yml" + + +def test_module_imports(): + _ = ArtifactCatalog diff --git a/tests/test_extensions.py b/tests/test_extensions.py index e7ded45608..800f5ce00e 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -976,6 +976,23 @@ def test_hook_empty_list_rejected(self, temp_dir, valid_manifest_data): with pytest.raises(ValidationError, match="must contain at least one entry"): ExtensionManifest(manifest_path) + def test_hook_colons_remain_accepted(self, temp_dir, valid_manifest_data): + """Artifact identifiers must not narrow the existing hook manifest contract.""" + import yaml + + valid_manifest_data["hooks"] = { + "custom:after": {"command": "/skill:speckit-test-ext-hello"} + } + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + assert manifest.hooks["custom:after"]["command"] == ( + "/skill:speckit-test-ext-hello" + ) + def test_hook_priority_field_validation(self, temp_dir, valid_manifest_data): """Hook entry ``priority`` must be a positive integer when provided.""" import yaml