From 617bcc5412999827b9340dcad4c61f01da853da8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:57:14 +0000 Subject: [PATCH 1/3] Initial plan From 647e02b8d6901800cad79c0024a5fe37188b92d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:04:04 +0000 Subject: [PATCH 2/3] feat: add specify artifact JSON command support Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/__init__.py | 7 + src/specify_cli/_identifier.py | 178 +++++ src/specify_cli/_script_variants.py | 32 + src/specify_cli/artifacts/__init__.py | 884 +++++++++++++++++++++++++ src/specify_cli/artifacts/_commands.py | 156 +++++ src/specify_cli/presets/__init__.py | 86 ++- tests/test_artifact_command.py | 678 +++++++++++++++++++ tests/test_artifact_command_parity.py | 140 ++++ 8 files changed, 2149 insertions(+), 12 deletions(-) create mode 100644 src/specify_cli/_identifier.py create mode 100644 src/specify_cli/_script_variants.py create mode 100644 src/specify_cli/artifacts/__init__.py create mode 100644 src/specify_cli/artifacts/_commands.py create mode 100644 tests/test_artifact_command.py create mode 100644 tests/test_artifact_command_parity.py 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/_identifier.py b/src/specify_cli/_identifier.py new file mode 100644 index 0000000000..4124157df5 --- /dev/null +++ b/src/specify_cli/_identifier.py @@ -0,0 +1,178 @@ +"""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). Two hook entries in the same source whose +declared fields produce byte-identical canonical JSON are rejected at manifest +load time — they are semantically identical listeners. + +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}" + + +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 that the hook discriminator + hashes and that the manifest loader uses to detect byte-identical duplicate + hook entries. + """ + 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..f986299621 --- /dev/null +++ b/src/specify_cli/artifacts/__init__.py @@ -0,0 +1,884 @@ +"""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, Iterable, Literal + +import yaml + +from .._assets import _locate_core_pack, _repo_root +from .._identifier import PROJECT_OVERRIDE_LAYER, derive_named_id +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. + + Prefers the wheel-installed ``core_pack`` bundle, then falls back to the + source-checkout layout. Mirrors the two-tier resolution 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. + """ + core = _locate_core_pack() + if core is not None: + candidate = core / subdir + if candidate.is_dir(): + return candidate + if subdir == "commands": + candidate = _repo_root() / "templates" / "commands" + elif subdir == "templates": + candidate = _repo_root() / "templates" + elif subdir == "scripts": + candidate = _repo_root() / "scripts" + else: # 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() -> 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 + + commands_dir = _core_asset_root("commands") + rows: list[_CoreBaselineRow] = [] + if commands_dir is None: + return rows + for stem in sorted(CORE_COMMAND_NAMES): + path = commands_dir / f"{stem}.md" + if not path.is_file(): + continue + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + text = "" + rows.append( + _CoreBaselineRow( + name=f"speckit.{stem}", + kind="command", + path=path, + description=_extract_frontmatter_description(text), + ) + ) + return rows + + +def _enumerate_core_templates() -> list[_CoreBaselineRow]: + templates_dir = _core_asset_root("templates") + rows: list[_CoreBaselineRow] = [] + if templates_dir is None: + return rows + for entry in sorted(templates_dir.iterdir(), key=lambda p: p.name): + if not entry.is_file() or entry.suffix != _TEMPLATE_SUFFIX: + continue + 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() -> list[_CoreBaselineRow]: + scripts_dir = _core_asset_root("scripts") + rows: list[_CoreBaselineRow] = [] + if scripts_dir is None: + return rows + seen: dict[str, _CoreBaselineRow] = {} + for runtime_dir in sorted(scripts_dir.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 None: + continue + if name in seen: + continue + try: + text = entry.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + text = "" + seen[name] = _CoreBaselineRow( + name=name, + kind="script", + path=entry, + description=_extract_script_description(text), + ) + rows.extend(sorted(seen.values(), key=lambda r: r.name)) + return rows + + +@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) -> "CoreBaseline": + return cls( + commands=tuple(_enumerate_core_commands()), + templates=tuple(_enumerate_core_templates()), + scripts=tuple(_enumerate_core_scripts()), + ) + + 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()``. + Core layers return ``None`` — they have no on-disk manifest that ships + with the project. Non-core layers walk upward from the contribution file + until they find the preset's ``preset.yml`` or the extension's + ``extension.yml``, then relativize against ``project_root``. + + 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. If the enclosing manifest is found outside + ``project_root`` (e.g. an unbounded parent walk from a convention-only + layer escapes the project), ``None`` is returned rather than an + absolute host path, preserving the repo-relative contract. + """ + lookup_id = layer.get("lookupId", "") + if lookup_id.startswith("core:"): + return None + source = layer.get("path") + if not isinstance(source, Path): + return None + manifest = _find_enclosing_manifest(source) + if manifest is None: + return None + try: + rel = manifest.relative_to(project_root) + except ValueError: + return None + return rel.as_posix() + + +def _find_enclosing_manifest(path: Path) -> Path | None: + """Walk parents of ``path`` looking for preset.yml or extension.yml.""" + for parent in path.parents: + for name in ("preset.yml", "extension.yml"): + candidate = parent / name + if candidate.is_file(): + return candidate + return None + + +def _preset_display_name(pack_dir: Path, pack_id: str) -> str: + """Return the preset's human-friendly name from ``preset.yml``. + + Falls back to the pack id when the manifest is missing or lacks a + ``metadata.name`` value. + """ + manifest_path = pack_dir / "preset.yml" + if not manifest_path.is_file(): + return pack_id + try: + data = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, yaml.YAMLError): + return pack_id + if not isinstance(data, dict): + return pack_id + preset = data.get("preset") + if isinstance(preset, dict): + display = preset.get("name") + if isinstance(display, str) and display: + return display + 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: prefer lookupId prefix (authoritative) with a + # source-string fallback for defensive parsing. + if lookup_id.startswith("core:") or 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 lookup_id.startswith("project:") or 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 lookup_id.startswith("extension:") or 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 _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 + + +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. + """ + _validate_project(self.project_root) + baseline = self._get_baseline() + + seen: dict[tuple[ArtifactKind, str], Artifact] = {} + + for row in (*baseline.commands, *baseline.templates, *baseline.scripts): + key = (row.kind, row.name) + if key not in seen: + seen[key] = Artifact( + id=f"{row.kind}:{row.name}", + name=row.name, + kind=row.kind, + description=row.description, + ) + + for kind, name, description in self._iter_contribution_artifacts(): + key = (kind, name) + if key not in seen: + seen[key] = Artifact( + id=f"{kind}:{name}", + name=name, + kind=kind, + description=description, + ) + elif description and not seen[key].description: + seen[key] = Artifact( + id=seen[key].id, + name=seen[key].name, + kind=seen[key].kind, + description=description, + ) + + kind_order = {"command": 0, "template": 1, "script": 2} + return sorted(seen.values(), 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) + 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] + + stack = _build_stack(self.project_root, resolved_kind, bare) + if not stack: + raise ArtifactNotFoundError(name) + + description = self._describe(resolved_kind, bare) + return { + "id": f"{resolved_kind}:{bare}", + "name": bare, + "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() + 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, + ) -> Iterable[tuple[ArtifactKind, str, str]]: + """Yield ``(kind, name, description)`` for resolver-visible contributions. + + Covers the two ways a pack can contribute an artifact: + + * manifest-declared entries (``preset.yml`` / ``extension.yml``), and + * convention-placed extension files (``commands/``, ``templates/``, + ``scripts/``) that the resolver picks up even without a manifest. + + 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 ..presets import PresetResolver # lazy: avoids circular import + + specify_dir = self.project_root / ".specify" + resolver = PresetResolver(self.project_root) + layers_by_artifact: dict[tuple[ArtifactKind, str], set[str]] = {} + + def _lookup_ids(kind: ArtifactKind, name: str) -> set[str]: + key = (kind, name) + if key not in layers_by_artifact: + layers_by_artifact[key] = { + candidate["lookupId"] + for candidate in resolver.collect_all_layers(name, kind) + } + return layers_by_artifact[key] + + for tier in ("presets", "extensions"): + tier_dir = specify_dir / tier + if not tier_dir.is_dir(): + continue + for pack_dir in sorted(tier_dir.iterdir(), key=lambda p: p.name): + if not pack_dir.is_dir(): + continue + manifest_name = "preset.yml" if tier == "presets" else "extension.yml" + manifest = pack_dir / manifest_name + layer = "preset" if tier == "presets" else "extension" + data: Any = None + if manifest.is_file(): + try: + data = yaml.safe_load(manifest.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, yaml.YAMLError): + data = None + if isinstance(data, dict): + for kind, name, description in _iter_manifest_contributions( + data, is_preset=tier == "presets" + ): + lookup_id = derive_named_id(layer, pack_dir.name, kind, name) + if lookup_id in _lookup_ids(kind, name): + yield kind, name, description + # 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, pack_dir.name, kind, name) + if lookup_id in _lookup_ids(kind, name): + yield kind, name, "" + + yield from self._iter_project_override_artifacts(resolver) + + def _iter_project_override_artifacts( + self, + resolver: Any, + ) -> Iterable[tuple[ArtifactKind, str, str]]: + """Yield ``(kind, name, "")`` for project-local override files. + + 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. + """ + 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 + 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 + ) + yield ("command" if backed_by_command else "template"), name, "" + scripts_dir = overrides_dir / "scripts" + if not scripts_dir.is_dir(): + return + for entry in sorted(scripts_dir.iterdir(), key=lambda p: p.name): + if entry.is_file() and entry.suffix == _SCRIPT_SUFFIX: + yield "script", entry.stem, "" + + +_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 + + +def _iter_manifest_contributions( + data: dict[str, Any], + *, + is_preset: bool = False, +) -> Iterable[tuple[ArtifactKind, str, str]]: + """Yield ``(kind, name, description)`` entries declared by a manifest. + + Extension manifests group entries by artifact kind: + + .. code-block:: yaml + + provides: + commands: [ {name: "...", description: "..."} , ... ] + templates: [ ... ] + scripts: [ ... ] + + Preset manifests instead place every contribution under ``templates`` and + identify its artifact kind with each entry's ``type`` field. + + Anything malformed at the entry level is skipped rather than raised — + the artifact command is a projection, not a validator. + """ + provides = data.get("provides") + if not isinstance(provides, dict): + return + if is_preset: + entries = provides.get("templates") + if not isinstance(entries, list): + return + for entry in entries: + if not isinstance(entry, dict): + continue + kind_value = entry.get("type") + name = entry.get("name") + if kind_value not in ("command", "template", "script"): + continue + if not isinstance(name, str) or not name or ":" in name: + continue + description = entry.get("description", "") + if not isinstance(description, str): + description = "" + yield kind_value, name, description + return + for kind_key, kind_value in ( + ("commands", "command"), + ("templates", "template"), + ("scripts", "script"), + ): + entries = provides.get(kind_key) + if not isinstance(entries, list): + continue + for entry in entries: + if isinstance(entry, str): + yield kind_value, entry, "" # type: ignore[misc] + continue + if not isinstance(entry, dict): + continue + name = entry.get("name") + if not isinstance(name, str) or not name or ":" in name: + continue + description = entry.get("description", "") + if not isinstance(description, str): + description = "" + yield kind_value, name, description # type: ignore[misc] + + +__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..0f30655c03 --- /dev/null +++ b/src/specify_cli/artifacts/_commands.py @@ -0,0 +1,156 @@ +"""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 ( + AmbiguousArtifactError, + ArtifactCatalog, + ArtifactError, + ArtifactKind, + ArtifactNotFoundError, + 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 (ArtifactNotFoundError, AmbiguousArtifactError, NotASpecKitProjectError) 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/presets/__init__.py b/src/specify_cli/presets/__init__.py index a5cea4f958..cecde61ee8 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -37,12 +37,17 @@ safe_extract_archive, ) from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority +from .._identifier import ( + PROJECT_OVERRIDE_LAYER, + derive_named_id, +) from .._init_options import ( MISSING_INIT_OPTIONS_FILE, is_ai_skills_enabled, load_init_options, resolve_active_agent_for_registration, ) +from .._script_variants import script_variant_paths from .._invocation_style import get_invocation_prefix from ..integrations.base import IntegrationBase from .._utils import dump_frontmatter, version_satisfies @@ -539,6 +544,30 @@ 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.""" + 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() @@ -5308,8 +5337,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 +5361,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 +5381,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 +5565,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 +5624,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 +5655,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 +5678,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 +5702,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 @@ -5683,10 +5739,13 @@ def _find_bundled_core( elif template_type == "command": c = core_pack / "commands" / f"{name}.md" elif template_type == "script": - c = core_pack / "scripts" / f"{name}{ext}" + c = next( + (path for path in script_variant_paths(core_pack / "scripts", name) if path.exists()), + None, + ) else: c = core_pack / f"{name}.md" - if c.exists(): + if c is not None and c.exists(): return c else: repo_root = _repo_root() @@ -5696,10 +5755,13 @@ def _find_bundled_core( elif template_type == "command": c = repo_root / "templates" / "commands" / f"{name}.md" elif template_type == "script": - c = repo_root / "scripts" / f"{name}{ext}" + c = next( + (path for path in script_variant_paths(repo_root / "scripts", name) if path.exists()), + None, + ) else: c = repo_root / f"{name}.md" - if c.exists(): + if c is not None and c.exists(): return c return None diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py new file mode 100644 index 0000000000..ad76eab2bf --- /dev/null +++ b/tests/test_artifact_command.py @@ -0,0 +1,678 @@ +"""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 re +from pathlib import Path + +import pytest +import yaml + +from specify_cli import app +from specify_cli.artifacts import ( + AmbiguousArtifactError, + Artifact, + ArtifactCatalog, + ArtifactNotFoundError, + ArtifactResolutionError, + NotASpecKitProjectError, +) + + +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 + + +def _install_preset(project_root: Path, pack_id: str, provides: dict, priority: int = 10) -> Path: + """Drop a minimal preset onto disk and register it in the ``.registry`` file.""" + pack_dir = project_root / ".specify" / "presets" / pack_id + pack_dir.mkdir(parents=True) + manifest = { + "id": pack_id, + "version": "1.0.0", + "metadata": {"name": f"Test preset {pack_id}"}, + "provides": provides, + } + (pack_dir / "preset.yml").write_text(yaml.safe_dump(manifest), encoding="utf-8") + registry_path = project_root / ".specify" / "presets" / ".registry" + if registry_path.is_file(): + registry = json.loads(registry_path.read_text(encoding="utf-8")) + else: + registry = {"schema_version": "1.0.0", "presets": {}} + registry["presets"][pack_id] = {"priority": priority, "version": "1.0.0"} + registry_path.write_text(json.dumps(registry), encoding="utf-8") + return pack_dir + + +# --------------------------------------------------------------------------- +# 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 + ): + from specify_cli.extensions import ExtensionRegistry + + 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 + + +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" + + +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" + ) + + +# --------------------------------------------------------------------------- +# 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): + from typer.testing import CliRunner + + 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): + from typer.testing import CliRunner + + 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): + from typer.testing import CliRunner + + 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): + from typer.testing import CliRunner + + 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): + from typer.testing import CliRunner + + 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 + ): + from typer.testing import CliRunner + + 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_not_a_project_error_envelope(self, non_project: Path, monkeypatch: pytest.MonkeyPatch): + from typer.testing import CliRunner + + 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): + from typer.testing import CliRunner + + 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, + ): + from typer.testing import CliRunner + + 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): + from typer.testing import CliRunner + + 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_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_unregistered_preset_template_without_manifest(self, spec_kit_project: Path): + pack_dir = _install_preset(spec_kit_project, "legacy-preset", provides={"templates": []}) + 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_enclosing_manifest_outside_project_root_is_none(self, tmp_path: Path): + from specify_cli.artifacts import _derive_manifest_path + + project_root = tmp_path / "proj" + project_root.mkdir() + + outside = tmp_path / "outside-pack" + (outside / "templates").mkdir(parents=True) + (outside / "preset.yml").write_text("id: outside-pack\n", encoding="utf-8") + source = outside / "templates" / "legacy-template.md" + source.write_text("body", encoding="utf-8") + + layer = {"lookupId": "preset:outside-pack:template:legacy-template", "path": source} + assert _derive_manifest_path(layer, project_root) is None + + +# --------------------------------------------------------------------------- +# Existing module-import placeholder retained for import safety. +# --------------------------------------------------------------------------- + + +def test_module_imports(): + from specify_cli.artifacts import ArtifactCatalog # noqa: F401 diff --git a/tests/test_artifact_command_parity.py b/tests/test_artifact_command_parity.py new file mode 100644 index 0000000000..5aa1435f3d --- /dev/null +++ b/tests/test_artifact_command_parity.py @@ -0,0 +1,140 @@ +"""Cross-OS and resolver-parity tests for the `specify artifact` command group. + +Focuses on invariants that either directly guard against OS-specific +regressions (POSIX-vs-Windows path separators, UTF-8 encoding) or verify +that the artifact output stays consistent with the underlying +:class:`~specify_cli.presets.PresetResolver`. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml + +from specify_cli.artifacts import ArtifactCatalog + + +def _install_preset(project_root: Path, pack_id: str, provides: dict, priority: int = 10) -> Path: + pack_dir = project_root / ".specify" / "presets" / pack_id + pack_dir.mkdir(parents=True) + manifest = { + "id": pack_id, + "version": "1.0.0", + "metadata": {"name": f"Test preset {pack_id}"}, + "provides": provides, + } + (pack_dir / "preset.yml").write_text(yaml.safe_dump(manifest), encoding="utf-8") + registry_path = project_root / ".specify" / "presets" / ".registry" + if registry_path.is_file(): + registry = json.loads(registry_path.read_text(encoding="utf-8")) + else: + registry = {"schema_version": "1.0.0", "presets": {}} + registry["presets"][pack_id] = {"priority": priority, "version": "1.0.0"} + registry_path.write_text(json.dumps(registry), encoding="utf-8") + return pack_dir + + +@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 TestManifestPathIsPosix: + """The ``manifestPath`` field MUST use forward slashes on every OS.""" + + def test_no_backslashes(self, spec_kit_project: Path): + pack = _install_preset( + spec_kit_project, + "test-posix", + {"commands": [{"name": "speckit.constitution", "description": "d"}]}, + ) + (pack / "commands").mkdir() + (pack / "commands" / "speckit.constitution.md").write_text( + "---\ndescription: d\n---\nbody", encoding="utf-8" + ) + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + for layer in info["stack"]: + path = layer["manifestPath"] + if path is None: + continue + assert "\\" not in path, f"backslash leak: {path!r}" + + def test_never_absolute(self, spec_kit_project: Path): + pack = _install_preset( + spec_kit_project, + "test-rel", + {"commands": [{"name": "speckit.constitution", "description": "d"}]}, + ) + (pack / "commands").mkdir() + (pack / "commands" / "speckit.constitution.md").write_text( + "---\ndescription: d\n---\nbody", encoding="utf-8" + ) + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + for layer in info["stack"]: + path = layer["manifestPath"] + if path is None: + continue + assert not path.startswith("/"), f"leading slash: {path!r}" + # Windows drive letter check. + assert not (len(path) >= 2 and path[1] == ":"), f"drive letter: {path!r}" + + +class TestResolverParity: + """The ``active: true`` row must be what :meth:`resolve_content` would pick.""" + + def test_active_layer_matches_resolver(self, spec_kit_project: Path): + from specify_cli.presets import PresetResolver + + pack = _install_preset( + spec_kit_project, + "test-parity", + {"commands": [{"name": "speckit.constitution", "description": "override"}]}, + ) + (pack / "commands").mkdir() + (pack / "commands" / "speckit.constitution.md").write_text( + "---\ndescription: override\n---\nbody-from-preset", encoding="utf-8" + ) + + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + active = next(layer for layer in info["stack"] if layer["active"]) + + resolver = PresetResolver(spec_kit_project) + winner = resolver.resolve_content("speckit.constitution", template_type="command") + assert winner is not None + # The active row's layer classification must correspond to a real + # winning layer — if a preset override was installed and picked up + # by the resolver, active.layer must not be "core". + assert "body-from-preset" in winner + assert active["layer"] == "preset" + + +class TestJSONShape: + """Reasserts JSON-envelope invariants at the whole-payload level.""" + + def test_no_trailing_whitespace(self, spec_kit_project: Path): + catalog = ArtifactCatalog(spec_kit_project) + rows = [a.to_json_dict() for a in catalog.list_artifacts()] + payload = json.dumps(rows, indent=2, sort_keys=True) + "\n" + for line in payload.splitlines(): + assert line == line.rstrip(), f"trailing ws: {line!r}" + + def test_terminated_by_single_newline(self, spec_kit_project: Path): + catalog = ArtifactCatalog(spec_kit_project) + rows = [a.to_json_dict() for a in catalog.list_artifacts()] + payload = json.dumps(rows, indent=2, sort_keys=True) + "\n" + assert payload.endswith("\n") + assert not payload.endswith("\n\n") + + +def test_module_imports(): + _ = ArtifactCatalog + From 2c976ba184349df30ad0734bd1f50e66243f22e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:09:54 +0000 Subject: [PATCH 3/3] fix: normalize script variant discovery in artifact inventory Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 89 ++++++++++++++++++++------- 1 file changed, 68 insertions(+), 21 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index f986299621..f48864f2ae 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -397,6 +397,11 @@ def _preset_display_name(pack_dir: Path, pack_id: str) -> str: return pack_id if not isinstance(data, dict): return pack_id + metadata = data.get("metadata") + if isinstance(metadata, dict): + display = metadata.get("name") + if isinstance(display, str) and display: + return display preset = data.get("preset") if isinstance(preset, dict): display = preset.get("name") @@ -773,8 +778,16 @@ def _iter_project_override_artifacts( 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: - yield "script", entry.stem, "" + if entry.is_file() and entry.suffix in (".sh", ".ps1", ".py"): + name = canonical_script_name(entry) + if name is None: + name = ( + entry.stem.replace("_", "-") + if entry.suffix == ".py" + else entry.stem + ) + if ":" not in name: + yield "script", name, "" _CONVENTION_SUBDIRS: tuple[tuple[str, ArtifactKind, str], ...] = ( @@ -796,8 +809,19 @@ def _iter_convention_contributions(pack_dir: Path) -> Iterable[tuple[ArtifactKin 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 + if not entry.is_file(): + continue + if kind != "script": + if entry.suffix == suffix and ":" not in entry.stem: + yield kind, entry.stem + continue + if entry.suffix not in (".sh", ".ps1", ".py"): + continue + name = canonical_script_name(entry) + if name is None: + name = entry.stem.replace("_", "-") if entry.suffix == ".py" else entry.stem + if ":" not in name: + yield kind, name def _iter_manifest_contributions( @@ -826,22 +850,47 @@ def _iter_manifest_contributions( if not isinstance(provides, dict): return if is_preset: + def _iter_kind_entries( + entries: Any, + kind: ArtifactKind, + ) -> Iterable[tuple[ArtifactKind, str, str]]: + if not isinstance(entries, list): + return + for entry in entries: + if isinstance(entry, str): + if entry and ":" not in entry: + yield kind, entry, "" + continue + if not isinstance(entry, dict): + continue + name = entry.get("name") + if not isinstance(name, str) or not name or ":" in name: + continue + description = entry.get("description", "") + if not isinstance(description, str): + description = "" + yield kind, name, description + entries = provides.get("templates") - if not isinstance(entries, list): - return - for entry in entries: - if not isinstance(entry, dict): - continue - kind_value = entry.get("type") - name = entry.get("name") - if kind_value not in ("command", "template", "script"): - continue - if not isinstance(name, str) or not name or ":" in name: - continue - description = entry.get("description", "") - if not isinstance(description, str): - description = "" - yield kind_value, name, description + if isinstance(entries, list): + for entry in entries: + if isinstance(entry, dict): + kind_value = entry.get("type") + if kind_value in ("command", "template", "script"): + name = entry.get("name") + if not isinstance(name, str) or not name or ":" in name: + continue + description = entry.get("description", "") + if not isinstance(description, str): + description = "" + yield kind_value, name, description + continue + for row in _iter_kind_entries([entry], "template"): + yield row + for row in _iter_kind_entries(provides.get("commands"), "command"): + yield row + for row in _iter_kind_entries(provides.get("scripts"), "script"): + yield row return for kind_key, kind_value in ( ("commands", "command"), @@ -880,5 +929,3 @@ def _iter_manifest_contributions( "StackLayer", "Strategy", ] - -_ = derive_named_id # keep the import edge visible for tooling