diff --git a/CHANGELOG.md b/CHANGELOG.md index ca3dde8da5..86ed306b9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## Unreleased + +### Added + +- feat(artifacts): include `sourcePath` on artifact stack layers for installed preset and extension contributions. + ## [1.0.1] - 2026-08-21 ### Changed diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md new file mode 100644 index 0000000000..6d32997c5a --- /dev/null +++ b/docs/reference/artifacts.md @@ -0,0 +1,167 @@ +# Artifacts + +An **artifact** is any command, template, or script Spec Kit exposes in a project, regardless of which layer contributes it — built-in assets, an installed preset, an installed extension, or a project-local override in `.specify/templates/overrides/`. + +The `specify artifact` command group is the read-only introspection surface for that inventory. `specify preset resolve ` answers "which file wins for this preset-managed name?"; `specify artifact` answers "what exists at all, and what is the full composition stack behind it?" — including built-in artifacts that no preset touches. + +Both subcommands currently require `--json`. Omitting it exits with code `2` and prints a usage message on stderr; no stdout is produced. Text rendering is deliberately deferred so the JSON shapes below are the only contract, and adding a default text renderer later stays a non-breaking, additive change. + +## List Artifacts + +```bash +specify artifact list --json +``` + +| Option | Description | +| -------- | -------------------------------------------------------- | +| `--json` | Required. Emit the inventory as a JSON array on stdout. | + +Prints the full inventory of every visible artifact — one row per `(kind, name)` pair, including its composition `stack` — sorted by kind (`command`, then `template`, then `script`) and then by name. + +```json +[ + { + "id": "command:speckit.specify", + "name": "speckit.specify", + "kind": "command", + "description": "Create or update the feature specification.", + "stack": [ + { + "id": "command:speckit.specify", + "layer": null, + "sourceId": null, + "presetId": null, + "presetName": null, + "strategy": "replace", + "active": true, + "hidden": false, + "manifestPath": null, + "lookupId": null, + "sourcePath": null + } + ] + }, + { + "id": "script:create-new-feature", + "name": "create-new-feature", + "kind": "script", + "description": "Create a new feature branch and spec directory.", + "stack": [ + { + "id": "script:create-new-feature", + "layer": null, + "sourceId": null, + "presetId": null, + "presetName": null, + "strategy": "replace", + "active": true, + "hidden": false, + "manifestPath": null, + "lookupId": null, + "sourcePath": null + } + ] + } +] +``` + +| Field | Description | +| ------------- | ------------------------------------------------------------------------- | +| `id` | `{kind}:{name}` — the shorthand `artifact info` accepts as its argument | +| `name` | Logical artifact name (commands use the `speckit.` namespace) | +| `kind` | One of `command`, `template`, `script` | +| `description` | Description from the highest-precedence layer that declares one, else `""` | +| `stack` | Composition stack for this artifact, using the same row shape as `artifact info` | + +Built-in artifacts always appear, even when nothing overrides them. Descriptions come from the highest-priority layer that has one — a preset or project override that hides a built-in command reports its own description, not the hidden built-in text. Skills (`.github/skills/**/SKILL.md`) are excluded: they are integration-specific output, not a shipped asset family. + +## Artifact Info + +```bash +specify artifact info --json +``` + +| Option | Description | +| ---------------- | ------------------------------------------------------------------- | +| `--json` | Required. Emit the composition stack as a JSON object on stdout. | +| `--kind ` | Narrow the lookup to `command`, `template`, or `script` | + +`` accepts either a bare name (`speckit.specify`) or the `kind:name` shorthand (`command:speckit.specify`). When both the shorthand and `--kind` are supplied they must agree. + +```json +{ + "id": "command:speckit.specify", + "name": "speckit.specify", + "kind": "command", + "description": "Create or update the feature specification.", + "stack": [ + { + "id": "command:speckit.specify", + "layer": "preset", + "sourceId": "compliance", + "presetId": "compliance", + "presetName": "Compliance Preset", + "strategy": "replace", + "active": true, + "hidden": false, + "manifestPath": ".specify/presets/compliance/preset.yml", + "lookupId": "preset:compliance:command:speckit.specify", + "sourcePath": ".github/skills/speckit-specify/SKILL.md" + }, + { + "id": "command:speckit.specify", + "layer": null, + "sourceId": null, + "presetId": null, + "presetName": null, + "strategy": "replace", + "active": false, + "hidden": true, + "manifestPath": null, + "lookupId": null, + "sourcePath": null + } + ] +} +``` + +The top-level `id`, `name`, `kind`, `description`, and `stack` fields match the corresponding row on `artifact list --json`. + +### Stack semantics + +`stack` is ordered by resolution precedence: index `0` is the layer that wins. Each row describes one contributing layer: + +| Field | Description | +| -------------- | -------------------------------------------------------------------------------- | +| `id` | `{kind}:{name}` — the source-agnostic round-trip key, identical on every row of the same artifact's stack | +| `layer` | `project`, `preset`, or `extension`; `null` for built-in layers | +| `sourceId` | Source component of `lookupId`, or `null` when the layer has no provenance | +| `presetId` | Preset pack directory id; `null` on built-in, `project`, and `extension` rows | +| `presetName` | Preset display name when its manifest declares one, else the pack id; `null` when `presetId` is `null` | +| `strategy` | `replace`, `wrap`, `prepend`, or `append` | +| `active` | `true` only for index `0` — the layer whose content is served | +| `hidden` | `true` when a lower-index `replace` layer cuts this layer out of the composition | +| `manifestPath` | Project-relative path to the declaring manifest, or `null` when none applies | +| `lookupId` | Deterministic `{layer}:{sourceId}:{kind}:{name}` identifier, or `null` for built-in layers | +| `sourcePath` | Project-relative POSIX path to the concrete file backing the layer, or `null` for built-in/synthetic layers | + +`active` and `hidden` are independent labels, not opposites. Composing strategies (`wrap`, `prepend`, `append`) keep lower layers in the composed output, so an inactive layer is not necessarily hidden: only layers below the first `replace` layer are marked `hidden`. Built-in rows have no provenance: `layer`, `sourceId`, and `lookupId` are `null` — but `id` is always populated, even on built-in rows. `id` is the round-trip key: `specify artifact info` accepts it as input (for example, `specify artifact info command:speckit.specify --json`), and it resolves the same artifact whether the caller passes the bare name or the `id`. + +Lookup IDs use the same grammar as [preset contribution identifiers](presets.md#contribution-identifiers), so a `lookupId` from this command joins directly to `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` for manifest-declared layers. Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID that intentionally matches no manifest contribution. `lookupId` is manifest-backed layer provenance, not the round-trip key — use `id` for that. `sourcePath` is populated only when the layer maps to a concrete installed preset/extension file or a tracked agent materialization; core, project-override, and other synthetic rows report `null`. + +## JSON Errors + +On failure, nothing is written to stdout. A single-key JSON envelope is written to stderr and the process exits with code `1`: + +```json +{ "error": "unknown artifact command:nope" } +``` + +| Message | Cause | +| --------------------------------------------------- | ---------------------------------------------------------------- | +| `not a Spec Kit project: no .specify/ directory found` | Run outside an initialized project | +| `unknown artifact ` | No artifact matches the requested name (and kind, when given) | +| `ambiguous artifact : matches kinds [...]` | The bare name matches more than one kind — re-run with `--kind` | +| `artifact resolution failed` | The preset/extension registries could not be read, or artifact content could not be composed | + +Exit code `2` is reserved for usage errors — a missing `--json` flag or an invalid `--kind` value — and emits a plain-text message on stderr rather than a JSON envelope. diff --git a/docs/reference/overview.md b/docs/reference/overview.md index 183ce84756..077eeb1d31 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -26,6 +26,12 @@ Presets customize how Spec Kit works — overriding command files, template file [Presets reference →](presets.md) +## Artifacts + +Artifacts are the commands, templates, and scripts a project exposes, whichever layer contributes them. The `specify artifact` command group is the read-only introspection surface over that inventory — a flat list of everything visible, plus the full composition stack behind any single entry, including which layer wins and which layers are hidden. + +[Artifacts reference →](artifacts.md) + ## Workflows Workflows automate multi-step Spec-Driven Development processes into repeatable sequences. They chain commands, prompts, shell steps, and human checkpoints together, with support for conditional logic, loops, fan-out/fan-in, and the ability to pause and resume from the exact point of interruption. diff --git a/docs/reference/presets.md b/docs/reference/presets.md index 1098abfb42..9889c92dbd 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -205,6 +205,27 @@ 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 extension is addressable at read time by a deterministic opaque identifier of the form: + +```text +{layer}:{sourceId}:{kind}:{name} +``` + +- `layer` is one of `preset` or `extension`. +- `sourceId` is 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 include a `lookupId` field for preset, extension, and project-override layers. For manifest-declared preset and extension layers, the `lookupId`'s `sourceId` component is the manifest's validated `id:` field, so it joins directly to the `id` used by `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` even when the installed directory was renamed. That join is guaranteed by the implementation, so consumers can key off `lookupId` directly rather than re-deriving the contribution id. Convention-only layers (undeclared in any manifest) have no manifest id to consult, so their `lookupId`'s `sourceId` falls back to the resolver's registry key or on-disk directory name instead; those layers have no manifest contribution to join to. Built-in fallback layers omit `lookupId`. Use `layer_kind_from_lookup_id` to classify lookup IDs rather than parsing the string yourself. 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. + +The `id` field (shape `kind:name`) is the stable round-trip key for every artifact and is accepted as input by `specify artifact info`. The `lookupId` field carries manifest-backed layer provenance and is present only for artifacts contributed by presets, extensions, or project overrides. Built-in-tier artifacts have no `lookupId`; use `id` to round-trip them. For example, given a stack row for a built-in artifact with only `id` populated, the round-trip is `specify artifact info command:speckit.plan --json`, which resolves the same artifact as `specify artifact info speckit.plan --json`. + +For the full grammar, including the hook name-component convention and last-write-wins deduplication 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/docs/toc.yml b/docs/toc.yml index d2f1b2bd21..c0a4264547 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -41,6 +41,8 @@ href: reference/extensions.md - name: Presets href: reference/presets.md + - name: Artifacts + href: reference/artifacts.md - name: Workflows href: reference/workflows.md - name: Bundles diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index a7bece0b89..60ba428d5b 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -10,6 +10,8 @@ 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) +8. [File System Layout](#file-system-layout) --- @@ -859,6 +861,67 @@ satisfied = version_satisfies("1.2.3", ">=1.0.0,<2.0.0") # bool --- +## Contribution Identifiers + +Every command, template, script, and hook contributed by an extension or preset is addressable at read time by a deterministic opaque identifier. Resolved artifact-stack layers carry a matching `lookupId` field when they have provenance. Manifest-declared preset and extension layers use the manifest's validated `id:` for `lookupId`'s `sourceId` component, so their `lookupId` joins directly to the matching `iter_contributions()` entry even after the installed directory is renamed; convention-only contributions have no manifest `id:` to consult and fall back to the on-disk directory / registry key instead (see [Determinism guarantees](#determinism-guarantees) below). 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 `preset` or `extension`. +- `sourceId` is the preset pack id for `preset`, or the extension id for `extension`. +- `kind` is one of `command`, `template`, or `script`. +- `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} +``` + +Within a single event list, repeated `command` values collapse last-write-wins and +move to the end, so each surviving `(eventName, command)` pair has the same +identifier form above with no suffix. + +### 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. + +### Project-local overrides + +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 project overrides, preset contributions, and extension contributions. Manifest-declared preset and extension layers use the manifest's validated `id:` as the `lookupId` source id, so it matches the id `iter_contributions()` yields for that same contribution. Convention-only layers (no manifest entry declares the contribution) have no manifest id to consult, so their `lookupId` falls back to the resolver's registry key or on-disk directory name. Built-in fallback layers omit `lookupId`. + +### Round-trip via the public `id` + +The `id` field (shape `kind:name`) is the stable round-trip key for every artifact and is accepted as input by `specify artifact info`. The `lookupId` field carries manifest-backed layer provenance and is present only for artifacts contributed by presets, extensions, or project overrides. Built-in-tier artifacts have no `lookupId`; use `id` to round-trip them. + +For example, given a `specify artifact list --json` / `specify artifact info` stack row for a built-in artifact — which has only `id` populated (`layer`, `sourceId`, and `lookupId` are `null`) — the round-trip is: + +```bash +specify artifact info command:speckit.plan --json +``` + +This resolves the same artifact as `specify artifact info speckit.plan --json`, because `id` (not `lookupId`) is the source-agnostic identifier every artifact carries. + +### 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. Manifest-declared resolver `lookupId` values share this stability — renaming the installed directory of a preset or extension that declares an `id:` does not change its `lookupId`. Only convention-only contributions (undeclared in any manifest) derive their `lookupId` from the on-disk directory name or registry key, so renaming that directory does change their `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 `:` — hook ids contain a compound `{eventName}:{command}` component 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. + ## File System Layout ```text 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..f6f20469f1 100644 --- a/src/specify_cli/_assets.py +++ b/src/specify_cli/_assets.py @@ -32,6 +32,28 @@ def _repo_root() -> Path: return Path(__file__).parent.parent.parent +def _locate_shared_asset_dir(subdir: str) -> Path | None: + """Return an asset directory from the wheel bundle or source checkout. + + ``subdir`` is ``"commands"``, ``"templates"``, or ``"scripts"``. + Checks ``core_pack//`` first. In a source checkout, commands live + under ``templates/commands/`` and the other asset families use ``/``. + """ + package_dir = Path(__file__).resolve().parent + source_dir = ( + _repo_root() / "templates" / "commands" + if subdir == "commands" + else _repo_root() / subdir + ) + for candidate in [ + package_dir / "core_pack" / subdir, + source_dir, + ]: + if candidate.is_dir(): + return candidate + return 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..21e87b3bfc --- /dev/null +++ b/src/specify_cli/_identifier.py @@ -0,0 +1,191 @@ +"""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 provenance-backed +layers of a resolved artifact stack carry 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 provenance-backed named contributions (commands, templates, scripts):: + + id = "{layer}:{sourceId}:{kind}:{name}" + + layer ∈ {"project", "preset", "extension"} + sourceId = "_" when layer == "project"; the preset or extension id otherwise + kind ∈ {"command", "template", "script"} + name = the contribution's declared ``name`` + +Hook identifiers use ``{eventName}:{command}`` as the name component:: + + id = "{layer}:{sourceId}:hook:{eventName}:{command}" + +Built-in artifacts have no public layer or lookup identifier. Their public +identifier is source-agnostic: ``"{kind}:{name}"``. + +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 + +from typing import Any + + +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. +""" + +_LAYER_KINDS = frozenset({PROJECT_OVERRIDE_LAYER, "preset", "extension"}) +_CONTRIBUTION_KINDS = frozenset({"command", "template", "script", "hook"}) +_NAMED_CONTRIBUTION_KINDS = _CONTRIBUTION_KINDS - {"hook"} +_HOOK_LAYERS = frozenset({"preset", "extension"}) + + +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. + + Each component is revalidated with :func:`validate_component` before the + join. Manifest-load-time validators generally validate ahead of the join, + but resolver callers can pass raw filesystem-derived names (POSIX permits + ``:`` in filenames the way manifest validators do not), and every layer + dict downstream relies on ``lookupId`` being a round-trippable string that + :func:`layer_kind_from_lookup_id` can parse — so this is the shared + derivation boundary that must enforce the grammar. Callers passing raw + strings should either pre-validate or handle + :class:`IdentifierComponentError`. + """ + validate_component(layer, "layer") + validate_component(source_id, "sourceId") + validate_component(kind, "kind") + if layer not in _LAYER_KINDS: + raise IdentifierComponentError(f"Invalid layer '{layer}'") + if kind not in _NAMED_CONTRIBUTION_KINDS: + raise IdentifierComponentError(f"Invalid named contribution kind '{kind}'") + validate_component(name, "name") + return f"{layer}:{source_id}:{kind}:{name}" + + +def derive_public_id(kind: str, name: str) -> str: + """Build the source-agnostic public identifier for an artifact.""" + validate_component(kind, "kind") + if kind not in _NAMED_CONTRIBUTION_KINDS: + raise IdentifierComponentError(f"Invalid public artifact kind '{kind}'") + validate_component(name, "name") + return f"{kind}:{name}" + + +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), including :data:`PROJECT_OVERRIDE_LAYER` for project-local + 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. + + Validates the complete shape, not just the presence of a layer prefix: + named contributions require exactly the four ``{layer}:{sourceId}:{kind}: + {name}`` components, and hook contributions require exactly the five + ``{layer}:{sourceId}:hook:{eventName}:{command}`` components, with every + component non-empty. A value such as ``"preset:x"`` has a recognized layer + prefix but the wrong number of components, so it is malformed and returns + ``None`` rather than being treated as authoritative. Hook IDs are only + valid on preset/extension layers (see :data:`_HOOK_LAYERS`); a value such + as ``"project:_:hook:some-event:some-command"`` is rejected even though it + otherwise has the right shape, matching :func:`derive_hook_id`'s refusal + to build hook IDs for other layers. + """ + parts = lookup_id.split(":") + if len(parts) < 4 or any(not part for part in parts): + return None + layer = parts[0] + if layer not in _LAYER_KINDS: + return None + if parts[2] not in _CONTRIBUTION_KINDS: + return None + expected_len = 5 if parts[2] == "hook" else 4 + if len(parts) != expected_len: + return None + if parts[2] == "hook" and layer not in _HOOK_LAYERS: + 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 derive_hook_id( + layer: str, + source_id: str, + event_name: str, + command: str, +) -> str: + """Build the identifier string for a hook contribution. + + Each component is revalidated with :func:`validate_component` — same + contract as :func:`derive_named_id`. + """ + validate_component(layer, "layer") + validate_component(source_id, "sourceId") + if layer not in _HOOK_LAYERS: + raise IdentifierComponentError(f"Invalid layer '{layer}'") + validate_component(event_name, "eventName") + validate_component(command, "command") + return f"{layer}:{source_id}:hook:{event_name}:{command}" 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/agents.py b/src/specify_cli/agents.py index dede50e0b1..6721001d1b 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -1049,6 +1049,34 @@ def _resolve_agent_dir( return legacy_dir return agent_dir + def resolve_agent_dir(self, agent_name: str, project_root: Path) -> Optional[Path]: + """Return the configured output directory for *agent_name*, if known.""" + self._ensure_configs() + agent_config = self.AGENT_CONFIGS.get(agent_name) + if agent_config is None: + return None + return self._resolve_agent_dir(agent_name, agent_config, project_root) + + def uses_skill_output(self, agent_name: str) -> bool: + """Return true when *agent_name* writes commands as ``SKILL.md`` files.""" + self._ensure_configs() + agent_config = self.AGENT_CONFIGS.get(agent_name) + return bool(agent_config and agent_config.get("extension") == "/SKILL.md") + + def resolve_command_output_path( + self, agent_name: str, cmd_name: str, project_root: Path + ) -> Optional[Path]: + """Return the command/skill output path this registrar uses for a command.""" + self._ensure_configs() + agent_config = self.AGENT_CONFIGS.get(agent_name) + if agent_config is None: + return None + output_name = self._compute_output_name(agent_name, cmd_name, agent_config) + return ( + self._resolve_agent_dir(agent_name, agent_config, project_root) + / f"{output_name}{agent_config['extension']}" + ) + def register_commands_for_all_agents( self, commands: List[Dict[str, Any]], diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py new file mode 100644 index 0000000000..1c2fbc8ba7 --- /dev/null +++ b/src/specify_cli/artifacts/__init__.py @@ -0,0 +1,1238 @@ +"""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_shared_asset_dir +from .._identifier import ( + PROJECT_OVERRIDE_LAYER, + IdentifierComponentError, + derive_public_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"] +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()``. + + ``id`` is the source-agnostic round-trip key (``f"{kind}:{name}"``) for the + artifact this stack row belongs to — every row in a given stack carries + the same ``id``, matching the top-level ``id`` on the ``info`` payload and + the corresponding row's ``id`` on ``artifact list``. It is populated for + every row, including built-in-tier rows that have no ``lookupId``. + ``lookupId`` is separate, manifest-backed layer provenance: it is only + present when the row has a specific preset/extension/project-override + layer to point at, and is ``None`` for the built-in tier. + """ + + id: str + layer: LayerName | None + sourceId: str | None + presetId: str | None + presetName: str | None + strategy: Strategy + active: bool + hidden: bool + manifestPath: str | None + lookupId: str | None + sourcePath: str | None + + def to_json_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "layer": self.layer, + "sourceId": self.sourceId, + "presetId": self.presetId, + "presetName": self.presetName, + "strategy": self.strategy, + "active": self.active, + "hidden": self.hidden, + "manifestPath": self.manifestPath, + "lookupId": self.lookupId, + "sourcePath": self.sourcePath, + } + + +# --------------------------------------------------------------------------- +# 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) + + +_TEMPLATE_SUFFIX = ".md" +_SCRIPT_SUFFIX = ".sh" + + +def _project_core_asset_root(project_root: Path | None, subdir: str) -> Path | None: + """Return the project-local built-in-tier directory for an asset family, if present.""" + if project_root is None: + return None + if subdir not in {"commands", "scripts", "templates"}: + return None # pragma: no cover — internal misuse + from ..presets import PresetResolver # lazy: avoids circular import + + candidate = PresetResolver(project_root).templates_dir + if subdir != "templates": + candidate = candidate / subdir + return candidate if candidate.is_dir() else None + + +def _core_command_logical_name(stem: str) -> str: + return stem if stem.startswith("speckit.") else f"speckit.{stem}" + + +def _extract_frontmatter_description(text: str) -> str: + """Return the ``description`` value from YAML frontmatter, else ``""``. + + Matches the frontmatter shape used by every core command/template on disk: + a ``---`` fence pair at the top of the file with a YAML mapping between + them. Anything malformed silently yields the empty string — the contract + forbids omission but permits ``""``. + """ + lines = text.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return "" + fence_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + fence_end = i + break + if fence_end == -1: + return "" + try: + data = yaml.safe_load("".join(lines[1:fence_end])) + except yaml.YAMLError: + return "" + if not isinstance(data, dict): + return "" + value = data.get("description", "") + return value if isinstance(value, str) else "" + + +def _extract_script_description(text: str) -> str: + """Return the first docstring/comment line of a script, else ``""``. + + Supports the three script runtimes 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 _describe_artifact_file(path: Path, kind: ArtifactKind) -> str: + """Return the on-disk description for an artifact file, else ``""``. + + Routes to the same extractors the inventory uses so a project + override reports its own metadata instead of inheriting the description + of the core/preset layer it hides. + """ + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return "" + if kind == "script": + return _extract_script_description(text) + return _extract_frontmatter_description(text) + + +# --------------------------------------------------------------------------- +# Resolver-adaptation helpers +# --------------------------------------------------------------------------- + + +def _public_layer_shape( + resolver_layer: dict[str, Any], +) -> tuple[LayerName | None, str | None, str | None]: + """Translate resolver provenance into the public layer identity triple. + + Layers without a lookup identifier have no public provenance. Preset, + extension, and project override identities are retained unchanged. + """ + lookup_id = resolver_layer.get("lookupId") + if lookup_id is None: + return None, None, None + if not isinstance(lookup_id, str): + raise ArtifactResolutionError() + layer_kind = layer_kind_from_lookup_id(lookup_id) + if layer_kind not in ("project", "preset", "extension"): + raise ArtifactResolutionError() + return layer_kind, lookup_id.split(":", 2)[1], lookup_id + + +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``. + + The resolver may set the ``lookupId``'s ``sourceId`` component to the + manifest-declared ``id:`` (which can differ from the on-disk directory + name for renamed packs), so ``lookupId`` is never parsed for the on-disk + directory here. The on-disk directory identity is read exclusively from + the layer's explicit provenance keys — ``preset_id`` / ``pack_dir`` for + preset layers, ``extension_id`` / ``extension_dir`` for extension + layers — which ``collect_all_layers()`` always sets alongside + ``lookupId``. Missing provenance keys mean no manifest path is available. + + 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 == "preset": + pack_dir = layer.get("pack_dir") + pack_id = layer.get("preset_id") + tier_dir, manifest_name = "presets", "preset.yml" + elif layer_kind == "extension": + pack_dir = layer.get("extension_dir") + pack_id = layer.get("extension_id") + tier_dir, manifest_name = "extensions", "extension.yml" + else: + return None + if isinstance(pack_dir, Path): + manifest_path = pack_dir / manifest_name + elif pack_id: + manifest_path = project_root / ".specify" / tier_dir / pack_id / manifest_name + else: + return None + if not manifest_path.is_file(): + return None + try: + return manifest_path.relative_to(project_root).as_posix() + except ValueError: + return None + + +def _repo_relative_existing_file(project_root: Path, path: Path) -> str | None: + """Return *path* relative to the project root when it is an existing file.""" + if not path.is_file(): + return None + try: + return path.relative_to(project_root).as_posix() + except ValueError: + return None + + +def _is_safe_path_component(value: str) -> bool: + """Return true when *value* is a single non-traversing path component.""" + if not value or value in (".", ".."): + return False + path = Path(value) + return not path.is_absolute() and len(path.parts) == 1 and path.name == value + + +def _materialized_command_source_path( + project_root: Path, + metadata: dict[str, Any] | None, + name: str, + *, + source: Literal["preset", "extension"], +) -> str | None: + """Return the tracked agent output path for an installed command layer.""" + if not isinstance(metadata, dict): + return None + + try: + from ..agents import CommandRegistrar + except ImportError: + return None + + registrar = CommandRegistrar() + + registered_commands = metadata.get("registered_commands") + if isinstance(registered_commands, dict): + for agent_name in sorted(registered_commands): + cmd_names = registered_commands.get(agent_name) + if not isinstance(cmd_names, list): + continue + if name not in cmd_names: + continue + agent_config = registrar.AGENT_CONFIGS.get(agent_name) + if agent_config is None: + continue + command_path = registrar.resolve_command_output_path( + agent_name, name, project_root + ) + if command_path is None: + continue + rel = _repo_relative_existing_file(project_root, command_path) + if rel is not None: + return rel + + registered_skills = metadata.get("registered_skills") + if source == "preset": + skill_names_by_agent = registered_skills if isinstance(registered_skills, dict) else {} + elif isinstance(registered_skills, list): + # Extension registries store skills as a flat list, unlike presets' + # per-agent map. Probe every known agent's project-local skills + # directory and return the first extant tracked file. + skill_names_by_agent = { + agent_name: registered_skills for agent_name in sorted(registrar.AGENT_CONFIGS) + } + else: + skill_names_by_agent = {} + + expected_skill_names: set[str] | None = None + if source == "extension": + try: + from ..extensions import ExtensionManager + + expected_skill_names = {ExtensionManager._skill_name_for_command(name)} + except ImportError: + expected_skill_names = None + else: + try: + from ..presets import PresetManager + + expected_skill_names = set(PresetManager._skill_names_for_command(name)) + except ImportError: + expected_skill_names = None + + if isinstance(skill_names_by_agent, dict): + from .. import _get_skills_dir as _project_skills_dir + + for agent_name in sorted(skill_names_by_agent): + skill_names = skill_names_by_agent.get(agent_name) + if not isinstance(agent_name, str) or not isinstance(skill_names, list): + continue + agent_config = registrar.AGENT_CONFIGS.get(agent_name) + if agent_config is None: + continue + if registrar.uses_skill_output(agent_name): + skills_dir = registrar.resolve_agent_dir(agent_name, project_root) + else: + skills_dir = _project_skills_dir(project_root, agent_name) + if skills_dir is None: + continue + for skill_name in sorted( + n for n in skill_names if isinstance(n, str) and _is_safe_path_component(n) + ): + if expected_skill_names is not None and skill_name not in expected_skill_names: + continue + skill_path = skills_dir / skill_name / "SKILL.md" + rel = _repo_relative_existing_file(project_root, skill_path) + if rel is not None: + return rel + + return None + + +def _derive_source_path( + layer: dict[str, Any], + project_root: Path, + kind: ArtifactKind, + name: str, + *, + active: bool, +) -> str | None: + """Return the repo-relative concrete file backing a preset/extension layer. + + ``layer`` is one raw ``PresetResolver.collect_all_layers()`` row. Preset + and extension rows carry explicit on-disk provenance keys + (``preset_id``/``pack_dir`` or ``extension_id``/``extension_dir``) + alongside ``lookupId``; core and project rows intentionally do not produce + a source path here. + + The tracked materialized agent output is shared by every stack row that + contributed the same command name, so it only reflects the winning + (``active``) row's content. Lower ``replace``/``merge`` rows must report + their own installed pack file instead of that shared output. + """ + lookup_id = layer.get("lookupId", "") + layer_kind = layer_kind_from_lookup_id(lookup_id) + if layer_kind == "preset": + pack_id = layer.get("preset_id") + if not isinstance(pack_id, str) or not pack_id: + return None + from ..presets import PresetRegistry + + metadata = PresetRegistry(project_root / ".specify" / "presets").get(pack_id) + if kind == "command" and active: + materialized = _materialized_command_source_path( + project_root, metadata, name, source="preset" + ) + if materialized is not None: + return materialized + elif layer_kind == "extension": + extension_id = layer.get("extension_id") + if not isinstance(extension_id, str) or not extension_id: + return None + from ..extensions import ExtensionRegistry + + metadata = ExtensionRegistry(project_root / ".specify" / "extensions").get(extension_id) + if kind == "command" and active: + materialized = _materialized_command_source_path( + project_root, metadata, name, source="extension" + ) + if materialized is not None: + return materialized + else: + # Core and project-override rows are built-in/synthetic from the public + # artifact contract's perspective, so their sourcePath stays null. + return None + + # Non-active command layers, non-command preset/extension layers, and + # active command layers without a tracked materialized agent output all + # report the installed pack file from the raw + # PresetResolver.collect_all_layers() row's concrete ``path`` key. + path = layer.get("path") + if isinstance(path, Path): + return _repo_relative_existing_file(project_root, path) + return None + + +def _preset_display_name(pack_dir: Path, pack_id: str) -> str: + """Return the preset's human-friendly name from ``preset.yml``, or ``pack_id``. + + Delegates parsing and validation to :class:`PresetManifest` — the same + class ``PresetManager.list_installed()`` and ``specify preset list`` use — + instead of re-parsing the YAML by hand. Falls back to ``pack_id`` when the + manifest file is missing or fails manifest validation (for example, an + older flat-layout manifest with no ``preset:`` section at all). + """ + from ..presets import PresetManifest, PresetValidationError # lazy: avoids circular import + + manifest_path = pack_dir / "preset.yml" + if not manifest_path.is_file(): + return pack_id + try: + return PresetManifest(manifest_path).name + except PresetValidationError: + return pack_id + + +def _build_stack( + project_root: Path, + kind: ArtifactKind, + name: str, + raw_layers: list[dict[str, Any]] | None = None, +) -> list[StackLayer]: + """Build the ordered stack for a single artifact. + + Delegates the actual composition math to + :meth:`PresetResolver.collect_all_layers`; this function only reshapes + each raw layer dict into a :class:`StackLayer` and computes the + ``active`` / ``hidden`` labels documented on the data model. + + Returns an empty list when the artifact is not visible from any tier + (no preset, no extension, no built-in asset). + """ + from ..presets import PresetError, PresetResolver # lazy: avoids circular import + + template_type = kind + if raw_layers is None: + resolver = PresetResolver(project_root) + try: + raw = resolver.collect_all_layers(name, template_type) + except (OSError, PresetError) as exc: + raise ArtifactResolutionError() from exc + else: + raw = raw_layers + if not raw: + return [] + + first_replace_idx = next( + (i for i, layer in enumerate(raw) if layer["strategy"] == "replace"), + None, + ) + + public_id = derive_public_id(kind, name) + rows: list[StackLayer] = [] + for idx, layer in enumerate(raw): + strategy = layer["strategy"] + active = idx == 0 + + if first_replace_idx is None: + hidden = False + else: + hidden = idx > first_replace_idx + + layer_kind, source_id, lookup_id = _public_layer_shape(layer) + source_path = _derive_source_path(layer, project_root, kind, name, active=active) + + if layer_kind == PROJECT_OVERRIDE_LAYER: + rows.append( + StackLayer( + id=public_id, + layer="project", + sourceId=source_id, + presetId=None, + presetName=None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=None, + lookupId=lookup_id, + sourcePath=source_path, + ) + ) + continue + + if layer_kind == "extension": + manifest_path = _derive_manifest_path(layer, project_root) + rows.append( + StackLayer( + id=public_id, + layer="extension", + sourceId=source_id, + presetId=None, + presetName=None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=manifest_path, + lookupId=lookup_id, + sourcePath=source_path, + ) + ) + continue + + if layer_kind is None: + rows.append( + StackLayer( + id=public_id, + layer=None, + sourceId=None, + presetId=None, + presetName=None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=None, + lookupId=None, + sourcePath=source_path, + ) + ) + continue + + # Preset layers carry the on-disk directory identity separately from + # ``lookupId`` (which may use the manifest-declared ``id:``): use the + # explicit ``preset_id`` / ``pack_dir`` keys ``collect_all_layers()`` + # always sets, never ``lookupId`` parsing, so a renamed pack still + # resolves to the right on-disk directory for display-name and + # manifest-path lookup. + pack_id = layer.get("preset_id") or "" + pack_dir_layer = layer.get("pack_dir") + if isinstance(pack_dir_layer, Path): + pack_dir = pack_dir_layer + else: + 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( + id=public_id, + layer="preset", + sourceId=source_id, + presetId=pack_id or None, + presetName=display or None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=manifest_path, + lookupId=lookup_id, + sourcePath=source_path, + ) + ) + return rows + + +# --------------------------------------------------------------------------- +# 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 _validate_preset_registry(project_root: Path) -> None: + """Fail closed when the preset registry is present but unreadable. + + ``PresetRegistry._load`` normalizes malformed JSON to an empty mapping so + install/enable/disable flows keep working, but that same recovery would + silently drop every installed preset from the artifact inventory. Callers + that treat the inventory as authoritative must therefore refuse to run + against a corrupt registry — same fail-closed contract as + :func:`_validate_extension_registry`. + """ + presets_dir = project_root / ".specify" / "presets" + if not presets_dir.exists(): + return + + from ..presets import PresetRegistry + + if PresetRegistry(presets_dir).is_corrupt(): + raise ArtifactResolutionError() + + +def _resolve_kind_hint(name: str, kind: ArtifactKind | None) -> tuple[str, ArtifactKind | None]: + """Parse ``kind:name`` shorthand and reconcile it with an explicit ``--kind`` flag. + + Returns ``(bare_name, resolved_kind)``. When ``name`` uses the ``kind:name`` + grammar and ``kind`` is also set explicitly, the two must agree — a + mismatch is treated as an unknown artifact. + """ + if ":" in name: + prefix, _, bare = name.partition(":") + if prefix in ("command", "template", "script"): + resolved: ArtifactKind = prefix # type: ignore[assignment] + if kind is not None and kind != resolved: + raise ArtifactNotFoundError(name) + return bare, resolved + return name, kind + + +def _validate_artifact_name(name: str, kind: ArtifactKind) -> str: + """Validate the structural identifier component constraints for ``name``.""" + try: + return validate_component(name, f"{kind} name") + except IdentifierComponentError as exc: + raise ArtifactNotFoundError(name) from exc + + +def _is_valid_artifact_name_component(name: Any, kind: ArtifactKind) -> bool: + """Return ``True`` when ``name`` can appear in an artifact identifier.""" + try: + validate_component(name, f"{kind} name") + except IdentifierComponentError: + return False + return True + + +class ArtifactCatalog: + """Read-only view over one Spec Kit project's artifact inventory.""" + + def __init__(self, project_root: Path) -> None: + self.project_root = project_root + + # ------------------------------------------------------------------ list + def list_artifacts(self) -> list[Artifact]: + """Return every artifact 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 no built-in assets is + still a valid Spec Kit project. + + Skills (``.github/skills/**/SKILL.md``) are intentionally excluded — + they are integration-specific output, not a shipped asset family. + + Descriptions are picked from the highest-priority layer that has one, + not the first layer discovered — a built-in command that an active + preset overrides must report the preset's description, and two + competing packs must report the higher-precedence one's. Precedence + is decided by :meth:`PresetResolver.collect_all_layers`'s own + ordering (index 0 = winner), not by enumeration order here. + """ + artifacts, _layers_cache = self._collect_inventory() + return artifacts + + def list_artifacts_with_stack(self) -> list[dict[str, Any]]: + """Return list rows enriched with each artifact's full composition stack.""" + artifacts, layers_cache = self._collect_inventory() + rows: list[dict[str, Any]] = [] + for artifact in artifacts: + stack = _build_stack( + self.project_root, + artifact.kind, + artifact.name, + raw_layers=layers_cache.get((artifact.kind, artifact.name)), + ) + row = artifact.to_json_dict() + row["stack"] = [layer.to_json_dict() for layer in stack] + rows.append(row) + return rows + + # ------------------------------------------------------------------ info + def get_artifact_info( + self, + name: str, + kind: ArtifactKind | None = None, + ) -> dict[str, Any]: + """Return the full JSON-ready dict for ``specify artifact info``. + + Argument resolution: + + * ``name`` accepts the ``kind:name`` grammar as shorthand; when both + the shorthand and ``kind`` are supplied they must agree. + * When neither the shorthand nor ``kind`` narrows the search and + more than one kind matches ``name``, raises + :class:`AmbiguousArtifactError`. + * When no artifact matches, raises :class:`ArtifactNotFoundError`. + """ + bare, resolved_kind = _resolve_kind_hint(name, kind) + + # Project and registry validation happens once, inside + # ``_collect_inventory`` below — the same chokepoint ``list_artifacts`` + # uses — so both public methods fail closed identically instead of + # each re-implementing the checks. + inventory, layers_cache = self._collect_inventory() + if resolved_kind is None: + matches = [ + (artifact.kind, artifact.name) + for artifact in inventory + if artifact.name == bare + ] + if not matches: + raise ArtifactNotFoundError(name) + if len(matches) > 1: + raise AmbiguousArtifactError(bare, [k for k, _ in matches]) + resolved_kind = matches[0][0] + + validated_name = _validate_artifact_name(bare, resolved_kind) + artifact = next( + ( + item + for item in inventory + if item.kind == resolved_kind and item.name == validated_name + ), + None, + ) + if artifact is None: + raise ArtifactNotFoundError(name) + stack = _build_stack( + self.project_root, + resolved_kind, + validated_name, + raw_layers=layers_cache.get((resolved_kind, validated_name)), + ) + if not stack: + raise ArtifactNotFoundError(name) + + return { + "id": derive_public_id(resolved_kind, validated_name), + "name": validated_name, + "kind": resolved_kind, + "description": artifact.description, + "stack": [layer.to_json_dict() for layer in stack], + } + + # -------------------------------------------------------------- internals + def _collect_inventory( + self, + ) -> tuple[ + list[Artifact], + dict[tuple[ArtifactKind, str], list[dict[str, Any]]], + ]: + _validate_project(self.project_root) + _validate_extension_registry(self.project_root) + _validate_preset_registry(self.project_root) + + from ..presets import PresetError, PresetResolver # lazy: avoids circular import + + resolver = PresetResolver(self.project_root) + layers_cache: dict[tuple[ArtifactKind, str], list[dict[str, Any]]] = {} + + def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: + key = (kind, name) + if key not in layers_cache: + try: + layers_cache[key] = resolver.collect_all_layers(name, kind) + except (OSError, PresetError) as exc: + raise ArtifactResolutionError() from exc + return layers_cache[key] + + def _has_any_replace_layer(layers: list[dict[str, Any]]) -> bool: + return any(layer.get("strategy") == "replace" for layer in layers) + + names: set[tuple[ArtifactKind, str]] = set() + try: + for kind, name in self._iter_candidate_artifacts(resolver): + key = (kind, name) + if not _is_valid_artifact_name_component(name, kind): + continue + layers = _layers_for(kind, name) + if layers and _has_any_replace_layer(layers): + names.add(key) + except (OSError, PresetError) as exc: + raise ArtifactResolutionError() from exc + + artifacts: list[Artifact] = [] + manifest_cache: dict[Path, Any | None] = {} + manifest_description_cache: dict[Path, dict[tuple[str, str, str], str]] = {} + for kind, name in names: + description = "" + for layer in _layers_for(kind, name): + candidate = self._describe_layer( + layer, kind, name, manifest_cache, manifest_description_cache + ) + if candidate: + description = candidate + break + artifacts.append( + Artifact( + id=derive_public_id(kind, name), + name=name, + kind=kind, + description=description, + ) + ) + + kind_order = {"command": 0, "template": 1, "script": 2} + return sorted(artifacts, key=lambda a: (kind_order[a.kind], a.name)), layers_cache + + def _iter_candidate_artifacts( + self, + resolver: Any, + ) -> Iterable[tuple[ArtifactKind, str]]: + """Yield candidate ``(kind, name)`` pairs from every resolver tier. + + Covers the ways a pack can contribute an artifact: + + * manifest-declared entries (``preset.yml`` / ``extension.yml``), read + 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 and extensions are enumerated through the resolver's public + ``iter_*_by_priority()`` helpers, so the candidate set follows the same + install/enable/priority rules as resolution. Project overrides and + resolver-compatible core asset paths are included only as candidate + names; :meth:`PresetResolver.collect_all_layers` remains the source of + truth for which candidates are actually present and which layer wins. + + Project-local overrides under ``.specify/templates/overrides`` are + included too, so an artifact that exists only as an override is still + listed. + + Silent on any manifest that fails to parse — that would already be + surfaced by ``specify preset list`` or ``specify extension list``, and + this command's job is to describe the composed inventory, not to be + the second validation surface. + """ + from ..extensions import ExtensionManager, ExtensionManifest, ValidationError + from ..presets import PresetManager # lazy: avoids circular import + + # -- Presets: the registry is authoritative, no unregistered fallback. + preset_manager = PresetManager(self.project_root) + for pack_id, _metadata in resolver.iter_presets_by_priority(): + pack_dir = preset_manager.presets_dir / pack_id + manifest = preset_manager.get_pack(pack_id) + yield from self._iter_pack_candidates(manifest, pack_dir) + + # -- 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 = resolver.extensions_dir / ext_id + if metadata is not None: + manifest = ext_manager.get_extension(ext_id) + else: + manifest_path = ext_dir / "extension.yml" + manifest = None + if manifest_path.is_file(): + try: + manifest = ExtensionManifest(manifest_path) + except (ValidationError, OSError, TypeError, AttributeError): + manifest = None + yield from self._iter_pack_candidates(manifest, ext_dir) + + yield from self._iter_project_override_candidates(resolver) + yield from self._iter_core_candidates() + + @staticmethod + def _iter_pack_candidates( + manifest: Any, + pack_dir: Path, + ) -> Iterable[tuple[ArtifactKind, str]]: + """Yield manifest-declared and convention-based candidate names.""" + if manifest is not None: + for contribution in manifest.iter_contributions(): + kind = contribution.get("kind") + name = contribution.get("name") + if ( + kind in ("command", "template", "script") + and isinstance(name, str) + and name + and ":" not in name + ): + yield kind, name + + yield from ((kind, name) for kind, name, _path in _iter_convention_contributions(pack_dir)) + + def _iter_project_override_candidates( + self, + resolver: Any, + ) -> Iterable[tuple[ArtifactKind, str]]: + """Yield candidate ``(kind, name)`` pairs for project overrides. + + A root ``overrides/.md`` file is the override for both the + ``template`` and the ``command`` lookup of ````. It is reported + for every kind backed by another layer; the fallback heuristic is used + only when the override is the sole layer. + + 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 + backed_kinds: list[ArtifactKind] = [] + for kind in ("command", "template"): + layers = resolver.collect_all_layers(name, kind) + if any( + layer_kind_from_lookup_id(str(layer.get("lookupId", ""))) + != PROJECT_OVERRIDE_LAYER + for layer in layers + ): + backed_kinds.append(kind) + if not backed_kinds: + backed_kinds.append("command" if is_dotted_command_name(name) else "template") + for kind in backed_kinds: + yield kind, name + scripts_dir = overrides_dir / "scripts" + if not scripts_dir.is_dir(): + return + for entry in sorted(scripts_dir.iterdir(), key=lambda p: p.name): + if entry.is_file() and entry.suffix == _SCRIPT_SUFFIX: + if not _is_valid_artifact_name_component(entry.stem, "script"): + continue + yield "script", entry.stem + + def _iter_core_candidates(self) -> Iterable[tuple[ArtifactKind, str]]: + """Yield candidate names from resolver-compatible core asset paths.""" + from ..extensions import CORE_COMMAND_NAMES # lazy: avoids circular import + from ..presets import PresetResolver + + project_commands_dir = _project_core_asset_root(self.project_root, "commands") + bundled_commands_dir = _locate_shared_asset_dir("commands") + command_dirs = tuple( + directory + for directory in (project_commands_dir, bundled_commands_dir) + if directory is not None + ) + command_names = {_core_command_logical_name(name) for name in CORE_COMMAND_NAMES} + for directory in command_dirs: + for entry in sorted(directory.iterdir(), key=lambda p: p.name): + if entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX: + command_names.add(_core_command_logical_name(entry.stem)) + for name in sorted(command_names): + if any( + (directory / f"{candidate}.md").is_file() + for directory in command_dirs + for candidate in PresetResolver.name_candidates(name) + ): + yield "command", name + + seen_templates: set[str] = set() + for directory in ( + _project_core_asset_root(self.project_root, "templates"), + _locate_shared_asset_dir("templates"), + ): + if directory is None: + continue + for entry in sorted(directory.iterdir(), key=lambda p: p.name): + if ( + entry.is_file() + and entry.suffix == _TEMPLATE_SUFFIX + and entry.stem not in seen_templates + ): + seen_templates.add(entry.stem) + yield "template", entry.stem + + seen_scripts: set[str] = set() + for directory in ( + _project_core_asset_root(self.project_root, "scripts"), + _locate_shared_asset_dir("scripts"), + ): + if directory is None: + continue + for entry in sorted(directory.glob(f"*{_SCRIPT_SUFFIX}"), key=lambda p: p.name): + if entry.stem not in seen_scripts: + seen_scripts.add(entry.stem) + yield "script", 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_scripts: + seen_scripts.add(name) + yield "script", name + + def _describe_layer( + self, + layer: dict[str, Any], + kind: ArtifactKind, + name: str, + manifest_cache: dict[Path, Any | None], + manifest_description_cache: dict[Path, dict[tuple[str, str, str], str]], + ) -> str: + """Return manifest metadata or on-disk metadata for one resolver layer.""" + manifest_description = self._manifest_description_for_layer( + layer, kind, name, manifest_cache, manifest_description_cache + ) + if manifest_description: + return manifest_description + path = layer.get("path") + if isinstance(path, Path): + return _describe_artifact_file(path, kind) + return "" + + def _manifest_description_for_layer( + self, + layer: dict[str, Any], + kind: ArtifactKind, + name: str, + manifest_cache: dict[Path, Any | None], + manifest_description_cache: dict[Path, dict[tuple[str, str, str], str]], + ) -> str: + lookup_id = layer.get("lookupId", "") + layer_kind = layer_kind_from_lookup_id(lookup_id) + manifest = None + if layer_kind == "preset": + pack_dir = layer.get("pack_dir") + if not isinstance(pack_dir, Path): + preset_id = layer.get("preset_id") + if not preset_id: + return "" + pack_dir = self.project_root / ".specify" / "presets" / preset_id + manifest_path = pack_dir / "preset.yml" + if manifest_path.is_file(): + if manifest_path not in manifest_cache: + try: + from ..presets import PresetManifest, PresetValidationError + + manifest_cache[manifest_path] = PresetManifest(manifest_path) + except ( + PresetValidationError, + yaml.YAMLError, + OSError, + TypeError, + AttributeError, + ): + manifest_cache[manifest_path] = None + manifest = manifest_cache[manifest_path] + elif layer_kind == "extension": + ext_dir = layer.get("extension_dir") + if not isinstance(ext_dir, Path): + extension_id = layer.get("extension_id") + if not extension_id: + return "" + ext_dir = self.project_root / ".specify" / "extensions" / extension_id + manifest_path = ext_dir / "extension.yml" + if manifest_path.is_file(): + if manifest_path not in manifest_cache: + try: + from ..extensions import ExtensionManifest, ValidationError + + manifest_cache[manifest_path] = ExtensionManifest(manifest_path) + except ( + ValidationError, + yaml.YAMLError, + OSError, + TypeError, + AttributeError, + ): + manifest_cache[manifest_path] = None + manifest = manifest_cache[manifest_path] + if manifest is None: + return "" + if manifest_path not in manifest_description_cache: + descriptions: dict[tuple[str, str, str], str] = {} + for contribution in manifest.iter_contributions(): + contribution_id = contribution.get("id") + contribution_kind = contribution.get("kind") + contribution_name = contribution.get("name") + description = contribution.get("description", "") + if ( + isinstance(contribution_id, str) + and isinstance(contribution_kind, str) + and isinstance(contribution_name, str) + and isinstance(description, str) + ): + descriptions[ + (contribution_kind, contribution_name, contribution_id) + ] = description + manifest_description_cache[manifest_path] = descriptions + return manifest_description_cache[manifest_path].get((kind, name, 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, Path]]: + """Yield ``(kind, name, path)`` for files exposed by convention. + + Templates are also accepted at the pack root for legacy compatibility, + matching the resolver's ``templates/``-then-root lookup order. README files + are packaging metadata rather than artifacts and are excluded consistently. + """ + for subdir, kind, suffix in _CONVENTION_SUBDIRS: + candidate_dir = pack_dir / subdir + if not candidate_dir.is_dir(): + continue + for entry in sorted(candidate_dir.iterdir(), key=lambda p: p.name): + if entry.is_file() and entry.suffix == suffix and ":" not in entry.stem: + yield kind, entry.stem, entry + if not pack_dir.is_dir(): + return + for entry in sorted(pack_dir.iterdir(), key=lambda p: p.name): + if ( + entry.is_file() + and entry.suffix == _TEMPLATE_SUFFIX + and entry.stem.lower() != "readme" + and ":" not in entry.stem + ): + yield "template", entry.stem, entry + + +__all__ = [ + "AmbiguousArtifactError", + "Artifact", + "ArtifactCatalog", + "ArtifactError", + "ArtifactKind", + "ArtifactNotFoundError", + "ArtifactResolutionError", + "LayerName", + "NotASpecKitProjectError", + "StackLayer", + "Strategy", +] diff --git a/src/specify_cli/artifacts/_commands.py b/src/specify_cli/artifacts/_commands.py new file mode 100644 index 0000000000..2c19b6166b --- /dev/null +++ b/src/specify_cli/artifacts/_commands.py @@ -0,0 +1,164 @@ +"""Typer sub-app for the `specify artifact` command group. + +Kept intentionally thin: the pure logic lives in ``specify_cli.artifacts``. +This module is only responsible for CLI wiring — argument parsing, JSON +serialization, exit-code selection, and error-envelope emission on stderr. + +Mirrors the shape used by ``src/specify_cli/presets/_commands.py`` and +``src/specify_cli/extensions/_commands.py``: a module-level Typer app plus a +``register(app)`` entry point invoked from ``src/specify_cli/__init__.py``. + +The user-facing contract for both subcommands — the ``list``/``info`` JSON +shapes, stack semantics (``active``/``hidden``, built-in rows, lookup IDs), and +the JSON error envelope — is documented in ``docs/reference/artifacts.md``. +""" + +from __future__ import annotations + +import contextlib +import io +import json +import sys +from pathlib import Path +from typing import Optional + +import typer + +from . import ( + ArtifactCatalog, + ArtifactError, + ArtifactKind, + ArtifactResolutionError, + NotASpecKitProjectError, +) +from ..presets import PresetError + +artifact_app = typer.Typer( + name="artifact", + help="Introspect commands, templates, and scripts SpecKit exposes.", + no_args_is_help=True, +) + + +def _resolve_project_root() -> Path: + """Return the project root without emitting Rich output on failure. + + Delegates to :func:`specify_cli._require_specify_project` — the same + resolution chokepoint every other project-scoped subcommand (``preset``, + ``extension``, ``workflow``, ...) uses, including its ``SPECIFY_INIT_DIR`` + override handling. That helper prints Rich error output and raises + ``typer.Exit`` on failure, which would corrupt the strict JSON envelope + ``specify artifact list --json`` and ``specify artifact info --json`` + emit on stdout/stderr. The Rich output is suppressed here and the + failure is re-raised as the module-local :class:`NotASpecKitProjectError` + for the shared error handler to serialize instead. + """ + from .. import _require_specify_project # lazy: avoids circular import + + with contextlib.redirect_stderr(io.StringIO()): + try: + return _require_specify_project() + except typer.Exit: + raise NotASpecKitProjectError() from None + + +def _emit_error_and_exit(exc: ArtifactError) -> None: + """Write ``{"error": "..."}`` to stderr and exit with code 1. + + The stdout stream is left completely untouched — the contract is that + machine consumers can rely on an empty stdout when the exit code is + non-zero, so no partial JSON payload leaks even on a late-stage failure. + """ + payload = json.dumps({"error": exc.message}, ensure_ascii=False) + print(payload, file=sys.stderr) + raise typer.Exit(code=1) + + +def _require_json_flag(json_flag: bool) -> None: + """Enforce the opt-in ``--json`` contract shared by both subcommands. + + A text-mode formatter is intentionally deferred so the initial release + can commit to exactly one output shape. Callers that omit ``--json`` + get a usage error (exit 2) with no stdout output — this makes future + addition of a default text renderer a purely additive, non-breaking + change. + """ + if json_flag: + return + print( + "specify artifact requires --json for now; text output is not yet implemented.", + file=sys.stderr, + ) + raise typer.Exit(code=2) + + +@artifact_app.command("list") +def artifact_list( + json_flag: bool = typer.Option( + False, + "--json", + help="Emit the inventory as a JSON array on stdout.", + ), +) -> None: + """List every command, template, and script SpecKit exposes.""" + _require_json_flag(json_flag) + try: + root = _resolve_project_root() + catalog = ArtifactCatalog(root) + rows = catalog.list_artifacts_with_stack() + except ArtifactError as exc: + _emit_error_and_exit(exc) + return # pragma: no cover — _emit_error_and_exit raises + except (OSError, PresetError): + _emit_error_and_exit(ArtifactResolutionError()) + return # pragma: no cover — _emit_error_and_exit raises + + sys.stdout.write(json.dumps(rows, indent=2, sort_keys=True, ensure_ascii=False)) + sys.stdout.write("\n") + + +@artifact_app.command("info") +def artifact_info( + name: str = typer.Argument(..., help="Artifact name, optionally 'kind:name'."), + json_flag: bool = typer.Option( + False, + "--json", + help="Emit the composition stack as a JSON object on stdout.", + ), + kind: Optional[str] = typer.Option( + None, + "--kind", + help="Narrow the lookup to one artifact family (command/template/script).", + ), +) -> None: + """Show one artifact and its full composition stack.""" + _require_json_flag(json_flag) + + resolved_kind: Optional[ArtifactKind] = None + if kind is not None: + if kind not in ("command", "template", "script"): + print( + f"invalid --kind {kind!r}: expected one of command, template, script", + file=sys.stderr, + ) + raise typer.Exit(code=2) + resolved_kind = kind # type: ignore[assignment] + + try: + root = _resolve_project_root() + catalog = ArtifactCatalog(root) + payload = catalog.get_artifact_info(name, kind=resolved_kind) + except ArtifactError as exc: + _emit_error_and_exit(exc) + return # pragma: no cover + except (OSError, PresetError): + _emit_error_and_exit(ArtifactResolutionError()) + return # pragma: no cover + + sys.stdout.write(json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False)) + sys.stdout.write("\n") + + +def register(app: typer.Typer) -> None: + """Attach the artifact command group to the root Typer app.""" + app.add_typer(artifact_app, name="artifact") diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 83da04d4fb..4096e45a69 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -551,17 +551,12 @@ def _find_command_template(command_name: str, project_root: Path) -> tuple[Path # templates/commands). The previous bespoke inspect.getfile() math # pointed at core_pack/templates/commands, which never exists in a # wheel build (force-include maps templates/commands -> core_pack/commands). - from ._assets import _locate_core_pack, _repo_root - core_pack = _locate_core_pack() - candidate_dirs = [ - core_pack / "commands" if core_pack is not None else None, - _repo_root() / "templates" / "commands", - ] + from ._assets import _locate_shared_asset_dir + + commands_dir = _locate_shared_asset_dir("commands") stem = command_name.replace("speckit.", "").replace("spec.", "") - for candidate_dir in candidate_dirs: - if candidate_dir is None or not candidate_dir.is_dir(): - continue - candidate = candidate_dir / f"{stem}.md" + if commands_dir is not None: + candidate = commands_dir / f"{stem}.md" if candidate.exists(): return candidate, None diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 3968e4fcbe..af370b05f7 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_shared_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_shared_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_shared_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,102 @@ 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) + + for event_name, hook_config in (self.hooks or {}).items(): + deduped: Dict[str, dict] = {} + for entry in coerce_hook_entries(hook_config): + if not isinstance(entry, dict): + continue + command_value = entry.get("command", "") + if command_value in deduped: + del deduped[command_value] + normalized = dict(entry) + # Overwrite (not setdefault) so an author-supplied + # ``eventName`` cannot contradict the containing hook key — + # otherwise an entry under ``before_plan`` carrying + # ``eventName: after_plan`` would be emitted with metadata + # that disagrees with its ``name`` and ``id`` (both of which + # derive from the hook key below). + normalized["eventName"] = event_name + deduped[command_value] = normalized + + for command_value, entry in deduped.items(): + enriched = dict(entry) + enriched.update( + layer="extension", + sourceId=source_id, + kind="hook", + name=f"{event_name}:{command_value}", + id=derive_hook_id( + "extension", source_id, event_name, command_value + ), + ) + 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() @@ -809,7 +920,7 @@ def is_corrupt(self) -> bool: return True if not isinstance(data, dict): return True - if "extensions" in data and not isinstance(data["extensions"], dict): + if "extensions" not in data or not isinstance(data["extensions"], dict): return True return False diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 27c43582b0..5c0a808808 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -27,6 +27,7 @@ import yaml +from .._assets import _locate_shared_asset_dir from .._invocation_style import get_invocation_prefix, is_dollar_skills_agent from .._toml_string import escape_toml_basic as _escape_toml_basic from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control @@ -435,16 +436,7 @@ def shared_commands_dir(self) -> Path | None: ``templates/commands/`` (source checkout). Returns ``None`` if neither exists. """ - import inspect - - pkg_dir = Path(inspect.getfile(IntegrationBase)).resolve().parent.parent - for candidate in [ - pkg_dir / "core_pack" / "commands", - pkg_dir.parent.parent / "templates" / "commands", - ]: - if candidate.is_dir(): - return candidate - return None + return _locate_shared_asset_dir("commands") def shared_templates_dir(self) -> Path | None: """Return path to the shared page templates directory. @@ -452,16 +444,7 @@ def shared_templates_dir(self) -> Path | None: Contains ``vscode-settings.json``, ``spec-template.md``, etc. Checks ``core_pack/templates/`` then ``templates/``. """ - import inspect - - pkg_dir = Path(inspect.getfile(IntegrationBase)).resolve().parent.parent - for candidate in [ - pkg_dir / "core_pack" / "templates", - pkg_dir.parent.parent / "templates", - ]: - if candidate.is_dir(): - return candidate - return None + return _locate_shared_asset_dir("templates") def list_command_templates(self) -> list[Path]: """Return ordered list of command template files from the shared directory.""" diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index a5cea4f958..3664625fba 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() @@ -602,6 +639,42 @@ def _save(self): with open(self.registry_path, 'w', encoding='utf-8') as f: json.dump(self.data, f, indent=2) + def is_corrupt(self) -> bool: + """Report whether an existing registry file is present but unreadable. + + ``_load`` deliberately recovers from a corrupt registry by normalizing + it to an empty mapping so install/enable/disable flows keep working. + Resolution paths (e.g. the artifact catalog), however, must fail + closed: a corrupt registry that normalizes to ``{}`` would otherwise + cause every installed preset to be silently dropped from the reported + inventory. This probe lets those callers distinguish "no registry" + (safe) from "registry exists but is invalid" (unsafe) without changing + recovery behavior. An absent registry returns ``False``; a directory, + broken or dangling symlink, non-regular file, unreadable file, + non-mapping root, or non-mapping ``presets`` value returns ``True``. + + Mirrors :meth:`ExtensionRegistry.is_corrupt` — the two registries have + the same corruption model, so both surfaces (artifact catalog, + extension enumeration) can share the same fail-closed pattern. + """ + # os.path.lexists (not Path.exists) so a dangling symlink is detected + # rather than followed to a non-existent target and mistaken for an + # absent registry. + if not os.path.lexists(self.registry_path): + return False + if not self.registry_path.is_file(): + return True + try: + with open(self.registry_path, "r", encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError, UnicodeDecodeError): + return True + if not isinstance(data, dict): + return True + if "presets" not in data or not isinstance(data["presets"], dict): + return True + return False + def add(self, pack_id: str, metadata: dict): """Add preset to registry. @@ -1788,7 +1861,7 @@ def record_written(written: Dict[str, List[str]]) -> None: if not registered: # Top layer is a non-preset source (extension, core, or # project override). Register directly from the layer path. - source = layers[0]["source"] + source = layers[0].get("source") or "" extension_id = None written: Dict[str, List[str]] = {} if source.startswith("extension:"): @@ -1912,7 +1985,7 @@ def record_written(written: Dict[str, List[str]]) -> None: shared_composed.mkdir(parents=True, exist_ok=True) composed_file = shared_composed / f"{cmd_name}.md" composed_file.write_text(composed, encoding="utf-8") - source = layers[0]["source"] + source = layers[0].get("source") or "" if source.startswith("extension:"): source_id = source.split(":", 1)[1].split(" ", 1)[0] else: @@ -3423,13 +3496,11 @@ def _unregister_skills_in_dir( and restore_from_bundled_core and extension_restore is None ): - from .. import _locate_core_pack, _repo_root + from .._assets import _locate_shared_asset_dir - _core_pack = _locate_core_pack() - if _core_pack is not None: - core_file = _core_pack / "commands" / f"{short_name}.md" - else: - core_file = _repo_root() / "templates" / "commands" / f"{short_name}.md" + commands_dir = _locate_shared_asset_dir("commands") + if commands_dir is not None: + core_file = commands_dir / f"{short_name}.md" if not core_file.exists(): core_file = None @@ -5036,6 +5107,19 @@ 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 / on-disk directory name. That key identifies *where* the pack + lives — it is used for lookup and provenance, and as the ``sourceId`` + of convention-only contribution IDs. Manifest-declared layers instead + take their ``lookupId`` ``sourceId`` from ``PresetManifest.id`` so the + ID joins directly to the manifest's own contributions even when the + installed directory was renamed. + """ + 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 +5265,19 @@ 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. That name identifies *where* the + extension lives — it is used for lookup and provenance, and as the + ``sourceId`` of convention-only contribution IDs. Manifest-declared + layers instead take their ``lookupId`` ``sourceId`` from + ``ExtensionManifest.id`` so the ID joins directly to the manifest's own + contributions even when the installed directory was renamed. + """ + 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 +5291,23 @@ def _core_stem(template_name: str) -> Optional[str]: return template_name[len("speckit."):] return None + @classmethod + def name_candidates(cls, logical_name: str) -> list[str]: + """Return exact-first filename candidates for a ``speckit.`` logical name. + + Given a logical name like ``speckit.plan``, returns + ``["speckit.plan", "plan"]`` so callers can try the fully-qualified + filename first and then fall back to the bare stem. + + Names that do not follow the ``speckit.`` convention return a + single-element list containing the original 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, @@ -5265,6 +5379,8 @@ def resolve( if subdir: candidate = pack_dir / subdir / f"{template_name}{ext}" else: + if template_name.lower() == "readme": + continue candidate = pack_dir / f"{template_name}{ext}" if candidate.exists(): return candidate @@ -5288,6 +5404,8 @@ def resolve( if subdir: candidate = ext_dir / subdir / f"{template_name}{ext}" else: + if template_name.lower() == "readme": + continue candidate = ext_dir / f"{template_name}{ext}" if candidate.exists(): return candidate @@ -5308,49 +5426,23 @@ 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 # (source-checkout / editable install). This is the canonical home for # speckit's built-in command/template files and must always be checked - # so that strategy:wrap presets can locate {CORE_TEMPLATE}. - from specify_cli import _locate_core_pack, _repo_root # local import to avoid cycles - _core_pack = _locate_core_pack() - if _core_pack is not None: - # Wheel install path - if template_type == "template": - candidate = _core_pack / "templates" / f"{template_name}.md" - elif template_type == "command": - candidate = _core_pack / "commands" / f"{template_name}.md" - if not candidate.exists(): - stem = self._core_stem(template_name) - if stem: - candidate = _core_pack / "commands" / f"{stem}.md" - elif template_type == "script": - candidate = _core_pack / "scripts" / f"{template_name}{ext}" - else: - candidate = _core_pack / f"{template_name}.md" - if candidate.exists(): - return candidate - else: - # Source-checkout / editable install: templates live at repo root - repo_root = _repo_root() - if template_type == "template": - candidate = repo_root / "templates" / f"{template_name}.md" - elif template_type == "command": - candidate = repo_root / "templates" / "commands" / f"{template_name}.md" - if not candidate.exists(): - stem = self._core_stem(template_name) - if stem: - candidate = repo_root / "templates" / "commands" / f"{stem}.md" - elif template_type == "script": - candidate = repo_root / "scripts" / f"{template_name}{ext}" - else: - candidate = repo_root / f"{template_name}.md" - if candidate.exists(): - return candidate + # so that strategy:wrap presets can locate {CORE_TEMPLATE}. Delegated + # to the shared core asset resolver via ``_find_bundled_core`` so this + # tier and ``collect_all_layers()`` never disagree about what "core" + # means on this machine. + bundled = self._find_bundled_core(template_name, template_type, ext) + if bundled is not None: + return bundled return None @@ -5512,6 +5604,8 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: if subdir: candidate = base_dir / subdir / f"{template_name}{ext}" else: + if template_name.lower() == "readme": + continue candidate = base_dir / f"{template_name}{ext}" if candidate.exists(): return candidate @@ -5527,6 +5621,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) @@ -5579,10 +5676,29 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: # strategy ("replace") when content is unreadable/invalid. pass version = metadata.get("version", "?") if metadata else "?" + # Manifest-declared entries derive their sourceId from the + # manifest's validated ``id:``, so ``lookupId`` joins + # directly to ``PresetManifest.iter_contributions()``'s + # ``id`` even when the installed directory (``pack_id``) + # was renamed. Convention-only contributions have no + # manifest to consult, so they fall back to the directory + # / registry key. The directory identity is still carried + # separately via ``preset_id`` / ``pack_dir`` / ``source`` + # for on-disk path lookup and provenance display. + source_id_for_lookup = pack_id + if entry is not None: + manifest = self._get_manifest(pack_dir) + if manifest is not None and isinstance(manifest.id, str) and manifest.id: + source_id_for_lookup = manifest.id layers.append({ "path": candidate, "source": f"{pack_id} v{version}", "strategy": strategy, + "preset_id": pack_id, + "pack_dir": pack_dir, + "lookupId": derive_named_id( + "preset", source_id_for_lookup, template_type, template_name + ), }) # Priority 3: Extension-provided templates (always "replace") @@ -5605,12 +5721,46 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: source = f"extension:{ext_id} v{version}" else: source = f"extension:{ext_id} (unregistered)" + # Manifest-declared entries use the manifest's validated ``id:`` + # for the lookupId's sourceId, so ``lookupId`` joins directly to + # ``ExtensionManifest.iter_contributions()``'s ``id`` even when + # the installed directory (``ext_id``) was renamed. Convention- + # only contributions have no manifest to consult and fall back + # to the directory identity. The directory identity is retained + # separately via ``extension_id`` / ``extension_dir`` for path + # / provenance lookup. + source_id_for_lookup = ext_id + if entry is not None: + ext_manifest_path = ext_dir / "extension.yml" + if ext_manifest_path.is_file(): + try: + from ..extensions import ( + ExtensionManifest, + ValidationError as ExtValidationError, + ) + ext_manifest = ExtensionManifest(ext_manifest_path) + if isinstance(ext_manifest.id, str) and ext_manifest.id: + source_id_for_lookup = ext_manifest.id + except ( + ExtValidationError, + yaml.YAMLError, + OSError, + TypeError, + AttributeError, + ): + # Fall back to the directory identity when the + # manifest can't be re-read — same recovery as + # ``_extension_manifest_declared_template``. + pass layers.append({ "path": candidate, "source": source, "strategy": "replace", "extension_id": ext_id, "extension_dir": ext_dir, + "lookupId": derive_named_id( + "extension", source_id_for_lookup, template_type, template_name + ), }) # Priority 4: Core templates (always "replace") @@ -5631,8 +5781,11 @@ 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({ @@ -5647,7 +5800,7 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: if bundled: layers.append({ "path": bundled, - "source": "core (bundled)", + "source": "core", "strategy": "replace", }) @@ -5664,43 +5817,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_shared_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_shared_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_shared_asset_dir("templates") + elif template_type == "command": + base = _locate_shared_asset_dir("commands") + elif template_type == "script": + base = _locate_shared_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.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..9cac1f7985 --- /dev/null +++ b/tests/test_artifact_command.py @@ -0,0 +1,1477 @@ +"""Unit and contract tests for the `specify artifact` command group. + +Covers the pure-logic layer (:class:`ArtifactCatalog`) plus the CLI wiring +(``specify artifact list``, ``specify artifact info``) exercised through +Typer's ``CliRunner``. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +from pathlib import Path + +import pytest +import yaml +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.artifacts import ( + AmbiguousArtifactError, + Artifact, + ArtifactCatalog, + ArtifactKind, + ArtifactNotFoundError, + ArtifactResolutionError, + NotASpecKitProjectError, + _derive_manifest_path, + _preset_display_name, + _public_layer_shape, +) +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]["layer"] is None + assert info["stack"][-1]["sourceId"] is None + assert info["stack"][-1]["lookupId"] is None + assert info["stack"][-1]["sourcePath"] is None + + def test_excludes_disabled_and_unusable_manifest_contributions( + self, spec_kit_project: Path + ): + extensions_dir = spec_kit_project / ".specify" / "extensions" + for extension_id, artifact_name, enabled, file_name in ( + ( + "disabled-ext", + "disabled-template", + False, + "templates/disabled-template.md", + ), + ( + "missing-file-ext", + "missing-template", + True, + "templates/missing-template.md", + ), + ): + extension_dir = extensions_dir / extension_id + extension_dir.mkdir() + (extension_dir / "extension.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "extension": { + "id": extension_id, + "name": extension_id, + "version": "1.0.0", + "description": "test", + "author": "test", + "repository": "https://example.com", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.2.0"}, + "provides": { + "templates": [ + { + "name": artifact_name, + "file": file_name, + "description": "Should not be listed", + } + ] + }, + } + ), + encoding="utf-8", + ) + if not enabled: + template = extension_dir / file_name + template.parent.mkdir() + template.write_text("# Disabled\n", encoding="utf-8") + ExtensionRegistry(extensions_dir).add( + extension_id, {"version": "1.0.0", "enabled": enabled} + ) + + names = {row.name for row in ArtifactCatalog(spec_kit_project).list_artifacts()} + assert "disabled-template" not in names + assert "missing-template" not in names + + def test_unregistered_extension_manifest_id_wins_for_lookup(self, spec_kit_project: Path): + ext_dir = spec_kit_project / ".specify" / "extensions" / "renamed" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + (ext_dir / "commands" / "actual.md").write_text( + "---\ndescription: Manifest identity wins\n---\nbody\n", + encoding="utf-8", + ) + (ext_dir / "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") + # Manifest-declared entries use ``extension.id`` for the ``lookupId`` + # so the join to ``ExtensionManifest.iter_contributions()`` stays + # direct even when the installed directory (``renamed``) was renamed. + assert info["stack"][0]["lookupId"] == "extension:original:command:speckit.original.hello" + assert ( + PresetResolver(spec_kit_project) + .collect_all_layers("speckit.original.hello", "command")[0]["lookupId"] + == "extension:original:command:speckit.original.hello" + ) + # The stack row's manifestPath must still reflect the actual on-disk + # extension directory (``renamed``), not the manifest id embedded in + # ``lookupId``. + assert ( + info["stack"][0]["manifestPath"] + == ".specify/extensions/renamed/extension.yml" + ) + + def test_includes_project_local_core_assets(self, spec_kit_project: Path): + templates_dir = spec_kit_project / ".specify" / "templates" + (templates_dir / "legacy-template.md").write_text( + "---\ndescription: Local template\n---\n", encoding="utf-8" + ) + commands_dir = templates_dir / "commands" + commands_dir.mkdir() + (commands_dir / "local-command.md").write_text( + "---\ndescription: Local command\n---\n", encoding="utf-8" + ) + scripts_dir = templates_dir / "scripts" + scripts_dir.mkdir() + (scripts_dir / "legacy-script.sh").write_text( + "# Local script\n", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + artifacts = {artifact.id: artifact for artifact in catalog.list_artifacts()} + + assert artifacts["template:legacy-template"].description == "Local template" + assert artifacts["command:speckit.local-command"].description == "Local command" + assert artifacts["script:legacy-script"].description == "Local script" + for name in ("speckit.local-command", "legacy-template", "legacy-script"): + layer = catalog.get_artifact_info(name)["stack"][0] + assert layer["layer"] is None + assert layer["sourceId"] is None + assert layer["lookupId"] is None + assert layer["sourcePath"] is None + + def test_includes_root_level_pack_template_but_excludes_readme( + self, spec_kit_project: Path + ): + extension_dir = spec_kit_project / ".specify" / "extensions" / "legacy" + extension_dir.mkdir() + (extension_dir / "legacy-root.md").write_text( + "---\ndescription: Legacy root template\n---\n", + encoding="utf-8", + ) + (extension_dir / "README.md").write_text("# Packaging notes\n", encoding="utf-8") + + catalog = ArtifactCatalog(spec_kit_project) + names = {row.name for row in catalog.list_artifacts()} + + assert "legacy-root" in names + assert "README" not in names + assert next( + row for row in catalog.list_artifacts() if row.name == "legacy-root" + ).description == "Legacy root template" + + @pytest.mark.parametrize("registry_dir, registry_name", [ + ("extensions", "extensions"), + ("presets", "presets"), + ]) + def test_registry_missing_collection_key_is_corrupt( + self, spec_kit_project: Path, registry_dir: str, registry_name: str + ): + registry_path = spec_kit_project / ".specify" / registry_dir / ".registry" + registry_path.write_text('{"schema_version": "1.0"}', encoding="utf-8") + + with pytest.raises(ArtifactResolutionError): + ArtifactCatalog(spec_kit_project).list_artifacts() + + @pytest.mark.skipif(os.name == "nt", reason="':' filenames are unsupported on Windows") + def test_skips_invalid_colon_names_in_project_local_inventory(self, spec_kit_project: Path): + templates_dir = spec_kit_project / ".specify" / "templates" + commands_dir = templates_dir / "commands" + scripts_dir = templates_dir / "scripts" + overrides_dir = templates_dir / "overrides" + override_scripts_dir = overrides_dir / "scripts" + commands_dir.mkdir(parents=True) + scripts_dir.mkdir(parents=True) + overrides_dir.mkdir(parents=True) + override_scripts_dir.mkdir(parents=True) + + (templates_dir / "bad:template.md").write_text("---\ndescription: bad\n---\n", encoding="utf-8") + (commands_dir / "bad:command.md").write_text("---\ndescription: bad\n---\n", encoding="utf-8") + (scripts_dir / "bad:script.sh").write_text("# bad\n", encoding="utf-8") + (overrides_dir / "bad:override.md").write_text("override", encoding="utf-8") + (override_scripts_dir / "bad:override-script.sh").write_text("# bad\n", encoding="utf-8") + + artifacts = ArtifactCatalog(spec_kit_project).list_artifacts() + assert all(":" not in artifact.name for artifact in artifacts) + + def test_preserves_prefixed_project_local_command_names(self, spec_kit_project: Path): + commands_dir = spec_kit_project / ".specify" / "templates" / "commands" + commands_dir.mkdir() + (commands_dir / "speckit.local-prefixed.md").write_text( + "---\ndescription: Local prefixed command\n---\n", encoding="utf-8" + ) + + artifacts = {artifact.id: artifact for artifact in ArtifactCatalog(spec_kit_project).list_artifacts()} + assert "command:speckit.local-prefixed" in artifacts + assert "command:speckit.speckit.local-prefixed" not in artifacts + + def test_prefers_exact_core_command_name(self, spec_kit_project: Path): + commands_dir = spec_kit_project / ".specify" / "templates" / "commands" + commands_dir.mkdir() + (commands_dir / "foo.md").write_text( + "---\ndescription: Stripped fallback\n---\n", encoding="utf-8" + ) + exact_path = commands_dir / "speckit.foo.md" + exact_path.write_text( + "---\ndescription: Exact logical name\n---\n", encoding="utf-8" + ) + + assert PresetResolver(spec_kit_project).resolve("speckit.foo", "command") == exact_path + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.foo") + assert info["description"] == "Exact logical name" + + def test_active_preset_description_overrides_hidden_core_description( + self, spec_kit_project: Path + ): + """A preset that overrides a core command must win the description too. + + Regression test: descriptions used to be merged "first non-empty + wins", and core rows were inserted before contributions — so an + active preset's replacement of a core command still reported the + (now-inactive) core description. + """ + commands_dir = spec_kit_project / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True) + (commands_dir / "speckit.constitution.md").write_text( + "---\ndescription: Core description\n---\n", encoding="utf-8" + ) + + pack = install_preset( + spec_kit_project, + "override-preset", + { + "commands": [ + {"name": "speckit.constitution", "description": "Preset description"} + ] + }, + ) + (pack / "commands").mkdir() + (pack / "commands" / "speckit.constitution.md").write_text( + "# Preset\n", encoding="utf-8" + ) + + artifacts = { + artifact.id: artifact + for artifact in ArtifactCatalog(spec_kit_project).list_artifacts() + } + assert artifacts["command:speckit.constitution"].description == "Preset description" + + def test_higher_precedence_preset_description_wins(self, spec_kit_project: Path): + """When two presets both provide an artifact, the winner's description wins. + + Lower ``priority`` number means higher precedence (see + ``PresetResolver.collect_all_layers``); the loser's description must + not leak through just because it happens to be enumerated first + alphabetically. + """ + pack_low = install_preset( + spec_kit_project, + "aaa-low-priority-preset", + {"templates": [{"name": "shared-artifact", "description": "Loser description"}]}, + priority=20, + ) + (pack_low / "templates").mkdir() + (pack_low / "templates" / "shared-artifact.md").write_text( + "# Loser\n", encoding="utf-8" + ) + + pack_high = install_preset( + spec_kit_project, + "zzz-high-priority-preset", + {"templates": [{"name": "shared-artifact", "description": "Winner description"}]}, + priority=5, + ) + (pack_high / "templates").mkdir() + (pack_high / "templates" / "shared-artifact.md").write_text( + "# Winner\n", encoding="utf-8" + ) + + artifacts = { + artifact.id: artifact + for artifact in ArtifactCatalog(spec_kit_project).list_artifacts() + } + assert artifacts["template:shared-artifact"].description == "Winner description" + + +class TestListSorting: + """Deterministic ordering: kind first (command/template/script), then name.""" + + def test_kind_grouping(self, spec_kit_project: Path): + rows = ArtifactCatalog(spec_kit_project).list_artifacts() + kinds_seen = [r.kind for r in rows] + # kinds must appear as contiguous groups in the fixed order + first_idx = {k: next((i for i, x in enumerate(kinds_seen) if x == k), None) for k in ("command", "template", "script")} + indices = [v for v in first_idx.values() if v is not None] + assert indices == sorted(indices) + + def test_name_sorted_within_kind(self, spec_kit_project: Path): + rows = ArtifactCatalog(spec_kit_project).list_artifacts() + by_kind: dict[str, list[str]] = {} + for r in rows: + by_kind.setdefault(r.kind, []).append(r.name) + for _, names in by_kind.items(): + assert names == sorted(names) + + +class TestEmptyProject: + def test_empty_stack_returns_empty_list(self, tmp_path: Path): + # A .specify/ dir with no presets/extensions and no accessible core. + # We can't easily wipe the core baseline in this process, so instead + # verify list_artifacts is at least callable and returns a list. + root = tmp_path / "empty" + root.mkdir() + (root / ".specify").mkdir() + rows = ArtifactCatalog(root).list_artifacts() + assert isinstance(rows, list) + + +# --------------------------------------------------------------------------- +# get_artifact_info contract +# --------------------------------------------------------------------------- + + +class TestInfoContract: + def test_stack_ordered_highest_first(self, spec_kit_project: Path): + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + assert info["stack"], "expected at least one stack layer" + + def test_exactly_one_active_row(self, spec_kit_project: Path): + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + actives = [layer for layer in info["stack"] if layer["active"]] + assert len(actives) == 1 + + def test_active_is_index_zero(self, spec_kit_project: Path): + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + assert info["stack"][0]["active"] is True + for layer in info["stack"][1:]: + assert layer["active"] is False + + def test_builtin_row_shape(self, spec_kit_project: Path): + resolver_layer = PresetResolver(spec_kit_project).collect_all_layers( + "speckit.constitution", "command" + )[-1] + assert resolver_layer["source"] == "core" + assert "lookupId" not in resolver_layer + + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + assert info["id"] == "command:speckit.constitution" + builtin = next(layer for layer in info["stack"] if layer["layer"] is None) + assert builtin["sourceId"] is None + assert builtin["presetId"] is None + assert builtin["presetName"] is None + assert builtin["manifestPath"] is None + assert builtin["strategy"] == "replace" + assert builtin["lookupId"] is None + assert builtin["sourcePath"] is None + + def test_public_layer_shape_preserves_non_core_identity(self): + assert _public_layer_shape( + { + "source": "preset:foo v1", + "lookupId": "preset:foo:template:spec-template", + } + ) == ("preset", "foo", "preset:foo:template:spec-template") + + 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["sourcePath"] is None + assert project["strategy"] == "replace" + assert project["sourceId"] == "_" + assert re.match(r"^project:_:(command|template|script):[^:]+$", project["lookupId"]) + + def test_lookup_id_grammar(self, spec_kit_project: Path): + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + for layer in info["stack"]: + if layer["lookupId"] is None: + assert layer["layer"] is None + assert layer["sourceId"] is None + continue + assert re.match( + r"^(project|preset|extension):[^:]+:(command|template|script):[^:]+(:[0-9a-f]{12})?$", + layer["lookupId"], + ) + + def test_id_matches_list(self, spec_kit_project: Path): + cat = ArtifactCatalog(spec_kit_project) + info = cat.get_artifact_info("speckit.constitution") + assert info["id"] == "command:speckit.constitution" + + def test_every_stack_row_carries_id(self, spec_kit_project: Path): + """Every stack row carries a non-null ``id``, including built-in rows. + + ``id`` is the source-agnostic round-trip key; it does not depend on + the row having a ``lookupId`` (manifest-backed layer provenance). + """ + overrides = spec_kit_project / ".specify" / "templates" / "overrides" + overrides.mkdir() + (overrides / "speckit.constitution.md").write_text("override", encoding="utf-8") + + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + assert len(info["stack"]) >= 2 + for layer in info["stack"]: + assert layer["id"] == "command:speckit.constitution" + + +# --------------------------------------------------------------------------- +# Error conditions — pinned strings for the artifact-error contract +# --------------------------------------------------------------------------- + + +class TestErrors: + def test_unknown_artifact_message(self, spec_kit_project: Path): + with pytest.raises(ArtifactNotFoundError) as excinfo: + ArtifactCatalog(spec_kit_project).get_artifact_info("no.such.thing") + assert excinfo.value.message == "unknown artifact no.such.thing" + assert ERROR_REGEX.match(excinfo.value.message) + + def test_not_a_project(self, non_project: Path): + with pytest.raises(NotASpecKitProjectError) as excinfo: + ArtifactCatalog(non_project).list_artifacts() + assert excinfo.value.message == "not a Spec Kit project: no .specify/ directory found" + assert ERROR_REGEX.match(excinfo.value.message) + + def test_ambiguous_artifact_message(self, spec_kit_project: Path): + """When both a command and a template share the same bare name.""" + # Register a preset that contributes 'shared-name' as both a + # template and a script — the info lookup with no kind hint should + # then be ambiguous. + pack = install_preset( + spec_kit_project, + "test-ambig", + { + "templates": [ + {"type": "template", "name": "shared-name", "description": "t"}, + {"type": "script", "name": "shared-name", "description": "s"}, + ], + }, + ) + (pack / "templates").mkdir() + (pack / "templates" / "shared-name.md").write_text("# Template\n") + (pack / "scripts").mkdir() + (pack / "scripts" / "shared-name.sh").write_text("#!/usr/bin/env bash\n") + with pytest.raises(AmbiguousArtifactError) as excinfo: + ArtifactCatalog(spec_kit_project).get_artifact_info("shared-name") + assert excinfo.value.message.startswith("ambiguous artifact shared-name: matches kinds") + assert ERROR_REGEX.match(excinfo.value.message) + + def test_resolution_error_message(self): + assert ArtifactResolutionError().message == "artifact resolution failed" + + def test_info_rejects_corrupt_extension_registry(self, spec_kit_project: Path): + registry = spec_kit_project / ".specify" / "extensions" / ".registry" + registry.write_text("{invalid", encoding="utf-8") + + with pytest.raises(ArtifactResolutionError): + ArtifactCatalog(spec_kit_project).get_artifact_info("command:speckit.constitution") + + def test_info_rejects_corrupt_preset_registry(self, spec_kit_project: Path): + registry = spec_kit_project / ".specify" / "presets" / ".registry" + registry.write_text("{invalid", encoding="utf-8") + + with pytest.raises(ArtifactResolutionError): + ArtifactCatalog(spec_kit_project).get_artifact_info("command:speckit.constitution") + + def test_list_rejects_corrupt_preset_registry(self, spec_kit_project: Path): + registry = spec_kit_project / ".specify" / "presets" / ".registry" + registry.write_text("{invalid", encoding="utf-8") + + with pytest.raises(ArtifactResolutionError): + ArtifactCatalog(spec_kit_project).list_artifacts() + + +class TestKindHint: + def test_kind_flag_disambiguates(self, spec_kit_project: Path): + install_preset( + spec_kit_project, + "test-kind", + {"templates": [{"name": "dup", "description": "t"}], + "scripts": [{"name": "dup", "description": "s"}]}, + ) + # No stack file backs these contributions on disk so the info call + # will raise unknown after resolving kind — either way it should + # not raise ambiguous when a kind is supplied. + try: + ArtifactCatalog(spec_kit_project).get_artifact_info("dup", kind="template") + except ArtifactNotFoundError: + pass # expected: manifest declared it but no file to compose + + def test_shorthand_grammar(self, spec_kit_project: Path): + # Even with core commands, the shorthand should route correctly. + info = ArtifactCatalog(spec_kit_project).get_artifact_info("command:speckit.constitution") + assert info["kind"] == "command" + + def test_conflicting_shorthand_and_flag(self, spec_kit_project: Path): + with pytest.raises(ArtifactNotFoundError): + ArtifactCatalog(spec_kit_project).get_artifact_info( + "template:speckit.constitution", kind="command" + ) + + @pytest.mark.parametrize( + ("kind", "name"), + ( + ("template", "../../outside"), + ("command", "template:foo"), + ("script", "script:name"), + ), + ) + def test_kind_hint_rejects_invalid_name_components( + self, spec_kit_project: Path, kind: ArtifactKind, name: str + ): + with pytest.raises(ArtifactNotFoundError): + ArtifactCatalog(spec_kit_project).get_artifact_info(name, kind=kind) + + def test_id_form_round_trips_to_same_artifact(self, spec_kit_project: Path): + """``artifact info`` accepts the public ``id`` form (``kind:name``). + + Given either the bare name or its ``id``, the resolved artifact is + the same — ``id`` is the source-agnostic round-trip key. + """ + cat = ArtifactCatalog(spec_kit_project) + by_bare = cat.get_artifact_info("speckit.plan") + by_id = cat.get_artifact_info("command:speckit.plan") + assert by_id == by_bare + + def test_id_form_resolves_template_despite_same_named_command( + self, spec_kit_project: Path + ): + """``kind:name`` disambiguates when a command shares a template's name.""" + pack_dir = install_preset( + spec_kit_project, + "collide-pack", + {"commands": [{"name": "spec-template", "description": "cmd"}]}, + ) + (pack_dir / "commands").mkdir(parents=True, exist_ok=True) + (pack_dir / "commands" / "spec-template.md").write_text( + "colliding command body", encoding="utf-8" + ) + + # Sanity check: without a kind hint, the bare name is ambiguous + # because both a command and a template named "spec-template" exist. + with pytest.raises(AmbiguousArtifactError): + ArtifactCatalog(spec_kit_project).get_artifact_info("spec-template") + + info = ArtifactCatalog(spec_kit_project).get_artifact_info("template:spec-template") + assert info["kind"] == "template" + assert info["id"] == "template:spec-template" + + +# --------------------------------------------------------------------------- +# Skills exclusion +# --------------------------------------------------------------------------- + + +class TestSkillsExcluded: + def test_no_skills_in_list(self, spec_kit_project: Path): + skills_dir = spec_kit_project / ".github" / "skills" / "speckit-my-skill" + skills_dir.mkdir(parents=True) + (skills_dir / "SKILL.md").write_text("---\nname: my-skill\n---\nbody", encoding="utf-8") + rows = ArtifactCatalog(spec_kit_project).list_artifacts() + assert not any("skill" in r.name.lower() for r in rows) + + +# --------------------------------------------------------------------------- +# CLI wiring — Typer CliRunner +# --------------------------------------------------------------------------- + + +class TestCLI: + def test_list_requires_json_flag(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list"]) + assert result.exit_code == 2 + assert result.stdout == "" + + def test_list_json_emits_array(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert isinstance(payload, list) + assert result.stdout.endswith("\n") + + def test_list_json_rows_include_stack(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert payload, "expected at least one artifact" + + row = payload[0] + assert set(row.keys()) == {"id", "name", "kind", "description", "stack"} + assert isinstance(row["stack"], list) + + info_result = runner.invoke(app, ["artifact", "info", row["id"], "--json"]) + assert info_result.exit_code == 0, info_result.stderr + info = json.loads(info_result.stdout) + assert row["stack"] == info["stack"] + + def test_hidden_command_layer_source_path_is_own_pack_file( + self, spec_kit_project: Path + ): + """A hidden (non-active) command row must not report the winner's + shared materialized agent output as its ``sourcePath``. + + Both presets below register the same command name and the same + agent skill name, so the tracked materialized output is a single + shared file. Only the active (winning) row may report that shared + file; the hidden loser row must report its own installed pack file. + """ + pack_low = install_preset( + spec_kit_project, + "aaa-low-priority-preset", + { + "commands": [ + { + "name": "speckit.compliance.plan", + "file": "commands/speckit.compliance.plan.md", + "description": "Loser", + } + ] + }, + priority=20, + ) + (pack_low / "commands").mkdir() + (pack_low / "commands" / "speckit.compliance.plan.md").write_text( + "---\ndescription: Loser\n---\nloser body\n", encoding="utf-8" + ) + PresetRegistry(spec_kit_project / ".specify" / "presets").update( + "aaa-low-priority-preset", + {"registered_skills": {"copilot": ["speckit-compliance-plan"]}}, + ) + + pack_high = install_preset( + spec_kit_project, + "zzz-high-priority-preset", + { + "commands": [ + { + "name": "speckit.compliance.plan", + "file": "commands/speckit.compliance.plan.md", + "description": "Winner", + } + ] + }, + priority=5, + ) + (pack_high / "commands").mkdir() + (pack_high / "commands" / "speckit.compliance.plan.md").write_text( + "---\ndescription: Winner\n---\nwinner body\n", encoding="utf-8" + ) + PresetRegistry(spec_kit_project / ".specify" / "presets").update( + "zzz-high-priority-preset", + {"registered_skills": {"copilot": ["speckit-compliance-plan"]}}, + ) + + skill_file = ( + spec_kit_project + / ".github" + / "skills" + / "speckit-compliance-plan" + / "SKILL.md" + ) + skill_file.parent.mkdir(parents=True) + skill_file.write_text("---\nname: speckit-compliance-plan\n---\n", encoding="utf-8") + + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.compliance.plan") + stack = info["stack"] + assert stack[0]["active"] is True + assert stack[0]["sourcePath"] == ".github/skills/speckit-compliance-plan/SKILL.md" + + hidden_rows = [layer for layer in stack if layer["active"] is False] + assert hidden_rows + for row in hidden_rows: + assert row["sourcePath"] != stack[0]["sourcePath"] + assert row["sourcePath"] == ( + ".specify/presets/aaa-low-priority-preset/commands/speckit.compliance.plan.md" + ) + + def test_list_json_stack_source_path_contract( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + preset_pack = install_preset( + spec_kit_project, + "compliance", + { + "commands": [ + { + "name": "speckit.compliance.plan", + "file": "commands/speckit.compliance.plan.md", + "description": "Compliance plan", + } + ] + }, + ) + (preset_pack / "commands").mkdir() + (preset_pack / "commands" / "speckit.compliance.plan.md").write_text( + "---\ndescription: Compliance plan\n---\nbody\n", encoding="utf-8" + ) + PresetRegistry(spec_kit_project / ".specify" / "presets").update( + "compliance", + { + "registered_skills": { + "copilot": ["speckit-compliance-plan"], + } + }, + ) + skill_file = ( + spec_kit_project + / ".github" + / "skills" + / "speckit-compliance-plan" + / "SKILL.md" + ) + skill_file.parent.mkdir(parents=True) + skill_file.write_text("---\nname: speckit-compliance-plan\n---\n", encoding="utf-8") + + extension_dir = spec_kit_project / ".specify" / "extensions" / "quality" + (extension_dir / "templates").mkdir(parents=True) + (extension_dir / "templates" / "checklist.md").write_text( + "---\ndescription: Extension checklist\n---\n", encoding="utf-8" + ) + (extension_dir / "extension.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "extension": { + "id": "quality", + "name": "Quality", + "version": "1.0.0", + "description": "test", + "author": "test", + "repository": "https://example.com", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.2.0"}, + "provides": { + "templates": [ + { + "name": "checklist", + "file": "templates/checklist.md", + "description": "Extension checklist", + } + ] + }, + } + ), + encoding="utf-8", + ) + ExtensionRegistry(spec_kit_project / ".specify" / "extensions").add( + "quality", {"version": "1.0.0", "enabled": True} + ) + + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + + non_null_source_paths: set[str] = set() + for row in payload: + for layer in row["stack"]: + assert "sourcePath" in layer + source_path = layer["sourcePath"] + if source_path is None: + continue + assert isinstance(source_path, str) + assert (spec_kit_project / source_path).is_file() + non_null_source_paths.add(source_path) + + assert ".github/skills/speckit-compliance-plan/SKILL.md" in non_null_source_paths + assert ".specify/extensions/quality/templates/checklist.md" in non_null_source_paths + + def test_list_json_is_pretty_printed(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert ' "id"' in result.stdout # 2-space indent visible + + def test_info_json_shape(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "info", "speckit.constitution", "--json"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert set(payload.keys()) == {"id", "name", "kind", "description", "stack"} + + def test_info_accepts_id_form_on_cli( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + by_bare = runner.invoke(app, ["artifact", "info", "speckit.plan", "--json"]) + by_id = runner.invoke(app, ["artifact", "info", "command:speckit.plan", "--json"]) + assert by_bare.exit_code == 0, by_bare.stderr + assert by_id.exit_code == 0, by_id.stderr + assert json.loads(by_id.stdout) == json.loads(by_bare.stdout) + + def test_info_unknown_error_envelope(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "info", "no.such.thing", "--json"]) + assert result.exit_code == 1 + assert result.stdout == "" + err = json.loads(result.stderr) + assert set(err.keys()) == {"error"} + assert ERROR_REGEX.match(err["error"]) + + def test_info_corrupt_extension_registry_uses_json_error_envelope( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + extensions_dir = spec_kit_project / ".specify" / "extensions" + (extensions_dir / ".registry").write_text("{invalid", encoding="utf-8") + monkeypatch.chdir(spec_kit_project) + result = CliRunner().invoke( + app, ["artifact", "info", "speckit.constitution", "--json"] + ) + assert result.exit_code == 1 + assert result.stdout == "" + assert json.loads(result.stderr) == {"error": "artifact resolution failed"} + + def test_list_corrupt_extension_registry_uses_json_error_envelope( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + extensions_dir = spec_kit_project / ".specify" / "extensions" + (extensions_dir / ".registry").write_text("{invalid", encoding="utf-8") + monkeypatch.chdir(spec_kit_project) + result = CliRunner().invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 1 + assert result.stdout == "" + assert json.loads(result.stderr) == {"error": "artifact resolution failed"} + + def test_not_a_project_error_envelope(self, non_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(non_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 1 + assert result.stdout == "" + err = json.loads(result.stderr) + assert err["error"] == "not a Spec Kit project: no .specify/ directory found" + + def test_stdout_empty_on_error(self, non_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(non_project) + runner = CliRunner() + for argv in ( + ["artifact", "list", "--json"], + ["artifact", "info", "x", "--json"], + ): + result = runner.invoke(app, argv) + assert result.stdout == "", f"stdout leak for {argv}: {result.stdout!r}" + + @pytest.mark.parametrize( + "override", + ("missing-project", "."), + ) + def test_invalid_init_dir_override_uses_json_error_envelope( + self, + non_project: Path, + monkeypatch: pytest.MonkeyPatch, + override: str, + ): + monkeypatch.chdir(non_project) + monkeypatch.setenv("SPECIFY_INIT_DIR", override) + runner = CliRunner() + for argv in ( + ["artifact", "list", "--json"], + ["artifact", "info", "x", "--json"], + ): + result = runner.invoke(app, argv) + assert result.exit_code == 1 + assert result.stdout == "" + assert json.loads(result.stderr) == { + "error": "not a Spec Kit project: no .specify/ directory found" + } + + +class TestUTF8NoBOM: + def test_output_is_utf8_without_bom(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 0 + # No BOM at start + assert not result.stdout.startswith("\ufeff") + + +# --------------------------------------------------------------------------- +# Preset composition integration — active/hidden semantics +# --------------------------------------------------------------------------- + + +class TestStackComposition: + def test_preset_command_uses_entry_type(self, spec_kit_project: Path): + pack = install_preset( + spec_kit_project, + "test-command", + { + "templates": [ + { + "type": "command", + "name": "speckit.constitution", + "description": "override", + } + ] + }, + ) + (pack / "commands").mkdir() + (pack / "commands" / "speckit.constitution.md").write_text( + "---\ndescription: override\n---\nbody", encoding="utf-8" + ) + + rows = ArtifactCatalog(spec_kit_project).list_artifacts() + assert any(row.id == "command:speckit.constitution" for row in rows) + assert ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution")["kind"] == "command" + + def test_preset_single_segment_command_id_from_list_is_resolvable( + self, spec_kit_project: Path + ): + pack = install_preset( + spec_kit_project, + "test-single-command", + {"commands": [{"name": "specify", "description": "single segment"}]}, + ) + (pack / "commands").mkdir() + (pack / "commands" / "specify.md").write_text( + "---\ndescription: single segment\n---\nbody", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + ids = {row.id for row in catalog.list_artifacts()} + + assert "command:specify" in ids + info = catalog.get_artifact_info("command:specify") + assert info["id"] == "command:specify" + assert catalog.get_artifact_info("specify", kind="command")["id"] == "command:specify" + + def test_append_only_candidate_without_base_is_not_listed( + self, spec_kit_project: Path + ): + pack = install_preset( + spec_kit_project, + "append-only", + { + "templates": [ + { + "type": "template", + "name": "append-only-template", + "strategy": "append", + } + ] + }, + ) + (pack / "templates").mkdir() + (pack / "templates" / "append-only-template.md").write_text( + "append", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + + assert "template:append-only-template" not in { + row.id for row in catalog.list_artifacts() + } + with pytest.raises(ArtifactNotFoundError): + catalog.get_artifact_info("append-only-template", kind="template") + + def test_preset_replace_hides_core(self, spec_kit_project: Path): + # Install a preset that replaces the constitution command. + pack = install_preset( + spec_kit_project, + "test-replace", + {"commands": [{"name": "speckit.constitution", "description": "override"}]}, + ) + (pack / "commands").mkdir() + (pack / "commands" / "speckit.constitution.md").write_text( + "---\ndescription: override\n---\nbody", encoding="utf-8" + ) + + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + stack = info["stack"] + assert stack[0]["active"] is True + assert stack[0]["hidden"] is False + # If a lower built-in layer exists it must be hidden. + built_in_rows = [layer for layer in stack if layer["layer"] is None] + for row in built_in_rows: + assert row["hidden"] is True + + +# --------------------------------------------------------------------------- +# Convention-based discovery — extensions without a manifest, project overrides +# --------------------------------------------------------------------------- + + +class TestConventionDiscovery: + def test_unregistered_extension_template_without_manifest(self, spec_kit_project: Path): + ext_dir = spec_kit_project / ".specify" / "extensions" / "legacy" / "templates" + ext_dir.mkdir(parents=True) + (ext_dir / "legacy-template.md").write_text("body", encoding="utf-8") + + catalog = ArtifactCatalog(spec_kit_project) + assert any(row.id == "template:legacy-template" for row in catalog.list_artifacts()) + info = catalog.get_artifact_info("legacy-template") + assert info["stack"][0]["lookupId"] == "extension:legacy:template:legacy-template" + + 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_project_override_reports_its_own_description(self, spec_kit_project: Path): + """An override's frontmatter/comment metadata wins over the hidden layer.""" + commands_dir = spec_kit_project / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True) + (commands_dir / "speckit.constitution.md").write_text( + "---\ndescription: Core description\n---\n", encoding="utf-8" + ) + overrides = spec_kit_project / ".specify" / "templates" / "overrides" + (overrides / "scripts").mkdir(parents=True) + (overrides / "speckit.constitution.md").write_text( + "---\ndescription: Override description\n---\n", encoding="utf-8" + ) + (overrides / "scripts" / "local-script.sh").write_text( + "#!/bin/sh\n# Override script description\n", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + rows = {row.id: row.description for row in catalog.list_artifacts()} + assert rows["command:speckit.constitution"] == "Override description" + assert rows["script:local-script"] == "Override script description" + + def test_project_override_without_metadata_falls_back(self, spec_kit_project: Path): + """A metadata-free override still reports the hidden layer's description.""" + commands_dir = spec_kit_project / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True) + (commands_dir / "speckit.constitution.md").write_text( + "---\ndescription: Core description\n---\n", encoding="utf-8" + ) + overrides = spec_kit_project / ".specify" / "templates" / "overrides" + overrides.mkdir(parents=True) + (overrides / "speckit.constitution.md").write_text("body\n", encoding="utf-8") + + catalog = ArtifactCatalog(spec_kit_project) + rows = {row.id: row.description for row in catalog.list_artifacts()} + assert rows["command:speckit.constitution"] == "Core description" + + def test_project_override_describes_both_backed_kinds(self, spec_kit_project: Path): + commands_dir = spec_kit_project / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True) + (commands_dir / "shared.md").write_text("command\n", encoding="utf-8") + templates_dir = spec_kit_project / ".specify" / "templates" + (templates_dir / "shared.md").write_text("template\n", encoding="utf-8") + overrides = templates_dir / "overrides" + overrides.mkdir(parents=True) + (overrides / "shared.md").write_text( + "---\ndescription: Shared override\n---\n", encoding="utf-8" + ) + + rows = {row.id: row.description for row in ArtifactCatalog(spec_kit_project).list_artifacts()} + + assert rows["command:shared"] == "Shared override" + assert rows["template:shared"] == "Shared override" + + 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_stale_registry_entry_with_missing_pack_dir_is_skipped( + self, spec_kit_project: Path + ): + pack_dir = spec_kit_project / ".specify" / "presets" / "removed-preset" + pack_dir.mkdir() + PresetRegistry(pack_dir.parent).add( + "removed-preset", {"priority": 10, "version": "1.0.0"} + ) + shutil.rmtree(pack_dir) + + catalog = ArtifactCatalog(spec_kit_project) + # Should not raise FileNotFoundError despite the registry entry + # pointing at a directory that no longer exists on disk; the stale + # preset contributes no artifacts. + ids = {row.id for row in catalog.list_artifacts()} + assert not any("removed-preset" in artifact_id for artifact_id in ids) + + def test_command_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", + "preset_id": "my-pack", + "pack_dir": pack_dir, + } + 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", + "extension_id": "my-ext", + "extension_dir": ext_dir, + } + assert ( + _derive_manifest_path(layer, project_root) + == ".specify/extensions/my-ext/extension.yml" + ) + + def test_renamed_pack_directory_wins_over_lookup_id_source(self, tmp_path: Path): + """The manifest path must track the on-disk directory, never a stale + directory guessed from ``lookupId``'s manifest-declared ``sourceId``.""" + project_root = tmp_path / "proj" + pack_dir = project_root / ".specify" / "presets" / "renamed-on-disk" + pack_dir.mkdir(parents=True) + (pack_dir / "preset.yml").write_text("id: original-manifest-id\n", encoding="utf-8") + # A stale directory matching the manifest id must not exist, so a + # lookupId-based guess would resolve to a nonexistent manifest. + stale_dir = project_root / ".specify" / "presets" / "original-manifest-id" + assert not stale_dir.exists() + + layer = { + "lookupId": "preset:original-manifest-id:template:spec-template", + "path": pack_dir / "spec-template.md", + "preset_id": "renamed-on-disk", + "pack_dir": pack_dir, + } + assert ( + _derive_manifest_path(layer, project_root) + == ".specify/presets/renamed-on-disk/preset.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", + "preset_id": "my-pack", + "pack_dir": pack_dir, + } + assert _derive_manifest_path(layer, project_root) is None + + def test_missing_provenance_keys_is_none(self, tmp_path: Path): + """Without explicit ``preset_id``/``pack_dir``, no path is guessed from + ``lookupId`` — the caller gets ``None`` instead of a wrong 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) is None + + def test_builtin_and_project_layers_have_no_manifest(self, tmp_path: Path): + project_root = tmp_path / "proj" + project_root.mkdir() + + builtin_layer = {} + project_layer = {"lookupId": "project:_:template:spec-template"} + assert _derive_manifest_path(builtin_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..be2ba80d91 --- /dev/null +++ b/tests/test_artifact_command_parity.py @@ -0,0 +1,119 @@ +"""Resolver-parity tests for the `specify artifact` command group. + +Verifies that the artifact output stays consistent with the underlying +:class:`~specify_cli.presets.PresetResolver`, including for contributions +that only a manifest can surface. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from specify_cli.artifacts import ArtifactCatalog +from specify_cli.presets import PresetResolver +from tests.conftest import install_preset + + +@pytest.fixture +def spec_kit_project(tmp_path: Path) -> Path: + root = tmp_path / "proj" + root.mkdir() + (root / ".specify").mkdir() + (root / ".specify" / "presets").mkdir() + (root / ".specify" / "extensions").mkdir() + (root / ".specify" / "templates").mkdir() + return root + + +class TestResolverParity: + """The ``active: true`` row must be what :meth:`resolve_content` would pick.""" + + def test_manifest_declared_artifact_matches_resolver(self, spec_kit_project: Path): + pack = install_preset( + spec_kit_project, + "test-manifest-parity", + { + "templates": [ + { + "type": "command", + "name": "speckit.manifest-declared", + "file": "commands/differently-named.md", + "description": "manifest contribution", + } + ] + }, + ) + (pack / "commands").mkdir() + (pack / "commands" / "differently-named.md").write_text( + "body-from-manifest", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + info = catalog.get_artifact_info("speckit.manifest-declared") + active = next(layer for layer in info["stack"] if layer["active"]) + winner = PresetResolver(spec_kit_project).resolve_content( + "speckit.manifest-declared", template_type="command" + ) + + assert winner == "body-from-manifest" + assert active["layer"] == "preset" + assert active["lookupId"] == "preset:test-manifest-parity:command:speckit.manifest-declared" + + def test_preset_manifest_id_mismatch_uses_manifest_id(self, spec_kit_project: Path): + pack = install_preset( + spec_kit_project, + "renamed-preset", + { + "commands": [ + { + "name": "speckit.preset-renamed.hello", + "file": "commands/actual.md", + "description": "manifest contribution", + } + ] + }, + ) + manifest_path = pack / "preset.yml" + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + manifest["preset"]["id"] = "original-preset" + manifest_path.write_text(yaml.safe_dump(manifest), encoding="utf-8") + (pack / "commands").mkdir() + (pack / "commands" / "actual.md").write_text( + "body-from-renamed-preset", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + assert "command:speckit.preset-renamed.hello" in { + row.id for row in catalog.list_artifacts() + } + info = catalog.get_artifact_info("speckit.preset-renamed.hello") + active = next(layer for layer in info["stack"] if layer["active"]) + winner = PresetResolver(spec_kit_project).resolve_content( + "speckit.preset-renamed.hello", template_type="command" + ) + + assert winner == "body-from-renamed-preset" + # Manifest-declared entries use the manifest's validated id, so the + # ``lookupId`` joins directly to ``PresetManifest.iter_contributions()`` + # regardless of the installed directory name. + assert active["lookupId"] == ( + "preset:original-preset:command:speckit.preset-renamed.hello" + ) + assert ( + PresetResolver(spec_kit_project) + .collect_all_layers("speckit.preset-renamed.hello", "command")[0]["lookupId"] + == "preset:original-preset:command:speckit.preset-renamed.hello" + ) + # The stack row's presetId / manifestPath must still reflect the + # actual on-disk directory (``renamed-preset``), not the manifest id + # embedded in ``lookupId`` — otherwise the display and manifest path + # would point to a non-existent location. + assert active["presetId"] == "renamed-preset" + assert active["manifestPath"] == ".specify/presets/renamed-preset/preset.yml" + + +def test_module_imports(): + _ = ArtifactCatalog diff --git a/tests/test_assets.py b/tests/test_assets.py new file mode 100644 index 0000000000..da79b81a4c --- /dev/null +++ b/tests/test_assets.py @@ -0,0 +1,66 @@ +"""Tests for the shared bundle-path resolvers in `specify_cli._assets`.""" + +from __future__ import annotations + +import specify_cli._assets as assets + + +class TestLocateSharedAssetDir: + """Tests for the shared wheel-then-source asset directory lookup.""" + + def test_prefers_wheel_core_pack_over_repo_checkout(self, tmp_path, monkeypatch): + package_dir = tmp_path / "site-packages" / "specify_cli" + core_pack = package_dir / "core_pack" + (core_pack / "commands").mkdir(parents=True) + repo_root = tmp_path / "repo" + (repo_root / "templates" / "commands").mkdir(parents=True) + + monkeypatch.setattr(assets, "__file__", str(package_dir / "_assets.py")) + monkeypatch.setattr(assets, "_repo_root", lambda: repo_root) + + assert assets._locate_shared_asset_dir("commands") == core_pack / "commands" + + def test_falls_back_to_repo_checkout_when_no_wheel_bundle(self, tmp_path, monkeypatch): + package_dir = tmp_path / "site-packages" / "specify_cli" + 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, "__file__", str(package_dir / "_assets.py")) + monkeypatch.setattr(assets, "_repo_root", lambda: repo_root) + + assert ( + assets._locate_shared_asset_dir("commands") + == repo_root / "templates" / "commands" + ) + assert assets._locate_shared_asset_dir("templates") == repo_root / "templates" + assert assets._locate_shared_asset_dir("scripts") == repo_root / "scripts" + + def test_returns_none_when_directory_missing(self, tmp_path, monkeypatch): + package_dir = tmp_path / "site-packages" / "specify_cli" + monkeypatch.setattr(assets, "__file__", str(package_dir / "_assets.py")) + monkeypatch.setattr(assets, "_repo_root", lambda: tmp_path / "nonexistent") + + assert assets._locate_shared_asset_dir("commands") is None + + def test_falls_back_to_repo_checkout_when_wheel_bundle_missing_subdir( + self, tmp_path, monkeypatch + ): + """A wheel bundle without the requested family subdir must not short-circuit + the source-checkout fallback, matching the "wheel, then source" pattern + used by ``_locate_bundled_extension``/``_locate_bundled_workflow``/ + ``_locate_bundled_preset``.""" + package_dir = tmp_path / "site-packages" / "specify_cli" + core_pack = package_dir / "core_pack" + core_pack.mkdir(parents=True) # bundle exists but has no "commands/" subdir + repo_root = tmp_path / "repo" + (repo_root / "templates" / "commands").mkdir(parents=True) + + monkeypatch.setattr(assets, "__file__", str(package_dir / "_assets.py")) + monkeypatch.setattr(assets, "_repo_root", lambda: repo_root) + + assert ( + assets._locate_shared_asset_dir("commands") + == repo_root / "templates" / "commands" + ) diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py new file mode 100644 index 0000000000..dab93db674 --- /dev/null +++ b/tests/test_contribution_ids.py @@ -0,0 +1,531 @@ +"""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, hook deduplication, 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, + derive_hook_id, + derive_named_id, + derive_public_id, + 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", + [ + ("project", "_", "command", "speckit.constitution", "project:_:command:speckit.constitution"), + ("project", "_", "template", "spec-template", "project:_:template:spec-template"), + ("project", "_", "script", "setup-plan", "project:_: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", + [ + ("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): + assert derive_hook_id(layer, source_id, event, command) == 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 + + def test_public_id_is_source_agnostic(self): + assert derive_public_id("command", "speckit.plan") == "command:speckit.plan" + + @pytest.mark.parametrize( + "args", + [ + ("preset", "speckit-core", "hook", "before_plan:speckit.plan"), + ("unknown", "source", "command", "speckit.plan"), + ("core", "_", "command", "speckit.plan"), + ], + ) + def test_named_id_rejects_invalid_layer_or_kind(self, args): + with pytest.raises(IdentifierComponentError): + derive_named_id(*args) + + def test_public_id_rejects_non_artifact_kind(self): + with pytest.raises(IdentifierComponentError): + derive_public_id("hook", "before_plan:speckit.plan") + + @pytest.mark.parametrize("layer", [PROJECT_OVERRIDE_LAYER, "core"]) + def test_hook_id_rejects_non_manifest_layer(self, layer): + with pytest.raises(IdentifierComponentError): + derive_hook_id(layer, "_", "before_plan", "speckit.plan") + + +class TestLayerKindFromLookupId: + """``layer_kind_from_lookup_id`` extracts the layer segment of a lookupId.""" + + @pytest.mark.parametrize( + "lookup_id, expected", + [ + ("preset:speckit-core:template:spec-template", "preset"), + ("extension:speckit-git:script:post-commit", "extension"), + (f"{PROJECT_OVERRIDE_LAYER}:_:template:spec-template", PROJECT_OVERRIDE_LAYER), + ( + "extension:speckit-git:hook:before_specify:speckit.git.branch", + "extension", + ), + ], + ) + 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", + "core", + ":_:command:speckit.plan", + "core:not-an-id", + "preset:x", + "core:_:command", + "extension:speckit-git:hook:before_specify", + "core::command:speckit.plan", + "core:_:bogus:speckit.plan", + "project:_:hook:some-event:some-command", + ], + ) + def test_unrecognized_or_malformed_returns_none(self, lookup_id): + assert layer_kind_from_lookup_id(lookup_id) is None + + +class TestHookContributions: + def test_duplicate_commands_are_last_wins_and_move_to_end(self, tmp_path): + data = _extension_data( + hooks={ + "before_plan": [ + {"command": "speckit.speckitgit.branch", "priority": 10}, + {"command": "speckit.speckitgit.status", "priority": 20}, + {"command": "speckit.speckitgit.branch", "priority": 30}, + ] + } + ) + 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 + assert [(hook["command"], hook["priority"]) for hook in hooks] == [ + ("speckit.speckitgit.status", 20), + ("speckit.speckitgit.branch", 30), + ] + assert hooks[-1]["id"] == ( + "extension:speckit-git:hook:before_plan:speckit.speckitgit.branch" + ) + assert manifest.contribution_id( + "hook", "before_plan:speckit.speckitgit.branch" + ) == hooks[-1]["id"] + + +# --------------------------------------------------------------------------- +# 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_builtin_layer_preserves_resolver_provenance(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") + builtin_layer = next(layer for layer in layers if layer["source"] == "core") + assert "lookupId" not in builtin_layer + + 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..bc1d9d9569 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_shared_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_shared_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_shared_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..ec6d2773c9 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1173,6 +1173,33 @@ def test_resolve_nonexistent(self, project_dir): result = resolver.resolve("nonexistent-template") assert result is None + def test_core_fallback_uses_shared_asset_resolver(self, project_dir, monkeypatch): + """resolve() tier 5 and collect_all_layers() must agree on "core". + + Regression test: the tier-5 branch used to read ``core_pack//`` + directly, so a wheel bundle missing ``scripts/`` made ``resolve()`` + return nothing while ``collect_all_layers()`` fell back to the source + checkout via ``_locate_shared_asset_dir``. + """ + import specify_cli._assets as assets + + core_pack = project_dir.parent / "core_pack" + (core_pack / "commands").mkdir(parents=True) # bundle exists, no scripts/ + repo_root = project_dir.parent / "repo" + (repo_root / "scripts" / "bash").mkdir(parents=True) + script = repo_root / "scripts" / "bash" / "core-only.sh" + script.write_text("#!/bin/sh\n", encoding="utf-8") + + monkeypatch.setattr( + assets, "__file__", str(project_dir.parent / "_assets.py") + ) + monkeypatch.setattr(assets, "_repo_root", lambda: repo_root) + + resolver = PresetResolver(project_dir) + assert resolver.resolve("core-only", "script") == script + layers = resolver.collect_all_layers("core-only", "script") + assert [layer["path"] for layer in layers] == [script] + def test_resolver_ignores_traversing_registry_ids(self, project_dir): """Registry IDs cannot escape preset or extension install roots.""" for registry_dir, registry_key, outside_name in ( @@ -1679,7 +1706,8 @@ def test_collect_all_layers_finds_bundled_core_without_specify_commands( resolver = PresetResolver(project_dir) layers = resolver.collect_all_layers("speckit.implement", "command") assert layers, "expected a bundled core base layer to be found" - assert layers[-1]["source"] == "core (bundled)" + assert layers[-1]["source"] == "core" + assert "lookupId" not in layers[-1] assert layers[-1]["path"].parts[-2:] == ("commands", "implement.md") def test_resolve_command_falls_back_to_bundled_core(self, project_dir): @@ -11945,6 +11973,17 @@ def test_extension_template_convention_lookup_unaffected_when_undeclared(self, p assert layers, "expected convention-based lookup to still find the template" assert layers[0]["path"] == tmpl_dir / "legacy-template.md" + @pytest.mark.parametrize("pack_kind", ["preset", "extension"]) + def test_root_readme_is_not_resolved_as_template(self, project_dir, pack_kind): + pack_dir = project_dir / ".specify" / f"{pack_kind}s" / "legacy" + pack_dir.mkdir(parents=True) + (pack_dir / "README.md").write_text("packaging notes\n") + + resolver = PresetResolver(project_dir) + + assert resolver.resolve("README", "template") is None + assert resolver.collect_all_layers("README", "template") == [] + def test_extension_manifest_wins_over_stale_conventional_file(self, project_dir): """A declared entry is authoritative even when a stale file also sits at the conventional path (templates/.md) — the manifest must win, @@ -12844,6 +12883,7 @@ def test_single_core_layer(self, project_dir): layers = resolver.collect_all_layers("spec-template") assert len(layers) == 1 assert layers[0]["source"] == "core" + assert "lookupId" not in layers[0] assert layers[0]["strategy"] == "replace" def test_layers_include_presets(self, project_dir, temp_dir, valid_pack_data): @@ -12911,6 +12951,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.""" @@ -13295,7 +13418,10 @@ def test_seeds_from_core_when_no_preset(self, project_dir): memory = project_dir / ".specify" / "memory" / "constitution.md" assert memory.exists() assert "[PROJECT_NAME]" in memory.read_text() - assert (memory.parent / ".constitution-template.json").exists() + provenance = json.loads( + (memory.parent / ".constitution-template.json").read_text() + ) + assert provenance["source"] == "core" def test_seeds_from_preset_when_installed(self, project_dir): from specify_cli.commands.init import ensure_constitution_from_template @@ -13742,7 +13868,9 @@ def test_resolve_accepts_dotted_command_name(self, project_dir): ) assert result.exit_code == 0, (result.output, result.exception) - assert "constitution.md" in "".join(strip_ansi(result.output).split()) + output = " ".join(strip_ansi(result.output).split()) + assert "constitution.md" in output + assert "top layer from: core" in output def test_resolve_rejects_empty_command_segments(self, project_dir): """Dotted command identifiers cannot contain empty path-like segments.""" @@ -13975,7 +14103,7 @@ def test_wrap_composes_over_core_constitution(self, project_dir): assert len(layers) >= 2, "expected preset wrap layer plus a core base" assert layers[0]["strategy"] == "wrap" assert any("constitution-sync" in str(layer["path"]) for layer in layers) - assert layers[-1]["source"] == "core (bundled)" + assert layers[-1]["source"] == "core" def test_resolved_content_embeds_core_and_sync_pass(self, project_dir): """resolve_content substitutes {CORE_TEMPLATE} so the effective command