diff --git a/docs/reference/presets.md b/docs/reference/presets.md index 1098abfb42..6f4a428908 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -205,6 +205,25 @@ specify preset add team-workflow --priority 10 For any file that both provide, `compliance` wins (priority 5 < 10). For files only one provides, that one is used. For files neither provides, the core default is used. +## Contribution Identifiers + +Every command, template, and script contributed by a preset (or an extension, or the core layer) is addressable at read time by a deterministic opaque identifier of the form: + +```text +{layer}:{sourceId}:{kind}:{name} +``` + +- `layer` is one of `core`, `preset`, or `extension`. +- `sourceId` is `_` for `core`, the preset pack id for `preset`, or the extension id for `extension`. +- `kind` is one of `command`, `template`, or `script`. +- `name` is the entry's declared `name` field. + +Identifiers are computed on demand from author-declared manifest content and are never persisted to `.specify/` or any cache. Copying a preset to another machine (or touching its files) does not change the identifiers it produces. + +`PresetResolver.collect_all_layers()` returns layer dicts that each include a `lookupId` field pointing back to the originating contribution's `id`. Project-local overrides in `.specify/templates/overrides/` are a resolver-only concept — they carry a synthetic `project:_:{kind}:{name}` `lookupId` that intentionally does not match any manifest contribution. + +For the full grammar, including the hook name-component convention and the discriminator recipe used by extensions, see the [Extension API Reference — Contribution Identifiers](../../extensions/EXTENSION-API-REFERENCE.md#contribution-identifiers) section. + ## FAQ ### Can I use multiple presets at the same time? diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index a7bece0b89..e26b3efa9c 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -10,6 +10,7 @@ Technical reference for Spec Kit extension system APIs and manifest schema. 4. [Configuration Schema](#configuration-schema) 5. [Hook System](#hook-system) 6. [CLI Commands](#cli-commands) +7. [Contribution Identifiers](#contribution-identifiers) --- @@ -859,7 +860,60 @@ satisfied = version_satisfies("1.2.3", ">=1.0.0,<2.0.0") # bool --- -## File System Layout +## Contribution Identifiers + +Every command, template, script, and hook contributed by an extension (or a preset, or the core layer) is addressable at read time by a deterministic opaque identifier. Resolved artifact-stack layers carry a matching `lookupId` field that points back to the contribution the layer came from. Identifiers are **computed on demand from author-declared manifest content** and are **never persisted** to `.specify/` or to any cache file. + +### Grammar + +Named contributions (commands, templates, scripts) follow: + +```text +{layer}:{sourceId}:{kind}:{name} +``` + +- `layer` is one of `core`, `preset`, or `extension`. +- `sourceId` is `_` for `core`, the preset pack id for `preset`, or the extension id for `extension`. +- `kind` is one of `command`, `template`, `script`, or `hook`. +- `name` is the contribution's declared `name` field. + +Hook contributions use a compound name-component built from the event and command: + +```text +{layer}:{sourceId}:hook:{eventName}:{command} +``` + +When two or more hook entries within the same source share the same `(eventName, command)` pair, a 12-hex-character discriminator is appended: + +```text +{layer}:{sourceId}:hook:{eventName}:{command}:{discriminator} +``` + +The discriminator is the first 12 lowercase hex characters of `sha256(canonical_json(entry - {eventName, command}))`. If two entries are byte-identical after removing `eventName` and `command`, they collapse under the existing per-event, per-command last-write-wins hook merge semantics. + +### Reserved character + +`:` is reserved as the identifier component separator. It cannot appear inside any of `layer`, `sourceId`, `kind`, `name`, `eventName`, or `command`. Extension ids, command names, template names, and script names are already constrained by their existing regex patterns (`^[a-z0-9-]+$` and friends), which forbid `:`. Hook event names (mapping keys) and hook `command` values are additionally validated to reject `:` at manifest load. + +### The `project:` sentinel + +Project-local overrides in `.specify/templates/overrides/` are a resolver-only concept — they have no backing manifest and cannot appear in `iter_contributions()`. Layers of that kind carry a synthetic `lookupId` of the form `project:_:{kind}:{name}` so consumers that reverse-lookup the id always see "not found", which is the intended behaviour: overrides are addressable at the stack level, not as first-class contributions. + +### Python API + +`ExtensionManifest.iter_contributions()` yields dicts of the form `{layer, sourceId, kind, name, id, ...author-declared fields}`; each entry's `id` is the computed identifier. `ExtensionManifest.contribution_id(kind, name)` returns the id for a single lookup, or `None` if no contribution matches. `PresetManifest` exposes the same two methods. + +`PresetResolver.collect_all_layers()` returns layer dicts that include a `lookupId` field for every layer type (`project override`, preset, extension, core, and bundled core). Resolver `lookupId` values identify the layer by the resolver's registry key or directory name, which can differ from the manifest-declared source id used by `iter_contributions()`. + +### Determinism guarantees + +Manifest contribution identifier derivation reads only the in-memory declared manifest content. No filesystem paths, no `os.environ`, no timestamps, and no file-content hashes contribute to those manifest ids. Copying an extension or preset to a different machine (or touching its files) does not change the identifiers it produces. Resolver `lookupId` values are stack identifiers, not manifest contribution ids: for example, an unregistered extension's directory name is the resolver source id, so renaming that directory changes its `lookupId`. + +### Opacity guidance + +Identifiers are stable, but treat them as **opaque strings** in stored data (registries, cache files, external tooling). Do not parse them by string-splitting on `:` — the discriminator suffix and future grammar extensions may otherwise catch you out. If you only need to classify a stack entry's layer, use `layer_kind_from_lookup_id`; `derive_named_id` and `derive_hook_id` construct new identifiers rather than parsing existing ones. + + ```text .specify/ 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/_assets.py b/src/specify_cli/_assets.py index 31fb9708e6..e19fc9816a 100644 --- a/src/specify_cli/_assets.py +++ b/src/specify_cli/_assets.py @@ -32,6 +32,32 @@ def _repo_root() -> Path: return Path(__file__).parent.parent.parent +def _locate_core_asset_dir(subdir: str) -> Path | None: + """Return the on-disk directory holding a family of core assets, or None. + + ``subdir`` is one of ``"commands"``, ``"templates"``, or ``"scripts"`` — + the three asset families every core baseline consumer needs to agree on. + Prefers the wheel-installed ``core_pack`` bundle, then falls back to the + source-checkout layout. This is the single place that knows the two-tier + resolution ("wheel bundle, else repo-root checkout") for locating core + assets, so callers (extension command-name discovery, the preset + resolver's core fallback, and the artifact command's core-baseline + enumeration) cannot silently diverge on what "core" means on a given + machine. + """ + if subdir not in ("commands", "templates", "scripts"): + return None + core = _locate_core_pack() + if core is not None: + candidate = core / subdir + return candidate if candidate.is_dir() else None + if subdir == "commands": + candidate = _repo_root() / "templates" / "commands" + else: + candidate = _repo_root() / subdir + return candidate if candidate.is_dir() else None + + def _locate_bundled_extension(extension_id: str) -> Path | None: """Return the path to a bundled extension, or None. diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py new file mode 100644 index 0000000000..dec1fc2d91 --- /dev/null +++ b/src/specify_cli/_identifier.py @@ -0,0 +1,210 @@ +"""Deterministic identifiers for Spec Kit contributions and resolved stack layers. + +Every command, template, script, and hook contribution surfaced by a preset or +extension manifest carries a computed opaque ``id`` string, and every layer of a +resolved artifact stack carries a matching ``lookupId``. The identifier value is +derived only from author-declared manifest data — it never depends on file +contents, timestamps, archive hashes, installation directory paths, install-time +random values, or list positions. That is what makes identifiers portable +across machines, project locations, and reinstalls, and what lets consumers use +them as stable join keys. + +Grammar for named contributions (commands, templates, scripts):: + + id = "{layer}:{sourceId}:{kind}:{name}" + + layer ∈ {"core", "preset", "extension"} + sourceId = "_" when layer == "core"; the preset id or extension id otherwise + kind ∈ {"command", "template", "script", "hook"} + name = the contribution's declared ``name`` + +Hook identifiers use ``{eventName}:{command}`` as the name component:: + + id = "{layer}:{sourceId}:hook:{eventName}:{command}[:{discriminator}]" + +The 12-lowercase-hex discriminator is appended only when at least one sibling +hook in the same source shares the same ``(eventName, command)`` pair, and it is +computed by SHA-256 of a canonical JSON serialization of the hook entry's +declared fields (with ``eventName`` and ``command`` removed, since they already +appear in the identifier prefix). + +The functions in this module are pure — inputs are strings or in-memory +mappings parsed from a manifest, outputs are strings. None of them read from +disk, look at ``os.environ``, call ``datetime``, or hash file contents. That +guarantee is what preserves portability, and it is enforced by inspection +rather than by runtime checks: any change here that adds an ambient input is a +change that breaks the identifier contract. +""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any, Iterable, Mapping + + +PROJECT_OVERRIDE_LAYER = "project" +"""Resolver-only layer label for project-local override layers. + +Project overrides are a resolver feature — they are not backed by any manifest +contribution. When a resolved artifact stack contains a project-override layer, +its ``lookupId`` uses this label so the round-trip invariant (every layer +carries a ``lookupId``) still holds. No manifest ``iter_contributions()`` will +ever emit a matching ``id``, so consumers see "not found" for the lookup, which +is the correct outcome for a layer with no originating manifest entry. +""" + +_DISCRIMINATOR_LENGTH = 12 + + +class IdentifierComponentError(ValueError): + """Raised when a manifest component would break identifier grammar.""" + + +def validate_component(value: Any, field_label: str) -> str: + """Return ``value`` unchanged if it is a non-empty ``:``-free string. + + Manifest components that appear in an identifier (``layer``, ``sourceId``, + ``kind``, ``name``, ``eventName``, ``command``) may not contain the ``:`` + delimiter — the grammar has no escape rule. This function is the guard used + by manifest validators to reject offending values at load time with a clear + message naming the field. + """ + 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_named_id(layer: str, source_id: str, kind: str, name: str) -> str: + """Build the identifier string for a named contribution kind. + + Callers are expected to have already validated each component with + :func:`validate_component` at manifest-load time; this function does not + revalidate — it is a pure string join so the identifier can be computed + cheaply on every read. + """ + return f"{layer}:{source_id}:{kind}:{name}" + + +_LAYER_KINDS = frozenset({"core", PROJECT_OVERRIDE_LAYER, "preset", "extension"}) + + +def layer_kind_from_lookup_id(lookup_id: str) -> str | None: + """Return the layer segment of a resolved-stack ``lookupId``, or ``None``. + + ``lookupId`` values on resolved stack layers follow the same + ``"{layer}:..."`` grammar as manifest-contribution ``id`` values (see + module docstring), with ``layer`` additionally taking on + :data:`PROJECT_OVERRIDE_LAYER` for resolver-only project-override layers. + This is the single place that knows the set of valid layer prefixes, so + consumers can classify a lookupId without re-deriving the grammar via + string-prefix checks of their own. + """ + layer, _, rest = lookup_id.partition(":") + if not rest or layer not in _LAYER_KINDS: + return None + return layer + + +def is_dotted_command_name(value: str) -> bool: + """Return ``True`` when ``value`` is a dotted command-style name. + + Command-style names allow lowercase alphanumerics and ``-`` in each segment + and require at least one ``.`` separator. + """ + if "." not in value: + return False + segments = value.split(".") + return all( + segment + and all((("0" <= char <= "9") or ("a" <= char <= "z") or char == "-") for char in segment) + for segment in segments + ) + + +def canonical_json(value: Any) -> bytes: + """Serialize ``value`` to a canonical UTF-8 JSON byte string. + + Mapping keys are sorted lexicographically at every depth, list order is + preserved (author intent), whitespace is stripped, and non-ASCII characters + are emitted verbatim. This is the byte string the hook discriminator hashes. + """ + normalized = _normalize_for_canonical_json(value) + return json.dumps( + normalized, + sort_keys=True, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + + +def _normalize_for_canonical_json(value: Any) -> Any: + if isinstance(value, Mapping): + return {str(k): _normalize_for_canonical_json(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_normalize_for_canonical_json(v) for v in value] + return value + + +def _has_hook_sibling_collision( + event_name: str, + command: str, + siblings: Iterable[Mapping[str, Any]], +) -> bool: + """Return True when at least one sibling shares the same event/command pair. + + ``siblings`` is the full same-source hook entry list including the entry + whose identifier is being derived. A collision therefore means at least two + entries share the pair. + """ + seen = 0 + for entry in siblings: + if entry.get("eventName") == event_name and entry.get("command") == command: + seen += 1 + if seen >= 2: + return True + return False + + +def hook_discriminator(declared_fields: Mapping[str, Any]) -> str: + """Compute the 12-hex-char SHA-256 discriminator for a hook entry. + + ``declared_fields`` is the entry as parsed from the manifest with + ``eventName`` and ``command`` removed — those two values already appear in + the identifier prefix, so hashing them would only reflect information the + consumer can already read. + """ + return hashlib.sha256(canonical_json(declared_fields)).hexdigest()[:_DISCRIMINATOR_LENGTH] + + +def derive_hook_id( + layer: str, + source_id: str, + event_name: str, + command: str, + siblings: Iterable[Mapping[str, Any]], + own_declared_fields: Mapping[str, Any], +) -> str: + """Build the identifier string for a hook contribution. + + The discriminator suffix is appended only when at least one sibling in the + same source shares the same ``(event_name, command)`` prefix. That keeps the + common case terse and the collision case unambiguous. ``siblings`` must + include every hook entry declared under this source (including the one + whose identifier is being derived); the function decides on its own whether + a collision exists. + """ + base = f"{layer}:{source_id}:hook:{event_name}:{command}" + if _has_hook_sibling_collision(event_name, command, siblings): + return f"{base}:{hook_discriminator(own_declared_fields)}" + return base diff --git a/src/specify_cli/_script_variants.py b/src/specify_cli/_script_variants.py new file mode 100644 index 0000000000..5a1b76c7c2 --- /dev/null +++ b/src/specify_cli/_script_variants.py @@ -0,0 +1,32 @@ +"""Canonical names and paths for the core script runtime variants.""" + +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path + +_SCRIPT_VARIANTS = ( + ("bash", ".sh", False), + ("powershell", ".ps1", False), + ("python", ".py", True), +) + + +def canonical_script_name(path: Path) -> str | None: + """Return the logical name shared by a core script's runtime variants.""" + for runtime, suffix, uses_underscores in _SCRIPT_VARIANTS: + if path.parent.name == runtime and path.suffix == suffix: + return path.stem.replace("_", "-") if uses_underscores else path.stem + return None + + +def script_variant_paths(scripts_dir: Path, name: str) -> Iterator[Path]: + """Yield candidate paths for the logical script *name*. + + The legacy flat Bash path (``/.sh``) is yielded first so + existing projects keep working, followed by the runtime-specific paths. + """ + yield scripts_dir / f"{name}.sh" + for runtime, suffix, uses_underscores in _SCRIPT_VARIANTS: + stem = name.replace("-", "_") if uses_underscores else name + yield scripts_dir / runtime / f"{stem}{suffix}" diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py new file mode 100644 index 0000000000..34f830ba48 --- /dev/null +++ b/src/specify_cli/artifacts/__init__.py @@ -0,0 +1,998 @@ +"""Pure logic for the `specify artifact` command group. 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 +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Iterable, Literal + +import yaml + +from .._assets import _locate_core_asset_dir +from .._identifier import ( + PROJECT_OVERRIDE_LAYER, + IdentifierComponentError, + derive_named_id, + is_dotted_command_name, + layer_kind_from_lookup_id, + validate_component, +) +from .._script_variants import canonical_script_name + +# --------------------------------------------------------------------------- +# Public data classes +# --------------------------------------------------------------------------- + +ArtifactKind = Literal["command", "template", "script"] +LayerName = Literal["project", "preset", "extension", "core"] +Strategy = Literal["replace", "wrap", "prepend", "append"] + + +@dataclass(frozen=True) +class Artifact: + """One row in the flat inventory returned by ``list_artifacts()``.""" + + 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 inside the ``stack`` array returned by ``get_artifact_info()``.""" + + layer: LayerName + presetId: str | None + presetName: str | None + strategy: Strategy + active: bool + hidden: bool + manifestPath: str | None + lookupId: str + + def to_json_dict(self) -> dict[str, Any]: + return { + "layer": self.layer, + "presetId": self.presetId, + "presetName": self.presetName, + "strategy": self.strategy, + "active": self.active, + "hidden": self.hidden, + "manifestPath": self.manifestPath, + "lookupId": self.lookupId, + } + + +# --------------------------------------------------------------------------- +# Exceptions — pinned error strings (see artifact-error contract regex) +# --------------------------------------------------------------------------- + + +class ArtifactError(Exception): + """Base class for the three logical error conditions this module raises. + + Each subclass carries a ``.message`` attribute whose value is the exact + string emitted to stderr under the ``error`` key of the JSON envelope. + The contract regex is ``^(unknown artifact |ambiguous artifact |artifact resolution failed|not a Spec Kit project)``. + """ + + 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) + + +# --------------------------------------------------------------------------- +# Core-baseline enumeration +# --------------------------------------------------------------------------- + +_TEMPLATE_SUFFIX = ".md" +_SCRIPT_SUFFIX = ".sh" + + +@dataclass(frozen=True) +class _CoreBaselineRow: + name: str + kind: ArtifactKind + path: Path + description: str + + +def _core_asset_root(subdir: str) -> Path | None: + """Return the on-disk directory holding a family of core assets, or None. + + Delegates to :func:`_locate_core_asset_dir`, the single shared resolver + also used by :func:`_load_core_command_names` and + :meth:`PresetResolver._find_bundled_core`, so all three code paths agree + on what "core" means on this machine instead of each re-deriving it. + """ + return _locate_core_asset_dir(subdir) + + +def _project_core_asset_root(project_root: Path | None, subdir: str) -> Path | None: + """Return the project-local core directory for an asset family, if present.""" + if project_root is None: + return None + candidate = project_root / ".specify" / "templates" + if subdir == "commands": + candidate /= "commands" + elif subdir == "scripts": + candidate /= "scripts" + elif subdir != "templates": # pragma: no cover — internal misuse + return None + return candidate if candidate.is_dir() else None + + +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 SpecKit 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 _enumerate_core_commands(project_root: Path | None = None) -> list[_CoreBaselineRow]: + """Enumerate every command shipped in the core baseline. + + Names are surfaced with the ``speckit.`` prefix so they collide with + preset/extension contributions in a stable way — this is what the id + grammar ``command:speckit.constitution`` requires. + """ + from ..extensions import CORE_COMMAND_NAMES # lazy: avoids circular import + from ..presets import PresetResolver + + commands_dir = _core_asset_root("commands") + project_commands_dir = _project_core_asset_root(project_root, "commands") + rows: list[_CoreBaselineRow] = [] + if commands_dir is None and project_commands_dir is None: + return rows + logical_names = { + name if name.startswith("speckit.") else f"speckit.{name}" + for name in CORE_COMMAND_NAMES + } + if commands_dir is not None: + logical_names.update( + entry.stem if entry.stem.startswith("speckit.") else f"speckit.{entry.stem}" + for entry in commands_dir.iterdir() + if entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX + ) + if project_commands_dir is not None: + logical_names.update( + entry.stem if entry.stem.startswith("speckit.") else f"speckit.{entry.stem}" + for entry in project_commands_dir.iterdir() + if entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX + ) + rows_by_name: dict[str, _CoreBaselineRow] = {} + for logical_name in sorted(logical_names): + name_candidates = PresetResolver.core_name_candidates(logical_name) + project_candidates = ( + tuple(project_commands_dir / f"{name}.md" for name in name_candidates) + if project_commands_dir is not None + else () + ) + bundled_candidates = ( + tuple(commands_dir / f"{name}.md" for name in name_candidates) + if commands_dir is not None + else () + ) + path = next( + ( + candidate + for candidate in (*project_candidates, *bundled_candidates) + if candidate.is_file() + ), + None, + ) + if path is None: + continue + if logical_name in rows_by_name: + continue + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + text = "" + rows_by_name[logical_name] = _CoreBaselineRow( + name=logical_name, + kind="command", + path=path, + description=_extract_frontmatter_description(text), + ) + rows.extend(rows_by_name[name] for name in sorted(rows_by_name)) + return rows + + +def _enumerate_core_templates(project_root: Path | None = None) -> list[_CoreBaselineRow]: + templates_dir = _core_asset_root("templates") + project_templates_dir = _project_core_asset_root(project_root, "templates") + rows: list[_CoreBaselineRow] = [] + seen: set[str] = set() + for directory in (project_templates_dir, templates_dir): + if directory is None: + continue + for entry in sorted(directory.iterdir(), key=lambda p: p.name): + if ( + not entry.is_file() + or entry.suffix != _TEMPLATE_SUFFIX + or entry.stem in seen + ): + continue + seen.add(entry.stem) + try: + text = entry.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + text = "" + rows.append( + _CoreBaselineRow( + name=entry.stem, + kind="template", + path=entry, + description=_extract_frontmatter_description(text), + ) + ) + return rows + + +def _enumerate_core_scripts(project_root: Path | None = None) -> list[_CoreBaselineRow]: + scripts_dir = _core_asset_root("scripts") + project_scripts_dir = _project_core_asset_root(project_root, "scripts") + rows: list[_CoreBaselineRow] = [] + seen: dict[str, _CoreBaselineRow] = {} + for directory in (project_scripts_dir, scripts_dir): + if directory is None: + continue + for entry in sorted(directory.glob(f"*{_SCRIPT_SUFFIX}"), key=lambda p: p.name): + if entry.stem not in seen: + seen[entry.stem] = _core_script_row(entry, entry.stem) + for runtime_dir in sorted(directory.iterdir(), key=lambda p: p.name): + if not runtime_dir.is_dir(): + continue + for entry in sorted(runtime_dir.iterdir(), key=lambda p: p.name): + if not entry.is_file(): + continue + name = canonical_script_name(entry) + if name is not None and name not in seen: + seen[name] = _core_script_row(entry, name) + rows.extend(sorted(seen.values(), key=lambda r: r.name)) + return rows + + +def _core_script_row(path: Path, name: str) -> _CoreBaselineRow: + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + text = "" + return _CoreBaselineRow( + name=name, + kind="script", + path=path, + description=_extract_script_description(text), + ) + + +@dataclass(frozen=True) +class CoreBaseline: + """The union of the three core enumerators, indexed for O(1) lookup.""" + + commands: tuple[_CoreBaselineRow, ...] + templates: tuple[_CoreBaselineRow, ...] + scripts: tuple[_CoreBaselineRow, ...] + + @classmethod + def load(cls, project_root: Path | None = None) -> "CoreBaseline": + return cls( + commands=tuple(_enumerate_core_commands(project_root)), + templates=tuple(_enumerate_core_templates(project_root)), + scripts=tuple(_enumerate_core_scripts(project_root)), + ) + + def by_kind(self, kind: ArtifactKind) -> tuple[_CoreBaselineRow, ...]: + return { + "command": self.commands, + "template": self.templates, + "script": self.scripts, + }[kind] + + def find(self, kind: ArtifactKind, name: str) -> _CoreBaselineRow | None: + for row in self.by_kind(kind): + if row.name == name: + return row + return None + + +# --------------------------------------------------------------------------- +# Resolver-adaptation helpers +# --------------------------------------------------------------------------- + + +def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | None: + """Return a repo-relative POSIX path to the manifest declaring this layer. + + ``layer`` is one dict entry from ``PresetResolver.collect_all_layers()``. + Only ``preset`` and ``extension`` layers have an on-disk manifest — core + and project-override layers return ``None``. + + ``PresetResolver.collect_all_layers`` always reads a pack's files from + ``project_root / ".specify" / "" / ""``, + whether or not that pack is registered — registration only changes which + priority/version metadata is attached, never where the pack lives on + disk. That means the manifest's location is fully determined by the + layer's own ``lookupId`` (``"{layer}:{sourceId}:..."``), so it is derived + directly rather than walking upward from the contribution file. + + Uses ``as_posix()`` so the string is stable across Windows and POSIX — a + caller comparing snapshots between operating systems gets the same value + on both. + """ + lookup_id = layer.get("lookupId", "") + layer_kind = layer_kind_from_lookup_id(lookup_id) + if layer_kind not in ("preset", "extension"): + return None + pack_id = _extract_lookup_pack_id(lookup_id) + if not pack_id: + return None + tier_dir, manifest_name = ( + ("presets", "preset.yml") + if layer_kind == "preset" + else ("extensions", "extension.yml") + ) + manifest_path = project_root / ".specify" / tier_dir / pack_id / manifest_name + if not manifest_path.is_file(): + return None + return manifest_path.relative_to(project_root).as_posix() + + +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 _extract_lookup_pack_id(lookup_id: str) -> str | None: + """Return the ``sourceId`` segment of a lookupId, or ``None`` if malformed.""" + parts = lookup_id.split(":") + if len(parts) < 4: + return None + return parts[1] + + +def _build_stack( + project_root: Path, + kind: ArtifactKind, + name: str, +) -> 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 core baseline row). + """ + from ..presets import PresetResolver # lazy: avoids circular import + + resolver = PresetResolver(project_root) + template_type = kind + raw = resolver.collect_all_layers(name, template_type) + if not raw: + return [] + + first_replace_idx = next( + (i for i, layer in enumerate(raw) if layer["strategy"] == "replace"), + None, + ) + + rows: list[StackLayer] = [] + for idx, layer in enumerate(raw): + lookup_id = layer.get("lookupId", "") + source = str(layer.get("source", "")) + strategy = layer["strategy"] + active = idx == 0 + + if first_replace_idx is None: + hidden = False + else: + hidden = idx > first_replace_idx + + # Layer classification: the lookupId prefix is the resolver's own + # grammar (see layer_kind_from_lookup_id) and is authoritative; the + # source-string check only guards against a malformed lookupId. + layer_kind = layer_kind_from_lookup_id(lookup_id) + + if layer_kind == "core" or (layer_kind is None and source.startswith("core")): + rows.append( + StackLayer( + layer="core", + presetId=None, + presetName=None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=None, + lookupId=lookup_id, + ) + ) + continue + + if layer_kind == PROJECT_OVERRIDE_LAYER or ( + layer_kind is None and source == "project override" + ): + rows.append( + StackLayer( + layer="project", + presetId=None, + presetName=None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=None, + lookupId=lookup_id, + ) + ) + continue + + if layer_kind == "extension" or ( + layer_kind is None and source.startswith("extension:") + ): + manifest_path = _derive_manifest_path(layer, project_root) + rows.append( + StackLayer( + layer="extension", + presetId=None, + presetName=None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=manifest_path, + lookupId=lookup_id, + ) + ) + continue + + pack_id = _extract_lookup_pack_id(lookup_id) or "" + pack_dir = 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(layer, project_root) + rows.append( + StackLayer( + layer="preset", + presetId=pack_id or None, + presetName=display or None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=manifest_path, + lookupId=lookup_id, + ) + ) + return rows + + +# --------------------------------------------------------------------------- +# 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 + self._baseline: CoreBaseline | None = None + + # ------------------------------------------------------------------ list + def list_artifacts(self) -> list[Artifact]: + """Return every artifact SpecKit 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 an empty core + baseline 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 core 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. + """ + _validate_project(self.project_root) + _validate_extension_registry(self.project_root) + baseline = self._get_baseline() + + from ..presets import PresetResolver # lazy: avoids circular import + + resolver = PresetResolver(self.project_root) + layers_cache: dict[tuple[ArtifactKind, str], list[dict[str, Any]]] = {} + + def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: + key = (kind, name) + if key not in layers_cache: + layers_cache[key] = resolver.collect_all_layers(name, kind) + return layers_cache[key] + + names: set[tuple[ArtifactKind, str]] = set() + descriptions_by_layer: dict[tuple[ArtifactKind, str], dict[str, str]] = {} + + for row in (*baseline.commands, *baseline.templates, *baseline.scripts): + if not _is_valid_artifact_name_component(row.name, row.kind): + continue + key = (row.kind, row.name) + names.add(key) + core_lookup_id = derive_named_id("core", "_", row.kind, row.name) + descriptions_by_layer.setdefault(key, {}).setdefault( + core_lookup_id, row.description + ) + + for kind, name, description, lookup_id in self._iter_contribution_artifacts( + resolver, _layers_for + ): + key = (kind, name) + names.add(key) + layer_descriptions = descriptions_by_layer.setdefault(key, {}) + if lookup_id not in layer_descriptions or ( + description and not layer_descriptions[lookup_id] + ): + layer_descriptions[lookup_id] = description + + artifacts: list[Artifact] = [] + for kind, name in names: + layer_descriptions = descriptions_by_layer.get((kind, name), {}) + description = "" + for layer in _layers_for(kind, name): + candidate = layer_descriptions.get(layer["lookupId"], "") + if candidate: + description = candidate + break + artifacts.append( + Artifact(id=f"{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)) + + # ------------------------------------------------------------------ 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`. + """ + _validate_project(self.project_root) + _validate_extension_registry(self.project_root) + bare, resolved_kind = _resolve_kind_hint(name, kind) + + if resolved_kind is None: + matches = self._find_matches(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) + if not any(kind_name == resolved_kind for kind_name, _ in self._find_matches(validated_name)): + raise ArtifactNotFoundError(name) + stack = _build_stack(self.project_root, resolved_kind, validated_name) + if not stack: + raise ArtifactNotFoundError(name) + + description = self._describe(resolved_kind, validated_name) + return { + "id": f"{resolved_kind}:{validated_name}", + "name": validated_name, + "kind": resolved_kind, + "description": description, + "stack": [layer.to_json_dict() for layer in stack], + } + + # -------------------------------------------------------------- internals + def _get_baseline(self) -> CoreBaseline: + if self._baseline is None: + self._baseline = CoreBaseline.load(self.project_root) + return self._baseline + + def _find_matches(self, name: str) -> list[tuple[ArtifactKind, str]]: + """Return every (kind, name) pair whose name matches exactly.""" + artifacts = self.list_artifacts() + return [(a.kind, a.name) for a in artifacts if a.name == name] + + def _describe(self, kind: ArtifactKind, name: str) -> str: + """Return the description that would appear on the flat-list row. + + Sources the value from :meth:`list_artifacts` so the two commands + agree on the same string for the same artifact — the ``info`` output + promises "matching the same field on 'artifact list --json'". + """ + for artifact in self.list_artifacts(): + if artifact.kind == kind and artifact.name == name: + return artifact.description + return "" + + def _iter_contribution_artifacts( + self, + resolver: Any, + layers_for: Callable[[ArtifactKind, str], list[dict[str, Any]]], + ) -> Iterable[tuple[ArtifactKind, str, str, str]]: + """Yield ``(kind, name, description, lookup_id)`` for visible contributions. + + Covers the two ways a pack can contribute an artifact: + + * manifest-declared entries (``preset.yml`` / ``extension.yml``), read + via each manifest class's own ``iter_contributions()`` rather than + re-parsing ``provides`` by hand, and + * convention-placed extension files (``commands/``, ``templates/``, + ``scripts/``) that the resolver picks up even without a manifest. + + Presets are enumerated through ``PresetManager.list_installed()`` — + presets have no unregistered-directory fallback in the resolver (see + ``PresetResolver._get_all_presets_by_priority``), so the registry is + the complete set. Extensions additionally admit unregistered + directories at implicit priority 10 (see + ``PresetResolver._get_all_extensions_by_priority``), so those are + folded in alongside the registered set. Either way, every yielded + contribution is still checked against the resolver's own + ``collect_all_layers()`` output (via ``layers_for``, the cache shared + with :meth:`list_artifacts`) before being surfaced, so a disabled + pack, an orphaned directory the resolver would not admit, or a + declared-but-unusable entry cannot appear in the inventory. + + The ``lookup_id`` is the same ``lookupId`` string + ``collect_all_layers()`` uses for this layer, so the caller can + resolve each artifact's description by precedence instead of + enumeration order. + + 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 + + def _lookup_ids(kind: ArtifactKind, name: str) -> set[str]: + return {layer["lookupId"] for layer in layers_for(kind, name)} + + # -- Presets: the registry is authoritative, no unregistered fallback. + preset_manager = PresetManager(self.project_root) + for pack_id, _metadata in resolver.iter_presets_by_priority(): + pack_dir = preset_manager.presets_dir / pack_id + manifest = preset_manager.get_pack(pack_id) + yield from self._iter_pack_contributions( + manifest, pack_dir, "preset", pack_id, _lookup_ids + ) + + # -- Extensions: use the resolver's own extension enumeration order and + # identity (directory name), including safe-id and corrupt-registry + # handling from PresetResolver.iter_extensions_by_priority(). + ext_manager = ExtensionManager(self.project_root) + for _priority, ext_id, metadata in resolver.iter_extensions_by_priority(): + ext_dir = ext_manager.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: + manifest = None + yield from self._iter_pack_contributions( + manifest, ext_dir, "extension", ext_id, _lookup_ids + ) + + yield from self._iter_project_override_artifacts(resolver) + + @staticmethod + def _iter_pack_contributions( + manifest: Any, + pack_dir: Path, + layer: str, + source_id: str, + lookup_ids: Callable[[ArtifactKind, str], set[str]], + ) -> Iterable[tuple[ArtifactKind, str, str, str]]: + """Yield ``(kind, name, description, lookup_id)`` for one pack. + + ``manifest`` is a validated ``PresetManifest``/``ExtensionManifest`` + (or ``None`` if the pack has no usable manifest). Declared + contributions come from the manifest's own ``iter_contributions()``; + convention-placed files are scanned separately since they exist + whether or not any manifest declares them. + """ + if manifest is not None: + for contribution in manifest.iter_contributions(): + kind = contribution.get("kind") + name = contribution.get("name") + if kind not in ("command", "template", "script"): + continue + if not isinstance(name, str) or not name or ":" in name: + continue + description = contribution.get("description", "") + if not isinstance(description, str): + description = "" + lookup_id = derive_named_id(layer, source_id, kind, name) + if lookup_id in lookup_ids(kind, name): + yield kind, name, description, lookup_id + + # Convention fallback: a preset/extension file placed at the + # conventional path resolves whether or not the manifest declares it, + # so it belongs in the inventory as well. + for kind, name in _iter_convention_contributions(pack_dir): + lookup_id = derive_named_id(layer, source_id, kind, name) + if lookup_id in lookup_ids(kind, name): + yield kind, name, "", lookup_id + + def _iter_project_override_artifacts( + self, + resolver: Any, + ) -> Iterable[tuple[ArtifactKind, str, str, str]]: + """Yield ``(kind, name, "", lookup_id)`` for project-local overrides. + + A root ``overrides/.md`` file is the override for both the + ``template`` and the ``command`` lookup of ````, so it is + reported as a command when some other layer already provides that + command and as a template otherwise. That keeps a command override + from also appearing as a second, spurious ``template:`` row. + + A dotted name (``speckit.local``) is treated as a command even when + the override is the only layer — matching the exact ID + ``preset resolve``/``artifact info`` accepts for it. + """ + 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 + command_layers = resolver.collect_all_layers(name, "command") + backed_by_command = any( + not str(layer.get("lookupId", "")).startswith( + f"{PROJECT_OVERRIDE_LAYER}:" + ) + for layer in command_layers + ) + is_command = backed_by_command or is_dotted_command_name(name) + kind: ArtifactKind = "command" if is_command else "template" + lookup_id = derive_named_id(PROJECT_OVERRIDE_LAYER, "_", kind, name) + yield kind, name, "", lookup_id + 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 + lookup_id = derive_named_id(PROJECT_OVERRIDE_LAYER, "_", "script", entry.stem) + yield "script", entry.stem, "", lookup_id + + +_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]]: + """Yield ``(kind, name)`` for files an extension exposes by convention. + + Only the conventional subdirectories are scanned; loose ``.md`` files at + the extension root (``README.md`` and friends) are deliberately skipped so + packaging files don't show up as templates. + """ + 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 + + +__all__ = [ + "AmbiguousArtifactError", + "Artifact", + "ArtifactCatalog", + "ArtifactError", + "ArtifactKind", + "ArtifactNotFoundError", + "ArtifactResolutionError", + "CoreBaseline", + "LayerName", + "NotASpecKitProjectError", + "StackLayer", + "Strategy", +] + +_ = derive_named_id # keep the import edge visible for tooling diff --git a/src/specify_cli/artifacts/_commands.py b/src/specify_cli/artifacts/_commands.py new file mode 100644 index 0000000000..ac7526db9e --- /dev/null +++ b/src/specify_cli/artifacts/_commands.py @@ -0,0 +1,154 @@ +"""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``. +""" + +from __future__ import annotations + +import json +import os +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 SpecKit exposes.", + no_args_is_help=True, +) + + +def _resolve_project_root() -> Path: + """Return the project root without emitting Rich output on failure. + + The stdout of ``specify artifact list --json`` and ``specify artifact + info --json`` is a strict JSON envelope; any incidental Rich + output would corrupt it. The shared ``_resolve_init_dir_override`` emits + Rich errors for invalid overrides, so validate the override quietly here + and raise the module-local :class:`NotASpecKitProjectError` for the shared + error handler to serialize. + """ + raw_override = os.environ.get("SPECIFY_INIT_DIR", "") + cwd = (Path.cwd() / raw_override).resolve() if raw_override else Path.cwd() + if not (cwd / ".specify").is_dir(): + raise NotASpecKitProjectError() + return cwd + + +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 list_command( + json_flag: bool = typer.Option( + False, + "--json", + help="Emit the inventory as a JSON array on stdout.", + ), +) -> None: + """List every command, template, and script SpecKit exposes.""" + _require_json_flag(json_flag) + try: + root = _resolve_project_root() + catalog = ArtifactCatalog(root) + rows = [artifact.to_json_dict() for artifact in catalog.list_artifacts()] + except ArtifactError as exc: + _emit_error_and_exit(exc) + return # pragma: no cover — _emit_error_and_exit raises + except 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 info_command( + 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 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/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 3968e4fcbe..0cae2dc296 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -27,7 +27,13 @@ from packaging import version as pkg_version from packaging.specifiers import InvalidSpecifier, SpecifierSet -from .._assets import _locate_core_pack, _repo_root +from .._assets import _locate_core_asset_dir +from .._identifier import ( + IdentifierComponentError, + derive_hook_id, + derive_named_id, + validate_component, +) from .._download_security import ( archive_format_from_name, archive_suffix, @@ -82,29 +88,19 @@ def _load_core_command_names() -> frozenset[str]: the source checkout when running from the repository. If neither is available, use the baked-in fallback set so validation still works. - Path resolution is delegated to the canonical ``_assets`` resolvers - (``_locate_core_pack`` / ``_repo_root``) — the same ones the presets and - bundle loaders use — rather than bespoke ``Path(__file__)`` arithmetic. - Hand-counted ``.parent`` chains silently broke discovery once already: the - #3014 move of this module from ``specify_cli/extensions.py`` to - ``specify_cli/extensions/__init__.py`` pushed the file one directory deeper - without updating the counts, so both candidates resolved to non-existent - paths and every call fell through to the fallback (#3274). The shared - resolvers are anchored to the package root, so discovery survives future - module moves. + Path resolution is delegated to :func:`_locate_core_asset_dir` — the same + resolver ``PresetResolver._find_bundled_core`` and the artifact command's + core-baseline enumeration use — rather than bespoke ``Path(__file__)`` + arithmetic. Hand-counted ``.parent`` chains silently broke discovery once + already: the #3014 move of this module from ``specify_cli/extensions.py`` + to ``specify_cli/extensions/__init__.py`` pushed the file one directory + deeper without updating the counts, so both candidates resolved to + non-existent paths and every call fell through to the fallback (#3274). + The shared resolver is anchored to the package root, so discovery + survives future module moves. """ - core_pack = _locate_core_pack() - candidate_dirs = [ - # Wheel install: force-include maps templates/commands → core_pack/commands. - core_pack / "commands" if core_pack is not None else None, - # Source checkout / editable install: repo-root templates/commands. - _repo_root() / "templates" / "commands", - ] - - for commands_dir in candidate_dirs: - if commands_dir is None or not commands_dir.is_dir(): - continue - + commands_dir = _locate_core_asset_dir("commands") + if commands_dir is not None: command_names = { command_file.stem for command_file in commands_dir.iterdir() @@ -415,6 +411,10 @@ def _validate(self): raise ValidationError( f"Invalid hook '{hook_name}': list must contain at least one entry" ) + try: + validate_component(hook_name, f"hook event name '{hook_name}'") + except IdentifierComponentError as exc: + raise ValidationError(str(exc)) from exc for entry in coerce_hook_entries(hook_config): if not isinstance(entry, dict): raise ValidationError( @@ -425,6 +425,13 @@ def _validate(self): raise ValidationError( f"Hook '{hook_name}' missing required 'command' field" ) + try: + validate_component( + entry["command"], + f"hook '{hook_name}' command", + ) + except IdentifierComponentError as exc: + raise ValidationError(str(exc)) from exc if "priority" in entry: priority = entry["priority"] if not isinstance(priority, int) or isinstance(priority, bool): @@ -523,14 +530,11 @@ def _validate(self): command_ref = entry.get("command") if not isinstance(command_ref, str): continue - # Step 1: apply any rename from the auto-correction pass. - after_rename = rename_map.get(command_ref, command_ref) - # Step 2: lift alias-form '{ext_id}.cmd' to canonical 'speckit.{ext_id}.cmd'. - parts = after_rename.split(".") - if len(parts) == 2 and parts[0] == ext["id"]: - final_ref = f"speckit.{ext['id']}.{parts[1]}" - else: - final_ref = after_rename + final_ref = self._canonicalize_command_ref( + command_ref, + ext["id"], + rename_map, + ) if final_ref != command_ref: entry["command"] = final_ref self.warnings.append( @@ -552,12 +556,11 @@ def _validate(self): command_ref = event_config.get("command") if not isinstance(command_ref, str): continue - after_rename = rename_map.get(command_ref, command_ref) - parts = after_rename.split(".") - if len(parts) == 2 and parts[0] == ext["id"]: - final_ref = f"speckit.{ext['id']}.{parts[1]}" - else: - final_ref = after_rename + final_ref = self._canonicalize_command_ref( + command_ref, + ext["id"], + rename_map, + ) if final_ref != command_ref: event_config["command"] = final_ref self.warnings.append( @@ -566,6 +569,18 @@ def _validate(self): f"The extension author should update the manifest." ) + @staticmethod + def _canonicalize_command_ref( + command_ref: str, + ext_id: str, + rename_map: Dict[str, str], + ) -> str: + after_rename = rename_map.get(command_ref, command_ref) + parts = after_rename.split(".") + if len(parts) == 2 and parts[0] == ext_id: + return f"speckit.{ext_id}.{parts[1]}" + return after_rename + @staticmethod def _validate_provided_artifacts(entries: List[Any], section: str, singular: str) -> None: """Validate provides.templates / provides.scripts entries. @@ -725,6 +740,112 @@ def hooks(self) -> Dict[str, Any]: """Get hook definitions.""" return self.data.get("hooks", {}) + def iter_contributions(self) -> List[Dict[str, Any]]: + """Return an enriched, ordered list of every contribution this manifest declares. + + Each dict is a shallow copy of the underlying manifest entry with four + derived keys added: ``layer`` (always ``"extension"``), ``sourceId`` + (this manifest's ``id``), ``kind`` (``"command"`` / ``"template"`` / + ``"script"`` / ``"hook"``), and ``id`` (the deterministic identifier). + Hook entries also carry a synthesized ``name`` field of the form + ``"{eventName}:{command}"`` alongside the original ``eventName`` / + ``command`` values, so consumers can locate a hook by its identifier's + name component without re-splitting the string. + + The underlying ``self.data`` mapping is never mutated — the enriched + dicts are constructed fresh on every call so callers can safely rely on + the identifiers reflecting the current in-memory manifest state. + """ + source_id = self.id + contributions: List[Dict[str, Any]] = [] + + for cmd in self.commands: + enriched = dict(cmd) + name = cmd.get("name", "") + enriched.update( + layer="extension", + sourceId=source_id, + kind="command", + id=derive_named_id("extension", source_id, "command", name), + ) + contributions.append(enriched) + + for tmpl in self.templates: + enriched = dict(tmpl) + name = tmpl.get("name", "") + enriched.update( + layer="extension", + sourceId=source_id, + kind="template", + id=derive_named_id("extension", source_id, "template", name), + ) + contributions.append(enriched) + + for scr in self.scripts: + enriched = dict(scr) + name = scr.get("name", "") + enriched.update( + layer="extension", + sourceId=source_id, + kind="script", + id=derive_named_id("extension", source_id, "script", name), + ) + contributions.append(enriched) + + hooks = self.hooks or {} + # Flatten every hook entry across every event so the discriminator + # decision has visibility into the full same-source sibling set. + flattened: List[tuple[str, dict]] = [] + for event_name, hook_config in hooks.items(): + for entry in coerce_hook_entries(hook_config): + if isinstance(entry, dict): + normalized = dict(entry) + normalized.setdefault("eventName", event_name) + flattened.append((event_name, normalized)) + + siblings_for_id = [ + {"eventName": event, "command": entry.get("command", "")} + for event, entry in flattened + ] + + for event_name, entry in flattened: + command_value = entry.get("command", "") + declared_fields = { + k: v + for k, v in entry.items() + if k not in ("eventName", "command") + } + hook_id = derive_hook_id( + "extension", + source_id, + event_name, + command_value, + siblings_for_id, + declared_fields, + ) + enriched = dict(entry) + enriched.update( + layer="extension", + sourceId=source_id, + kind="hook", + name=f"{event_name}:{command_value}", + id=hook_id, + ) + contributions.append(enriched) + + return contributions + + def contribution_id(self, kind: str, name: str) -> Optional[str]: + """Return the computed identifier for a single contribution, if declared. + + ``name`` is the declared name for command/template/script kinds, or the + ``"{eventName}:{command}"`` compound for hook kinds. + """ + for entry in self.iter_contributions(): + if entry["kind"] == kind and entry.get("name") == name: + return entry["id"] + return None + def get_hash(self) -> str: """Calculate SHA256 hash of manifest file.""" h = hashlib.sha256() diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index a5cea4f958..29416abb0b 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -37,6 +37,11 @@ safe_extract_archive, ) from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority +from .._identifier import ( + PROJECT_OVERRIDE_LAYER, + derive_named_id, +) +from .._script_variants import script_variant_paths from .._init_options import ( MISSING_INIT_OPTIONS_FILE, is_ai_skills_enabled, @@ -539,6 +544,38 @@ def tags(self) -> List[str]: """Get preset tags.""" return self.data.get("tags", []) + def iter_contributions(self) -> List[Dict[str, Any]]: + """Return an enriched, ordered list of every contribution this preset declares. + + Each dict is a shallow copy of the underlying ``provides.templates[]`` + entry with four derived keys added: ``layer`` (always ``"preset"``), + ``sourceId`` (this preset's ``id``), ``kind`` (mirrors the entry's + ``type`` — one of ``"command"`` / ``"template"`` / ``"script"``), and + ``id`` (the deterministic identifier). The underlying manifest data is + not mutated. + """ + source_id = self.id + contributions: List[Dict[str, Any]] = [] + for entry in self.templates: + kind = entry.get("type", "") + name = entry.get("name", "") + enriched = dict(entry) + enriched.update( + layer="preset", + sourceId=source_id, + kind=kind, + id=derive_named_id("preset", source_id, kind, name), + ) + contributions.append(enriched) + return contributions + + def contribution_id(self, kind: str, name: str) -> Optional[str]: + """Return the computed identifier for a single contribution, if declared.""" + for entry in self.iter_contributions(): + if entry["kind"] == kind and entry.get("name") == name: + return entry["id"] + return None + def get_hash(self) -> str: """Calculate SHA256 hash of manifest file.""" h = hashlib.sha256() @@ -5036,6 +5073,14 @@ def _get_all_presets_by_priority(self) -> List[tuple[str, dict]]: if self._is_safe_registry_id(pack_id) ] + def iter_presets_by_priority(self) -> List[tuple[str, dict]]: + """Return preset directories in resolver lookup order. + + Each entry is ``(pack_id, metadata)`` where ``pack_id`` is the registry + key/directory name used in lookup identifiers. + """ + return self._get_all_presets_by_priority() + def _manifest_declared_template( self, pack_dir: Path, template_name: str, template_type: str ) -> tuple[dict | None, Path | None]: @@ -5181,6 +5226,14 @@ def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: all_extensions.sort(key=lambda x: (x[0], x[1])) return all_extensions + def iter_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: + """Return extension directories in resolver lookup order. + + Each entry is ``(priority, ext_id, metadata_or_none)`` where ``ext_id`` + is always the on-disk directory name used in lookup identifiers. + """ + return self._get_all_extensions_by_priority() + @staticmethod def _core_stem(template_name: str) -> Optional[str]: """Extract the stem for core command lookup. @@ -5194,6 +5247,15 @@ def _core_stem(template_name: str) -> Optional[str]: return template_name[len("speckit."):] return None + @classmethod + def core_name_candidates(cls, logical_name: str) -> list[str]: + """Return exact-first filename candidates for a core logical name.""" + names = [logical_name] + stem = cls._core_stem(logical_name) + if stem and stem != logical_name: + names.append(stem) + return names + def resolve( self, template_name: str, @@ -5308,8 +5370,11 @@ def resolve( if core.exists(): return core elif template_type == "script": - core = self.templates_dir / "scripts" / f"{template_name}{ext}" - if core.exists(): + core = next( + (path for path in script_variant_paths(self.templates_dir / "scripts", template_name) if path.exists()), + None, + ) + if core is not None: return core # Priority 5: Bundled core_pack (wheel install) or repo-root templates @@ -5329,10 +5394,13 @@ def resolve( if stem: candidate = _core_pack / "commands" / f"{stem}.md" elif template_type == "script": - candidate = _core_pack / "scripts" / f"{template_name}{ext}" + candidate = next( + (path for path in script_variant_paths(_core_pack / "scripts", template_name) if path.exists()), + None, + ) else: candidate = _core_pack / f"{template_name}.md" - if candidate.exists(): + if candidate is not None and candidate.exists(): return candidate else: # Source-checkout / editable install: templates live at repo root @@ -5346,10 +5414,13 @@ def resolve( if stem: candidate = repo_root / "templates" / "commands" / f"{stem}.md" elif template_type == "script": - candidate = repo_root / "scripts" / f"{template_name}{ext}" + candidate = next( + (path for path in script_variant_paths(repo_root / "scripts", template_name) if path.exists()), + None, + ) else: candidate = repo_root / f"{template_name}.md" - if candidate.exists(): + if candidate is not None and candidate.exists(): return candidate return None @@ -5527,6 +5598,9 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: "path": override, "source": "project override", "strategy": "replace", + "lookupId": derive_named_id( + PROJECT_OVERRIDE_LAYER, "_", template_type, template_name + ), }) # Priority 2: Installed presets (sorted by priority — lower number = higher precedence) @@ -5583,6 +5657,9 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: "path": candidate, "source": f"{pack_id} v{version}", "strategy": strategy, + "lookupId": derive_named_id( + "preset", pack_id, template_type, template_name + ), }) # Priority 3: Extension-provided templates (always "replace") @@ -5611,6 +5688,9 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: "strategy": "replace", "extension_id": ext_id, "extension_dir": ext_dir, + "lookupId": derive_named_id( + "extension", ext_id, template_type, template_name + ), }) # Priority 4: Core templates (always "replace") @@ -5631,14 +5711,20 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: if c.exists(): core = c elif template_type == "script": - c = self.templates_dir / "scripts" / f"{template_name}{ext}" - if c.exists(): + c = next( + (path for path in script_variant_paths(self.templates_dir / "scripts", template_name) if path.exists()), + None, + ) + if c is not None: core = c if core: layers.append({ "path": core, "source": "core", "strategy": "replace", + "lookupId": derive_named_id( + "core", "_", template_type, template_name + ), }) else: # Priority 5: Bundled core_pack (wheel install) or repo-root @@ -5649,6 +5735,9 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: "path": bundled, "source": "core (bundled)", "strategy": "replace", + "lookupId": derive_named_id( + "core", "_", template_type, template_name + ), }) return layers @@ -5664,43 +5753,40 @@ def _find_bundled_core( Mirrors the tier-5 fallback logic in ``resolve()`` so that ``collect_all_layers()`` can locate base layers even when ``.specify/templates/`` doesn't contain the core file. + + Directory resolution is delegated to the shared + ``_locate_core_asset_dir`` resolver — the same one the artifact + command's core-baseline enumeration and the extensions module's + core-command-name discovery use — so all three code paths agree on + what "core" means on this machine. """ try: - from specify_cli import _locate_core_pack, _repo_root + from specify_cli._assets import _locate_core_asset_dir except ImportError: return None - stem = self._core_stem(template_name) - names = [template_name] - if stem and stem != template_name: - names.append(stem) - - core_pack = _locate_core_pack() - if core_pack is not None: - for name in names: - if template_type == "template": - c = core_pack / "templates" / f"{name}.md" - elif template_type == "command": - c = core_pack / "commands" / f"{name}.md" - elif template_type == "script": - c = core_pack / "scripts" / f"{name}{ext}" - else: - c = core_pack / f"{name}.md" - if c.exists(): - return c + if template_type == "template": + base = _locate_core_asset_dir("templates") + elif template_type == "command": + base = _locate_core_asset_dir("commands") + elif template_type == "script": + base = _locate_core_asset_dir("scripts") else: - repo_root = _repo_root() - for name in names: - if template_type == "template": - c = repo_root / "templates" / f"{name}.md" - elif template_type == "command": - c = repo_root / "templates" / "commands" / f"{name}.md" - elif template_type == "script": - c = repo_root / "scripts" / f"{name}{ext}" - else: - c = repo_root / f"{name}.md" - if c.exists(): - return c + base = None + + if base is None: + return None + + for name in self.core_name_candidates(template_name): + if template_type == "script": + c = next( + (path for path in script_variant_paths(base, name) if path.exists()), + None, + ) + else: + c = base / f"{name}.md" + if c is not None and c.exists(): + return c return None def resolve_content( 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..97f8790706 --- /dev/null +++ b/tests/test_artifact_command.py @@ -0,0 +1,1002 @@ +"""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 +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, + _derive_manifest_path, + _preset_display_name, +) +from specify_cli.extensions import 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_core_script_variants_have_one_resolvable_logical_name( + self, spec_kit_project: Path + ): + 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", + "common", + "create-new-feature", + "resolve-template", + "setup-plan", + "setup-tasks", + } + for script in scripts: + info = catalog.get_artifact_info(script.id) + assert info["stack"][-1]["lookupId"] == f"core:_:script:{script.name}" + + 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_uses_directory_id_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: Dir identity wins\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") + assert info["stack"][0]["lookupId"] == "extension:renamed:command:speckit.original.hello" + assert ( + PresetResolver(spec_kit_project) + .collect_all_layers("speckit.original.hello", "command")[0]["lookupId"] + == "extension:renamed:command:speckit.original.hello" + ) + + 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" + assert catalog.get_artifact_info("speckit.local-command")["stack"][0]["lookupId"] == ( + "core:_:command:speckit.local-command" + ) + assert catalog.get_artifact_info("legacy-template")["stack"][0]["lookupId"] == ( + "core:_:template:legacy-template" + ) + assert catalog.get_artifact_info("legacy-script")["stack"][0]["lookupId"] == ( + "core:_:script:legacy-script" + ) + + @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_core_row_shape(self, spec_kit_project: Path): + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + core = next(layer for layer in info["stack"] if layer["layer"] == "core") + assert core["presetId"] is None + assert core["presetName"] is None + assert core["manifestPath"] is None + assert core["strategy"] == "replace" + assert re.match(r"^core:_:(command|template|script):[^:]+$", core["lookupId"]) + + 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("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["strategy"] == "replace" + 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"]: + assert re.match( + r"^(project|preset|extension|core):[^:]+:(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" + + +# --------------------------------------------------------------------------- +# 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) + + +# --------------------------------------------------------------------------- +# 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_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_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: + 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_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 core layer exists it must be hidden. + core_rows = [layer for layer in stack if layer["layer"] == "core"] + for row in core_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" + + 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_is_not_listed_as_template(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") + + ids = {row.id for row in ArtifactCatalog(spec_kit_project).list_artifacts()} + assert "template:README" not in ids + + 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 "template:local-template" in ids + assert "script:local-script" in ids + info = catalog.get_artifact_info("local-template") + assert info["stack"][0]["layer"] == "project" + + def test_dotted_override_only_artifact_is_a_command(self, spec_kit_project: Path): + overrides = spec_kit_project / ".specify" / "templates" / "overrides" + overrides.mkdir(parents=True) + (overrides / "speckit.local.md").write_text("body", encoding="utf-8") + + catalog = ArtifactCatalog(spec_kit_project) + ids = {row.id for row in catalog.list_artifacts()} + assert "command:speckit.local" in ids + assert "template:speckit.local" not in ids + with pytest.raises(ArtifactNotFoundError): + catalog.get_artifact_info("template:speckit.local") + info = catalog.get_artifact_info("command:speckit.local") + assert info["kind"] == "command" + assert info["stack"][0]["layer"] == "project" + + def test_malformed_dotted_override_is_not_forced_to_command( + self, spec_kit_project: Path + ): + overrides = spec_kit_project / ".specify" / "templates" / "overrides" + overrides.mkdir(parents=True) + (overrides / "speckit..local.md").write_text("body", encoding="utf-8") + + catalog = ArtifactCatalog(spec_kit_project) + ids = {row.id for row in catalog.list_artifacts()} + assert "template:speckit..local" in ids + assert "command:speckit..local" not in ids + + 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_command_override_is_not_duplicated_as_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" not in ids + assert catalog.get_artifact_info("speckit.legacy")["kind"] == "command" + + +class TestManifestPathPortability: + """`_derive_manifest_path` must never leak an absolute host path.""" + + def test_preset_manifest_path_is_repo_relative(self, tmp_path: Path): + project_root = tmp_path / "proj" + pack_dir = project_root / ".specify" / "presets" / "my-pack" + pack_dir.mkdir(parents=True) + (pack_dir / "preset.yml").write_text("id: my-pack\n", encoding="utf-8") + + layer = { + "lookupId": "preset:my-pack:template:spec-template", + "path": pack_dir / "spec-template.md", + } + assert ( + _derive_manifest_path(layer, project_root) + == ".specify/presets/my-pack/preset.yml" + ) + + def test_extension_manifest_path_is_repo_relative(self, tmp_path: Path): + project_root = tmp_path / "proj" + ext_dir = project_root / ".specify" / "extensions" / "my-ext" + ext_dir.mkdir(parents=True) + (ext_dir / "extension.yml").write_text("id: my-ext\n", encoding="utf-8") + + layer = { + "lookupId": "extension:my-ext:command:speckit.my-ext.go", + "path": ext_dir / "commands" / "speckit.my-ext.go.md", + } + assert ( + _derive_manifest_path(layer, project_root) + == ".specify/extensions/my-ext/extension.yml" + ) + + def test_missing_manifest_file_is_none(self, tmp_path: Path): + project_root = tmp_path / "proj" + pack_dir = project_root / ".specify" / "presets" / "my-pack" + pack_dir.mkdir(parents=True) + + layer = { + "lookupId": "preset:my-pack:template:spec-template", + "path": pack_dir / "spec-template.md", + } + assert _derive_manifest_path(layer, project_root) is None + + def test_core_and_project_layers_have_no_manifest(self, tmp_path: Path): + project_root = tmp_path / "proj" + project_root.mkdir() + + core_layer = {"lookupId": "core:_:template:spec-template"} + project_layer = {"lookupId": "project:_:template:spec-template"} + assert _derive_manifest_path(core_layer, project_root) is None + assert _derive_manifest_path(project_layer, project_root) is None + + +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..e06f2a8bb2 --- /dev/null +++ b/tests/test_artifact_command_parity.py @@ -0,0 +1,110 @@ +"""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_registry_key(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" + assert active["lookupId"] == ( + "preset:renamed-preset:command:speckit.preset-renamed.hello" + ) + assert ( + PresetResolver(spec_kit_project) + .collect_all_layers("speckit.preset-renamed.hello", "command")[0]["lookupId"] + == "preset:renamed-preset:command:speckit.preset-renamed.hello" + ) + + +def test_module_imports(): + _ = ArtifactCatalog diff --git a/tests/test_assets.py b/tests/test_assets.py new file mode 100644 index 0000000000..b8149272d7 --- /dev/null +++ b/tests/test_assets.py @@ -0,0 +1,48 @@ +"""Tests for the shared bundle-path resolvers in `specify_cli._assets`.""" + +from __future__ import annotations + +import specify_cli._assets as assets +from specify_cli._assets import _locate_core_asset_dir + + +class TestLocateCoreAssetDir: + """`_locate_core_asset_dir` is the single source of truth every core-asset + consumer (extension command-name discovery, the preset resolver's core + fallback, and the artifact command's core-baseline enumeration) shares.""" + + def test_prefers_wheel_core_pack_over_repo_checkout(self, tmp_path, monkeypatch): + core_pack = tmp_path / "core_pack" + (core_pack / "commands").mkdir(parents=True) + repo_root = tmp_path / "repo" + (repo_root / "templates" / "commands").mkdir(parents=True) + + monkeypatch.setattr(assets, "_locate_core_pack", lambda: core_pack) + monkeypatch.setattr(assets, "_repo_root", lambda: repo_root) + + assert _locate_core_asset_dir("commands") == core_pack / "commands" + + def test_falls_back_to_repo_checkout_when_no_wheel_bundle(self, tmp_path, monkeypatch): + repo_root = tmp_path / "repo" + (repo_root / "templates" / "commands").mkdir(parents=True) + (repo_root / "templates").mkdir(exist_ok=True) + (repo_root / "scripts").mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr(assets, "_locate_core_pack", lambda: None) + monkeypatch.setattr(assets, "_repo_root", lambda: repo_root) + + assert _locate_core_asset_dir("commands") == repo_root / "templates" / "commands" + assert _locate_core_asset_dir("templates") == repo_root / "templates" + assert _locate_core_asset_dir("scripts") == repo_root / "scripts" + + def test_returns_none_when_directory_missing(self, tmp_path, monkeypatch): + monkeypatch.setattr(assets, "_locate_core_pack", lambda: None) + monkeypatch.setattr(assets, "_repo_root", lambda: tmp_path / "nonexistent") + + assert _locate_core_asset_dir("commands") is None + + def test_returns_none_for_unknown_subdir(self, tmp_path, monkeypatch): + monkeypatch.setattr(assets, "_locate_core_pack", lambda: None) + monkeypatch.setattr(assets, "_repo_root", lambda: tmp_path) + + assert _locate_core_asset_dir("bogus") is None diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py new file mode 100644 index 0000000000..99b2e042db --- /dev/null +++ b/tests/test_contribution_ids.py @@ -0,0 +1,570 @@ +"""Tests for the deterministic contribution-id and stack lookup-id feature. + +Every command / template / script / hook contribution surfaced by a preset or +extension manifest exposes a computed ``id`` derived from author-declared data +only, and every layer of a resolved artifact stack exposes a matching +``lookupId``. The scenarios below cover: the identifier grammar across every +``layer x kind`` combination, the hook discriminator collision + rejection +rules, cross-process byte-stability, path/mtime independence, and the +additive-only shape guarantee for the enriched contribution dicts. +""" + +from __future__ import annotations + +import copy +import json +import os +import shutil +import subprocess +import sys +import textwrap +import time +from pathlib import Path + +import pytest +import yaml + +from specify_cli._identifier import ( + IdentifierComponentError, + PROJECT_OVERRIDE_LAYER, + canonical_json, + derive_hook_id, + derive_named_id, + hook_discriminator, + layer_kind_from_lookup_id, + validate_component, +) +from specify_cli.extensions import ExtensionManifest, ValidationError +from specify_cli.presets import PresetManifest, PresetResolver + + +# --------------------------------------------------------------------------- +# Fixture builders (programmatic — no on-disk fixture tree) +# --------------------------------------------------------------------------- + + +def _preset_data(pack_id: str = "speckit-core") -> dict: + return { + "schema_version": "1.0", + "preset": { + "id": pack_id, + "name": pack_id, + "version": "1.0.0", + "description": "Fixture preset", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + {"type": "command", "name": "speckit.plan", "file": "commands/plan.md"}, + {"type": "template", "name": "spec-template", "file": "templates/spec.md"}, + {"type": "script", "name": "setup-plan", "file": "scripts/setup-plan.sh"}, + ] + }, + } + + +def _extension_data( + ext_id: str = "speckit-git", + hooks: dict | None = None, + with_commands: bool = True, + with_templates: bool = True, + with_scripts: bool = True, +) -> dict: + data = { + "schema_version": "1.0", + "extension": { + "id": ext_id, + "name": ext_id, + "version": "1.0.0", + "description": "Fixture extension", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {}, + } + if with_commands: + data["provides"]["commands"] = [ + { + "name": f"speckit.{ext_id.replace('-', '')}.branch", + "file": "commands/branch.md", + "description": "Fixture command", + } + ] + if with_templates: + data["provides"]["templates"] = [ + {"name": "pr-body", "file": "templates/pr-body.md"} + ] + if with_scripts: + data["provides"]["scripts"] = [ + {"name": "post-commit", "file": "scripts/post-commit.sh"} + ] + if hooks is not None: + data["hooks"] = hooks + return data + + +def _write_manifest(tmp_path: Path, data: dict, filename: str) -> Path: + manifest_path = tmp_path / filename + with open(manifest_path, "w", encoding="utf-8") as fh: + yaml.safe_dump(data, fh, sort_keys=False) + return manifest_path + + +# --------------------------------------------------------------------------- +# Identifier grammar — layer x kind derivation matrix +# --------------------------------------------------------------------------- + + +class TestIdentifierDerivation: + """Every layer x kind combination produces the expected grammar.""" + + @pytest.mark.parametrize( + "layer, source_id, kind, name, expected", + [ + ("core", "_", "command", "speckit.constitution", "core:_:command:speckit.constitution"), + ("core", "_", "template", "spec-template", "core:_:template:spec-template"), + ("core", "_", "script", "setup-plan", "core:_:script:setup-plan"), + ("preset", "speckit-core", "command", "speckit.plan", "preset:speckit-core:command:speckit.plan"), + ("preset", "speckit-core", "template", "spec-template", "preset:speckit-core:template:spec-template"), + ("preset", "speckit-core", "script", "setup-plan", "preset:speckit-core:script:setup-plan"), + ("extension", "speckit-git", "command", "speckit.git.branch", "extension:speckit-git:command:speckit.git.branch"), + ("extension", "speckit-git", "template", "pr-body", "extension:speckit-git:template:pr-body"), + ("extension", "speckit-git", "script", "post-commit", "extension:speckit-git:script:post-commit"), + ], + ) + def test_named_id_grammar(self, layer, source_id, kind, name, expected): + assert derive_named_id(layer, source_id, kind, name) == expected + + @pytest.mark.parametrize( + "layer, source_id, event, command, expected", + [ + ("core", "_", "before_specify", "speckit.constitution", "core:_:hook:before_specify:speckit.constitution"), + ("preset", "speckit-core", "before_plan", "speckit.plan", "preset:speckit-core:hook:before_plan:speckit.plan"), + ("extension", "speckit-git", "before_specify", "speckit.git.branch", "extension:speckit-git:hook:before_specify:speckit.git.branch"), + ], + ) + def test_hook_id_no_discriminator(self, layer, source_id, event, command, expected): + siblings = [{"eventName": event, "command": command}] + assert ( + derive_hook_id(layer, source_id, event, command, siblings, {}) + == expected + ) + + def test_named_id_stable_across_two_derivations(self): + a = derive_named_id("preset", "speckit-core", "command", "speckit.plan") + b = derive_named_id("preset", "speckit-core", "command", "speckit.plan") + assert a == b + + +class TestLayerKindFromLookupId: + """``layer_kind_from_lookup_id`` extracts the layer segment of a lookupId.""" + + @pytest.mark.parametrize( + "lookup_id, expected", + [ + ("core:_:command:speckit.constitution", "core"), + ("preset:speckit-core:template:spec-template", "preset"), + ("extension:speckit-git:script:post-commit", "extension"), + (f"{PROJECT_OVERRIDE_LAYER}:_:template:spec-template", PROJECT_OVERRIDE_LAYER), + ], + ) + def test_recognized_layer_prefixes(self, lookup_id, expected): + assert layer_kind_from_lookup_id(lookup_id) == expected + + @pytest.mark.parametrize( + "lookup_id", + [ + "", + "bogus:_:command:speckit.plan", + "core", + ":_:command:speckit.plan", + ], + ) + def test_unrecognized_or_malformed_returns_none(self, lookup_id): + assert layer_kind_from_lookup_id(lookup_id) is None + + +# --------------------------------------------------------------------------- +# Canonical JSON +# --------------------------------------------------------------------------- + + +class TestCanonicalJson: + def test_sorts_mapping_keys_at_every_depth(self): + payload = {"z": 1, "a": {"y": 2, "x": [3, {"n": 4, "m": 5}]}} + assert canonical_json(payload) == b'{"a":{"x":[3,{"m":5,"n":4}],"y":2},"z":1}' + + def test_preserves_list_order(self): + assert canonical_json([3, 1, 2]) == b"[3,1,2]" + + def test_utf8_no_ensure_ascii(self): + assert canonical_json({"k": "café"}).decode("utf-8") == '{"k":"café"}' + + +# --------------------------------------------------------------------------- +# Hook discriminator behaviour +# --------------------------------------------------------------------------- + + +class TestHookDiscriminator: + def test_no_discriminator_when_unique(self, tmp_path): + data = _extension_data( + hooks={ + "before_specify": {"command": "speckit.speckitgit.branch"}, + } + ) + manifest = ExtensionManifest(_write_manifest(tmp_path, data, "extension.yml")) + hooks = [c for c in manifest.iter_contributions() if c["kind"] == "hook"] + assert len(hooks) == 1 + assert hooks[0]["id"] == "extension:speckit-git:hook:before_specify:speckit.speckitgit.branch" + + def test_discriminator_when_colliding(self, tmp_path): + data = _extension_data( + hooks={ + "before_plan": [ + {"command": "speckit.speckitgit.branch", "priority": 10}, + {"command": "speckit.speckitgit.branch", "priority": 20}, + ] + } + ) + manifest = ExtensionManifest(_write_manifest(tmp_path, data, "extension.yml")) + hooks = [c for c in manifest.iter_contributions() if c["kind"] == "hook"] + assert len(hooks) == 2 + prefixes = {"extension:speckit-git:hook:before_plan:speckit.speckitgit.branch"} + for h in hooks: + assert h["id"].startswith(next(iter(prefixes)) + ":") + suffix = h["id"].rsplit(":", 1)[-1] + assert len(suffix) == 12 + assert all(ch in "0123456789abcdef" for ch in suffix) + assert hooks[0]["id"] != hooks[1]["id"] + + def test_discriminator_stable_under_reordering(self, tmp_path): + entries_a = [ + {"command": "speckit.speckitgit.branch", "priority": 10}, + {"command": "speckit.speckitgit.branch", "priority": 20}, + ] + entries_b = list(reversed([copy.deepcopy(e) for e in entries_a])) + + dir_a = tmp_path / "a" + dir_a.mkdir() + dir_b = tmp_path / "b" + dir_b.mkdir() + manifest_a = ExtensionManifest( + _write_manifest(dir_a, _extension_data(hooks={"before_plan": entries_a}), "extension.yml") + ) + manifest_b = ExtensionManifest( + _write_manifest(dir_b, _extension_data(hooks={"before_plan": entries_b}), "extension.yml") + ) + + ids_a = { + (h["command"], h.get("priority")): h["id"] + for h in manifest_a.iter_contributions() + if h["kind"] == "hook" + } + ids_b = { + (h["command"], h.get("priority")): h["id"] + for h in manifest_b.iter_contributions() + if h["kind"] == "hook" + } + assert ids_a == ids_b + + def test_hook_discriminator_helper_is_deterministic(self): + payload = {"priority": 10, "optional": True, "prompt": "Run?"} + a = hook_discriminator(payload) + b = hook_discriminator(dict(reversed(list(payload.items())))) + assert a == b + assert len(a) == 12 + + +# --------------------------------------------------------------------------- +# Manifest component `:` guard +# --------------------------------------------------------------------------- + + +class TestComponentGuard: + def test_validate_component_rejects_colon(self): + with pytest.raises(IdentifierComponentError) as exc_info: + validate_component("has:colon", "test field") + assert "':' is reserved" in str(exc_info.value) + + def test_validate_component_rejects_empty(self): + with pytest.raises(IdentifierComponentError): + validate_component("", "test field") + + def test_validate_component_rejects_non_string(self): + with pytest.raises(IdentifierComponentError): + validate_component(42, "test field") + + def test_extension_hook_event_name_with_colon_rejected(self, tmp_path): + data = _extension_data( + hooks={"before:plan": {"command": "speckit.speckitgit.branch"}} + ) + with pytest.raises(ValidationError) as exc_info: + ExtensionManifest(_write_manifest(tmp_path, data, "extension.yml")) + assert "':' is reserved" in str(exc_info.value) + + def test_extension_hook_command_with_colon_rejected(self, tmp_path): + data = _extension_data( + hooks={"before_plan": {"command": "speckit:bad:command"}} + ) + with pytest.raises(ValidationError) as exc_info: + ExtensionManifest(_write_manifest(tmp_path, data, "extension.yml")) + assert "':' is reserved" in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# `iter_contributions` output surface +# --------------------------------------------------------------------------- + + +class TestContributionSurface: + def test_preset_iter_contributions_matrix(self, tmp_path): + manifest = PresetManifest(_write_manifest(tmp_path, _preset_data(), "preset.yml")) + entries = manifest.iter_contributions() + by_kind = {e["kind"]: e for e in entries} + assert by_kind["command"]["id"] == "preset:speckit-core:command:speckit.plan" + assert by_kind["template"]["id"] == "preset:speckit-core:template:spec-template" + assert by_kind["script"]["id"] == "preset:speckit-core:script:setup-plan" + for entry in entries: + assert entry["layer"] == "preset" + assert entry["sourceId"] == "speckit-core" + + def test_extension_iter_contributions_matrix(self, tmp_path): + data = _extension_data( + hooks={"before_specify": {"command": "speckit.speckitgit.branch"}} + ) + manifest = ExtensionManifest(_write_manifest(tmp_path, data, "extension.yml")) + entries = manifest.iter_contributions() + kinds = {e["kind"]: e for e in entries} + assert kinds["command"]["id"] == "extension:speckit-git:command:speckit.speckitgit.branch" + assert kinds["template"]["id"] == "extension:speckit-git:template:pr-body" + assert kinds["script"]["id"] == "extension:speckit-git:script:post-commit" + assert kinds["hook"]["id"] == "extension:speckit-git:hook:before_specify:speckit.speckitgit.branch" + assert kinds["hook"]["name"] == "before_specify:speckit.speckitgit.branch" + + def test_contribution_id_lookup(self, tmp_path): + manifest = PresetManifest(_write_manifest(tmp_path, _preset_data(), "preset.yml")) + assert ( + manifest.contribution_id("command", "speckit.plan") + == "preset:speckit-core:command:speckit.plan" + ) + assert manifest.contribution_id("command", "does-not-exist") is None + + def test_representation_shape_is_additive_for_preset(self, tmp_path): + original = _preset_data() + manifest = PresetManifest(_write_manifest(tmp_path, original, "preset.yml")) + derived_keys = {"layer", "sourceId", "kind", "id"} + for src_entry, out_entry in zip(original["provides"]["templates"], manifest.iter_contributions()): + assert set(src_entry.keys()).issubset(out_entry.keys()) + assert derived_keys.issubset(out_entry.keys()) + + def test_representation_shape_is_additive_for_extension(self, tmp_path): + original = _extension_data( + hooks={"before_specify": {"command": "speckit.speckitgit.branch"}} + ) + manifest = ExtensionManifest(_write_manifest(tmp_path, original, "extension.yml")) + entries = manifest.iter_contributions() + derived_named = {"layer", "sourceId", "kind", "id"} + + cmd_entry = original["provides"]["commands"][0] + cmd_out = next(e for e in entries if e["kind"] == "command") + assert set(cmd_entry.keys()).issubset(cmd_out.keys()) + assert derived_named.issubset(cmd_out.keys()) + + hook_entry = original["hooks"]["before_specify"] + hook_out = next(e for e in entries if e["kind"] == "hook") + assert set(hook_entry.keys()).issubset(hook_out.keys()) + assert derived_named.issubset(hook_out.keys()) + assert hook_out["name"] == "before_specify:speckit.speckitgit.branch" + + def test_underlying_data_not_mutated(self, tmp_path): + original = _preset_data() + original_snapshot = copy.deepcopy(original) + manifest = PresetManifest(_write_manifest(tmp_path, original, "preset.yml")) + _ = manifest.iter_contributions() + assert manifest.data == original_snapshot + + +# --------------------------------------------------------------------------- +# `lookupId` round-trip through the resolver +# --------------------------------------------------------------------------- + + +def _make_project(root: Path) -> Path: + """Create a minimal project layout the resolver understands.""" + (root / ".specify" / "presets").mkdir(parents=True) + (root / ".specify" / "extensions").mkdir(parents=True) + (root / ".specify" / "memory").mkdir(parents=True) + (root / "templates" / "commands").mkdir(parents=True) + (root / "templates" / "scripts").mkdir(parents=True) + return root + + +class TestLookupIdRoundTrip: + def test_project_override_layer_carries_sentinel_lookup_id(self, tmp_path): + project = _make_project(tmp_path) + overrides_dir = project / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + (overrides_dir / "spec-template.md").write_text("override", encoding="utf-8") + resolver = PresetResolver(project) + layers = resolver.collect_all_layers("spec-template", "template") + override_layer = next( + layer for layer in layers if layer["source"] == "project override" + ) + assert override_layer["lookupId"] == derive_named_id( + PROJECT_OVERRIDE_LAYER, "_", "template", "spec-template" + ) + + def test_core_layer_carries_core_lookup_id(self, tmp_path): + project = _make_project(tmp_path) + (project / "templates" / "spec-template.md").write_text("core", encoding="utf-8") + # PresetResolver reads templates from a bundled/repo path — point the + # resolver at the fixture project by monkey-patching the templates_dir. + resolver = PresetResolver(project) + resolver.templates_dir = project / "templates" + layers = resolver.collect_all_layers("spec-template", "template") + core_layer = next(layer for layer in layers if layer["source"] == "core") + assert core_layer["lookupId"] == derive_named_id( + "core", "_", "template", "spec-template" + ) + + def test_preset_layer_lookup_id_matches_manifest_contribution_id(self, tmp_path): + project = _make_project(tmp_path) + pack_id = "speckit-fixture" + pack_dir = project / ".specify" / "presets" / pack_id + (pack_dir / "templates").mkdir(parents=True) + (pack_dir / "templates" / "spec-template.md").write_text("preset", encoding="utf-8") + _write_manifest( + pack_dir, + { + "schema_version": "1.0", + "preset": { + "id": pack_id, + "name": pack_id, + "version": "1.0.0", + "description": "Fixture", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + } + ] + }, + }, + "preset.yml", + ) + registry = { + "schema_version": "1.0", + "presets": { + pack_id: {"version": "1.0.0", "priority": 10, "enabled": True} + }, + } + (project / ".specify" / "presets" / ".registry").write_text( + json.dumps(registry), encoding="utf-8" + ) + resolver = PresetResolver(project) + layers = resolver.collect_all_layers("spec-template", "template") + preset_layer = next( + layer for layer in layers if layer["source"].startswith(pack_id) + ) + manifest = PresetManifest(pack_dir / "preset.yml") + assert preset_layer["lookupId"] == manifest.contribution_id("template", "spec-template") + assert preset_layer["lookupId"] == f"preset:{pack_id}:template:spec-template" + + +# --------------------------------------------------------------------------- +# Determinism across environments +# --------------------------------------------------------------------------- + + +_SUBPROCESS_SCRIPT = textwrap.dedent( + """ + import sys, json + from specify_cli.extensions import ExtensionManifest + manifest = ExtensionManifest(sys.argv[1]) + ids = [c["id"] for c in manifest.iter_contributions()] + sys.stdout.write(json.dumps(ids)) + """ +) + + +class TestDeterminism: + def _fixture_manifest(self, tmp_path: Path) -> Path: + data = _extension_data( + hooks={ + "before_specify": {"command": "speckit.speckitgit.branch"}, + "before_plan": [ + {"command": "speckit.speckitgit.branch", "priority": 10}, + {"command": "speckit.speckitgit.branch", "priority": 20}, + ], + } + ) + return _write_manifest(tmp_path, data, "extension.yml") + + def test_identifiers_match_across_subprocesses(self, tmp_path): + manifest_path = self._fixture_manifest(tmp_path) + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join( + [str(Path(__file__).resolve().parent.parent / "src"), env.get("PYTHONPATH", "")] + ) + + def _run() -> str: + proc = subprocess.run( + [sys.executable, "-c", _SUBPROCESS_SCRIPT, str(manifest_path)], + capture_output=True, + text=True, + env=env, + check=True, + ) + return proc.stdout + + assert _run() == _run() + + def test_ids_independent_of_paths_and_mtimes(self, tmp_path): + original_dir = tmp_path / "orig" + copied_dir = tmp_path / "copy" + original_dir.mkdir() + manifest_path = self._fixture_manifest(original_dir) + original_ids = [c["id"] for c in ExtensionManifest(manifest_path).iter_contributions()] + + shutil.copytree(original_dir, copied_dir) + distant_past = time.time() - 3600 + os.utime(copied_dir / manifest_path.name, (distant_past, distant_past)) + copied_ids = [ + c["id"] for c in ExtensionManifest(copied_dir / manifest_path.name).iter_contributions() + ] + assert original_ids == copied_ids + + +# --------------------------------------------------------------------------- +# Identifiers never persisted +# --------------------------------------------------------------------------- + + +class TestNoPersistence: + def test_no_id_written_to_manifest_files(self, tmp_path): + data = _extension_data( + hooks={"before_specify": {"command": "speckit.speckitgit.branch"}} + ) + manifest_path = _write_manifest(tmp_path, data, "extension.yml") + # Read identifiers to force the derivation code path. + manifest = ExtensionManifest(manifest_path) + ids = [c["id"] for c in manifest.iter_contributions()] + assert ids # sanity check — feature actually ran + on_disk = manifest_path.read_text(encoding="utf-8") + assert ":command:" not in on_disk + assert ":template:" not in on_disk + assert ":script:" not in on_disk + assert ":hook:" not in on_disk + + def test_no_id_written_to_preset_manifest_files(self, tmp_path): + preset_path = _write_manifest(tmp_path, _preset_data(), "preset.yml") + manifest = PresetManifest(preset_path) + _ = [c["id"] for c in manifest.iter_contributions()] + on_disk = preset_path.read_text(encoding="utf-8") + assert ":command:" not in on_disk + assert ":template:" not in on_disk + assert ":script:" not in on_disk diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 6642da2b09..2307d5be79 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -276,9 +276,10 @@ def test_load_core_command_names_discovers_from_source_checkout(self, monkeypatc The fallback set happens to equal the real command stems today, so an equality check against the live tree cannot tell a working loader apart - from a dead one. Point ``_repo_root`` at a temp tree with *different* - command names: the old off-by-one path math read nothing and returned - the baked-in fallback; the fixed loader returns the temp stems. + from a dead one. Point the shared ``_locate_core_asset_dir`` resolver + at a temp tree with *different* command names: the old off-by-one path + math read nothing and returned the baked-in fallback; the fixed loader + returns the temp stems. """ from specify_cli.extensions import ( _load_core_command_names, @@ -294,34 +295,17 @@ def test_load_core_command_names_discovers_from_source_checkout(self, monkeypatc (commands / "notacommand.txt").write_text("skip me", encoding="utf-8") # No wheel bundle in this scenario; force the source-checkout path. - monkeypatch.setattr(ext, "_locate_core_pack", lambda: None) - monkeypatch.setattr(ext, "_repo_root", lambda: Path(tmp)) + monkeypatch.setattr( + ext, + "_locate_core_asset_dir", + lambda subdir: commands if subdir == "commands" else None, + ) result = _load_core_command_names() assert result == {"widget", "gadget"} assert result != _FALLBACK_CORE_COMMAND_NAMES - def test_load_core_command_names_prefers_wheel_core_pack(self, monkeypatch): - """When a wheel ``core_pack`` bundle exists, discovery reads - ``core_pack/commands`` (the force-include target) ahead of the source - tree (#3274).""" - from specify_cli.extensions import _load_core_command_names - import specify_cli.extensions as ext - - with tempfile.TemporaryDirectory() as tmp: - core_pack = Path(tmp) / "core_pack" - (core_pack / "commands").mkdir(parents=True) - (core_pack / "commands" / "sprocket.md").write_text("# sprocket", encoding="utf-8") - - monkeypatch.setattr(ext, "_locate_core_pack", lambda: core_pack) - # Source fallback should be ignored while the bundle resolves. - monkeypatch.setattr(ext, "_repo_root", lambda: Path(tmp) / "nonexistent") - - result = _load_core_command_names() - - assert result == {"sprocket"} - def test_load_core_command_names_falls_back_when_nothing_found(self, monkeypatch): """With neither a bundle nor a source tree, discovery returns the baked-in fallback so validation still works (#3274).""" @@ -331,11 +315,9 @@ def test_load_core_command_names_falls_back_when_nothing_found(self, monkeypatch ) import specify_cli.extensions as ext - with tempfile.TemporaryDirectory() as tmp: - monkeypatch.setattr(ext, "_locate_core_pack", lambda: None) - monkeypatch.setattr(ext, "_repo_root", lambda: Path(tmp) / "nonexistent") + monkeypatch.setattr(ext, "_locate_core_asset_dir", lambda subdir: None) - assert _load_core_command_names() == _FALLBACK_CORE_COMMAND_NAMES + assert _load_core_command_names() == _FALLBACK_CORE_COMMAND_NAMES def test_missing_required_field(self, temp_dir): """Test manifest missing required field.""" @@ -962,6 +944,30 @@ def test_hook_list_command_refs_normalized(self, temp_dir, valid_manifest_data): lifted = [w for w in manifest.warnings if "updated to canonical form" in w] assert len(lifted) == 2 + def test_duplicate_hook_entries_allowed_after_command_normalization( + self, + temp_dir, + valid_manifest_data, + ): + """Equivalent hook entries are accepted after command refs canonicalize.""" + import yaml + + valid_manifest_data["provides"]["commands"][0]["name"] = "speckit.hello" + valid_manifest_data["hooks"]["after_tasks"] = [ + {"command": "speckit.hello", "optional": True}, + {"command": "speckit.test-ext.hello", "optional": True}, + ] + + 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 [entry["command"] for entry in manifest.hooks["after_tasks"]] == [ + "speckit.test-ext.hello", + "speckit.test-ext.hello", + ] + def test_hook_empty_list_rejected(self, temp_dir, valid_manifest_data): """An empty list for a hook event is rejected rather than silently registering nothing.""" diff --git a/tests/test_presets.py b/tests/test_presets.py index f30ab4909e..92576eb44d 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -12911,6 +12911,89 @@ def test_layers_read_strategy_from_manifest(self, project_dir, temp_dir, valid_p assert layers[1]["strategy"] == "replace" +class TestCoreScriptRuntimeVariants: + """Core scripts resolve through whichever runtime variant is installed.""" + + @staticmethod + def _write_core_script(project_dir, runtime, filename, body): + script_dir = project_dir / ".specify" / "templates" / "scripts" / runtime + script_dir.mkdir(parents=True, exist_ok=True) + path = script_dir / filename + path.write_text(body) + return path + + def test_resolve_finds_powershell_only_core_script(self, project_dir): + """Only the .ps1 variant exists — resolve() must still find it.""" + path = self._write_core_script( + project_dir, "powershell", "ps-only-helper.ps1", "Write-Output 'ps'\n" + ) + + resolver = PresetResolver(project_dir) + assert resolver.resolve("ps-only-helper", "script") == path + + def test_collect_all_layers_finds_powershell_only_core_script(self, project_dir): + """Only the .ps1 variant exists — collect_all_layers() must find it.""" + path = self._write_core_script( + project_dir, "powershell", "ps-only-helper.ps1", "Write-Output 'ps'\n" + ) + + layers = PresetResolver(project_dir).collect_all_layers( + "ps-only-helper", "script" + ) + assert len(layers) == 1 + assert layers[0]["path"] == path + assert layers[0]["source"] == "core" + + def test_resolve_finds_python_only_core_script(self, project_dir): + """Only the underscored .py variant exists — the hyphenated logical + name must still resolve.""" + path = self._write_core_script( + project_dir, "python", "py_only_helper.py", "print('py')\n" + ) + + resolver = PresetResolver(project_dir) + assert resolver.resolve("py-only-helper", "script") == path + + def test_collect_all_layers_finds_python_only_core_script(self, project_dir): + """Only the underscored .py variant exists — collect_all_layers() must + map the hyphenated logical name onto it.""" + path = self._write_core_script( + project_dir, "python", "py_only_helper.py", "print('py')\n" + ) + + layers = PresetResolver(project_dir).collect_all_layers( + "py-only-helper", "script" + ) + assert len(layers) == 1 + assert layers[0]["path"] == path + assert layers[0]["source"] == "core" + + def test_resolve_finds_legacy_flat_core_script(self, project_dir): + """The legacy flat .specify/templates/scripts/.sh layout still + resolves.""" + scripts_dir = project_dir / ".specify" / "templates" / "scripts" + scripts_dir.mkdir(parents=True, exist_ok=True) + path = scripts_dir / "flat-helper.sh" + path.write_text("echo 'flat'\n") + + resolver = PresetResolver(project_dir) + assert resolver.resolve("flat-helper", "script") == path + + def test_collect_all_layers_finds_legacy_flat_core_script(self, project_dir): + """collect_all_layers() also honours the legacy flat layout.""" + scripts_dir = project_dir / ".specify" / "templates" / "scripts" + scripts_dir.mkdir(parents=True, exist_ok=True) + path = scripts_dir / "flat-helper.sh" + path.write_text("echo 'flat'\n") + + layers = PresetResolver(project_dir).collect_all_layers( + "flat-helper", "script" + ) + assert len(layers) == 1 + assert layers[0]["path"] == path + assert layers[0]["source"] == "core" + + class TestRemoveReconciliation: """Test that removing a preset re-registers the next layer's command."""