From 6da4330a50b5f4f7a5271ad2b3bb031e48602725 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 21 Aug 2026 13:58:32 -0500 Subject: [PATCH 01/49] Add deterministic contribution IDs and stack lookup IDs for resolved artifacts Every command, template, script, and hook contribution returned by preset and extension manifest surfaces now carries a computed opaque identifier of the form {layer}:{sourceId}:{kind}:{name}, and every resolved artifact-stack layer carries a matching lookupId derived from the same recipe. Identifiers are computed at read time from author-declared manifest content only. No paths, timestamps, or file-content hashes contribute to derivation, so identifiers are stable across machines, reinstalls, and directory moves. Nothing is persisted to .specify/ or any cache. Hooks that collide within a source on (eventName, command) get a 12-hex SHA-256 discriminator computed from the canonical JSON of the entry's declared fields minus eventName/command. Two hook entries with byte-identical remaining fields are rejected at manifest load because there is no meaningful way to distinguish them. The change is purely additive: all existing name-based resolution behaviour is preserved, and no consumer keys off the new id or lookupId fields. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous) --- docs/reference/presets.md | 19 + extensions/EXTENSION-API-REFERENCE.md | 56 ++- src/specify_cli/_identifier.py | 178 ++++++++ src/specify_cli/extensions/__init__.py | 154 +++++++ src/specify_cli/presets/__init__.py | 51 +++ tests/test_contribution_ids.py | 552 +++++++++++++++++++++++++ 6 files changed, 1009 insertions(+), 1 deletion(-) create mode 100644 src/specify_cli/_identifier.py create mode 100644 tests/test_contribution_ids.py diff --git a/docs/reference/presets.md b/docs/reference/presets.md index 1098abfb42..6f4a428908 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -205,6 +205,25 @@ specify preset add team-workflow --priority 10 For any file that both provide, `compliance` wins (priority 5 < 10). For files only one provides, that one is used. For files neither provides, the core default is used. +## Contribution Identifiers + +Every command, template, and script contributed by a preset (or an extension, or the core layer) is addressable at read time by a deterministic opaque identifier of the form: + +```text +{layer}:{sourceId}:{kind}:{name} +``` + +- `layer` is one of `core`, `preset`, or `extension`. +- `sourceId` is `_` for `core`, the preset pack id for `preset`, or the extension id for `extension`. +- `kind` is one of `command`, `template`, or `script`. +- `name` is the entry's declared `name` field. + +Identifiers are computed on demand from author-declared manifest content and are never persisted to `.specify/` or any cache. Copying a preset to another machine (or touching its files) does not change the identifiers it produces. + +`PresetResolver.collect_all_layers()` returns layer dicts that each include a `lookupId` field pointing back to the originating contribution's `id`. Project-local overrides in `.specify/templates/overrides/` are a resolver-only concept — they carry a synthetic `project:_:{kind}:{name}` `lookupId` that intentionally does not match any manifest contribution. + +For the full grammar, including the hook name-component convention and the discriminator recipe used by extensions, see the [Extension API Reference — Contribution Identifiers](../../extensions/EXTENSION-API-REFERENCE.md#contribution-identifiers) section. + ## FAQ ### Can I use multiple presets at the same time? diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index a7bece0b89..475c3c8212 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -10,6 +10,7 @@ Technical reference for Spec Kit extension system APIs and manifest schema. 4. [Configuration Schema](#configuration-schema) 5. [Hook System](#hook-system) 6. [CLI Commands](#cli-commands) +7. [Contribution Identifiers](#contribution-identifiers) --- @@ -859,7 +860,60 @@ satisfied = version_satisfies("1.2.3", ">=1.0.0,<2.0.0") # bool --- -## File System Layout +## Contribution Identifiers + +Every command, template, script, and hook contributed by an extension (or a preset, or the core layer) is addressable at read time by a deterministic opaque identifier. Resolved artifact-stack layers carry a matching `lookupId` field that points back to the contribution the layer came from. Identifiers are **computed on demand from author-declared manifest content** and are **never persisted** to `.specify/` or to any cache file. + +### Grammar + +Named contributions (commands, templates, scripts) follow: + +```text +{layer}:{sourceId}:{kind}:{name} +``` + +- `layer` is one of `core`, `preset`, or `extension`. +- `sourceId` is `_` for `core`, the preset pack id for `preset`, or the extension id for `extension`. +- `kind` is one of `command`, `template`, `script`, or `hook`. +- `name` is the contribution's declared `name` field. + +Hook contributions use a compound name-component built from the event and command: + +```text +{layer}:{sourceId}:hook:{eventName}:{command} +``` + +When two or more hook entries within the same source share the same `(eventName, command)` pair, a 12-hex-character discriminator is appended: + +```text +{layer}:{sourceId}:hook:{eventName}:{command}:{discriminator} +``` + +The discriminator is the first 12 lowercase hex characters of `sha256(canonical_json(entry - {eventName, command}))`. Two hook entries with byte-identical declared fields (after removing `eventName` and `command`) are rejected at manifest load with a `ValidationError` naming both positions — there is no meaningful way to distinguish them at read time. + +### Reserved character + +`:` is reserved as the identifier component separator. It cannot appear inside any of `layer`, `sourceId`, `kind`, `name`, `eventName`, or `command`. Extension ids, command names, template names, and script names are already constrained by their existing regex patterns (`^[a-z0-9-]+$` and friends), which forbid `:`. Hook event names (mapping keys) and hook `command` values are additionally validated to reject `:` at manifest load. + +### The `project:` sentinel + +Project-local overrides in `.specify/templates/overrides/` are a resolver-only concept — they have no backing manifest and cannot appear in `iter_contributions()`. Layers of that kind carry a synthetic `lookupId` of the form `project:_:{kind}:{name}` so consumers that reverse-lookup the id always see "not found", which is the intended behaviour: overrides are addressable at the stack level, not as first-class contributions. + +### Python API + +`ExtensionManifest.iter_contributions()` yields dicts of the form `{layer, sourceId, kind, name, id, ...author-declared fields}`; each entry's `id` is the computed identifier. `ExtensionManifest.contribution_id(kind, name)` returns the id for a single lookup, or `None` if no contribution matches. `PresetManifest` exposes the same two methods. + +`PresetResolver.collect_all_layers()` returns layer dicts that include a `lookupId` field for every layer type (`project override`, preset, extension, core, and bundled core). + +### Determinism guarantees + +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 any id. Copying an extension or preset to a different machine (or renaming its directory, or touching its files) does not change the identifiers it produces. + +### Opacity guidance + +Identifiers are stable, but treat them as **opaque strings** in stored data (registries, cache files, external tooling). Parse them with the helpers in `specify_cli._identifier` (`derive_named_id`, `derive_hook_id`) rather than by string-splitting on `:` — the discriminator suffix and future grammar extensions may otherwise catch you out. + + ```text .specify/ diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py new file mode 100644 index 0000000000..4124157df5 --- /dev/null +++ b/src/specify_cli/_identifier.py @@ -0,0 +1,178 @@ +"""Deterministic identifiers for Spec Kit contributions and resolved stack layers. + +Every command, template, script, and hook contribution surfaced by a preset or +extension manifest carries a computed opaque ``id`` string, and every layer of a +resolved artifact stack carries a matching ``lookupId``. The identifier value is +derived only from author-declared manifest data — it never depends on file +contents, timestamps, archive hashes, installation directory paths, install-time +random values, or list positions. That is what makes identifiers portable +across machines, project locations, and reinstalls, and what lets consumers use +them as stable join keys. + +Grammar for named contributions (commands, templates, scripts):: + + id = "{layer}:{sourceId}:{kind}:{name}" + + layer ∈ {"core", "preset", "extension"} + sourceId = "_" when layer == "core"; the preset id or extension id otherwise + kind ∈ {"command", "template", "script", "hook"} + name = the contribution's declared ``name`` + +Hook identifiers use ``{eventName}:{command}`` as the name component:: + + id = "{layer}:{sourceId}:hook:{eventName}:{command}[:{discriminator}]" + +The 12-lowercase-hex discriminator is appended only when at least one sibling +hook in the same source shares the same ``(eventName, command)`` pair, and it is +computed by SHA-256 of a canonical JSON serialization of the hook entry's +declared fields (with ``eventName`` and ``command`` removed, since they already +appear in the identifier prefix). Two hook entries in the same source whose +declared fields produce byte-identical canonical JSON are rejected at manifest +load time — they are semantically identical listeners. + +The functions in this module are pure — inputs are strings or in-memory +mappings parsed from a manifest, outputs are strings. None of them read from +disk, look at ``os.environ``, call ``datetime``, or hash file contents. That +guarantee is what preserves portability, and it is enforced by inspection +rather than by runtime checks: any change here that adds an ambient input is a +change that breaks the identifier contract. +""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any, Iterable, Mapping + + +PROJECT_OVERRIDE_LAYER = "project" +"""Resolver-only layer label for project-local override layers. + +Project overrides are a resolver feature — they are not backed by any manifest +contribution. When a resolved artifact stack contains a project-override layer, +its ``lookupId`` uses this label so the round-trip invariant (every layer +carries a ``lookupId``) still holds. No manifest ``iter_contributions()`` will +ever emit a matching ``id``, so consumers see "not found" for the lookup, which +is the correct outcome for a layer with no originating manifest entry. +""" + +_DISCRIMINATOR_LENGTH = 12 + + +class IdentifierComponentError(ValueError): + """Raised when a manifest component would break identifier grammar.""" + + +def validate_component(value: Any, field_label: str) -> str: + """Return ``value`` unchanged if it is a non-empty ``:``-free string. + + Manifest components that appear in an identifier (``layer``, ``sourceId``, + ``kind``, ``name``, ``eventName``, ``command``) may not contain the ``:`` + delimiter — the grammar has no escape rule. This function is the guard used + by manifest validators to reject offending values at load time with a clear + message naming the field. + """ + if not isinstance(value, str): + raise IdentifierComponentError( + f"Invalid {field_label}: expected a string, got {type(value).__name__}" + ) + if not value: + raise IdentifierComponentError( + f"Invalid {field_label}: value must not be empty" + ) + if ":" in value: + raise IdentifierComponentError( + f"Invalid {field_label} '{value}': ':' is reserved as an identifier delimiter" + ) + return value + + +def derive_named_id(layer: str, source_id: str, kind: str, name: str) -> str: + """Build the identifier string for a named contribution kind. + + Callers are expected to have already validated each component with + :func:`validate_component` at manifest-load time; this function does not + revalidate — it is a pure string join so the identifier can be computed + cheaply on every read. + """ + return f"{layer}:{source_id}:{kind}:{name}" + + +def canonical_json(value: Any) -> bytes: + """Serialize ``value`` to a canonical UTF-8 JSON byte string. + + Mapping keys are sorted lexicographically at every depth, list order is + preserved (author intent), whitespace is stripped, and non-ASCII characters + are emitted verbatim. This is the byte string that the hook discriminator + hashes and that the manifest loader uses to detect byte-identical duplicate + hook entries. + """ + normalized = _normalize_for_canonical_json(value) + return json.dumps( + normalized, + sort_keys=True, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + + +def _normalize_for_canonical_json(value: Any) -> Any: + if isinstance(value, Mapping): + return {str(k): _normalize_for_canonical_json(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_normalize_for_canonical_json(v) for v in value] + return value + + +def _has_hook_sibling_collision( + event_name: str, + command: str, + siblings: Iterable[Mapping[str, Any]], +) -> bool: + """Return True when at least one sibling shares the same event/command pair. + + ``siblings`` is the full same-source hook entry list including the entry + whose identifier is being derived. A collision therefore means at least two + entries share the pair. + """ + seen = 0 + for entry in siblings: + if entry.get("eventName") == event_name and entry.get("command") == command: + seen += 1 + if seen >= 2: + return True + return False + + +def hook_discriminator(declared_fields: Mapping[str, Any]) -> str: + """Compute the 12-hex-char SHA-256 discriminator for a hook entry. + + ``declared_fields`` is the entry as parsed from the manifest with + ``eventName`` and ``command`` removed — those two values already appear in + the identifier prefix, so hashing them would only reflect information the + consumer can already read. + """ + return hashlib.sha256(canonical_json(declared_fields)).hexdigest()[:_DISCRIMINATOR_LENGTH] + + +def derive_hook_id( + layer: str, + source_id: str, + event_name: str, + command: str, + siblings: Iterable[Mapping[str, Any]], + own_declared_fields: Mapping[str, Any], +) -> str: + """Build the identifier string for a hook contribution. + + The discriminator suffix is appended only when at least one sibling in the + same source shares the same ``(event_name, command)`` prefix. That keeps the + common case terse and the collision case unambiguous. ``siblings`` must + include every hook entry declared under this source (including the one + whose identifier is being derived); the function decides on its own whether + a collision exists. + """ + base = f"{layer}:{source_id}:hook:{event_name}:{command}" + if _has_hook_sibling_collision(event_name, command, siblings): + return f"{base}:{hook_discriminator(own_declared_fields)}" + return base diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 3968e4fcbe..9ab8283319 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -28,6 +28,13 @@ from packaging.specifiers import InvalidSpecifier, SpecifierSet from .._assets import _locate_core_pack, _repo_root +from .._identifier import ( + IdentifierComponentError, + canonical_json, + derive_hook_id, + derive_named_id, + validate_component, +) from .._download_security import ( archive_format_from_name, archive_suffix, @@ -415,6 +422,11 @@ 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 + event_entries: List[dict] = [] for entry in coerce_hook_entries(hook_config): if not isinstance(entry, dict): raise ValidationError( @@ -425,6 +437,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): @@ -437,6 +456,35 @@ def _validate(self): f"Hook '{hook_name}' has invalid 'priority': " "must be >= 1" ) + event_entries.append(entry) + + # Reject two hook entries under the same (event, command) whose + # declared fields (with eventName/command stripped) canonicalize + # to the same byte string — those are semantically identical + # listeners with no way to address them separately. + by_command: Dict[str, List[tuple[int, dict]]] = {} + for idx, entry in enumerate(event_entries): + by_command.setdefault(entry["command"], []).append((idx, entry)) + for command_value, group in by_command.items(): + if len(group) < 2: + continue + seen_canonical: Dict[bytes, int] = {} + for idx, entry in group: + stripped = { + k: v + for k, v in entry.items() + if k not in ("eventName", "command") + } + key = canonical_json(stripped) + if key in seen_canonical: + first_idx = seen_canonical[key] + raise ValidationError( + f"Duplicate hook entries for event '{hook_name}' " + f"command '{command_value}': entries at positions " + f"{first_idx} and {idx} have byte-identical declared " + "fields and cannot be uniquely identified" + ) + seen_canonical[key] = idx # Validate commands; track renames so hook references can be rewritten. rename_map: Dict[str, str] = {} @@ -725,6 +773,112 @@ def hooks(self) -> Dict[str, Any]: """Get hook definitions.""" return self.data.get("hooks", {}) + def iter_contributions(self) -> List[Dict[str, Any]]: + """Return an enriched, ordered list of every contribution this manifest declares. + + Each dict is a shallow copy of the underlying manifest entry with four + derived keys added: ``layer`` (always ``"extension"``), ``sourceId`` + (this manifest's ``id``), ``kind`` (``"command"`` / ``"template"`` / + ``"script"`` / ``"hook"``), and ``id`` (the deterministic identifier). + Hook entries also carry a synthesized ``name`` field of the form + ``"{eventName}:{command}"`` alongside the original ``eventName`` / + ``command`` values, so consumers can locate a hook by its identifier's + name component without re-splitting the string. + + The underlying ``self.data`` mapping is never mutated — the enriched + dicts are constructed fresh on every call so callers can safely rely on + the identifiers reflecting the current in-memory manifest state. + """ + source_id = self.id + contributions: List[Dict[str, Any]] = [] + + for cmd in self.commands: + enriched = dict(cmd) + name = cmd.get("name", "") + enriched.update( + layer="extension", + sourceId=source_id, + kind="command", + id=derive_named_id("extension", source_id, "command", name), + ) + contributions.append(enriched) + + for tmpl in self.templates: + enriched = dict(tmpl) + name = tmpl.get("name", "") + enriched.update( + layer="extension", + sourceId=source_id, + kind="template", + id=derive_named_id("extension", source_id, "template", name), + ) + contributions.append(enriched) + + for scr in self.scripts: + enriched = dict(scr) + name = scr.get("name", "") + enriched.update( + layer="extension", + sourceId=source_id, + kind="script", + id=derive_named_id("extension", source_id, "script", name), + ) + contributions.append(enriched) + + hooks = self.hooks or {} + # Flatten every hook entry across every event so the discriminator + # decision has visibility into the full same-source sibling set. + flattened: List[tuple[str, dict]] = [] + for event_name, hook_config in hooks.items(): + for entry in coerce_hook_entries(hook_config): + if isinstance(entry, dict): + normalized = dict(entry) + normalized.setdefault("eventName", event_name) + flattened.append((event_name, normalized)) + + siblings_for_id = [ + {"eventName": event, "command": entry.get("command", "")} + for event, entry in flattened + ] + + for event_name, entry in flattened: + command_value = entry.get("command", "") + declared_fields = { + k: v + for k, v in entry.items() + if k not in ("eventName", "command") + } + hook_id = derive_hook_id( + "extension", + source_id, + event_name, + command_value, + siblings_for_id, + declared_fields, + ) + enriched = dict(entry) + enriched.update( + layer="extension", + sourceId=source_id, + kind="hook", + name=f"{event_name}:{command_value}", + id=hook_id, + ) + contributions.append(enriched) + + return contributions + + def contribution_id(self, kind: str, name: str) -> Optional[str]: + """Return the computed identifier for a single contribution, if declared. + + ``name`` is the declared name for command/template/script kinds, or the + ``"{eventName}:{command}"`` compound for hook kinds. + """ + for entry in self.iter_contributions(): + if entry["kind"] == kind and entry.get("name") == name: + return entry["id"] + return None + def get_hash(self) -> str: """Calculate SHA256 hash of manifest file.""" h = hashlib.sha256() diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index a5cea4f958..95398e0d31 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -37,6 +37,10 @@ safe_extract_archive, ) from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority +from .._identifier import ( + PROJECT_OVERRIDE_LAYER, + derive_named_id, +) from .._init_options import ( MISSING_INIT_OPTIONS_FILE, is_ai_skills_enabled, @@ -539,6 +543,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() @@ -5527,6 +5563,9 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: "path": override, "source": "project override", "strategy": "replace", + "lookupId": derive_named_id( + PROJECT_OVERRIDE_LAYER, "_", template_type, template_name + ), }) # Priority 2: Installed presets (sorted by priority — lower number = higher precedence) @@ -5583,6 +5622,9 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: "path": candidate, "source": f"{pack_id} v{version}", "strategy": strategy, + "lookupId": derive_named_id( + "preset", pack_id, template_type, template_name + ), }) # Priority 3: Extension-provided templates (always "replace") @@ -5611,6 +5653,9 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: "strategy": "replace", "extension_id": ext_id, "extension_dir": ext_dir, + "lookupId": derive_named_id( + "extension", ext_id, template_type, template_name + ), }) # Priority 4: Core templates (always "replace") @@ -5639,6 +5684,9 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: "path": core, "source": "core", "strategy": "replace", + "lookupId": derive_named_id( + "core", "_", template_type, template_name + ), }) else: # Priority 5: Bundled core_pack (wheel install) or repo-root @@ -5649,6 +5697,9 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: "path": bundled, "source": "core (bundled)", "strategy": "replace", + "lookupId": derive_named_id( + "core", "_", template_type, template_name + ), }) return layers diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py new file mode 100644 index 0000000000..e26a224c3b --- /dev/null +++ b/tests/test_contribution_ids.py @@ -0,0 +1,552 @@ +"""Tests for the deterministic contribution-id and stack lookup-id feature. + +Every command / template / script / hook contribution surfaced by a preset or +extension manifest exposes a computed ``id`` derived from author-declared data +only, and every layer of a resolved artifact stack exposes a matching +``lookupId``. The scenarios below cover: the identifier grammar across every +``layer x kind`` combination, the hook discriminator collision + rejection +rules, cross-process byte-stability, path/mtime independence, and the +additive-only shape guarantee for the enriched contribution dicts. +""" + +from __future__ import annotations + +import copy +import json +import os +import shutil +import subprocess +import sys +import textwrap +import time +from pathlib import Path + +import pytest +import yaml + +from specify_cli._identifier import ( + IdentifierComponentError, + PROJECT_OVERRIDE_LAYER, + canonical_json, + derive_hook_id, + derive_named_id, + hook_discriminator, + validate_component, +) +from specify_cli.extensions import ExtensionManifest, ValidationError +from specify_cli.presets import PresetManifest, PresetResolver + + +# --------------------------------------------------------------------------- +# Fixture builders (programmatic — no on-disk fixture tree) +# --------------------------------------------------------------------------- + + +def _preset_data(pack_id: str = "speckit-core") -> dict: + return { + "schema_version": "1.0", + "preset": { + "id": pack_id, + "name": pack_id, + "version": "1.0.0", + "description": "Fixture preset", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + {"type": "command", "name": "speckit.plan", "file": "commands/plan.md"}, + {"type": "template", "name": "spec-template", "file": "templates/spec.md"}, + {"type": "script", "name": "setup-plan", "file": "scripts/setup-plan.sh"}, + ] + }, + } + + +def _extension_data( + ext_id: str = "speckit-git", + hooks: dict | None = None, + with_commands: bool = True, + with_templates: bool = True, + with_scripts: bool = True, +) -> dict: + data = { + "schema_version": "1.0", + "extension": { + "id": ext_id, + "name": ext_id, + "version": "1.0.0", + "description": "Fixture extension", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {}, + } + if with_commands: + data["provides"]["commands"] = [ + { + "name": f"speckit.{ext_id.replace('-', '')}.branch", + "file": "commands/branch.md", + "description": "Fixture command", + } + ] + if with_templates: + data["provides"]["templates"] = [ + {"name": "pr-body", "file": "templates/pr-body.md"} + ] + if with_scripts: + data["provides"]["scripts"] = [ + {"name": "post-commit", "file": "scripts/post-commit.sh"} + ] + if hooks is not None: + data["hooks"] = hooks + return data + + +def _write_manifest(tmp_path: Path, data: dict, filename: str) -> Path: + manifest_path = tmp_path / filename + with open(manifest_path, "w", encoding="utf-8") as fh: + yaml.safe_dump(data, fh, sort_keys=False) + return manifest_path + + +# --------------------------------------------------------------------------- +# Identifier grammar — layer x kind derivation matrix +# --------------------------------------------------------------------------- + + +class TestIdentifierDerivation: + """Every layer x kind combination produces the expected grammar.""" + + @pytest.mark.parametrize( + "layer, source_id, kind, name, expected", + [ + ("core", "_", "command", "speckit.constitution", "core:_:command:speckit.constitution"), + ("core", "_", "template", "spec-template", "core:_:template:spec-template"), + ("core", "_", "script", "setup-plan", "core:_:script:setup-plan"), + ("preset", "speckit-core", "command", "speckit.plan", "preset:speckit-core:command:speckit.plan"), + ("preset", "speckit-core", "template", "spec-template", "preset:speckit-core:template:spec-template"), + ("preset", "speckit-core", "script", "setup-plan", "preset:speckit-core:script:setup-plan"), + ("extension", "speckit-git", "command", "speckit.git.branch", "extension:speckit-git:command:speckit.git.branch"), + ("extension", "speckit-git", "template", "pr-body", "extension:speckit-git:template:pr-body"), + ("extension", "speckit-git", "script", "post-commit", "extension:speckit-git:script:post-commit"), + ], + ) + def test_named_id_grammar(self, layer, source_id, kind, name, expected): + assert derive_named_id(layer, source_id, kind, name) == expected + + @pytest.mark.parametrize( + "layer, source_id, event, command, expected", + [ + ("core", "_", "before_specify", "speckit.constitution", "core:_:hook:before_specify:speckit.constitution"), + ("preset", "speckit-core", "before_plan", "speckit.plan", "preset:speckit-core:hook:before_plan:speckit.plan"), + ("extension", "speckit-git", "before_specify", "speckit.git.branch", "extension:speckit-git:hook:before_specify:speckit.git.branch"), + ], + ) + def test_hook_id_no_discriminator(self, layer, source_id, event, command, expected): + siblings = [{"eventName": event, "command": command}] + assert ( + derive_hook_id(layer, source_id, event, command, siblings, {}) + == expected + ) + + def test_named_id_stable_across_two_derivations(self): + a = derive_named_id("preset", "speckit-core", "command", "speckit.plan") + b = derive_named_id("preset", "speckit-core", "command", "speckit.plan") + assert a == b + + +# --------------------------------------------------------------------------- +# Canonical JSON +# --------------------------------------------------------------------------- + + +class TestCanonicalJson: + def test_sorts_mapping_keys_at_every_depth(self): + payload = {"z": 1, "a": {"y": 2, "x": [3, {"n": 4, "m": 5}]}} + assert canonical_json(payload) == b'{"a":{"x":[3,{"m":5,"n":4}],"y":2},"z":1}' + + def test_preserves_list_order(self): + assert canonical_json([3, 1, 2]) == b"[3,1,2]" + + def test_utf8_no_ensure_ascii(self): + assert canonical_json({"k": "café"}).decode("utf-8") == '{"k":"café"}' + + +# --------------------------------------------------------------------------- +# Hook discriminator behaviour +# --------------------------------------------------------------------------- + + +class TestHookDiscriminator: + def test_no_discriminator_when_unique(self, tmp_path): + data = _extension_data( + hooks={ + "before_specify": {"command": "speckit.speckitgit.branch"}, + } + ) + manifest = ExtensionManifest(_write_manifest(tmp_path, data, "extension.yml")) + hooks = [c for c in manifest.iter_contributions() if c["kind"] == "hook"] + assert len(hooks) == 1 + assert hooks[0]["id"] == "extension:speckit-git:hook:before_specify:speckit.speckitgit.branch" + + def test_discriminator_when_colliding(self, tmp_path): + data = _extension_data( + hooks={ + "before_plan": [ + {"command": "speckit.speckitgit.branch", "priority": 10}, + {"command": "speckit.speckitgit.branch", "priority": 20}, + ] + } + ) + manifest = ExtensionManifest(_write_manifest(tmp_path, data, "extension.yml")) + hooks = [c for c in manifest.iter_contributions() if c["kind"] == "hook"] + assert len(hooks) == 2 + prefixes = {"extension:speckit-git:hook:before_plan:speckit.speckitgit.branch"} + for h in hooks: + assert h["id"].startswith(next(iter(prefixes)) + ":") + suffix = h["id"].rsplit(":", 1)[-1] + assert len(suffix) == 12 + assert all(ch in "0123456789abcdef" for ch in suffix) + assert hooks[0]["id"] != hooks[1]["id"] + + def test_discriminator_stable_under_reordering(self, tmp_path): + entries_a = [ + {"command": "speckit.speckitgit.branch", "priority": 10}, + {"command": "speckit.speckitgit.branch", "priority": 20}, + ] + entries_b = list(reversed([copy.deepcopy(e) for e in entries_a])) + + dir_a = tmp_path / "a" + dir_a.mkdir() + dir_b = tmp_path / "b" + dir_b.mkdir() + manifest_a = ExtensionManifest( + _write_manifest(dir_a, _extension_data(hooks={"before_plan": entries_a}), "extension.yml") + ) + manifest_b = ExtensionManifest( + _write_manifest(dir_b, _extension_data(hooks={"before_plan": entries_b}), "extension.yml") + ) + + ids_a = { + (h["command"], h.get("priority")): h["id"] + for h in manifest_a.iter_contributions() + if h["kind"] == "hook" + } + ids_b = { + (h["command"], h.get("priority")): h["id"] + for h in manifest_b.iter_contributions() + if h["kind"] == "hook" + } + assert ids_a == ids_b + + def test_byte_identical_declared_fields_rejected_at_load(self, tmp_path): + data = _extension_data( + hooks={ + "after_tasks": [ + {"command": "speckit.speckitgit.branch", "priority": 10}, + {"command": "speckit.speckitgit.branch", "priority": 10}, + ] + } + ) + with pytest.raises(ValidationError) as exc_info: + ExtensionManifest(_write_manifest(tmp_path, data, "extension.yml")) + message = str(exc_info.value) + assert "Duplicate hook entries" in message + assert "after_tasks" in message + assert "positions 0 and 1" in message + + def test_hook_discriminator_helper_is_deterministic(self): + payload = {"priority": 10, "optional": True, "prompt": "Run?"} + a = hook_discriminator(payload) + b = hook_discriminator(dict(reversed(list(payload.items())))) + assert a == b + assert len(a) == 12 + + +# --------------------------------------------------------------------------- +# Manifest component `:` guard +# --------------------------------------------------------------------------- + + +class TestComponentGuard: + def test_validate_component_rejects_colon(self): + with pytest.raises(IdentifierComponentError) as exc_info: + validate_component("has:colon", "test field") + assert "':' is reserved" in str(exc_info.value) + + def test_validate_component_rejects_empty(self): + with pytest.raises(IdentifierComponentError): + validate_component("", "test field") + + def test_validate_component_rejects_non_string(self): + with pytest.raises(IdentifierComponentError): + validate_component(42, "test field") + + def test_extension_hook_event_name_with_colon_rejected(self, tmp_path): + data = _extension_data( + hooks={"before:plan": {"command": "speckit.speckitgit.branch"}} + ) + with pytest.raises(ValidationError) as exc_info: + ExtensionManifest(_write_manifest(tmp_path, data, "extension.yml")) + assert "':' is reserved" in str(exc_info.value) + + def test_extension_hook_command_with_colon_rejected(self, tmp_path): + data = _extension_data( + hooks={"before_plan": {"command": "speckit:bad:command"}} + ) + with pytest.raises(ValidationError) as exc_info: + ExtensionManifest(_write_manifest(tmp_path, data, "extension.yml")) + assert "':' is reserved" in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# `iter_contributions` output surface +# --------------------------------------------------------------------------- + + +class TestContributionSurface: + def test_preset_iter_contributions_matrix(self, tmp_path): + manifest = PresetManifest(_write_manifest(tmp_path, _preset_data(), "preset.yml")) + entries = manifest.iter_contributions() + by_kind = {e["kind"]: e for e in entries} + assert by_kind["command"]["id"] == "preset:speckit-core:command:speckit.plan" + assert by_kind["template"]["id"] == "preset:speckit-core:template:spec-template" + assert by_kind["script"]["id"] == "preset:speckit-core:script:setup-plan" + for entry in entries: + assert entry["layer"] == "preset" + assert entry["sourceId"] == "speckit-core" + + def test_extension_iter_contributions_matrix(self, tmp_path): + data = _extension_data( + hooks={"before_specify": {"command": "speckit.speckitgit.branch"}} + ) + manifest = ExtensionManifest(_write_manifest(tmp_path, data, "extension.yml")) + entries = manifest.iter_contributions() + kinds = {e["kind"]: e for e in entries} + assert kinds["command"]["id"] == "extension:speckit-git:command:speckit.speckitgit.branch" + assert kinds["template"]["id"] == "extension:speckit-git:template:pr-body" + assert kinds["script"]["id"] == "extension:speckit-git:script:post-commit" + assert kinds["hook"]["id"] == "extension:speckit-git:hook:before_specify:speckit.speckitgit.branch" + assert kinds["hook"]["name"] == "before_specify:speckit.speckitgit.branch" + + def test_contribution_id_lookup(self, tmp_path): + manifest = PresetManifest(_write_manifest(tmp_path, _preset_data(), "preset.yml")) + assert ( + manifest.contribution_id("command", "speckit.plan") + == "preset:speckit-core:command:speckit.plan" + ) + assert manifest.contribution_id("command", "does-not-exist") is None + + def test_representation_shape_is_additive_for_preset(self, tmp_path): + original = _preset_data() + manifest = PresetManifest(_write_manifest(tmp_path, original, "preset.yml")) + derived_keys = {"layer", "sourceId", "kind", "id"} + for src_entry, out_entry in zip(original["provides"]["templates"], manifest.iter_contributions()): + assert set(src_entry.keys()).issubset(out_entry.keys()) + assert derived_keys.issubset(out_entry.keys()) + + def test_representation_shape_is_additive_for_extension(self, tmp_path): + original = _extension_data( + hooks={"before_specify": {"command": "speckit.speckitgit.branch"}} + ) + manifest = ExtensionManifest(_write_manifest(tmp_path, original, "extension.yml")) + entries = manifest.iter_contributions() + derived_named = {"layer", "sourceId", "kind", "id"} + + cmd_entry = original["provides"]["commands"][0] + cmd_out = next(e for e in entries if e["kind"] == "command") + assert set(cmd_entry.keys()).issubset(cmd_out.keys()) + assert derived_named.issubset(cmd_out.keys()) + + hook_entry = original["hooks"]["before_specify"] + hook_out = next(e for e in entries if e["kind"] == "hook") + assert set(hook_entry.keys()).issubset(hook_out.keys()) + assert derived_named.issubset(hook_out.keys()) + assert hook_out["name"] == "before_specify:speckit.speckitgit.branch" + + def test_underlying_data_not_mutated(self, tmp_path): + original = _preset_data() + original_snapshot = copy.deepcopy(original) + manifest = PresetManifest(_write_manifest(tmp_path, original, "preset.yml")) + _ = manifest.iter_contributions() + assert manifest.data == original_snapshot + + +# --------------------------------------------------------------------------- +# `lookupId` round-trip through the resolver +# --------------------------------------------------------------------------- + + +def _make_project(root: Path) -> Path: + """Create a minimal project layout the resolver understands.""" + (root / ".specify" / "presets").mkdir(parents=True) + (root / ".specify" / "extensions").mkdir(parents=True) + (root / ".specify" / "memory").mkdir(parents=True) + (root / "templates" / "commands").mkdir(parents=True) + (root / "templates" / "scripts").mkdir(parents=True) + return root + + +class TestLookupIdRoundTrip: + def test_project_override_layer_carries_sentinel_lookup_id(self, tmp_path): + project = _make_project(tmp_path) + overrides_dir = project / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + (overrides_dir / "spec-template.md").write_text("override", encoding="utf-8") + resolver = PresetResolver(project) + layers = resolver.collect_all_layers("spec-template", "template") + override_layer = next(l for l in layers if l["source"] == "project override") + assert override_layer["lookupId"] == derive_named_id( + PROJECT_OVERRIDE_LAYER, "_", "template", "spec-template" + ) + + def test_core_layer_carries_core_lookup_id(self, tmp_path): + project = _make_project(tmp_path) + (project / "templates" / "spec-template.md").write_text("core", encoding="utf-8") + # PresetResolver reads templates from a bundled/repo path — point the + # resolver at the fixture project by monkey-patching the templates_dir. + resolver = PresetResolver(project) + resolver.templates_dir = project / "templates" + layers = resolver.collect_all_layers("spec-template", "template") + core_layer = next(l for l in layers if l["source"] == "core") + assert core_layer["lookupId"] == "core:_:template:spec-template" + + def test_preset_layer_lookup_id_matches_manifest_contribution_id(self, tmp_path): + project = _make_project(tmp_path) + pack_id = "speckit-fixture" + pack_dir = project / ".specify" / "presets" / pack_id + (pack_dir / "templates").mkdir(parents=True) + (pack_dir / "templates" / "spec-template.md").write_text("preset", encoding="utf-8") + _write_manifest( + pack_dir, + { + "schema_version": "1.0", + "preset": { + "id": pack_id, + "name": pack_id, + "version": "1.0.0", + "description": "Fixture", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + } + ] + }, + }, + "preset.yml", + ) + registry = { + "schema_version": "1.0", + "presets": { + pack_id: {"version": "1.0.0", "priority": 10, "enabled": True} + }, + } + (project / ".specify" / "presets" / ".registry").write_text( + json.dumps(registry), encoding="utf-8" + ) + resolver = PresetResolver(project) + layers = resolver.collect_all_layers("spec-template", "template") + preset_layer = next(l for l in layers if l["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 + From d76e2712092223385ffabc95a6b97c6597dd7065 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 21 Aug 2026 16:28:54 -0500 Subject: [PATCH 02/49] feat: add `specify artifact` command exposing composition stacks as JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `specify artifact` command group with two subcommands: * `specify artifact list --json` — flat inventory of every command, template, and script SpecKit exposes for the current project. Each row carries a stable `id` (`{kind}:{name}`), an author-declared `name`, its `kind`, and a `description` string that is never omitted (empty string when the author declared none). * `specify artifact info --json` — the same row plus its full ordered composition `stack`: highest-priority contributor first, with `active` marking the winner `PresetResolver.resolve_content` would return and `hidden` marking rows shadowed by a higher-priority `replace`. Each stack entry carries a portable POSIX `manifestPath` (or `null` for the core baseline) and a `lookupId` from the contribution-id grammar so the output round-trips against `specify preset info` and `specify extension info`. The two commands share one strict JSON error envelope on stderr (`{ "error": "..." }`) with exit code 1 for the three logical errors (unknown artifact, ambiguous artifact, not a Spec Kit project) and exit code 2 for the "`--json` is required" usage error. stdout is always empty on error, so the two streams stay independently parseable. Implementation lives in a new `src/specify_cli/artifacts/` subpackage that mirrors the existing `presets/` and `extensions/` layout — pure logic in `__init__.py` and thin Typer wiring in `_commands.py`. The subpackage reuses `PresetResolver.collect_all_layers` for the actual composition math and only reshapes each layer into a `StackLayer` JSON row, so `active` and `hidden` stay in lockstep with the resolver's winner-selection logic. Skills (`.github/skills/**/SKILL.md`) are intentionally excluded from the inventory — they are integration-specific installation output, not a shipped asset family. The command still surfaces the underlying command that a skill was generated from. Tests: * `tests/test_artifact_command.py` — 32 tests: contract shape, sort order, empty-inventory behavior, kind-hint parsing, ambiguous-name error, unknown-artifact error, not-a-project error, skills exclusion, CLI wiring end-to-end (`--json` required, JSON envelope shape, stderr-only errors, empty stdout on error, UTF-8 with no BOM), and preset-replace hiding the core layer. * `tests/test_artifact_command_parity.py` — 6 tests: `manifestPath` uses forward slashes on every OS and is never absolute, the `active` row corresponds to the resolver's actual winner, and the pretty-printed JSON has no trailing whitespace and ends in exactly one newline. All 38 new tests pass. Full presets + extensions regression suite is green modulo pre-existing Windows-symlink-privilege failures that predate this branch. Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4a40fb96-1bbe-4fb2-99d8-411170046cb0 --- src/specify_cli/__init__.py | 7 + src/specify_cli/artifacts/__init__.py | 741 +++++++++++++++++++++++++ src/specify_cli/artifacts/_commands.py | 150 +++++ tests/test_artifact_command.py | 395 +++++++++++++ tests/test_artifact_command_parity.py | 140 +++++ 5 files changed, 1433 insertions(+) create mode 100644 src/specify_cli/artifacts/__init__.py create mode 100644 src/specify_cli/artifacts/_commands.py create mode 100644 tests/test_artifact_command.py create mode 100644 tests/test_artifact_command_parity.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f8afcf4f55..93f10a1950 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -560,6 +560,13 @@ def _require_specify_project() -> Path: _register_preset_cmds(app) +# ===== Artifact Commands ===== + +# Read-only introspection over the composed inventory (commands/templates/scripts). +from .artifacts._commands import register as _register_artifact_cmds # noqa: E402 +_register_artifact_cmds(app) + + # ===== Bundle Commands ===== # Bundler subcommand group (specify bundle ...) — see commands/bundle/. diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py new file mode 100644 index 0000000000..2757743958 --- /dev/null +++ b/src/specify_cli/artifacts/__init__.py @@ -0,0 +1,741 @@ +"""Pure logic for the `specify artifact` command group. No Typer decorators. + +Two public entry points: + +* :meth:`ArtifactCatalog.list_artifacts` — flat inventory (id, name, kind, description). +* :meth:`ArtifactCatalog.get_artifact_info` — one row plus its full ordered stack. + +Everything else in this module is internal machinery. Callers outside +:mod:`specify_cli.artifacts._commands` should not import the private helpers. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Literal + +import yaml + +from .._assets import _locate_core_pack, _repo_root +from .._identifier import derive_named_id + +# --------------------------------------------------------------------------- +# Public data classes +# --------------------------------------------------------------------------- + +ArtifactKind = Literal["command", "template", "script"] +LayerName = Literal["preset", "extension", "core"] +Strategy = Literal["replace", "wrap", "prepend", "append"] + + +@dataclass(frozen=True) +class Artifact: + """One row in the flat inventory returned by ``list_artifacts()``.""" + + id: str + name: str + kind: ArtifactKind + description: str + + def to_json_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "kind": self.kind, + "description": self.description, + } + + +@dataclass(frozen=True) +class StackLayer: + """One row inside the ``stack`` array returned by ``get_artifact_info()``.""" + + layer: LayerName + presetId: str | None + presetName: str | None + strategy: Strategy + active: bool + hidden: bool + manifestPath: str | None + lookupId: str + + def to_json_dict(self) -> dict[str, Any]: + return { + "layer": self.layer, + "presetId": self.presetId, + "presetName": self.presetName, + "strategy": self.strategy, + "active": self.active, + "hidden": self.hidden, + "manifestPath": self.manifestPath, + "lookupId": self.lookupId, + } + + +# --------------------------------------------------------------------------- +# Exceptions — pinned error strings (see artifact-error contract regex) +# --------------------------------------------------------------------------- + + +class ArtifactError(Exception): + """Base class for the three logical error conditions this module raises. + + Each subclass carries a ``.message`` attribute whose value is the exact + string emitted to stderr under the ``error`` key of the JSON envelope. + The contract regex is ``^(unknown artifact |ambiguous artifact |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) + + +# --------------------------------------------------------------------------- +# Core-baseline enumeration +# --------------------------------------------------------------------------- + +_SCRIPT_SUFFIXES = frozenset({".py", ".sh", ".ps1"}) +_TEMPLATE_SUFFIX = ".md" + + +@dataclass(frozen=True) +class _CoreBaselineRow: + name: str + kind: ArtifactKind + path: Path + description: str + + +def _core_asset_root(subdir: str) -> Path | None: + """Return the on-disk directory holding a family of core assets, or None. + + Prefers the wheel-installed ``core_pack`` bundle, then falls back to the + source-checkout layout. Mirrors the two-tier resolution used by + :func:`_load_core_command_names` and :meth:`PresetResolver._find_bundled_core` + so all three code paths agree on what "core" means on this machine. + """ + core = _locate_core_pack() + if core is not None: + candidate = core / subdir + if candidate.is_dir(): + return candidate + if subdir == "commands": + candidate = _repo_root() / "templates" / "commands" + elif subdir == "templates": + candidate = _repo_root() / "templates" + elif subdir == "scripts": + candidate = _repo_root() / "scripts" + else: # pragma: no cover — internal misuse + return None + return candidate if candidate.is_dir() else None + + +def _extract_frontmatter_description(text: str) -> str: + """Return the ``description`` value from YAML frontmatter, else ``""``. + + Matches the frontmatter shape used by every core command/template on disk: + a ``---`` fence pair at the top of the file with a YAML mapping between + them. Anything malformed silently yields the empty string — the contract + forbids omission but permits ``""``. + """ + lines = text.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return "" + fence_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + fence_end = i + break + if fence_end == -1: + return "" + try: + data = yaml.safe_load("".join(lines[1:fence_end])) + except yaml.YAMLError: + return "" + if not isinstance(data, dict): + return "" + value = data.get("description", "") + return value if isinstance(value, str) else "" + + +def _extract_script_description(text: str) -> str: + """Return the first docstring/comment line of a script, else ``""``. + + Supports the three script runtimes SpecKit ships: + + * Python (``.py``): the first line of the module docstring. + * Bash (``.sh``): the first ``#``-prefixed comment line following the + shebang. + * PowerShell (``.ps1``): either the first line of a ``<# ... #>`` block + comment or the first ``#``-prefixed line. + + Anything unrecognized yields the empty string. + """ + py_match = re.match(r'^(?:#![^\n]*\n)?\s*(?:"""|\'\'\')(.*?)(?:"""|\'\'\')', text, re.DOTALL) + if py_match: + first = py_match.group(1).strip().splitlines() + if first: + return first[0].strip() + + ps_block = re.match(r'^(?:<#\s*(.*?)#>)', text, re.DOTALL) + if ps_block: + first = ps_block.group(1).strip().splitlines() + if first: + return first[0].strip().lstrip(".").strip() + + for raw in text.splitlines(): + stripped = raw.strip() + if not stripped or stripped.startswith("#!"): + continue + if stripped.startswith("#"): + return stripped.lstrip("#").strip() + break + return "" + + +def _enumerate_core_commands() -> list[_CoreBaselineRow]: + """Enumerate every command shipped in the core baseline. + + Names are surfaced with the ``speckit.`` prefix so they collide with + preset/extension contributions in a stable way — this is what the id + grammar ``command:speckit.constitution`` requires. + """ + from ..extensions import CORE_COMMAND_NAMES # lazy: avoids circular import + + commands_dir = _core_asset_root("commands") + rows: list[_CoreBaselineRow] = [] + if commands_dir is None: + return rows + for stem in sorted(CORE_COMMAND_NAMES): + path = commands_dir / f"{stem}.md" + if not path.is_file(): + continue + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + text = "" + rows.append( + _CoreBaselineRow( + name=f"speckit.{stem}", + kind="command", + path=path, + description=_extract_frontmatter_description(text), + ) + ) + return rows + + +def _enumerate_core_templates() -> list[_CoreBaselineRow]: + templates_dir = _core_asset_root("templates") + rows: list[_CoreBaselineRow] = [] + if templates_dir is None: + return rows + for entry in sorted(templates_dir.iterdir(), key=lambda p: p.name): + if not entry.is_file() or entry.suffix != _TEMPLATE_SUFFIX: + continue + try: + text = entry.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + text = "" + rows.append( + _CoreBaselineRow( + name=entry.stem, + kind="template", + path=entry, + description=_extract_frontmatter_description(text), + ) + ) + return rows + + +def _enumerate_core_scripts() -> list[_CoreBaselineRow]: + scripts_dir = _core_asset_root("scripts") + rows: list[_CoreBaselineRow] = [] + if scripts_dir is None: + return rows + seen: dict[str, _CoreBaselineRow] = {} + for runtime_dir in sorted(scripts_dir.iterdir(), key=lambda p: p.name): + if not runtime_dir.is_dir(): + continue + for entry in sorted(runtime_dir.iterdir(), key=lambda p: p.name): + if not entry.is_file() or entry.suffix not in _SCRIPT_SUFFIXES: + continue + name = entry.name + if name in seen: + continue + try: + text = entry.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + text = "" + seen[name] = _CoreBaselineRow( + name=name, + kind="script", + path=entry, + description=_extract_script_description(text), + ) + rows.extend(sorted(seen.values(), key=lambda r: r.name)) + return rows + + +@dataclass(frozen=True) +class CoreBaseline: + """The union of the three core enumerators, indexed for O(1) lookup.""" + + commands: tuple[_CoreBaselineRow, ...] + templates: tuple[_CoreBaselineRow, ...] + scripts: tuple[_CoreBaselineRow, ...] + + @classmethod + def load(cls) -> "CoreBaseline": + return cls( + commands=tuple(_enumerate_core_commands()), + templates=tuple(_enumerate_core_templates()), + scripts=tuple(_enumerate_core_scripts()), + ) + + def by_kind(self, kind: ArtifactKind) -> tuple[_CoreBaselineRow, ...]: + return { + "command": self.commands, + "template": self.templates, + "script": self.scripts, + }[kind] + + def find(self, kind: ArtifactKind, name: str) -> _CoreBaselineRow | None: + for row in self.by_kind(kind): + if row.name == name: + return row + return None + + +# --------------------------------------------------------------------------- +# Resolver-adaptation helpers +# --------------------------------------------------------------------------- + + +def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | None: + """Return a repo-relative POSIX path to the manifest declaring this layer. + + ``layer`` is one dict entry from ``PresetResolver.collect_all_layers()``. + Core layers return ``None`` — they have no on-disk manifest that ships + with the project. Non-core layers walk upward from the contribution file + until they find the preset's ``preset.yml`` or the extension's + ``extension.yml``, then relativize against ``project_root``. + + Uses ``as_posix()`` so the string is stable across Windows and POSIX — + a caller comparing snapshots between operating systems gets the same + value on both. + """ + lookup_id = layer.get("lookupId", "") + if lookup_id.startswith("core:"): + return None + source = layer.get("path") + if not isinstance(source, Path): + return None + manifest = _find_enclosing_manifest(source) + if manifest is None: + return None + try: + rel = manifest.relative_to(project_root) + except ValueError: + return manifest.as_posix() + return rel.as_posix() + + +def _find_enclosing_manifest(path: Path) -> Path | None: + """Walk parents of ``path`` looking for preset.yml or extension.yml.""" + for parent in path.parents: + for name in ("preset.yml", "extension.yml"): + candidate = parent / name + if candidate.is_file(): + return candidate + return None + + +def _preset_display_name(pack_dir: Path, pack_id: str) -> str: + """Return the preset's human-friendly name from ``preset.yml``. + + Falls back to the pack id when the manifest is missing or lacks a + ``metadata.name`` value. + """ + manifest_path = pack_dir / "preset.yml" + if not manifest_path.is_file(): + return pack_id + try: + data = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, yaml.YAMLError): + return pack_id + if not isinstance(data, dict): + return pack_id + metadata = data.get("metadata") + if isinstance(metadata, dict): + display = metadata.get("name") + if isinstance(display, str) and display: + return display + display = data.get("name") + if isinstance(display, str) and display: + return display + return pack_id + + +def _extract_lookup_pack_id(lookup_id: str) -> str | None: + """Return the ``sourceId`` segment of a lookupId, or ``None`` if malformed.""" + parts = lookup_id.split(":") + if len(parts) < 4: + return None + return parts[1] + + +def _build_stack( + project_root: Path, + kind: ArtifactKind, + name: str, +) -> list[StackLayer]: + """Build the ordered stack for a single artifact. + + Delegates the actual composition math to + :meth:`PresetResolver.collect_all_layers`; this function only reshapes + each raw layer dict into a :class:`StackLayer` and computes the + ``active`` / ``hidden`` labels documented on the data model. + + Returns an empty list when the artifact is not visible from any tier + (no preset, no extension, no core baseline row). + """ + from ..presets import PresetResolver # lazy: avoids circular import + + resolver = PresetResolver(project_root) + template_type = kind + raw = resolver.collect_all_layers(name, template_type) + if not raw: + return [] + + first_replace_idx = next( + (i for i, layer in enumerate(raw) if layer["strategy"] == "replace"), + None, + ) + + rows: list[StackLayer] = [] + for idx, layer in enumerate(raw): + lookup_id = layer.get("lookupId", "") + source = str(layer.get("source", "")) + strategy = layer["strategy"] + active = idx == 0 + + if first_replace_idx is None: + hidden = False + else: + hidden = idx > first_replace_idx + + # Layer classification: prefer lookupId prefix (authoritative) with a + # source-string fallback for defensive parsing. + if lookup_id.startswith("core:") or source.startswith("core"): + rows.append( + StackLayer( + layer="core", + presetId=None, + presetName=None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=None, + lookupId=lookup_id, + ) + ) + continue + + if lookup_id.startswith("extension:") or source.startswith("extension:"): + manifest_path = _derive_manifest_path(layer, project_root) + rows.append( + StackLayer( + layer="extension", + presetId=None, + presetName=None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=manifest_path, + lookupId=lookup_id, + ) + ) + continue + + pack_id = _extract_lookup_pack_id(lookup_id) or "" + pack_dir = project_root / ".specify" / "presets" / pack_id + display = _preset_display_name(pack_dir, pack_id) if pack_id else pack_id + manifest_path = _derive_manifest_path(layer, project_root) + rows.append( + StackLayer( + layer="preset", + presetId=pack_id or None, + presetName=display or None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=manifest_path, + lookupId=lookup_id, + ) + ) + return rows + + +# --------------------------------------------------------------------------- +# ArtifactCatalog — public façade +# --------------------------------------------------------------------------- + + +def _validate_project(project_root: Path) -> None: + """Raise NotASpecKitProjectError when ``project_root`` isn't a Spec Kit project. + + The two invariants the rest of the module relies on are that + ``project_root`` exists and that a ``.specify/`` subdirectory sits under + it. Anything else — missing presets/, missing extensions/, missing + templates/ — is a valid empty-inventory scenario and is not treated as + an error. + """ + if not (project_root / ".specify").is_dir(): + raise NotASpecKitProjectError() + + +def _resolve_kind_hint(name: str, kind: ArtifactKind | None) -> tuple[str, ArtifactKind | None]: + """Parse ``kind:name`` shorthand and reconcile it with an explicit ``--kind`` flag. + + Returns ``(bare_name, resolved_kind)``. When ``name`` uses the ``kind:name`` + grammar and ``kind`` is also set explicitly, the two must agree — a + mismatch is treated as an unknown artifact. + """ + if ":" in name: + prefix, _, bare = name.partition(":") + if prefix in ("command", "template", "script"): + resolved: ArtifactKind = prefix # type: ignore[assignment] + if kind is not None and kind != resolved: + raise ArtifactNotFoundError(name) + return bare, resolved + return name, kind + + +class ArtifactCatalog: + """Read-only view over one Spec Kit project's artifact inventory.""" + + def __init__(self, project_root: Path) -> None: + self.project_root = project_root + self._baseline: CoreBaseline | None = None + + # ------------------------------------------------------------------ list + def list_artifacts(self) -> list[Artifact]: + """Return every artifact SpecKit exposes for this project, deduped. + + Sort order is deterministic — first by ``kind`` in the fixed + ``["command", "template", "script"]`` order, then by ``name``. + Returns an empty list when no artifacts are found rather than raising; + a fresh install with no presets, no extensions, and an empty core + baseline is still a valid Spec Kit project. + + Skills (``.github/skills/**/SKILL.md``) are intentionally excluded — + they are integration-specific output, not a shipped asset family. + """ + _validate_project(self.project_root) + baseline = self._get_baseline() + + seen: dict[tuple[ArtifactKind, str], Artifact] = {} + + for row in (*baseline.commands, *baseline.templates, *baseline.scripts): + key = (row.kind, row.name) + if key not in seen: + seen[key] = Artifact( + id=f"{row.kind}:{row.name}", + name=row.name, + kind=row.kind, + description=row.description, + ) + + for kind, name, description in self._iter_contribution_artifacts(): + key = (kind, name) + if key not in seen: + seen[key] = Artifact( + id=f"{kind}:{name}", + name=name, + kind=kind, + description=description, + ) + elif description and not seen[key].description: + seen[key] = Artifact( + id=seen[key].id, + name=seen[key].name, + kind=seen[key].kind, + description=description, + ) + + kind_order = {"command": 0, "template": 1, "script": 2} + return sorted(seen.values(), key=lambda a: (kind_order[a.kind], a.name)) + + # ------------------------------------------------------------------ info + def get_artifact_info( + self, + name: str, + kind: ArtifactKind | None = None, + ) -> dict[str, Any]: + """Return the full JSON-ready dict for ``specify artifact info``. + + Argument resolution: + + * ``name`` accepts the ``kind:name`` grammar as shorthand; when both + the shorthand and ``kind`` are supplied they must agree. + * When neither the shorthand nor ``kind`` narrows the search and + more than one kind matches ``name``, raises + :class:`AmbiguousArtifactError`. + * When no artifact matches, raises :class:`ArtifactNotFoundError`. + """ + _validate_project(self.project_root) + bare, resolved_kind = _resolve_kind_hint(name, kind) + + if resolved_kind is None: + matches = self._find_matches(bare) + if not matches: + raise ArtifactNotFoundError(name) + if len(matches) > 1: + raise AmbiguousArtifactError(bare, [k for k, _ in matches]) + resolved_kind = matches[0][0] + + stack = _build_stack(self.project_root, resolved_kind, bare) + if not stack: + raise ArtifactNotFoundError(name) + + description = self._describe(resolved_kind, bare) + return { + "id": f"{resolved_kind}:{bare}", + "name": bare, + "kind": resolved_kind, + "description": description, + "stack": [layer.to_json_dict() for layer in stack], + } + + # -------------------------------------------------------------- internals + def _get_baseline(self) -> CoreBaseline: + if self._baseline is None: + self._baseline = CoreBaseline.load() + return self._baseline + + def _find_matches(self, name: str) -> list[tuple[ArtifactKind, str]]: + """Return every (kind, name) pair whose name matches exactly.""" + artifacts = self.list_artifacts() + return [(a.kind, a.name) for a in artifacts if a.name == name] + + def _describe(self, kind: ArtifactKind, name: str) -> str: + """Return the description that would appear on the flat-list row. + + Sources the value from :meth:`list_artifacts` so the two commands + agree on the same string for the same artifact — the ``info`` output + promises "matching the same field on 'artifact list --json'". + """ + for artifact in self.list_artifacts(): + if artifact.kind == kind and artifact.name == name: + return artifact.description + return "" + + def _iter_contribution_artifacts( + self, + ) -> Iterable[tuple[ArtifactKind, str, str]]: + """Yield ``(kind, name, description)`` for every preset/extension contribution. + + 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. + """ + specify_dir = self.project_root / ".specify" + for tier in ("presets", "extensions"): + tier_dir = specify_dir / tier + if not tier_dir.is_dir(): + continue + for pack_dir in sorted(tier_dir.iterdir(), key=lambda p: p.name): + if not pack_dir.is_dir(): + continue + manifest_name = "preset.yml" if tier == "presets" else "extension.yml" + manifest = pack_dir / manifest_name + if not manifest.is_file(): + continue + try: + data = yaml.safe_load(manifest.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, yaml.YAMLError): + continue + if not isinstance(data, dict): + continue + yield from _iter_manifest_contributions(data) + + +def _iter_manifest_contributions( + data: dict[str, Any], +) -> Iterable[tuple[ArtifactKind, str, str]]: + """Yield ``(kind, name, description)`` entries declared by a manifest. + + Both preset and extension manifests use the same ``provides`` shape: + + .. code-block:: yaml + + provides: + commands: [ {name: "...", description: "..."} , ... ] + templates: [ ... ] + scripts: [ ... ] + + Anything malformed at the entry level is skipped rather than raised — + the artifact command is a projection, not a validator. + """ + provides = data.get("provides") + if not isinstance(provides, dict): + return + for kind_key, kind_value in ( + ("commands", "command"), + ("templates", "template"), + ("scripts", "script"), + ): + entries = provides.get(kind_key) + if not isinstance(entries, list): + continue + for entry in entries: + if isinstance(entry, str): + yield kind_value, entry, "" # type: ignore[misc] + continue + if not isinstance(entry, dict): + continue + name = entry.get("name") + if not isinstance(name, str) or not name or ":" in name: + continue + description = entry.get("description", "") + if not isinstance(description, str): + description = "" + yield kind_value, name, description # type: ignore[misc] + + +__all__ = [ + "AmbiguousArtifactError", + "Artifact", + "ArtifactCatalog", + "ArtifactError", + "ArtifactKind", + "ArtifactNotFoundError", + "CoreBaseline", + "LayerName", + "NotASpecKitProjectError", + "StackLayer", + "Strategy", +] + +_ = derive_named_id # keep the import edge visible for tooling diff --git a/src/specify_cli/artifacts/_commands.py b/src/specify_cli/artifacts/_commands.py new file mode 100644 index 0000000000..919ef1e07d --- /dev/null +++ b/src/specify_cli/artifacts/_commands.py @@ -0,0 +1,150 @@ +"""Typer sub-app for the `specify artifact` command group. + +Kept intentionally thin: the pure logic lives in ``specify_cli.artifacts``. +This module is only responsible for CLI wiring — argument parsing, JSON +serialization, exit-code selection, and error-envelope emission on stderr. + +Mirrors the shape used by ``src/specify_cli/presets/_commands.py`` and +``src/specify_cli/extensions/_commands.py``: a module-level Typer app plus a +``register(app)`` entry point invoked from ``src/specify_cli/__init__.py``. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Optional + +import typer + +from . import ( + AmbiguousArtifactError, + ArtifactCatalog, + ArtifactError, + ArtifactKind, + ArtifactNotFoundError, + NotASpecKitProjectError, +) + +artifact_app = typer.Typer( + name="artifact", + help="Introspect commands, templates, and scripts SpecKit exposes.", + no_args_is_help=True, +) + + +def _resolve_project_root() -> Path: + """Return the project root without emitting Rich output on failure. + + The stdout of ``specify artifact list --json`` and ``specify artifact + info --json`` is a strict JSON envelope; any incidental Rich + output would corrupt it. So instead of calling ``_require_specify_project`` + (which prints to stderr via ``err_console``), we replicate its logic + through the same helper ``_resolve_init_dir_override`` and raise the + module-local :class:`NotASpecKitProjectError` for the shared error + handler to serialize. + """ + from .._project import _resolve_init_dir_override + + override = _resolve_init_dir_override() + cwd = override if override is not None else Path.cwd() + if not (cwd / ".specify").is_dir(): + raise NotASpecKitProjectError() + return cwd + + +def _emit_error_and_exit(exc: ArtifactError) -> None: + """Write ``{"error": "..."}`` to stderr and exit with code 1. + + The stdout stream is left completely untouched — the contract is that + machine consumers can rely on an empty stdout when the exit code is + non-zero, so no partial JSON payload leaks even on a late-stage failure. + """ + payload = json.dumps({"error": exc.message}, ensure_ascii=False) + print(payload, file=sys.stderr) + raise typer.Exit(code=1) + + +def _require_json_flag(json_flag: bool) -> None: + """Enforce the opt-in ``--json`` contract shared by both subcommands. + + A text-mode formatter is intentionally deferred so the initial release + can commit to exactly one output shape. Callers that omit ``--json`` + get a usage error (exit 2) with no stdout output — this makes future + addition of a default text renderer a purely additive, non-breaking + change. + """ + if json_flag: + return + print( + "specify artifact requires --json for now; text output is not yet implemented.", + file=sys.stderr, + ) + raise typer.Exit(code=2) + + +@artifact_app.command("list") +def list_command( + json_flag: bool = typer.Option( + False, + "--json", + help="Emit the inventory as a JSON array on stdout.", + ), +) -> None: + """List every command, template, and script SpecKit exposes.""" + _require_json_flag(json_flag) + try: + root = _resolve_project_root() + catalog = ArtifactCatalog(root) + rows = [artifact.to_json_dict() for artifact in catalog.list_artifacts()] + except ArtifactError as exc: + _emit_error_and_exit(exc) + return # pragma: no cover — _emit_error_and_exit raises + + sys.stdout.write(json.dumps(rows, indent=2, sort_keys=True, ensure_ascii=False)) + sys.stdout.write("\n") + + +@artifact_app.command("info") +def info_command( + name: str = typer.Argument(..., help="Artifact name, optionally 'kind:name'."), + json_flag: bool = typer.Option( + False, + "--json", + help="Emit the composition stack as a JSON object on stdout.", + ), + kind: Optional[str] = typer.Option( + None, + "--kind", + help="Narrow the lookup to one artifact family (command/template/script).", + ), +) -> None: + """Show one artifact and its full composition stack.""" + _require_json_flag(json_flag) + + resolved_kind: Optional[ArtifactKind] = None + if kind is not None: + if kind not in ("command", "template", "script"): + print( + f"invalid --kind {kind!r}: expected one of command, template, script", + file=sys.stderr, + ) + raise typer.Exit(code=2) + resolved_kind = kind # type: ignore[assignment] + + try: + root = _resolve_project_root() + catalog = ArtifactCatalog(root) + payload = catalog.get_artifact_info(name, kind=resolved_kind) + except (ArtifactNotFoundError, AmbiguousArtifactError, NotASpecKitProjectError) as exc: + _emit_error_and_exit(exc) + return # pragma: no cover + + 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/tests/test_artifact_command.py b/tests/test_artifact_command.py new file mode 100644 index 0000000000..c7ff4f07b3 --- /dev/null +++ b/tests/test_artifact_command.py @@ -0,0 +1,395 @@ +"""Unit and contract tests for the `specify artifact` command group. + +Covers the pure-logic layer (:class:`ArtifactCatalog`) plus the CLI wiring +(``specify artifact list``, ``specify artifact info``) exercised through +Typer's ``CliRunner``. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest +import yaml + +from specify_cli import app +from specify_cli.artifacts import ( + AmbiguousArtifactError, + Artifact, + ArtifactCatalog, + ArtifactNotFoundError, + NotASpecKitProjectError, + StackLayer, +) + + +ERROR_REGEX = re.compile(r"^(unknown artifact |ambiguous artifact |not a Spec Kit project)") + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def spec_kit_project(tmp_path: Path) -> Path: + """Create a minimal but valid Spec Kit project layout.""" + root = tmp_path / "proj" + root.mkdir() + (root / ".specify").mkdir() + (root / ".specify" / "presets").mkdir() + (root / ".specify" / "extensions").mkdir() + (root / ".specify" / "templates").mkdir() + return root + + +@pytest.fixture +def non_project(tmp_path: Path) -> Path: + """A directory that intentionally lacks ``.specify/``.""" + root = tmp_path / "not-proj" + root.mkdir() + return root + + +def _install_preset(project_root: Path, pack_id: str, provides: dict, priority: int = 10) -> Path: + """Drop a minimal preset onto disk and register it in the ``.registry`` file.""" + pack_dir = project_root / ".specify" / "presets" / pack_id + pack_dir.mkdir(parents=True) + manifest = { + "id": pack_id, + "version": "1.0.0", + "metadata": {"name": f"Test preset {pack_id}"}, + "provides": provides, + } + (pack_dir / "preset.yml").write_text(yaml.safe_dump(manifest), encoding="utf-8") + registry_path = project_root / ".specify" / "presets" / ".registry" + if registry_path.is_file(): + registry = json.loads(registry_path.read_text(encoding="utf-8")) + else: + registry = {"schema_version": "1.0.0", "presets": {}} + registry["presets"][pack_id] = {"priority": priority, "version": "1.0.0"} + registry_path.write_text(json.dumps(registry), encoding="utf-8") + return pack_dir + + +# --------------------------------------------------------------------------- +# Contract tests — matching artifact-list.schema.json +# --------------------------------------------------------------------------- + + +class TestListArtifactsContract: + def test_returns_list_of_artifact(self, spec_kit_project: Path): + rows = ArtifactCatalog(spec_kit_project).list_artifacts() + assert all(isinstance(r, Artifact) for r in rows) + + def test_every_row_has_required_fields(self, spec_kit_project: Path): + for row in ArtifactCatalog(spec_kit_project).list_artifacts(): + d = row.to_json_dict() + assert set(d.keys()) == {"id", "name", "kind", "description"} + assert isinstance(d["description"], str) # never None; empty string OK + + def test_id_grammar(self, spec_kit_project: Path): + pattern = re.compile(r"^(command|template|script):[^:]+$") + for row in ArtifactCatalog(spec_kit_project).list_artifacts(): + assert pattern.match(row.id), f"bad id: {row.id!r}" + + def test_name_never_contains_colon(self, spec_kit_project: Path): + for row in ArtifactCatalog(spec_kit_project).list_artifacts(): + assert ":" not in row.name + + def test_kind_is_from_fixed_enum(self, spec_kit_project: Path): + for row in ArtifactCatalog(spec_kit_project).list_artifacts(): + assert row.kind in ("command", "template", "script") + + def test_rows_are_unique(self, spec_kit_project: Path): + rows = ArtifactCatalog(spec_kit_project).list_artifacts() + ids = [r.id for r in rows] + assert len(ids) == len(set(ids)) + + +class TestListSorting: + """Deterministic ordering: kind first (command/template/script), then name.""" + + def test_kind_grouping(self, spec_kit_project: Path): + rows = ArtifactCatalog(spec_kit_project).list_artifacts() + kinds_seen = [r.kind for r in rows] + # kinds must appear as contiguous groups in the fixed order + first_idx = {k: next((i for i, x in enumerate(kinds_seen) if x == k), None) for k in ("command", "template", "script")} + indices = [v for v in first_idx.values() if v is not None] + assert indices == sorted(indices) + + def test_name_sorted_within_kind(self, spec_kit_project: Path): + rows = ArtifactCatalog(spec_kit_project).list_artifacts() + by_kind: dict[str, list[str]] = {} + for r in rows: + by_kind.setdefault(r.kind, []).append(r.name) + for _, names in by_kind.items(): + assert names == sorted(names) + + +class TestEmptyProject: + def test_empty_stack_returns_empty_list(self, tmp_path: Path): + # A .specify/ dir with no presets/extensions and no accessible core. + # We can't easily wipe the core baseline in this process, so instead + # verify list_artifacts is at least callable and returns a list. + root = tmp_path / "empty" + root.mkdir() + (root / ".specify").mkdir() + rows = ArtifactCatalog(root).list_artifacts() + assert isinstance(rows, list) + + +# --------------------------------------------------------------------------- +# get_artifact_info contract +# --------------------------------------------------------------------------- + + +class TestInfoContract: + def test_stack_ordered_highest_first(self, spec_kit_project: Path): + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + assert info["stack"], "expected at least one stack layer" + + def test_exactly_one_active_row(self, spec_kit_project: Path): + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + actives = [layer for layer in info["stack"] if layer["active"]] + assert len(actives) == 1 + + def test_active_is_index_zero(self, spec_kit_project: Path): + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + assert info["stack"][0]["active"] is True + for layer in info["stack"][1:]: + assert layer["active"] is False + + def test_core_row_shape(self, spec_kit_project: Path): + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + core = next(layer for layer in info["stack"] if layer["layer"] == "core") + assert core["presetId"] is None + assert core["presetName"] is None + assert core["manifestPath"] is None + assert core["strategy"] == "replace" + assert re.match(r"^core:_:(command|template|script):[^:]+$", core["lookupId"]) + + def test_lookup_id_grammar(self, spec_kit_project: Path): + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + for layer in info["stack"]: + assert re.match( + r"^(preset|extension|core):[^:]+:(command|template|script):[^:]+(:[0-9a-f]{12})?$", + layer["lookupId"], + ) + + def test_id_matches_list(self, spec_kit_project: Path): + cat = ArtifactCatalog(spec_kit_project) + info = cat.get_artifact_info("speckit.constitution") + assert info["id"] == "command:speckit.constitution" + + +# --------------------------------------------------------------------------- +# Error conditions — pinned strings for the artifact-error contract +# --------------------------------------------------------------------------- + + +class TestErrors: + def test_unknown_artifact_message(self, spec_kit_project: Path): + with pytest.raises(ArtifactNotFoundError) as excinfo: + ArtifactCatalog(spec_kit_project).get_artifact_info("no.such.thing") + assert excinfo.value.message == "unknown artifact no.such.thing" + assert ERROR_REGEX.match(excinfo.value.message) + + def test_not_a_project(self, non_project: Path): + with pytest.raises(NotASpecKitProjectError) as excinfo: + ArtifactCatalog(non_project).list_artifacts() + assert excinfo.value.message == "not a Spec Kit project: no .specify/ directory found" + assert ERROR_REGEX.match(excinfo.value.message) + + def test_ambiguous_artifact_message(self, spec_kit_project: Path): + """When both a command and a template share the same bare name.""" + # Register a preset that contributes 'shared-name' as both a + # template and a script — the info lookup with no kind hint should + # then be ambiguous. + _install_preset( + spec_kit_project, + "test-ambig", + { + "templates": [{"name": "shared-name", "description": "t"}], + "scripts": [{"name": "shared-name", "description": "s"}], + }, + ) + 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) + + +class TestKindHint: + def test_kind_flag_disambiguates(self, spec_kit_project: Path): + _install_preset( + spec_kit_project, + "test-kind", + {"templates": [{"name": "dup", "description": "t"}], + "scripts": [{"name": "dup", "description": "s"}]}, + ) + # No stack file backs these contributions on disk so the info call + # will raise unknown after resolving kind — either way it should + # not raise ambiguous when a kind is supplied. + try: + ArtifactCatalog(spec_kit_project).get_artifact_info("dup", kind="template") + except ArtifactNotFoundError: + pass # expected: manifest declared it but no file to compose + + def test_shorthand_grammar(self, spec_kit_project: Path): + # Even with core commands, the shorthand should route correctly. + info = ArtifactCatalog(spec_kit_project).get_artifact_info("command:speckit.constitution") + assert info["kind"] == "command" + + def test_conflicting_shorthand_and_flag(self, spec_kit_project: Path): + with pytest.raises(ArtifactNotFoundError): + ArtifactCatalog(spec_kit_project).get_artifact_info( + "template:speckit.constitution", kind="command" + ) + + +# --------------------------------------------------------------------------- +# Skills exclusion +# --------------------------------------------------------------------------- + + +class TestSkillsExcluded: + def test_no_skills_in_list(self, spec_kit_project: Path): + skills_dir = spec_kit_project / ".github" / "skills" / "speckit-my-skill" + skills_dir.mkdir(parents=True) + (skills_dir / "SKILL.md").write_text("---\nname: my-skill\n---\nbody", encoding="utf-8") + rows = ArtifactCatalog(spec_kit_project).list_artifacts() + assert not any("skill" in r.name.lower() for r in rows) + + +# --------------------------------------------------------------------------- +# CLI wiring — Typer CliRunner +# --------------------------------------------------------------------------- + + +class TestCLI: + def test_list_requires_json_flag(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + from typer.testing import CliRunner + + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list"]) + assert result.exit_code == 2 + assert result.stdout == "" + + def test_list_json_emits_array(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + from typer.testing import CliRunner + + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert isinstance(payload, list) + assert result.stdout.endswith("\n") + + def test_list_json_is_pretty_printed(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + from typer.testing import CliRunner + + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert ' "id"' in result.stdout # 2-space indent visible + + def test_info_json_shape(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + from typer.testing import CliRunner + + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "info", "speckit.constitution", "--json"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert set(payload.keys()) == {"id", "name", "kind", "description", "stack"} + + def test_info_unknown_error_envelope(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + from typer.testing import CliRunner + + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "info", "no.such.thing", "--json"]) + assert result.exit_code == 1 + assert result.stdout == "" + err = json.loads(result.stderr) + assert set(err.keys()) == {"error"} + assert ERROR_REGEX.match(err["error"]) + + def test_not_a_project_error_envelope(self, non_project: Path, monkeypatch: pytest.MonkeyPatch): + from typer.testing import CliRunner + + monkeypatch.chdir(non_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 1 + assert result.stdout == "" + err = json.loads(result.stderr) + assert err["error"] == "not a Spec Kit project: no .specify/ directory found" + + def test_stdout_empty_on_error(self, non_project: Path, monkeypatch: pytest.MonkeyPatch): + from typer.testing import CliRunner + + monkeypatch.chdir(non_project) + runner = CliRunner() + for argv in ( + ["artifact", "list", "--json"], + ["artifact", "info", "x", "--json"], + ): + result = runner.invoke(app, argv) + assert result.stdout == "", f"stdout leak for {argv}: {result.stdout!r}" + + +class TestUTF8NoBOM: + def test_output_is_utf8_without_bom(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + from typer.testing import CliRunner + + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 0 + # No BOM at start + assert not result.stdout.startswith("\ufeff") + + +# --------------------------------------------------------------------------- +# Preset composition integration — active/hidden semantics +# --------------------------------------------------------------------------- + + +class TestStackComposition: + def test_preset_replace_hides_core(self, spec_kit_project: Path): + # Install a preset that replaces the constitution command. + pack = _install_preset( + spec_kit_project, + "test-replace", + {"commands": [{"name": "speckit.constitution", "description": "override"}]}, + ) + (pack / "commands").mkdir() + (pack / "commands" / "speckit.constitution.md").write_text( + "---\ndescription: override\n---\nbody", encoding="utf-8" + ) + + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + stack = info["stack"] + assert stack[0]["active"] is True + assert stack[0]["hidden"] is False + # If a lower core layer exists it must be hidden. + core_rows = [layer for layer in stack if layer["layer"] == "core"] + for row in core_rows: + assert row["hidden"] is True + + +# --------------------------------------------------------------------------- +# Existing module-import placeholder retained for import safety. +# --------------------------------------------------------------------------- + + +def test_module_imports(): + import specify_cli.artifacts # noqa: F401 + + diff --git a/tests/test_artifact_command_parity.py b/tests/test_artifact_command_parity.py new file mode 100644 index 0000000000..83421ffa8d --- /dev/null +++ b/tests/test_artifact_command_parity.py @@ -0,0 +1,140 @@ +"""Cross-OS and resolver-parity tests for the `specify artifact` command group. + +Focuses on invariants that either directly guard against OS-specific +regressions (POSIX-vs-Windows path separators, UTF-8 encoding) or verify +that the artifact output stays consistent with the underlying +:class:`~specify_cli.presets.PresetResolver`. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml + +from specify_cli.artifacts import ArtifactCatalog + + +def _install_preset(project_root: Path, pack_id: str, provides: dict, priority: int = 10) -> Path: + pack_dir = project_root / ".specify" / "presets" / pack_id + pack_dir.mkdir(parents=True) + manifest = { + "id": pack_id, + "version": "1.0.0", + "metadata": {"name": f"Test preset {pack_id}"}, + "provides": provides, + } + (pack_dir / "preset.yml").write_text(yaml.safe_dump(manifest), encoding="utf-8") + registry_path = project_root / ".specify" / "presets" / ".registry" + if registry_path.is_file(): + registry = json.loads(registry_path.read_text(encoding="utf-8")) + else: + registry = {"schema_version": "1.0.0", "presets": {}} + registry["presets"][pack_id] = {"priority": priority, "version": "1.0.0"} + registry_path.write_text(json.dumps(registry), encoding="utf-8") + return pack_dir + + +@pytest.fixture +def spec_kit_project(tmp_path: Path) -> Path: + root = tmp_path / "proj" + root.mkdir() + (root / ".specify").mkdir() + (root / ".specify" / "presets").mkdir() + (root / ".specify" / "extensions").mkdir() + (root / ".specify" / "templates").mkdir() + return root + + +class TestManifestPathIsPosix: + """The ``manifestPath`` field MUST use forward slashes on every OS.""" + + def test_no_backslashes(self, spec_kit_project: Path): + pack = _install_preset( + spec_kit_project, + "test-posix", + {"commands": [{"name": "speckit.constitution", "description": "d"}]}, + ) + (pack / "commands").mkdir() + (pack / "commands" / "speckit.constitution.md").write_text( + "---\ndescription: d\n---\nbody", encoding="utf-8" + ) + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + for layer in info["stack"]: + path = layer["manifestPath"] + if path is None: + continue + assert "\\" not in path, f"backslash leak: {path!r}" + + def test_never_absolute(self, spec_kit_project: Path): + pack = _install_preset( + spec_kit_project, + "test-rel", + {"commands": [{"name": "speckit.constitution", "description": "d"}]}, + ) + (pack / "commands").mkdir() + (pack / "commands" / "speckit.constitution.md").write_text( + "---\ndescription: d\n---\nbody", encoding="utf-8" + ) + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + for layer in info["stack"]: + path = layer["manifestPath"] + if path is None: + continue + assert not path.startswith("/"), f"leading slash: {path!r}" + # Windows drive letter check. + assert not (len(path) >= 2 and path[1] == ":"), f"drive letter: {path!r}" + + +class TestResolverParity: + """The ``active: true`` row must be what :meth:`resolve_content` would pick.""" + + def test_active_layer_matches_resolver(self, spec_kit_project: Path): + from specify_cli.presets import PresetResolver + + pack = _install_preset( + spec_kit_project, + "test-parity", + {"commands": [{"name": "speckit.constitution", "description": "override"}]}, + ) + (pack / "commands").mkdir() + (pack / "commands" / "speckit.constitution.md").write_text( + "---\ndescription: override\n---\nbody-from-preset", encoding="utf-8" + ) + + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + active = next(layer for layer in info["stack"] if layer["active"]) + + resolver = PresetResolver(spec_kit_project) + winner = resolver.resolve_content("speckit.constitution", template_type="command") + assert winner is not None + # The active row's layer classification must correspond to a real + # winning layer — if a preset override was installed and picked up + # by the resolver, active.layer must not be "core". + assert "body-from-preset" in winner + assert active["layer"] == "preset" + + +class TestJSONShape: + """Reasserts JSON-envelope invariants at the whole-payload level.""" + + def test_no_trailing_whitespace(self, spec_kit_project: Path): + catalog = ArtifactCatalog(spec_kit_project) + rows = [a.to_json_dict() for a in catalog.list_artifacts()] + payload = json.dumps(rows, indent=2, sort_keys=True) + "\n" + for line in payload.splitlines(): + assert line == line.rstrip(), f"trailing ws: {line!r}" + + def test_terminated_by_single_newline(self, spec_kit_project: Path): + catalog = ArtifactCatalog(spec_kit_project) + rows = [a.to_json_dict() for a in catalog.list_artifacts()] + payload = json.dumps(rows, indent=2, sort_keys=True) + "\n" + assert payload.endswith("\n") + assert not payload.endswith("\n\n") + + +def test_module_imports(): + import specify_cli.artifacts # noqa: F401 + From 64aa2eb42a58fd36577417ffe4804422010e5a91 Mon Sep 17 00:00:00 2001 From: nicolehaugen Date: Mon, 24 Aug 2026 10:14:36 -0500 Subject: [PATCH 03/49] Potential fix for pull request finding 'Module is imported with 'import' and 'import from'' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- tests/test_artifact_command_parity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_artifact_command_parity.py b/tests/test_artifact_command_parity.py index 83421ffa8d..5aa1435f3d 100644 --- a/tests/test_artifact_command_parity.py +++ b/tests/test_artifact_command_parity.py @@ -136,5 +136,5 @@ def test_terminated_by_single_newline(self, spec_kit_project: Path): def test_module_imports(): - import specify_cli.artifacts # noqa: F401 + _ = ArtifactCatalog From 2988e0f71261009b6040a808a48781e7658444a5 Mon Sep 17 00:00:00 2001 From: nicolehaugen Date: Mon, 24 Aug 2026 10:15:51 -0500 Subject: [PATCH 04/49] Potential fix for pull request finding 'Module is imported with 'import' and 'import from'' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- tests/test_artifact_command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index c7ff4f07b3..3b7dd407f7 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -390,6 +390,6 @@ def test_preset_replace_hides_core(self, spec_kit_project: Path): def test_module_imports(): - import specify_cli.artifacts # noqa: F401 + from specify_cli.artifacts import ArtifactCatalog # noqa: F401 From 812ac9438427d3ea93ee0e810a5b7bf450d372e5 Mon Sep 17 00:00:00 2001 From: nicolehaugen Date: Mon, 24 Aug 2026 10:20:46 -0500 Subject: [PATCH 05/49] Potential fix for pull request finding 'Unused import' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- tests/test_artifact_command.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 3b7dd407f7..7eca8a06e1 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -21,7 +21,6 @@ ArtifactCatalog, ArtifactNotFoundError, NotASpecKitProjectError, - StackLayer, ) From 83f9f8d246c1ee2450996405dd53ec3385f631f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:35:00 +0000 Subject: [PATCH 06/49] Project preset artifacts by entry type Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 27 ++++++++++++++++++++++-- tests/test_artifact_command.py | 30 ++++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 2757743958..c1ed1f8dba 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -678,15 +678,17 @@ def _iter_contribution_artifacts( continue if not isinstance(data, dict): continue - yield from _iter_manifest_contributions(data) + yield from _iter_manifest_contributions(data, is_preset=tier == "presets") def _iter_manifest_contributions( data: dict[str, Any], + *, + is_preset: bool = False, ) -> Iterable[tuple[ArtifactKind, str, str]]: """Yield ``(kind, name, description)`` entries declared by a manifest. - Both preset and extension manifests use the same ``provides`` shape: + Extension manifests group entries by artifact kind: .. code-block:: yaml @@ -695,12 +697,33 @@ def _iter_manifest_contributions( templates: [ ... ] scripts: [ ... ] + Preset manifests instead place every contribution under ``templates`` and + identify its artifact kind with each entry's ``type`` field. + Anything malformed at the entry level is skipped rather than raised — the artifact command is a projection, not a validator. """ provides = data.get("provides") if not isinstance(provides, dict): return + if is_preset: + entries = provides.get("templates") + if not isinstance(entries, list): + return + for entry in entries: + if not isinstance(entry, dict): + continue + kind_value = entry.get("type") + name = entry.get("name") + if kind_value not in ("command", "template", "script"): + continue + if not isinstance(name, str) or not name or ":" in name: + continue + description = entry.get("description", "") + if not isinstance(description, str): + description = "" + yield kind_value, name, description + return for kind_key, kind_value in ( ("commands", "command"), ("templates", "template"), diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 7eca8a06e1..ca58eff512 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -211,8 +211,10 @@ def test_ambiguous_artifact_message(self, spec_kit_project: Path): spec_kit_project, "test-ambig", { - "templates": [{"name": "shared-name", "description": "t"}], - "scripts": [{"name": "shared-name", "description": "s"}], + "templates": [ + {"type": "template", "name": "shared-name", "description": "t"}, + {"type": "script", "name": "shared-name", "description": "s"}, + ], }, ) with pytest.raises(AmbiguousArtifactError) as excinfo: @@ -361,6 +363,29 @@ def test_output_is_utf8_without_bom(self, spec_kit_project: Path, monkeypatch: p class TestStackComposition: + def test_preset_command_uses_entry_type(self, spec_kit_project: Path): + pack = _install_preset( + spec_kit_project, + "test-command", + { + "templates": [ + { + "type": "command", + "name": "speckit.constitution", + "description": "override", + } + ] + }, + ) + (pack / "commands").mkdir() + (pack / "commands" / "speckit.constitution.md").write_text( + "---\ndescription: override\n---\nbody", encoding="utf-8" + ) + + rows = ArtifactCatalog(spec_kit_project).list_artifacts() + assert any(row.id == "command:speckit.constitution" for row in rows) + assert ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution")["kind"] == "command" + def test_preset_replace_hides_core(self, spec_kit_project: Path): # Install a preset that replaces the constitution command. pack = _install_preset( @@ -391,4 +416,3 @@ def test_preset_replace_hides_core(self, spec_kit_project: Path): def test_module_imports(): from specify_cli.artifacts import ArtifactCatalog # noqa: F401 - From 4d14990490845b49b86b718d0349e118b2c78e9d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:39:21 +0000 Subject: [PATCH 07/49] Represent project override artifact layers Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 17 ++++++++++++++++- tests/test_artifact_command.py | 17 +++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index c1ed1f8dba..8b1a0a7a07 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -26,7 +26,7 @@ # --------------------------------------------------------------------------- ArtifactKind = Literal["command", "template", "script"] -LayerName = Literal["preset", "extension", "core"] +LayerName = Literal["project", "preset", "extension", "core"] Strategy = Literal["replace", "wrap", "prepend", "append"] @@ -461,6 +461,21 @@ def _build_stack( ) continue + if lookup_id.startswith("project:") or source == "project override": + rows.append( + StackLayer( + layer="project", + presetId=None, + presetName=None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=None, + lookupId=lookup_id, + ) + ) + continue + if lookup_id.startswith("extension:") or source.startswith("extension:"): manifest_path = _derive_manifest_path(layer, project_root) rows.append( diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index ca58eff512..66aa34c7ab 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -170,11 +170,25 @@ def test_core_row_shape(self, spec_kit_project: Path): assert core["strategy"] == "replace" assert re.match(r"^core:_:(command|template|script):[^:]+$", core["lookupId"]) + def test_project_override_row_shape(self, spec_kit_project: Path): + overrides = spec_kit_project / ".specify" / "templates" / "overrides" + overrides.mkdir() + (overrides / "speckit.constitution.md").write_text("override", encoding="utf-8") + + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + + project = next(layer for layer in info["stack"] if layer["layer"] == "project") + assert project["presetId"] is None + assert project["presetName"] is None + assert project["manifestPath"] is None + assert project["strategy"] == "replace" + assert re.match(r"^project:_:(command|template|script):[^:]+$", project["lookupId"]) + def test_lookup_id_grammar(self, spec_kit_project: Path): info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") for layer in info["stack"]: assert re.match( - r"^(preset|extension|core):[^:]+:(command|template|script):[^:]+(:[0-9a-f]{12})?$", + r"^(project|preset|extension|core):[^:]+:(command|template|script):[^:]+(:[0-9a-f]{12})?$", layer["lookupId"], ) @@ -415,4 +429,3 @@ def test_preset_replace_hides_core(self, spec_kit_project: Path): def test_module_imports(): from specify_cli.artifacts import ArtifactCatalog # noqa: F401 - From 51a001578ebfc9ed8e6ff0892974e4f19ce0c5bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:41:37 +0000 Subject: [PATCH 08/49] Preserve artifact JSON init-dir errors Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/_commands.py | 16 +++++++--------- tests/test_artifact_command.py | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/artifacts/_commands.py b/src/specify_cli/artifacts/_commands.py index 919ef1e07d..279edf85a2 100644 --- a/src/specify_cli/artifacts/_commands.py +++ b/src/specify_cli/artifacts/_commands.py @@ -12,6 +12,7 @@ from __future__ import annotations import json +import os import sys from pathlib import Path from typing import Optional @@ -39,16 +40,13 @@ def _resolve_project_root() -> Path: The stdout of ``specify artifact list --json`` and ``specify artifact info --json`` is a strict JSON envelope; any incidental Rich - output would corrupt it. So instead of calling ``_require_specify_project`` - (which prints to stderr via ``err_console``), we replicate its logic - through the same helper ``_resolve_init_dir_override`` and raise the - module-local :class:`NotASpecKitProjectError` for the shared error - handler to serialize. + output would corrupt it. The shared ``_resolve_init_dir_override`` emits + Rich errors for invalid overrides, so validate the override quietly here + and raise the module-local :class:`NotASpecKitProjectError` for the shared + error handler to serialize. """ - from .._project import _resolve_init_dir_override - - override = _resolve_init_dir_override() - cwd = override if override is not None else Path.cwd() + raw_override = os.environ.get("SPECIFY_INIT_DIR", "") + cwd = (Path.cwd() / raw_override).resolve() if raw_override else Path.cwd() if not (cwd / ".specify").is_dir(): raise NotASpecKitProjectError() return cwd diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 66aa34c7ab..704ee9bb12 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -358,6 +358,32 @@ def test_stdout_empty_on_error(self, non_project: Path, monkeypatch: pytest.Monk result = runner.invoke(app, argv) assert result.stdout == "", f"stdout leak for {argv}: {result.stdout!r}" + @pytest.mark.parametrize( + "override", + ("missing-project", "."), + ) + def test_invalid_init_dir_override_uses_json_error_envelope( + self, + non_project: Path, + monkeypatch: pytest.MonkeyPatch, + override: str, + ): + from typer.testing import CliRunner + + monkeypatch.chdir(non_project) + monkeypatch.setenv("SPECIFY_INIT_DIR", override) + runner = CliRunner() + for argv in ( + ["artifact", "list", "--json"], + ["artifact", "info", "x", "--json"], + ): + result = runner.invoke(app, argv) + assert result.exit_code == 1 + assert result.stdout == "" + assert json.loads(result.stderr) == { + "error": "not a Spec Kit project: no .specify/ directory found" + } + class TestUTF8NoBOM: def test_output_is_utf8_without_bom(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): From 4b026163107384afd50bc335aa77a3adab9220f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:42:50 +0000 Subject: [PATCH 09/49] Canonicalize core script artifacts Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/_script_variants.py | 27 +++++++++++++++++ src/specify_cli/artifacts/__init__.py | 8 +++-- src/specify_cli/presets/__init__.py | 43 +++++++++++++++++++-------- tests/test_artifact_command.py | 18 +++++++++++ 4 files changed, 81 insertions(+), 15 deletions(-) create mode 100644 src/specify_cli/_script_variants.py diff --git a/src/specify_cli/_script_variants.py b/src/specify_cli/_script_variants.py new file mode 100644 index 0000000000..de26065da7 --- /dev/null +++ b/src/specify_cli/_script_variants.py @@ -0,0 +1,27 @@ +"""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 runtime-specific paths for the logical script *name*.""" + for runtime, suffix, uses_underscores in _SCRIPT_VARIANTS: + stem = name.replace("-", "_") if uses_underscores else name + yield scripts_dir / runtime / f"{stem}{suffix}" diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 8b1a0a7a07..18efc8c64d 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -20,6 +20,7 @@ from .._assets import _locate_core_pack, _repo_root from .._identifier import derive_named_id +from .._script_variants import canonical_script_name # --------------------------------------------------------------------------- # Public data classes @@ -113,7 +114,6 @@ def __init__(self) -> None: # Core-baseline enumeration # --------------------------------------------------------------------------- -_SCRIPT_SUFFIXES = frozenset({".py", ".sh", ".ps1"}) _TEMPLATE_SUFFIX = ".md" @@ -277,9 +277,11 @@ def _enumerate_core_scripts() -> list[_CoreBaselineRow]: if not runtime_dir.is_dir(): continue for entry in sorted(runtime_dir.iterdir(), key=lambda p: p.name): - if not entry.is_file() or entry.suffix not in _SCRIPT_SUFFIXES: + if not entry.is_file(): + continue + name = canonical_script_name(entry) + if name is None: continue - name = entry.name if name in seen: continue try: diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 95398e0d31..4a78a4cbb9 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -41,6 +41,7 @@ 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, @@ -5344,8 +5345,11 @@ def resolve( if core.exists(): return core elif template_type == "script": - core = self.templates_dir / "scripts" / f"{template_name}{ext}" - if core.exists(): + core = next( + (path for path in script_variant_paths(self.templates_dir / "scripts", template_name) if path.exists()), + None, + ) + if core is not None: return core # Priority 5: Bundled core_pack (wheel install) or repo-root templates @@ -5365,10 +5369,13 @@ def resolve( if stem: candidate = _core_pack / "commands" / f"{stem}.md" elif template_type == "script": - candidate = _core_pack / "scripts" / f"{template_name}{ext}" + candidate = next( + (path for path in script_variant_paths(_core_pack / "scripts", template_name) if path.exists()), + None, + ) else: candidate = _core_pack / f"{template_name}.md" - if candidate.exists(): + if candidate is not None and candidate.exists(): return candidate else: # Source-checkout / editable install: templates live at repo root @@ -5382,10 +5389,13 @@ def resolve( if stem: candidate = repo_root / "templates" / "commands" / f"{stem}.md" elif template_type == "script": - candidate = repo_root / "scripts" / f"{template_name}{ext}" + candidate = next( + (path for path in script_variant_paths(repo_root / "scripts", template_name) if path.exists()), + None, + ) else: candidate = repo_root / f"{template_name}.md" - if candidate.exists(): + if candidate is not None and candidate.exists(): return candidate return None @@ -5676,8 +5686,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({ @@ -5734,10 +5747,13 @@ def _find_bundled_core( elif template_type == "command": c = core_pack / "commands" / f"{name}.md" elif template_type == "script": - c = core_pack / "scripts" / f"{name}{ext}" + c = next( + (path for path in script_variant_paths(core_pack / "scripts", name) if path.exists()), + None, + ) else: c = core_pack / f"{name}.md" - if c.exists(): + if c is not None and c.exists(): return c else: repo_root = _repo_root() @@ -5747,10 +5763,13 @@ def _find_bundled_core( elif template_type == "command": c = repo_root / "templates" / "commands" / f"{name}.md" elif template_type == "script": - c = repo_root / "scripts" / f"{name}{ext}" + c = next( + (path for path in script_variant_paths(repo_root / "scripts", name) if path.exists()), + None, + ) else: c = repo_root / f"{name}.md" - if c.exists(): + if c is not None and c.exists(): return c return None diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 704ee9bb12..0c84040bce 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -107,6 +107,24 @@ def test_rows_are_unique(self, spec_kit_project: Path): ids = [r.id for r in rows] assert len(ids) == len(set(ids)) + def test_core_script_variants_have_one_resolvable_logical_name( + self, spec_kit_project: Path + ): + catalog = ArtifactCatalog(spec_kit_project) + scripts = [row for row in catalog.list_artifacts() if row.kind == "script"] + + assert {row.name for row in scripts} == { + "check-prerequisites", + "common", + "create-new-feature", + "resolve-template", + "setup-plan", + "setup-tasks", + } + for script in scripts: + info = catalog.get_artifact_info(script.id) + assert info["stack"][-1]["lookupId"] == f"core:_:script:{script.name}" + class TestListSorting: """Deterministic ordering: kind first (command/template/script), then name.""" From 5bf5b3a17e1bddf02524b68a539668923c6f9028 Mon Sep 17 00:00:00 2001 From: nicolehaugen Date: Mon, 24 Aug 2026 10:46:37 -0500 Subject: [PATCH 10/49] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 18efc8c64d..b4162f28fd 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -387,14 +387,11 @@ def _preset_display_name(pack_dir: Path, pack_id: str) -> str: return pack_id if not isinstance(data, dict): return pack_id - metadata = data.get("metadata") - if isinstance(metadata, dict): - display = metadata.get("name") + preset = data.get("preset") + if isinstance(preset, dict): + display = preset.get("name") if isinstance(display, str) and display: return display - display = data.get("name") - if isinstance(display, str) and display: - return display return pack_id From 0be89c815133261d1ba12c6e4102b6e186bb10f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:55:57 +0000 Subject: [PATCH 11/49] Fix artifact inventory resolver filtering Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 16 ++++++- tests/test_artifact_command.py | 67 ++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index b4162f28fd..3af620f556 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -667,14 +667,17 @@ def _describe(self, kind: ArtifactKind, name: str) -> str: def _iter_contribution_artifacts( self, ) -> Iterable[tuple[ArtifactKind, str, str]]: - """Yield ``(kind, name, description)`` for every preset/extension contribution. + """Yield ``(kind, name, description)`` for resolver-visible contributions. Silent on any manifest that fails to parse — that would already be surfaced by ``specify preset list`` or ``specify extension list``, and this command's job is to describe the composed inventory, not to be the second validation surface. """ + from ..presets import PresetResolver # lazy: avoids circular import + specify_dir = self.project_root / ".specify" + resolver = PresetResolver(self.project_root) for tier in ("presets", "extensions"): tier_dir = specify_dir / tier if not tier_dir.is_dir(): @@ -692,7 +695,16 @@ def _iter_contribution_artifacts( continue if not isinstance(data, dict): continue - yield from _iter_manifest_contributions(data, is_preset=tier == "presets") + layer = "preset" if tier == "presets" else "extension" + for kind, name, description in _iter_manifest_contributions( + data, is_preset=tier == "presets" + ): + lookup_id = derive_named_id(layer, pack_dir.name, kind, name) + if any( + candidate["lookupId"] == lookup_id + for candidate in resolver.collect_all_layers(name, kind) + ): + yield kind, name, description def _iter_manifest_contributions( diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 0c84040bce..1cbdd2c6f9 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -125,6 +125,67 @@ def test_core_script_variants_have_one_resolvable_logical_name( info = catalog.get_artifact_info(script.id) assert info["stack"][-1]["lookupId"] == f"core:_:script:{script.name}" + def test_excludes_disabled_and_unusable_manifest_contributions( + self, spec_kit_project: Path + ): + from specify_cli.extensions import ExtensionRegistry + + extensions_dir = spec_kit_project / ".specify" / "extensions" + for extension_id, artifact_name, enabled, file_name in ( + ( + "disabled-ext", + "disabled-template", + False, + "templates/disabled-template.md", + ), + ( + "missing-file-ext", + "missing-template", + True, + "templates/missing-template.md", + ), + ): + extension_dir = extensions_dir / extension_id + extension_dir.mkdir() + (extension_dir / "extension.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "extension": { + "id": extension_id, + "name": extension_id, + "version": "1.0.0", + "description": "test", + "author": "test", + "repository": "https://example.com", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.2.0"}, + "provides": { + "templates": [ + { + "name": artifact_name, + "file": file_name, + "description": "Should not be listed", + } + ] + }, + } + ), + encoding="utf-8", + ) + if not enabled: + template = extension_dir / file_name + template.parent.mkdir() + template.write_text("# Disabled\n", encoding="utf-8") + ExtensionRegistry(extensions_dir).add( + extension_id, {"version": "1.0.0", "enabled": enabled} + ) + + names = {row.name for row in ArtifactCatalog(spec_kit_project).list_artifacts()} + assert "disabled-template" not in names + assert "missing-template" not in names + class TestListSorting: """Deterministic ordering: kind first (command/template/script), then name.""" @@ -239,7 +300,7 @@ def test_ambiguous_artifact_message(self, spec_kit_project: Path): # 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. - _install_preset( + pack = _install_preset( spec_kit_project, "test-ambig", { @@ -249,6 +310,10 @@ def test_ambiguous_artifact_message(self, spec_kit_project: Path): ], }, ) + (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") From 792c276c8b1e7461dac485f50855ec182d7b0e36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:56:37 +0000 Subject: [PATCH 12/49] Add resolver tests for single-runtime core scripts Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- tests/test_presets.py | 58 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/test_presets.py b/tests/test_presets.py index f30ab4909e..c4831cba3a 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -12911,6 +12911,64 @@ 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" + + class TestRemoveReconciliation: """Test that removing a preset re-registers the next layer's command.""" From dcf492a46460888cc6f64b589de36f315710dd7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:56:53 +0000 Subject: [PATCH 13/49] Cache artifact resolver lookups Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 3af620f556..40a1f8c03b 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -678,6 +678,7 @@ def _iter_contribution_artifacts( specify_dir = self.project_root / ".specify" resolver = PresetResolver(self.project_root) + layers_by_artifact: dict[tuple[ArtifactKind, str], set[str]] = {} for tier in ("presets", "extensions"): tier_dir = specify_dir / tier if not tier_dir.is_dir(): @@ -700,10 +701,13 @@ def _iter_contribution_artifacts( data, is_preset=tier == "presets" ): lookup_id = derive_named_id(layer, pack_dir.name, kind, name) - if any( - candidate["lookupId"] == lookup_id - for candidate in resolver.collect_all_layers(name, kind) - ): + key = (kind, name) + if key not in layers_by_artifact: + layers_by_artifact[key] = { + candidate["lookupId"] + for candidate in resolver.collect_all_layers(name, kind) + } + if lookup_id in layers_by_artifact[key]: yield kind, name, description From 6689003bb7d91bf7fe49cdff0c53d9761c58a924 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:56:10 +0000 Subject: [PATCH 14/49] Handle artifact resolver failures Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 6 ++++++ src/specify_cli/artifacts/_commands.py | 8 ++++++++ tests/test_artifact_command.py | 23 ++++++++++++++++++++++- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 40a1f8c03b..c0646388d0 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -110,6 +110,12 @@ def __init__(self) -> None: super().__init__(self.message) +class ArtifactResolutionError(ArtifactError): + def __init__(self) -> None: + self.message = "artifact resolution failed" + super().__init__(self.message) + + # --------------------------------------------------------------------------- # Core-baseline enumeration # --------------------------------------------------------------------------- diff --git a/src/specify_cli/artifacts/_commands.py b/src/specify_cli/artifacts/_commands.py index 279edf85a2..0f30655c03 100644 --- a/src/specify_cli/artifacts/_commands.py +++ b/src/specify_cli/artifacts/_commands.py @@ -25,8 +25,10 @@ ArtifactError, ArtifactKind, ArtifactNotFoundError, + ArtifactResolutionError, NotASpecKitProjectError, ) +from ..presets import PresetError artifact_app = typer.Typer( name="artifact", @@ -99,6 +101,9 @@ def list_command( except ArtifactError as exc: _emit_error_and_exit(exc) return # pragma: no cover — _emit_error_and_exit raises + except PresetError: + _emit_error_and_exit(ArtifactResolutionError()) + return # pragma: no cover — _emit_error_and_exit raises sys.stdout.write(json.dumps(rows, indent=2, sort_keys=True, ensure_ascii=False)) sys.stdout.write("\n") @@ -138,6 +143,9 @@ def info_command( except (ArtifactNotFoundError, AmbiguousArtifactError, NotASpecKitProjectError) as exc: _emit_error_and_exit(exc) return # pragma: no cover + except PresetError: + _emit_error_and_exit(ArtifactResolutionError()) + return # pragma: no cover sys.stdout.write(json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False)) sys.stdout.write("\n") diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 1cbdd2c6f9..fc2cb857f2 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -20,11 +20,14 @@ Artifact, ArtifactCatalog, ArtifactNotFoundError, + ArtifactResolutionError, NotASpecKitProjectError, ) -ERROR_REGEX = re.compile(r"^(unknown artifact |ambiguous artifact |not a Spec Kit project)") +ERROR_REGEX = re.compile( + r"^(unknown artifact |ambiguous artifact |artifact resolution failed|not a Spec Kit project)" +) # --------------------------------------------------------------------------- @@ -319,6 +322,9 @@ def test_ambiguous_artifact_message(self, spec_kit_project: Path): assert excinfo.value.message.startswith("ambiguous artifact shared-name: matches kinds") assert ERROR_REGEX.match(excinfo.value.message) + def test_resolution_error_message(self): + assert ArtifactResolutionError().message == "artifact resolution failed" + class TestKindHint: def test_kind_flag_disambiguates(self, spec_kit_project: Path): @@ -418,6 +424,21 @@ def test_info_unknown_error_envelope(self, spec_kit_project: Path, monkeypatch: assert set(err.keys()) == {"error"} assert ERROR_REGEX.match(err["error"]) + def test_info_corrupt_extension_registry_uses_json_error_envelope( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + from typer.testing import CliRunner + + extensions_dir = spec_kit_project / ".specify" / "extensions" + (extensions_dir / ".registry").write_text("{invalid", encoding="utf-8") + monkeypatch.chdir(spec_kit_project) + result = CliRunner().invoke( + app, ["artifact", "info", "speckit.constitution", "--json"] + ) + assert result.exit_code == 1 + assert result.stdout == "" + assert json.loads(result.stderr) == {"error": "artifact resolution failed"} + def test_not_a_project_error_envelope(self, non_project: Path, monkeypatch: pytest.MonkeyPatch): from typer.testing import CliRunner From f07d76488579b319c1c671b1b5e5742d0f6f5c04 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:57:04 +0000 Subject: [PATCH 15/49] Document artifact resolution error Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index c0646388d0..9884a2660d 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -85,7 +85,7 @@ class ArtifactError(Exception): 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 |not a Spec Kit project)``. + The contract regex is ``^(unknown artifact |ambiguous artifact |artifact resolution failed|not a Spec Kit project)``. """ message: str From e8a806d9e992a00c13ebc49c2acf6e9953d8a798 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:03:51 +0000 Subject: [PATCH 16/49] Include convention-based artifacts in inventory Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 124 +++++++++++++++++++++----- tests/test_artifact_command.py | 81 +++++++++++++++++ 2 files changed, 184 insertions(+), 21 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 9884a2660d..5b3c0fa1d8 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -19,7 +19,7 @@ import yaml from .._assets import _locate_core_pack, _repo_root -from .._identifier import derive_named_id +from .._identifier import PROJECT_OVERRIDE_LAYER, derive_named_id from .._script_variants import canonical_script_name # --------------------------------------------------------------------------- @@ -121,6 +121,7 @@ def __init__(self) -> None: # --------------------------------------------------------------------------- _TEMPLATE_SUFFIX = ".md" +_SCRIPT_SUFFIX = ".sh" @dataclass(frozen=True) @@ -675,6 +676,16 @@ def _iter_contribution_artifacts( ) -> Iterable[tuple[ArtifactKind, str, str]]: """Yield ``(kind, name, description)`` for resolver-visible contributions. + Covers the two ways a pack can contribute an artifact: + + * manifest-declared entries (``preset.yml`` / ``extension.yml``), and + * convention-placed extension files (``commands/``, ``templates/``, + ``scripts/``) that the resolver picks up even without a manifest. + + Project-local overrides under ``.specify/templates/overrides`` are + included too, so an artifact that exists only as an override is still + listed. + Silent on any manifest that fails to parse — that would already be surfaced by ``specify preset list`` or ``specify extension list``, and this command's job is to describe the composed inventory, not to be @@ -685,6 +696,16 @@ def _iter_contribution_artifacts( specify_dir = self.project_root / ".specify" resolver = PresetResolver(self.project_root) layers_by_artifact: dict[tuple[ArtifactKind, str], set[str]] = {} + + def _lookup_ids(kind: ArtifactKind, name: str) -> set[str]: + key = (kind, name) + if key not in layers_by_artifact: + layers_by_artifact[key] = { + candidate["lookupId"] + for candidate in resolver.collect_all_layers(name, kind) + } + return layers_by_artifact[key] + for tier in ("presets", "extensions"): tier_dir = specify_dir / tier if not tier_dir.is_dir(): @@ -694,27 +715,88 @@ def _iter_contribution_artifacts( continue manifest_name = "preset.yml" if tier == "presets" else "extension.yml" manifest = pack_dir / manifest_name - if not manifest.is_file(): - continue - try: - data = yaml.safe_load(manifest.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, yaml.YAMLError): - continue - if not isinstance(data, dict): - continue layer = "preset" if tier == "presets" else "extension" - for kind, name, description in _iter_manifest_contributions( - data, is_preset=tier == "presets" - ): - lookup_id = derive_named_id(layer, pack_dir.name, kind, name) - key = (kind, name) - if key not in layers_by_artifact: - layers_by_artifact[key] = { - candidate["lookupId"] - for candidate in resolver.collect_all_layers(name, kind) - } - if lookup_id in layers_by_artifact[key]: - yield kind, name, description + data: Any = None + if manifest.is_file(): + try: + data = yaml.safe_load(manifest.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, yaml.YAMLError): + data = None + if isinstance(data, dict): + for kind, name, description in _iter_manifest_contributions( + data, is_preset=tier == "presets" + ): + lookup_id = derive_named_id(layer, pack_dir.name, kind, name) + if lookup_id in _lookup_ids(kind, name): + yield kind, name, description + if tier != "extensions": + continue + # Convention fallback: an extension file placed at the + # conventional path resolves whether or not the manifest + # declares it, so it belongs in the inventory as well. + for kind, name in _iter_convention_contributions(pack_dir): + lookup_id = derive_named_id("extension", pack_dir.name, kind, name) + if lookup_id in _lookup_ids(kind, name): + yield kind, name, "" + + yield from self._iter_project_override_artifacts(resolver) + + def _iter_project_override_artifacts( + self, + resolver: Any, + ) -> Iterable[tuple[ArtifactKind, str, str]]: + """Yield ``(kind, name, "")`` for project-local override files. + + A root ``overrides/.md`` file is the override for both the + ``template`` and the ``command`` lookup of ````, so it is + reported as a command when some other layer already provides that + command and as a template otherwise. That keeps a command override + from also appearing as a second, spurious ``template:`` row. + """ + overrides_dir = resolver.overrides_dir + if not overrides_dir.is_dir(): + return + for entry in sorted(overrides_dir.iterdir(), key=lambda p: p.name): + if not entry.is_file() or entry.suffix != _TEMPLATE_SUFFIX: + continue + name = entry.stem + command_layers = resolver.collect_all_layers(name, "command") + backed_by_command = any( + not str(layer.get("lookupId", "")).startswith( + f"{PROJECT_OVERRIDE_LAYER}:" + ) + for layer in command_layers + ) + yield ("command" if backed_by_command else "template"), name, "" + scripts_dir = overrides_dir / "scripts" + if not scripts_dir.is_dir(): + return + for entry in sorted(scripts_dir.iterdir(), key=lambda p: p.name): + if entry.is_file() and entry.suffix == _SCRIPT_SUFFIX: + yield "script", entry.stem, "" + + +_CONVENTION_SUBDIRS: tuple[tuple[str, ArtifactKind, str], ...] = ( + ("commands", "command", _TEMPLATE_SUFFIX), + ("templates", "template", _TEMPLATE_SUFFIX), + ("scripts", "script", _SCRIPT_SUFFIX), +) + + +def _iter_convention_contributions(pack_dir: Path) -> Iterable[tuple[ArtifactKind, str]]: + """Yield ``(kind, name)`` for files an extension exposes by convention. + + Only the conventional subdirectories are scanned; loose ``.md`` files at + the extension root (``README.md`` and friends) are deliberately skipped so + packaging files don't show up as templates. + """ + for subdir, kind, suffix in _CONVENTION_SUBDIRS: + candidate_dir = pack_dir / subdir + if not candidate_dir.is_dir(): + continue + for entry in sorted(candidate_dir.iterdir(), key=lambda p: p.name): + if entry.is_file() and entry.suffix == suffix and ":" not in entry.stem: + yield kind, entry.stem def _iter_manifest_contributions( diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index fc2cb857f2..6aa83007f3 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -552,6 +552,87 @@ def test_preset_replace_hides_core(self, spec_kit_project: Path): 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_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" + + # --------------------------------------------------------------------------- # Existing module-import placeholder retained for import safety. # --------------------------------------------------------------------------- From 6089babff23c2da6941c85417f15010553781593 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:12:20 +0000 Subject: [PATCH 17/49] Restore legacy flat core script lookup Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/_script_variants.py | 7 ++++++- tests/test_presets.py | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/_script_variants.py b/src/specify_cli/_script_variants.py index de26065da7..5a1b76c7c2 100644 --- a/src/specify_cli/_script_variants.py +++ b/src/specify_cli/_script_variants.py @@ -21,7 +21,12 @@ def canonical_script_name(path: Path) -> str | None: def script_variant_paths(scripts_dir: Path, name: str) -> Iterator[Path]: - """Yield runtime-specific paths for the logical script *name*.""" + """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/tests/test_presets.py b/tests/test_presets.py index c4831cba3a..92576eb44d 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -12968,6 +12968,31 @@ def test_collect_all_layers_finds_python_only_core_script(self, project_dir): 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.""" From 29262bc10323f39205ffe26414c6e8c2b368482d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:53:56 +0000 Subject: [PATCH 18/49] Extend convention discovery to presets in artifact inventory Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 6 ++---- tests/test_artifact_command.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 5b3c0fa1d8..efea1adc2f 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -729,13 +729,11 @@ def _lookup_ids(kind: ArtifactKind, name: str) -> set[str]: lookup_id = derive_named_id(layer, pack_dir.name, kind, name) if lookup_id in _lookup_ids(kind, name): yield kind, name, description - if tier != "extensions": - continue - # Convention fallback: an extension file placed at the + # Convention fallback: a preset/extension file placed at the # conventional path resolves whether or not the manifest # declares it, so it belongs in the inventory as well. for kind, name in _iter_convention_contributions(pack_dir): - lookup_id = derive_named_id("extension", pack_dir.name, kind, name) + lookup_id = derive_named_id(layer, pack_dir.name, kind, name) if lookup_id in _lookup_ids(kind, name): yield kind, name, "" diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 6aa83007f3..b167da32e8 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -618,6 +618,23 @@ def test_project_override_only_artifact_is_listed(self, spec_kit_project: Path): info = catalog.get_artifact_info("local-template") assert info["stack"][0]["layer"] == "project" + def test_unregistered_preset_template_without_manifest(self, spec_kit_project: Path): + pack_dir = _install_preset(spec_kit_project, "legacy-preset", provides={"templates": []}) + preset_templates_dir = pack_dir / "templates" + preset_templates_dir.mkdir() + (preset_templates_dir / "legacy-preset-template.md").write_text( + "body", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + assert any( + row.id == "template:legacy-preset-template" for row in catalog.list_artifacts() + ) + info = catalog.get_artifact_info("legacy-preset-template") + assert info["stack"][0]["lookupId"] == ( + "preset:legacy-preset:template:legacy-preset-template" + ) + def test_command_override_is_not_duplicated_as_template(self, spec_kit_project: Path): ext_dir = spec_kit_project / ".specify" / "extensions" / "legacy" / "commands" ext_dir.mkdir(parents=True) From 59b96c8bdccc60b06b73883933553925f0a07019 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:56:49 +0000 Subject: [PATCH 19/49] Fix manifest path portability and export ArtifactResolutionError Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 8 ++++++-- tests/test_artifact_command.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index efea1adc2f..f986299621 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -351,7 +351,10 @@ def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | No 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. + value on both. If the enclosing manifest is found outside + ``project_root`` (e.g. an unbounded parent walk from a convention-only + layer escapes the project), ``None`` is returned rather than an + absolute host path, preserving the repo-relative contract. """ lookup_id = layer.get("lookupId", "") if lookup_id.startswith("core:"): @@ -365,7 +368,7 @@ def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | No try: rel = manifest.relative_to(project_root) except ValueError: - return manifest.as_posix() + return None return rel.as_posix() @@ -870,6 +873,7 @@ def _iter_manifest_contributions( "ArtifactError", "ArtifactKind", "ArtifactNotFoundError", + "ArtifactResolutionError", "CoreBaseline", "LayerName", "NotASpecKitProjectError", diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index b167da32e8..ad76eab2bf 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -650,6 +650,25 @@ def test_command_override_is_not_duplicated_as_template(self, spec_kit_project: assert catalog.get_artifact_info("speckit.legacy")["kind"] == "command" +class TestManifestPathPortability: + """`_derive_manifest_path` must never leak an absolute host path.""" + + def test_enclosing_manifest_outside_project_root_is_none(self, tmp_path: Path): + from specify_cli.artifacts import _derive_manifest_path + + project_root = tmp_path / "proj" + project_root.mkdir() + + outside = tmp_path / "outside-pack" + (outside / "templates").mkdir(parents=True) + (outside / "preset.yml").write_text("id: outside-pack\n", encoding="utf-8") + source = outside / "templates" / "legacy-template.md" + source.write_text("body", encoding="utf-8") + + layer = {"lookupId": "preset:outside-pack:template:legacy-template", "path": source} + assert _derive_manifest_path(layer, project_root) is None + + # --------------------------------------------------------------------------- # Existing module-import placeholder retained for import safety. # --------------------------------------------------------------------------- From 27f14811282111a8df604804799585fe276734af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:10:11 +0000 Subject: [PATCH 20/49] Bound artifact manifest search to project root Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 18 +++++++++++------- tests/test_artifact_command.py | 12 ++++++++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index f986299621..327a5e1b88 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -351,10 +351,9 @@ def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | No Uses ``as_posix()`` so the string is stable across Windows and POSIX — a caller comparing snapshots between operating systems gets the same - value on both. If the enclosing manifest is found outside - ``project_root`` (e.g. an unbounded parent walk from a convention-only - layer escapes the project), ``None`` is returned rather than an - absolute host path, preserving the repo-relative contract. + value on both. The enclosing-manifest search is bounded at + ``project_root`` so convention-only layers cannot walk out of the + project and serialize absolute host paths. """ lookup_id = layer.get("lookupId", "") if lookup_id.startswith("core:"): @@ -362,7 +361,7 @@ def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | No source = layer.get("path") if not isinstance(source, Path): return None - manifest = _find_enclosing_manifest(source) + manifest = _find_enclosing_manifest(source, project_root) if manifest is None: return None try: @@ -372,9 +371,14 @@ def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | No return rel.as_posix() -def _find_enclosing_manifest(path: Path) -> Path | None: - """Walk parents of ``path`` looking for preset.yml or extension.yml.""" +def _find_enclosing_manifest(path: Path, project_root: Path) -> Path | None: + """Walk parents of ``path`` up to ``project_root`` looking for a manifest.""" + root = project_root.resolve() for parent in path.parents: + try: + parent.resolve().relative_to(root) + except ValueError: + break for name in ("preset.yml", "extension.yml"): candidate = parent / name if candidate.is_file(): diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index ad76eab2bf..3edced920b 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -668,6 +668,18 @@ def test_enclosing_manifest_outside_project_root_is_none(self, tmp_path: Path): layer = {"lookupId": "preset:outside-pack:template:legacy-template", "path": source} assert _derive_manifest_path(layer, project_root) is None + def test_enclosing_manifest_search_stops_at_project_root(self, tmp_path: Path): + from specify_cli.artifacts import _find_enclosing_manifest + + project_root = tmp_path / "proj" + source_dir = project_root / ".specify" / "templates" + source_dir.mkdir(parents=True) + source = source_dir / "legacy-template.md" + source.write_text("body", encoding="utf-8") + (tmp_path / "preset.yml").write_text("id: outside-pack\n", encoding="utf-8") + + assert _find_enclosing_manifest(source, project_root) is None + # --------------------------------------------------------------------------- # Existing module-import placeholder retained for import safety. From 45338b6653324de20928f54c49ce1b48959ff201 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:10:59 +0000 Subject: [PATCH 21/49] Cover project-root artifact manifests Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- tests/test_artifact_command.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 3edced920b..786047da88 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -680,6 +680,19 @@ def test_enclosing_manifest_search_stops_at_project_root(self, tmp_path: Path): assert _find_enclosing_manifest(source, project_root) is None + def test_enclosing_manifest_search_includes_project_root(self, tmp_path: Path): + from specify_cli.artifacts import _find_enclosing_manifest + + project_root = tmp_path / "proj" + source_dir = project_root / ".specify" / "templates" + source_dir.mkdir(parents=True) + source = source_dir / "legacy-template.md" + source.write_text("body", encoding="utf-8") + manifest = project_root / "preset.yml" + manifest.write_text("id: root-pack\n", encoding="utf-8") + + assert _find_enclosing_manifest(source, project_root) == manifest + # --------------------------------------------------------------------------- # Existing module-import placeholder retained for import safety. From ac09641f0c82355ea92c8f5883e6536e5c4de357 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:11:45 +0000 Subject: [PATCH 22/49] Handle directory artifact manifest lookups Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 3 ++- tests/test_artifact_command.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 327a5e1b88..3d7ec9d73c 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -374,7 +374,8 @@ def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | No def _find_enclosing_manifest(path: Path, project_root: Path) -> Path | None: """Walk parents of ``path`` up to ``project_root`` looking for a manifest.""" root = project_root.resolve() - for parent in path.parents: + start = path if path.is_dir() else path.parent + for parent in (start, *start.parents): try: parent.resolve().relative_to(root) except ValueError: diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 786047da88..0806263315 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -693,6 +693,16 @@ def test_enclosing_manifest_search_includes_project_root(self, tmp_path: Path): assert _find_enclosing_manifest(source, project_root) == manifest + def test_enclosing_manifest_search_accepts_project_root_path(self, tmp_path: Path): + from specify_cli.artifacts import _find_enclosing_manifest + + project_root = tmp_path / "proj" + project_root.mkdir() + manifest = project_root / "preset.yml" + manifest.write_text("id: root-pack\n", encoding="utf-8") + + assert _find_enclosing_manifest(project_root, project_root) == manifest + # --------------------------------------------------------------------------- # Existing module-import placeholder retained for import safety. From 43cf9bcfbb71de38cd11dff50c7d96333fff97a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:23:43 +0000 Subject: [PATCH 23/49] Fall back to top-level preset name in artifact stacks Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 8 +++++-- tests/test_artifact_command.py | 34 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 3d7ec9d73c..d27afe88ac 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -390,8 +390,9 @@ def _find_enclosing_manifest(path: Path, project_root: Path) -> Path | None: def _preset_display_name(pack_dir: Path, pack_id: str) -> str: """Return the preset's human-friendly name from ``preset.yml``. - Falls back to the pack id when the manifest is missing or lacks a - ``metadata.name`` value. + Reads ``preset.name`` first and falls back to a top-level ``name`` key for + manifests written in the older flat layout. Falls back to the pack id when + the manifest is missing or declares no usable name. """ manifest_path = pack_dir / "preset.yml" if not manifest_path.is_file(): @@ -407,6 +408,9 @@ def _preset_display_name(pack_dir: Path, pack_id: str) -> str: display = preset.get("name") if isinstance(display, str) and display: return display + display = data.get("name") + if isinstance(display, str) and display: + return display return pack_id diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 0806263315..540b3a34b0 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -704,6 +704,40 @@ def test_enclosing_manifest_search_accepts_project_root_path(self, tmp_path: Pat assert _find_enclosing_manifest(project_root, project_root) == manifest +class TestPresetDisplayName: + """`_preset_display_name` reads nested and flat manifest layouts.""" + + def test_reads_nested_preset_name(self, tmp_path: Path): + from specify_cli.artifacts import _preset_display_name + + pack_dir = tmp_path / "pack" + pack_dir.mkdir() + (pack_dir / "preset.yml").write_text( + "preset:\n id: pack\n name: Nested Name\nname: Flat Name\n", + encoding="utf-8", + ) + + assert _preset_display_name(pack_dir, "pack") == "Nested Name" + + def test_falls_back_to_top_level_name(self, tmp_path: Path): + from specify_cli.artifacts import _preset_display_name + + 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") == "Flat Name" + + def test_falls_back_to_pack_id_without_name(self, tmp_path: Path): + from specify_cli.artifacts import _preset_display_name + + pack_dir = tmp_path / "pack" + pack_dir.mkdir() + (pack_dir / "preset.yml").write_text("id: pack\n", encoding="utf-8") + + assert _preset_display_name(pack_dir, "pack") == "pack" + + # --------------------------------------------------------------------------- # Existing module-import placeholder retained for import safety. # --------------------------------------------------------------------------- From ca426717c64921d6ac445e36948b31c39cab4e78 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:26:01 +0000 Subject: [PATCH 24/49] Include project-local core artifacts in inventory Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 141 +++++++++++++++++--------- tests/test_artifact_command.py | 32 ++++++ 2 files changed, 126 insertions(+), 47 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index d27afe88ac..ea68fbf6cb 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -156,6 +156,20 @@ def _core_asset_root(subdir: str) -> Path | None: return candidate if candidate.is_dir() else None +def _project_core_asset_root(project_root: Path | None, subdir: str) -> Path | None: + """Return the project-local core directory for an asset family, if present.""" + if project_root is None: + return None + candidate = project_root / ".specify" / "templates" + if subdir == "commands": + candidate /= "commands" + elif subdir == "scripts": + candidate /= "scripts" + elif subdir != "templates": # pragma: no cover — internal misuse + return None + return candidate if candidate.is_dir() else None + + def _extract_frontmatter_description(text: str) -> str: """Return the ``description`` value from YAML frontmatter, else ``""``. @@ -219,7 +233,7 @@ def _extract_script_description(text: str) -> str: return "" -def _enumerate_core_commands() -> list[_CoreBaselineRow]: +def _enumerate_core_commands(project_root: Path | None = None) -> list[_CoreBaselineRow]: """Enumerate every command shipped in the core baseline. Names are surfaced with the ``speckit.`` prefix so they collide with @@ -229,11 +243,30 @@ def _enumerate_core_commands() -> list[_CoreBaselineRow]: from ..extensions import CORE_COMMAND_NAMES # lazy: avoids circular import commands_dir = _core_asset_root("commands") + project_commands_dir = _project_core_asset_root(project_root, "commands") rows: list[_CoreBaselineRow] = [] - if commands_dir is None: + if commands_dir is None and project_commands_dir is None: return rows - for stem in sorted(CORE_COMMAND_NAMES): - path = commands_dir / f"{stem}.md" + project_stems = ( + { + entry.stem + for entry in project_commands_dir.iterdir() + if entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX + } + if project_commands_dir is not None + else set() + ) + for stem in sorted(set(CORE_COMMAND_NAMES) | project_stems): + path = ( + project_commands_dir / f"{stem}.md" + if project_commands_dir is not None + and (project_commands_dir / f"{stem}.md").is_file() + else commands_dir / f"{stem}.md" + if commands_dir is not None + else None + ) + if path is None: + continue if not path.is_file(): continue try: @@ -251,60 +284,74 @@ def _enumerate_core_commands() -> list[_CoreBaselineRow]: return rows -def _enumerate_core_templates() -> list[_CoreBaselineRow]: +def _enumerate_core_templates(project_root: Path | None = None) -> list[_CoreBaselineRow]: templates_dir = _core_asset_root("templates") + project_templates_dir = _project_core_asset_root(project_root, "templates") rows: list[_CoreBaselineRow] = [] - if templates_dir is None: - return rows - for entry in sorted(templates_dir.iterdir(), key=lambda p: p.name): - if not entry.is_file() or entry.suffix != _TEMPLATE_SUFFIX: + seen: set[str] = set() + for directory in (project_templates_dir, templates_dir): + if directory is None: continue - try: - text = entry.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - text = "" - rows.append( - _CoreBaselineRow( - name=entry.stem, - kind="template", - path=entry, - description=_extract_frontmatter_description(text), + for entry in sorted(directory.iterdir(), key=lambda p: p.name): + if ( + not entry.is_file() + or entry.suffix != _TEMPLATE_SUFFIX + or entry.stem in seen + ): + continue + seen.add(entry.stem) + try: + text = entry.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + text = "" + rows.append( + _CoreBaselineRow( + name=entry.stem, + kind="template", + path=entry, + description=_extract_frontmatter_description(text), + ) ) - ) return rows -def _enumerate_core_scripts() -> list[_CoreBaselineRow]: +def _enumerate_core_scripts(project_root: Path | None = None) -> list[_CoreBaselineRow]: scripts_dir = _core_asset_root("scripts") + project_scripts_dir = _project_core_asset_root(project_root, "scripts") rows: list[_CoreBaselineRow] = [] - if scripts_dir is None: - return rows seen: dict[str, _CoreBaselineRow] = {} - for runtime_dir in sorted(scripts_dir.iterdir(), key=lambda p: p.name): - if not runtime_dir.is_dir(): + for directory in (project_scripts_dir, scripts_dir): + if directory is None: continue - for entry in sorted(runtime_dir.iterdir(), key=lambda p: p.name): - if not entry.is_file(): - continue - name = canonical_script_name(entry) - if name is None: + for entry in sorted(directory.glob(f"*{_SCRIPT_SUFFIX}"), key=lambda p: p.name): + if entry.stem not in seen: + seen[entry.stem] = _core_script_row(entry, entry.stem) + for runtime_dir in sorted(directory.iterdir(), key=lambda p: p.name): + if not runtime_dir.is_dir(): continue - if name in seen: - continue - try: - text = entry.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - text = "" - seen[name] = _CoreBaselineRow( - name=name, - kind="script", - path=entry, - description=_extract_script_description(text), - ) + for entry in sorted(runtime_dir.iterdir(), key=lambda p: p.name): + if not entry.is_file(): + continue + name = canonical_script_name(entry) + if name is not None and name not in seen: + seen[name] = _core_script_row(entry, name) rows.extend(sorted(seen.values(), key=lambda r: r.name)) return rows +def _core_script_row(path: Path, name: str) -> _CoreBaselineRow: + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + text = "" + return _CoreBaselineRow( + name=name, + kind="script", + path=path, + description=_extract_script_description(text), + ) + + @dataclass(frozen=True) class CoreBaseline: """The union of the three core enumerators, indexed for O(1) lookup.""" @@ -314,11 +361,11 @@ class CoreBaseline: scripts: tuple[_CoreBaselineRow, ...] @classmethod - def load(cls) -> "CoreBaseline": + def load(cls, project_root: Path | None = None) -> "CoreBaseline": return cls( - commands=tuple(_enumerate_core_commands()), - templates=tuple(_enumerate_core_templates()), - scripts=tuple(_enumerate_core_scripts()), + commands=tuple(_enumerate_core_commands(project_root)), + templates=tuple(_enumerate_core_templates(project_root)), + scripts=tuple(_enumerate_core_scripts(project_root)), ) def by_kind(self, kind: ArtifactKind) -> tuple[_CoreBaselineRow, ...]: @@ -663,7 +710,7 @@ def get_artifact_info( # -------------------------------------------------------------- internals def _get_baseline(self) -> CoreBaseline: if self._baseline is None: - self._baseline = CoreBaseline.load() + self._baseline = CoreBaseline.load(self.project_root) return self._baseline def _find_matches(self, name: str) -> list[tuple[ArtifactKind, str]]: diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 540b3a34b0..c3fb2e0ee4 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -189,6 +189,38 @@ def test_excludes_disabled_and_unusable_manifest_contributions( assert "disabled-template" not in names assert "missing-template" not in names + def test_includes_project_local_core_assets(self, spec_kit_project: Path): + templates_dir = spec_kit_project / ".specify" / "templates" + (templates_dir / "legacy-template.md").write_text( + "---\ndescription: Local template\n---\n", encoding="utf-8" + ) + commands_dir = templates_dir / "commands" + commands_dir.mkdir() + (commands_dir / "local-command.md").write_text( + "---\ndescription: Local command\n---\n", encoding="utf-8" + ) + scripts_dir = templates_dir / "scripts" + scripts_dir.mkdir() + (scripts_dir / "legacy-script.sh").write_text( + "# Local script\n", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + artifacts = {artifact.id: artifact for artifact in catalog.list_artifacts()} + + assert artifacts["template:legacy-template"].description == "Local template" + assert artifacts["command:speckit.local-command"].description == "Local command" + assert artifacts["script:legacy-script"].description == "Local script" + assert catalog.get_artifact_info("speckit.local-command")["stack"][0]["lookupId"] == ( + "core:_:command:speckit.local-command" + ) + assert catalog.get_artifact_info("legacy-template")["stack"][0]["lookupId"] == ( + "core:_:template:legacy-template" + ) + assert catalog.get_artifact_info("legacy-script")["stack"][0]["lookupId"] == ( + "core:_:script:legacy-script" + ) + class TestListSorting: """Deterministic ordering: kind first (command/template/script), then name.""" From 7e3b50df8312e79ffc7ceae149569aaa7c0a990b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:41:44 +0000 Subject: [PATCH 25/49] Address inline review feedback on artifact resolver helpers Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/_assets.py | 26 +++++ src/specify_cli/_identifier.py | 20 ++++ src/specify_cli/artifacts/__init__.py | 133 ++++++++++--------------- src/specify_cli/extensions/__init__.py | 36 +++---- src/specify_cli/presets/__init__.py | 60 ++++++----- tests/test_artifact_command.py | 105 +++++++++++-------- tests/test_assets.py | 57 +++++++++++ tests/test_contribution_ids.py | 29 ++++++ tests/test_extensions.py | 30 +++--- 9 files changed, 309 insertions(+), 187 deletions(-) create mode 100644 tests/test_assets.py diff --git a/src/specify_cli/_assets.py b/src/specify_cli/_assets.py index 31fb9708e6..f77378b3fc 100644 --- a/src/specify_cli/_assets.py +++ b/src/specify_cli/_assets.py @@ -32,6 +32,32 @@ def _repo_root() -> Path: return Path(__file__).parent.parent.parent +def _locate_core_asset_dir(subdir: str) -> Path | None: + """Return the on-disk directory holding a family of core assets, or None. + + ``subdir`` is one of ``"commands"``, ``"templates"``, or ``"scripts"`` — + the three asset families every core baseline consumer needs to agree on. + Prefers the wheel-installed ``core_pack`` bundle, then falls back to the + source-checkout layout. This is the single place that knows the two-tier + resolution ("wheel bundle, else repo-root checkout") for locating core + assets, so callers (extension command-name discovery, the preset + resolver's core fallback, and the artifact command's core-baseline + enumeration) cannot silently diverge on what "core" means on a given + machine. + """ + core = _locate_core_pack() + if core is not None: + candidate = core / subdir + return candidate if candidate.is_dir() else None + if subdir == "commands": + candidate = _repo_root() / "templates" / "commands" + elif subdir in ("templates", "scripts"): + candidate = _repo_root() / subdir + else: # pragma: no cover — internal misuse + return None + return candidate if candidate.is_dir() else None + + def _locate_bundled_extension(extension_id: str) -> Path | None: """Return the path to a bundled extension, or None. diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py index 4124157df5..37e0baae0b 100644 --- a/src/specify_cli/_identifier.py +++ b/src/specify_cli/_identifier.py @@ -98,6 +98,26 @@ def derive_named_id(layer: str, source_id: str, kind: str, name: str) -> str: return f"{layer}:{source_id}:{kind}:{name}" +_LAYER_KINDS = frozenset({"core", PROJECT_OVERRIDE_LAYER, "preset", "extension"}) + + +def layer_kind_from_lookup_id(lookup_id: str) -> str | None: + """Return the layer segment of a resolved-stack ``lookupId``, or ``None``. + + ``lookupId`` values on resolved stack layers follow the same + ``"{layer}:..."`` grammar as manifest-contribution ``id`` values (see + module docstring), with ``layer`` additionally taking on + :data:`PROJECT_OVERRIDE_LAYER` for resolver-only project-override layers. + This is the single place that knows the set of valid layer prefixes, so + consumers can classify a lookupId without re-deriving the grammar via + string-prefix checks of their own. + """ + layer, _, rest = lookup_id.partition(":") + if not rest or layer not in _LAYER_KINDS: + return None + return layer + + def canonical_json(value: Any) -> bytes: """Serialize ``value`` to a canonical UTF-8 JSON byte string. diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index ea68fbf6cb..e55c82ab48 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -18,8 +18,8 @@ import yaml -from .._assets import _locate_core_pack, _repo_root -from .._identifier import PROJECT_OVERRIDE_LAYER, derive_named_id +from .._assets import _locate_core_asset_dir +from .._identifier import PROJECT_OVERRIDE_LAYER, derive_named_id, layer_kind_from_lookup_id from .._script_variants import canonical_script_name # --------------------------------------------------------------------------- @@ -135,25 +135,12 @@ class _CoreBaselineRow: def _core_asset_root(subdir: str) -> Path | None: """Return the on-disk directory holding a family of core assets, or None. - Prefers the wheel-installed ``core_pack`` bundle, then falls back to the - source-checkout layout. Mirrors the two-tier resolution used by - :func:`_load_core_command_names` and :meth:`PresetResolver._find_bundled_core` - so all three code paths agree on what "core" means on this machine. + Delegates to :func:`_locate_core_asset_dir`, the single shared resolver + also used by :func:`_load_core_command_names` and + :meth:`PresetResolver._find_bundled_core`, so all three code paths agree + on what "core" means on this machine instead of each re-deriving it. """ - core = _locate_core_pack() - if core is not None: - candidate = core / subdir - if candidate.is_dir(): - return candidate - if subdir == "commands": - candidate = _repo_root() / "templates" / "commands" - elif subdir == "templates": - candidate = _repo_root() / "templates" - elif subdir == "scripts": - candidate = _repo_root() / "scripts" - else: # pragma: no cover — internal misuse - return None - return candidate if candidate.is_dir() else None + return _locate_core_asset_dir(subdir) def _project_core_asset_root(project_root: Path | None, subdir: str) -> Path | None: @@ -391,74 +378,57 @@ def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | No """Return a repo-relative POSIX path to the manifest declaring this layer. ``layer`` is one dict entry from ``PresetResolver.collect_all_layers()``. - Core layers return ``None`` — they have no on-disk manifest that ships - with the project. Non-core layers walk upward from the contribution file - until they find the preset's ``preset.yml`` or the extension's - ``extension.yml``, then relativize against ``project_root``. - - Uses ``as_posix()`` so the string is stable across Windows and POSIX — - a caller comparing snapshots between operating systems gets the same - value on both. The enclosing-manifest search is bounded at - ``project_root`` so convention-only layers cannot walk out of the - project and serialize absolute host paths. + Only ``preset`` and ``extension`` layers have an on-disk manifest — core + and project-override layers return ``None``. + + ``PresetResolver.collect_all_layers`` always reads a pack's files from + ``project_root / ".specify" / "" / ""``, + whether or not that pack is registered — registration only changes which + priority/version metadata is attached, never where the pack lives on + disk. That means the manifest's location is fully determined by the + layer's own ``lookupId`` (``"{layer}:{sourceId}:..."``), so it is derived + directly rather than walking upward from the contribution file. + + Uses ``as_posix()`` so the string is stable across Windows and POSIX — a + caller comparing snapshots between operating systems gets the same value + on both. """ lookup_id = layer.get("lookupId", "") - if lookup_id.startswith("core:"): + layer_kind = layer_kind_from_lookup_id(lookup_id) + if layer_kind not in ("preset", "extension"): return None - source = layer.get("path") - if not isinstance(source, Path): + pack_id = _extract_lookup_pack_id(lookup_id) + if not pack_id: return None - manifest = _find_enclosing_manifest(source, project_root) - if manifest is None: - return None - try: - rel = manifest.relative_to(project_root) - except ValueError: + tier_dir, manifest_name = ( + ("presets", "preset.yml") + if layer_kind == "preset" + else ("extensions", "extension.yml") + ) + manifest_path = project_root / ".specify" / tier_dir / pack_id / manifest_name + if not manifest_path.is_file(): return None - return rel.as_posix() - - -def _find_enclosing_manifest(path: Path, project_root: Path) -> Path | None: - """Walk parents of ``path`` up to ``project_root`` looking for a manifest.""" - root = project_root.resolve() - start = path if path.is_dir() else path.parent - for parent in (start, *start.parents): - try: - parent.resolve().relative_to(root) - except ValueError: - break - for name in ("preset.yml", "extension.yml"): - candidate = parent / name - if candidate.is_file(): - return candidate - return None + return manifest_path.relative_to(project_root).as_posix() def _preset_display_name(pack_dir: Path, pack_id: str) -> str: - """Return the preset's human-friendly name from ``preset.yml``. + """Return the preset's human-friendly name from ``preset.yml``, or ``pack_id``. - Reads ``preset.name`` first and falls back to a top-level ``name`` key for - manifests written in the older flat layout. Falls back to the pack id when - the manifest is missing or declares no usable name. + 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: - data = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, yaml.YAMLError): - return pack_id - if not isinstance(data, dict): + return PresetManifest(manifest_path).name + except PresetValidationError: return pack_id - preset = data.get("preset") - if isinstance(preset, dict): - display = preset.get("name") - if isinstance(display, str) and display: - return display - display = data.get("name") - if isinstance(display, str) and display: - return display - return pack_id def _extract_lookup_pack_id(lookup_id: str) -> str | None: @@ -509,9 +479,12 @@ def _build_stack( else: hidden = idx > first_replace_idx - # Layer classification: prefer lookupId prefix (authoritative) with a - # source-string fallback for defensive parsing. - if lookup_id.startswith("core:") or source.startswith("core"): + # Layer classification: the lookupId prefix is the resolver's own + # grammar (see layer_kind_from_lookup_id) and is authoritative; the + # source-string check only guards against a malformed lookupId. + layer_kind = layer_kind_from_lookup_id(lookup_id) + + if layer_kind == "core" or (layer_kind is None and source.startswith("core")): rows.append( StackLayer( layer="core", @@ -526,7 +499,9 @@ def _build_stack( ) continue - if lookup_id.startswith("project:") or source == "project override": + if layer_kind == PROJECT_OVERRIDE_LAYER or ( + layer_kind is None and source == "project override" + ): rows.append( StackLayer( layer="project", @@ -541,7 +516,9 @@ def _build_stack( ) continue - if lookup_id.startswith("extension:") or source.startswith("extension:"): + if layer_kind == "extension" or ( + layer_kind is None and source.startswith("extension:") + ): manifest_path = _derive_manifest_path(layer, project_root) rows.append( StackLayer( diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 9ab8283319..f956b151e3 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -27,7 +27,7 @@ from packaging import version as pkg_version from packaging.specifiers import InvalidSpecifier, SpecifierSet -from .._assets import _locate_core_pack, _repo_root +from .._assets import _locate_core_asset_dir from .._identifier import ( IdentifierComponentError, canonical_json, @@ -89,29 +89,19 @@ def _load_core_command_names() -> frozenset[str]: the source checkout when running from the repository. If neither is available, use the baked-in fallback set so validation still works. - Path resolution is delegated to the canonical ``_assets`` resolvers - (``_locate_core_pack`` / ``_repo_root``) — the same ones the presets and - bundle loaders use — rather than bespoke ``Path(__file__)`` arithmetic. - Hand-counted ``.parent`` chains silently broke discovery once already: the - #3014 move of this module from ``specify_cli/extensions.py`` to - ``specify_cli/extensions/__init__.py`` pushed the file one directory deeper - without updating the counts, so both candidates resolved to non-existent - paths and every call fell through to the fallback (#3274). The shared - resolvers are anchored to the package root, so discovery survives future - module moves. + Path resolution is delegated to :func:`_locate_core_asset_dir` — the same + resolver ``PresetResolver._find_bundled_core`` and the artifact command's + core-baseline enumeration use — rather than bespoke ``Path(__file__)`` + arithmetic. Hand-counted ``.parent`` chains silently broke discovery once + already: the #3014 move of this module from ``specify_cli/extensions.py`` + to ``specify_cli/extensions/__init__.py`` pushed the file one directory + deeper without updating the counts, so both candidates resolved to + non-existent paths and every call fell through to the fallback (#3274). + The shared resolver is anchored to the package root, so discovery + survives future module moves. """ - core_pack = _locate_core_pack() - candidate_dirs = [ - # Wheel install: force-include maps templates/commands → core_pack/commands. - core_pack / "commands" if core_pack is not None else None, - # Source checkout / editable install: repo-root templates/commands. - _repo_root() / "templates" / "commands", - ] - - for commands_dir in candidate_dirs: - if commands_dir is None or not commands_dir.is_dir(): - continue - + commands_dir = _locate_core_asset_dir("commands") + if commands_dir is not None: command_names = { command_file.stem for command_file in commands_dir.iterdir() diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 4a78a4cbb9..a2695c251e 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -5728,9 +5728,15 @@ def _find_bundled_core( Mirrors the tier-5 fallback logic in ``resolve()`` so that ``collect_all_layers()`` can locate base layers even when ``.specify/templates/`` doesn't contain the core file. + + Directory resolution is delegated to the shared + ``_locate_core_asset_dir`` resolver — the same one the artifact + command's core-baseline enumeration and the extensions module's + core-command-name discovery use — so all three code paths agree on + what "core" means on this machine. """ try: - from specify_cli import _locate_core_pack, _repo_root + from specify_cli._assets import _locate_core_asset_dir except ImportError: return None @@ -5739,38 +5745,28 @@ def _find_bundled_core( 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 = next( - (path for path in script_variant_paths(core_pack / "scripts", name) if path.exists()), - None, - ) - else: - c = core_pack / f"{name}.md" - if c is not None and c.exists(): - return c + if template_type == "template": + base = _locate_core_asset_dir("templates") + elif template_type == "command": + base = _locate_core_asset_dir("commands") + elif template_type == "script": + base = _locate_core_asset_dir("scripts") else: - repo_root = _repo_root() - for name in names: - if template_type == "template": - c = repo_root / "templates" / f"{name}.md" - elif template_type == "command": - c = repo_root / "templates" / "commands" / f"{name}.md" - elif template_type == "script": - c = next( - (path for path in script_variant_paths(repo_root / "scripts", name) if path.exists()), - None, - ) - else: - c = repo_root / f"{name}.md" - if c is not None and c.exists(): - return c + base = None + + if base is None: + return None + + for name in names: + 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/test_artifact_command.py b/tests/test_artifact_command.py index c3fb2e0ee4..78dfa9cde0 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -685,87 +685,108 @@ def test_command_override_is_not_duplicated_as_template(self, spec_kit_project: class TestManifestPathPortability: """`_derive_manifest_path` must never leak an absolute host path.""" - def test_enclosing_manifest_outside_project_root_is_none(self, tmp_path: Path): + def test_preset_manifest_path_is_repo_relative(self, tmp_path: Path): from specify_cli.artifacts import _derive_manifest_path project_root = tmp_path / "proj" - project_root.mkdir() - - outside = tmp_path / "outside-pack" - (outside / "templates").mkdir(parents=True) - (outside / "preset.yml").write_text("id: outside-pack\n", encoding="utf-8") - source = outside / "templates" / "legacy-template.md" - source.write_text("body", encoding="utf-8") + 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:outside-pack:template:legacy-template", "path": source} - assert _derive_manifest_path(layer, project_root) is None + layer = { + "lookupId": "preset:my-pack:template:spec-template", + "path": pack_dir / "spec-template.md", + } + assert ( + _derive_manifest_path(layer, project_root) + == ".specify/presets/my-pack/preset.yml" + ) - def test_enclosing_manifest_search_stops_at_project_root(self, tmp_path: Path): - from specify_cli.artifacts import _find_enclosing_manifest + def test_extension_manifest_path_is_repo_relative(self, tmp_path: Path): + from specify_cli.artifacts import _derive_manifest_path project_root = tmp_path / "proj" - source_dir = project_root / ".specify" / "templates" - source_dir.mkdir(parents=True) - source = source_dir / "legacy-template.md" - source.write_text("body", encoding="utf-8") - (tmp_path / "preset.yml").write_text("id: outside-pack\n", encoding="utf-8") + 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") - assert _find_enclosing_manifest(source, project_root) is None + layer = { + "lookupId": "extension:my-ext:command:speckit.my-ext.go", + "path": ext_dir / "commands" / "speckit.my-ext.go.md", + } + assert ( + _derive_manifest_path(layer, project_root) + == ".specify/extensions/my-ext/extension.yml" + ) - def test_enclosing_manifest_search_includes_project_root(self, tmp_path: Path): - from specify_cli.artifacts import _find_enclosing_manifest + def test_missing_manifest_file_is_none(self, tmp_path: Path): + from specify_cli.artifacts import _derive_manifest_path project_root = tmp_path / "proj" - source_dir = project_root / ".specify" / "templates" - source_dir.mkdir(parents=True) - source = source_dir / "legacy-template.md" - source.write_text("body", encoding="utf-8") - manifest = project_root / "preset.yml" - manifest.write_text("id: root-pack\n", encoding="utf-8") + pack_dir = project_root / ".specify" / "presets" / "my-pack" + pack_dir.mkdir(parents=True) - assert _find_enclosing_manifest(source, project_root) == manifest + 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_enclosing_manifest_search_accepts_project_root_path(self, tmp_path: Path): - from specify_cli.artifacts import _find_enclosing_manifest + def test_core_and_project_layers_have_no_manifest(self, tmp_path: Path): + from specify_cli.artifacts import _derive_manifest_path project_root = tmp_path / "proj" project_root.mkdir() - manifest = project_root / "preset.yml" - manifest.write_text("id: root-pack\n", encoding="utf-8") - assert _find_enclosing_manifest(project_root, project_root) == manifest + core_layer = {"lookupId": "core:_:template:spec-template"} + project_layer = {"lookupId": "project:_:template:spec-template"} + assert _derive_manifest_path(core_layer, project_root) is None + assert _derive_manifest_path(project_layer, project_root) is None class TestPresetDisplayName: - """`_preset_display_name` reads nested and flat manifest layouts.""" + """`_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_nested_preset_name(self, tmp_path: Path): + def test_reads_validated_preset_name(self, tmp_path: Path): from specify_cli.artifacts import _preset_display_name pack_dir = tmp_path / "pack" pack_dir.mkdir() - (pack_dir / "preset.yml").write_text( - "preset:\n id: pack\n name: Nested Name\nname: Flat Name\n", - encoding="utf-8", - ) + (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_top_level_name(self, tmp_path: Path): + 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.""" from specify_cli.artifacts import _preset_display_name 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") == "Flat Name" + assert _preset_display_name(pack_dir, "pack") == "pack" - def test_falls_back_to_pack_id_without_name(self, tmp_path: Path): + def test_falls_back_to_pack_id_without_manifest_file(self, tmp_path: Path): from specify_cli.artifacts import _preset_display_name pack_dir = tmp_path / "pack" pack_dir.mkdir() - (pack_dir / "preset.yml").write_text("id: pack\n", encoding="utf-8") assert _preset_display_name(pack_dir, "pack") == "pack" diff --git a/tests/test_assets.py b/tests/test_assets.py new file mode 100644 index 0000000000..0f5e7113e9 --- /dev/null +++ b/tests/test_assets.py @@ -0,0 +1,57 @@ +"""Tests for the shared bundle-path resolvers in `specify_cli._assets`.""" + +from __future__ import annotations + +from pathlib import Path + +from specify_cli._assets import _locate_core_asset_dir + + +class TestLocateCoreAssetDir: + """`_locate_core_asset_dir` is the single source of truth every core-asset + consumer (extension command-name discovery, the preset resolver's core + fallback, and the artifact command's core-baseline enumeration) shares.""" + + def test_prefers_wheel_core_pack_over_repo_checkout(self, tmp_path, monkeypatch): + import specify_cli._assets as assets + + core_pack = tmp_path / "core_pack" + (core_pack / "commands").mkdir(parents=True) + repo_root = tmp_path / "repo" + (repo_root / "templates" / "commands").mkdir(parents=True) + + monkeypatch.setattr(assets, "_locate_core_pack", lambda: core_pack) + monkeypatch.setattr(assets, "_repo_root", lambda: repo_root) + + assert _locate_core_asset_dir("commands") == core_pack / "commands" + + def test_falls_back_to_repo_checkout_when_no_wheel_bundle(self, tmp_path, monkeypatch): + import specify_cli._assets as assets + + repo_root = tmp_path / "repo" + (repo_root / "templates" / "commands").mkdir(parents=True) + (repo_root / "templates").mkdir(exist_ok=True) + (repo_root / "scripts").mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr(assets, "_locate_core_pack", lambda: None) + monkeypatch.setattr(assets, "_repo_root", lambda: repo_root) + + assert _locate_core_asset_dir("commands") == repo_root / "templates" / "commands" + assert _locate_core_asset_dir("templates") == repo_root / "templates" + assert _locate_core_asset_dir("scripts") == repo_root / "scripts" + + def test_returns_none_when_directory_missing(self, tmp_path, monkeypatch): + import specify_cli._assets as assets + + monkeypatch.setattr(assets, "_locate_core_pack", lambda: None) + monkeypatch.setattr(assets, "_repo_root", lambda: tmp_path / "nonexistent") + + assert _locate_core_asset_dir("commands") is None + + def test_returns_none_for_unknown_subdir(self, tmp_path, monkeypatch): + import specify_cli._assets as assets + + monkeypatch.setattr(assets, "_locate_core_pack", lambda: None) + monkeypatch.setattr(assets, "_repo_root", lambda: tmp_path) + + assert _locate_core_asset_dir("bogus") is None diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py index e26a224c3b..9937b83e09 100644 --- a/tests/test_contribution_ids.py +++ b/tests/test_contribution_ids.py @@ -31,6 +31,7 @@ derive_hook_id, derive_named_id, hook_discriminator, + layer_kind_from_lookup_id, validate_component, ) from specify_cli.extensions import ExtensionManifest, ValidationError @@ -154,6 +155,34 @@ def test_named_id_stable_across_two_derivations(self): assert a == b +class TestLayerKindFromLookupId: + """``layer_kind_from_lookup_id`` extracts the layer segment of a lookupId.""" + + @pytest.mark.parametrize( + "lookup_id, expected", + [ + ("core:_:command:speckit.constitution", "core"), + ("preset:speckit-core:template:spec-template", "preset"), + ("extension:speckit-git:script:post-commit", "extension"), + (f"{PROJECT_OVERRIDE_LAYER}:_:template:spec-template", PROJECT_OVERRIDE_LAYER), + ], + ) + def test_recognized_layer_prefixes(self, lookup_id, expected): + assert layer_kind_from_lookup_id(lookup_id) == expected + + @pytest.mark.parametrize( + "lookup_id", + [ + "", + "bogus:_:command:speckit.plan", + "core", + ":_:command:speckit.plan", + ], + ) + def test_unrecognized_or_malformed_returns_none(self, lookup_id): + assert layer_kind_from_lookup_id(lookup_id) is None + + # --------------------------------------------------------------------------- # Canonical JSON # --------------------------------------------------------------------------- diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 6642da2b09..e0138e63ef 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -276,9 +276,10 @@ def test_load_core_command_names_discovers_from_source_checkout(self, monkeypatc The fallback set happens to equal the real command stems today, so an equality check against the live tree cannot tell a working loader apart - from a dead one. Point ``_repo_root`` at a temp tree with *different* - command names: the old off-by-one path math read nothing and returned - the baked-in fallback; the fixed loader returns the temp stems. + from a dead one. Point the shared ``_locate_core_asset_dir`` resolver + at a temp tree with *different* command names: the old off-by-one path + math read nothing and returned the baked-in fallback; the fixed loader + returns the temp stems. """ from specify_cli.extensions import ( _load_core_command_names, @@ -294,8 +295,11 @@ def test_load_core_command_names_discovers_from_source_checkout(self, monkeypatc (commands / "notacommand.txt").write_text("skip me", encoding="utf-8") # No wheel bundle in this scenario; force the source-checkout path. - monkeypatch.setattr(ext, "_locate_core_pack", lambda: None) - monkeypatch.setattr(ext, "_repo_root", lambda: Path(tmp)) + monkeypatch.setattr( + ext, + "_locate_core_asset_dir", + lambda subdir: commands if subdir == "commands" else None, + ) result = _load_core_command_names() @@ -314,9 +318,13 @@ def test_load_core_command_names_prefers_wheel_core_pack(self, monkeypatch): (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") + # The shared resolver itself picks the bundle ahead of the source + # tree; here we just stand in for its already-resolved result. + monkeypatch.setattr( + ext, + "_locate_core_asset_dir", + lambda subdir: core_pack / "commands" if subdir == "commands" else None, + ) result = _load_core_command_names() @@ -331,11 +339,9 @@ def test_load_core_command_names_falls_back_when_nothing_found(self, monkeypatch ) import specify_cli.extensions as ext - with tempfile.TemporaryDirectory() as tmp: - monkeypatch.setattr(ext, "_locate_core_pack", lambda: None) - monkeypatch.setattr(ext, "_repo_root", lambda: Path(tmp) / "nonexistent") + monkeypatch.setattr(ext, "_locate_core_asset_dir", lambda subdir: None) - assert _load_core_command_names() == _FALLBACK_CORE_COMMAND_NAMES + assert _load_core_command_names() == _FALLBACK_CORE_COMMAND_NAMES def test_missing_required_field(self, temp_dir): """Test manifest missing required field.""" From 985b713f6f50ccf1bdf153393ebd210c2bd77cf9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:50:54 +0000 Subject: [PATCH 26/49] Reuse manifest/registry APIs in artifact contribution enumeration Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 185 ++++++++++++-------------- tests/test_assets.py | 2 - 2 files changed, 86 insertions(+), 101 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index e55c82ab48..393f692527 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -14,7 +14,7 @@ import re from dataclasses import dataclass from pathlib import Path -from typing import Any, Iterable, Literal +from typing import Any, Callable, Iterable, Literal import yaml @@ -714,10 +714,24 @@ def _iter_contribution_artifacts( Covers the two ways a pack can contribute an artifact: - * manifest-declared entries (``preset.yml`` / ``extension.yml``), and + * manifest-declared entries (``preset.yml`` / ``extension.yml``), read + via each manifest class's own ``iter_contributions()`` rather than + re-parsing ``provides`` by hand, and * convention-placed extension files (``commands/``, ``templates/``, ``scripts/``) that the resolver picks up even without a manifest. + Presets are enumerated through ``PresetManager.list_installed()`` — + presets have no unregistered-directory fallback in the resolver (see + ``PresetResolver._get_all_presets_by_priority``), so the registry is + the complete set. Extensions additionally admit unregistered + directories at implicit priority 10 (see + ``PresetResolver._get_all_extensions_by_priority``), so those are + folded in alongside the registered set. Either way, every yielded + contribution is still checked against the resolver's own + ``collect_all_layers()`` output before being surfaced, so a disabled + pack, an orphaned directory the resolver would not admit, or a + declared-but-unusable entry cannot appear in the inventory. + Project-local overrides under ``.specify/templates/overrides`` are included too, so an artifact that exists only as an override is still listed. @@ -727,9 +741,9 @@ def _iter_contribution_artifacts( this command's job is to describe the composed inventory, not to be the second validation surface. """ - from ..presets import PresetResolver # lazy: avoids circular import + from ..extensions import ExtensionManager, ExtensionManifest, ValidationError + from ..presets import PresetManager, PresetResolver # lazy: avoids circular import - specify_dir = self.project_root / ".specify" resolver = PresetResolver(self.project_root) layers_by_artifact: dict[tuple[ArtifactKind, str], set[str]] = {} @@ -742,39 +756,78 @@ def _lookup_ids(kind: ArtifactKind, name: str) -> set[str]: } return layers_by_artifact[key] - for tier in ("presets", "extensions"): - tier_dir = specify_dir / tier - if not tier_dir.is_dir(): - continue - for pack_dir in sorted(tier_dir.iterdir(), key=lambda p: p.name): - if not pack_dir.is_dir(): - continue - manifest_name = "preset.yml" if tier == "presets" else "extension.yml" - manifest = pack_dir / manifest_name - layer = "preset" if tier == "presets" else "extension" - data: Any = None - if manifest.is_file(): + # -- Presets: the registry is authoritative, no unregistered fallback. + preset_manager = PresetManager(self.project_root) + for entry in sorted(preset_manager.list_installed(), key=lambda e: e["id"]): + pack_id = entry["id"] + pack_dir = preset_manager.presets_dir / pack_id + manifest = preset_manager.get_pack(pack_id) + yield from self._iter_pack_contributions( + manifest, pack_dir, _lookup_ids + ) + + # -- Extensions: registered ids plus on-disk unregistered directories, + # mirroring PresetResolver._get_all_extensions_by_priority. + ext_manager = ExtensionManager(self.project_root) + registered_ext_ids = {e["id"] for e in ext_manager.list_installed()} + ext_ids = set(registered_ext_ids) + if ext_manager.extensions_dir.is_dir(): + ext_ids.update( + p.name for p in ext_manager.extensions_dir.iterdir() if p.is_dir() + ) + for ext_id in sorted(ext_ids): + ext_dir = ext_manager.extensions_dir / ext_id + if ext_id in registered_ext_ids: + manifest = ext_manager.get_extension(ext_id) + else: + manifest_path = ext_dir / "extension.yml" + manifest = None + if manifest_path.is_file(): try: - data = yaml.safe_load(manifest.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, yaml.YAMLError): - data = None - if isinstance(data, dict): - for kind, name, description in _iter_manifest_contributions( - data, is_preset=tier == "presets" - ): - lookup_id = derive_named_id(layer, pack_dir.name, kind, name) - if lookup_id in _lookup_ids(kind, name): - yield kind, name, description - # Convention fallback: a preset/extension file placed at the - # conventional path resolves whether or not the manifest - # declares it, so it belongs in the inventory as well. - for kind, name in _iter_convention_contributions(pack_dir): - lookup_id = derive_named_id(layer, pack_dir.name, kind, name) - if lookup_id in _lookup_ids(kind, name): - yield kind, name, "" + manifest = ExtensionManifest(manifest_path) + except ValidationError: + manifest = None + yield from self._iter_pack_contributions(manifest, ext_dir, _lookup_ids) yield from self._iter_project_override_artifacts(resolver) + @staticmethod + def _iter_pack_contributions( + manifest: Any, + pack_dir: Path, + lookup_ids: Callable[[ArtifactKind, str], set[str]], + ) -> Iterable[tuple[ArtifactKind, str, str]]: + """Yield ``(kind, name, description)`` for one preset or extension pack. + + ``manifest`` is a validated ``PresetManifest``/``ExtensionManifest`` + (or ``None`` if the pack has no usable manifest). Declared + contributions come from the manifest's own ``iter_contributions()``; + convention-placed files are scanned separately since they exist + whether or not any manifest declares them. + """ + if manifest is not None: + for contribution in manifest.iter_contributions(): + kind = contribution.get("kind") + name = contribution.get("name") + if kind not in ("command", "template", "script"): + continue + if not isinstance(name, str) or not name or ":" in name: + continue + description = contribution.get("description", "") + if not isinstance(description, str): + description = "" + if contribution["id"] in lookup_ids(kind, name): + yield kind, name, description + + # Convention fallback: a preset/extension file placed at the + # conventional path resolves whether or not the manifest declares it, + # so it belongs in the inventory as well. + layer = "preset" if pack_dir.parent.name == "presets" else "extension" + for kind, name in _iter_convention_contributions(pack_dir): + lookup_id = derive_named_id(layer, pack_dir.name, kind, name) + if lookup_id in lookup_ids(kind, name): + yield kind, name, "" + def _iter_project_override_artifacts( self, resolver: Any, @@ -833,72 +886,6 @@ def _iter_convention_contributions(pack_dir: Path) -> Iterable[tuple[ArtifactKin yield kind, entry.stem -def _iter_manifest_contributions( - data: dict[str, Any], - *, - is_preset: bool = False, -) -> Iterable[tuple[ArtifactKind, str, str]]: - """Yield ``(kind, name, description)`` entries declared by a manifest. - - Extension manifests group entries by artifact kind: - - .. code-block:: yaml - - provides: - commands: [ {name: "...", description: "..."} , ... ] - templates: [ ... ] - scripts: [ ... ] - - Preset manifests instead place every contribution under ``templates`` and - identify its artifact kind with each entry's ``type`` field. - - Anything malformed at the entry level is skipped rather than raised — - the artifact command is a projection, not a validator. - """ - provides = data.get("provides") - if not isinstance(provides, dict): - return - if is_preset: - entries = provides.get("templates") - if not isinstance(entries, list): - return - for entry in entries: - if not isinstance(entry, dict): - continue - kind_value = entry.get("type") - name = entry.get("name") - if kind_value not in ("command", "template", "script"): - continue - if not isinstance(name, str) or not name or ":" in name: - continue - description = entry.get("description", "") - if not isinstance(description, str): - description = "" - yield kind_value, name, description - return - for kind_key, kind_value in ( - ("commands", "command"), - ("templates", "template"), - ("scripts", "script"), - ): - entries = provides.get(kind_key) - if not isinstance(entries, list): - continue - for entry in entries: - if isinstance(entry, str): - yield kind_value, entry, "" # type: ignore[misc] - continue - if not isinstance(entry, dict): - continue - name = entry.get("name") - if not isinstance(name, str) or not name or ":" in name: - continue - description = entry.get("description", "") - if not isinstance(description, str): - description = "" - yield kind_value, name, description # type: ignore[misc] - - __all__ = [ "AmbiguousArtifactError", "Artifact", diff --git a/tests/test_assets.py b/tests/test_assets.py index 0f5e7113e9..1e2cf08122 100644 --- a/tests/test_assets.py +++ b/tests/test_assets.py @@ -2,8 +2,6 @@ from __future__ import annotations -from pathlib import Path - from specify_cli._assets import _locate_core_asset_dir From 1ae8d1beb469a6b487a0bf44b1336dc4f3e97977 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:52:58 +0000 Subject: [PATCH 27/49] Pass layer explicitly to _iter_pack_contributions instead of inferring from parent dir Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 393f692527..6216a48f7b 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -763,7 +763,7 @@ def _lookup_ids(kind: ArtifactKind, name: str) -> set[str]: pack_dir = preset_manager.presets_dir / pack_id manifest = preset_manager.get_pack(pack_id) yield from self._iter_pack_contributions( - manifest, pack_dir, _lookup_ids + manifest, pack_dir, "preset", _lookup_ids ) # -- Extensions: registered ids plus on-disk unregistered directories, @@ -787,7 +787,7 @@ def _lookup_ids(kind: ArtifactKind, name: str) -> set[str]: manifest = ExtensionManifest(manifest_path) except ValidationError: manifest = None - yield from self._iter_pack_contributions(manifest, ext_dir, _lookup_ids) + yield from self._iter_pack_contributions(manifest, ext_dir, "extension", _lookup_ids) yield from self._iter_project_override_artifacts(resolver) @@ -795,6 +795,7 @@ def _lookup_ids(kind: ArtifactKind, name: str) -> set[str]: def _iter_pack_contributions( manifest: Any, pack_dir: Path, + layer: str, lookup_ids: Callable[[ArtifactKind, str], set[str]], ) -> Iterable[tuple[ArtifactKind, str, str]]: """Yield ``(kind, name, description)`` for one preset or extension pack. @@ -822,7 +823,6 @@ def _iter_pack_contributions( # Convention fallback: a preset/extension file placed at the # conventional path resolves whether or not the manifest declares it, # so it belongs in the inventory as well. - layer = "preset" if pack_dir.parent.name == "presets" else "extension" for kind, name in _iter_convention_contributions(pack_dir): lookup_id = derive_named_id(layer, pack_dir.name, kind, name) if lookup_id in lookup_ids(kind, name): From bcaa172bf7fea309714b1a9cd27c141cb1229a1c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:02:04 +0000 Subject: [PATCH 28/49] Fix core command namespacing and validate names for kind-scoped lookups Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 93 ++++++++++++++++++------- tests/test_artifact_command.py | 97 +++++++++++++++++++++++++++ tests/test_contribution_ids.py | 10 ++- 3 files changed, 171 insertions(+), 29 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 6216a48f7b..85163ee0e6 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -19,7 +19,13 @@ import yaml from .._assets import _locate_core_asset_dir -from .._identifier import PROJECT_OVERRIDE_LAYER, derive_named_id, layer_kind_from_lookup_id +from .._identifier import ( + PROJECT_OVERRIDE_LAYER, + IdentifierComponentError, + derive_named_id, + layer_kind_from_lookup_id, + validate_component, +) from .._script_variants import canonical_script_name # --------------------------------------------------------------------------- @@ -122,6 +128,7 @@ def __init__(self) -> None: _TEMPLATE_SUFFIX = ".md" _SCRIPT_SUFFIX = ".sh" +_COMMAND_NAMESPACE = "speckit." @dataclass(frozen=True) @@ -220,49 +227,55 @@ def _extract_script_description(text: str) -> str: return "" +def _core_command_logical_name(stem: str) -> str: + """Return the namespaced logical name for a core command file stem. + + Bundled core commands are stored unprefixed (``analyze.md``) and are + published as ``speckit.analyze``. A project-local file may already carry + the namespace (``.specify/templates/commands/speckit.analyze.md``), in + which case the prefix is preserved rather than doubled — the resolver + accepts the file under the logical name ``speckit.analyze``, so the + inventory has to publish that same name. + """ + return stem if stem.startswith(_COMMAND_NAMESPACE) else f"{_COMMAND_NAMESPACE}{stem}" + + def _enumerate_core_commands(project_root: Path | None = None) -> list[_CoreBaselineRow]: """Enumerate every command shipped in the core baseline. Names are surfaced with the ``speckit.`` prefix so they collide with preset/extension contributions in a stable way — this is what the id grammar ``command:speckit.constitution`` requires. + + The bundled baseline and the project-local core tree + (``.specify/templates/commands/``, resolver tier 4) are unioned; on a + logical-name collision the project-local file wins, matching the + resolver's own precedence. """ from ..extensions import CORE_COMMAND_NAMES # lazy: avoids circular import commands_dir = _core_asset_root("commands") project_commands_dir = _project_core_asset_root(project_root, "commands") + candidates: dict[str, Path] = {} + if commands_dir is not None: + for stem in sorted(CORE_COMMAND_NAMES): + path = commands_dir / f"{stem}{_TEMPLATE_SUFFIX}" + if path.is_file(): + candidates[_core_command_logical_name(stem)] = path + if project_commands_dir is not None: + for entry in sorted(project_commands_dir.iterdir(), key=lambda p: p.name): + if entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX: + candidates[_core_command_logical_name(entry.stem)] = entry rows: list[_CoreBaselineRow] = [] - if commands_dir is None and project_commands_dir is None: - return rows - project_stems = ( - { - entry.stem - for entry in project_commands_dir.iterdir() - if entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX - } - if project_commands_dir is not None - else set() - ) - for stem in sorted(set(CORE_COMMAND_NAMES) | project_stems): - path = ( - project_commands_dir / f"{stem}.md" - if project_commands_dir is not None - and (project_commands_dir / f"{stem}.md").is_file() - else commands_dir / f"{stem}.md" - if commands_dir is not None - else None - ) - if path is None: - continue - if not path.is_file(): - continue + for name in sorted(candidates): + path = candidates[name] try: text = path.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): text = "" rows.append( _CoreBaselineRow( - name=f"speckit.{stem}", + name=name, kind="command", path=path, description=_extract_frontmatter_description(text), @@ -588,6 +601,30 @@ def _resolve_kind_hint(name: str, kind: ArtifactKind | None) -> tuple[str, Artif return name, kind +_COMMAND_NAME_RE = re.compile(r"[a-z0-9-]+(?:\.[a-z0-9-]+)+") +_SIMPLE_NAME_RE = re.compile(r"[a-z0-9-]+") + + +def _is_valid_artifact_name(name: str, kind: ArtifactKind) -> bool: + """Return True when ``name`` is a legal artifact name for ``kind``. + + Applies the same grammars ``specify preset resolve`` enforces — dotted + lowercase segments for commands, a single lowercase segment for templates + and scripts — plus the identifier-component rule that forbids ``:``. This + is the guard for the lookup path that skips the inventory (an explicit + ``--kind`` or a ``kind:name`` shorthand), where the name would otherwise + flow straight into the resolver's path joins and could both escape the + project tree (``../../outside``) and yield identifiers that violate the + colon-free grammar. + """ + try: + validate_component(name, "artifact name") + except IdentifierComponentError: + return False + pattern = _COMMAND_NAME_RE if kind == "command" else _SIMPLE_NAME_RE + return pattern.fullmatch(name) is not None + + class ArtifactCatalog: """Read-only view over one Spec Kit project's artifact inventory.""" @@ -670,6 +707,10 @@ def get_artifact_info( if len(matches) > 1: raise AmbiguousArtifactError(bare, [k for k, _ in matches]) resolved_kind = matches[0][0] + elif not _is_valid_artifact_name(bare, resolved_kind): + # A caller-supplied kind skips the inventory lookup, so the name + # has to be validated before it reaches the resolver. + raise ArtifactNotFoundError(name) stack = _build_stack(self.project_root, resolved_kind, bare) if not stack: diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 78dfa9cde0..a17052180d 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -221,6 +221,75 @@ def test_includes_project_local_core_assets(self, spec_kit_project: Path): "core:_:script:legacy-script" ) + def test_project_local_command_keeps_existing_namespace( + self, spec_kit_project: Path + ): + """A project-local ``speckit.*.md`` must not be published as ``speckit.speckit.*``.""" + commands_dir = spec_kit_project / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True) + (commands_dir / "speckit.local.md").write_text( + "---\ndescription: Local command\n---\n", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + names = {row.name for row in catalog.list_artifacts() if row.kind == "command"} + assert "speckit.local" in names + assert "speckit.speckit.local" not in names + assert catalog.get_artifact_info("speckit.local")["stack"][0]["lookupId"] == ( + "core:_:command:speckit.local" + ) + + def test_manifest_declared_preset_contribution_uses_manifest_description( + self, spec_kit_project: Path + ): + """Declared entries come from ``PresetManifest.iter_contributions()``.""" + pack_dir = spec_kit_project / ".specify" / "presets" / "valid-pack" + pack_dir.mkdir(parents=True) + (pack_dir / "preset.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "preset": { + "id": "valid-pack", + "name": "Valid Pack", + "version": "1.0.0", + "description": "Fixture", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "declared-template", + "description": "From the manifest", + "file": "templates/declared-template.md", + } + ] + }, + } + ), + encoding="utf-8", + ) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "declared-template.md").write_text( + "body", encoding="utf-8" + ) + registry_path = spec_kit_project / ".specify" / "presets" / ".registry" + registry_path.write_text( + json.dumps( + { + "schema_version": "1.0", + "presets": {"valid-pack": {"version": "1.0.0", "priority": 10}}, + } + ), + encoding="utf-8", + ) + + artifacts = { + row.id: row for row in ArtifactCatalog(spec_kit_project).list_artifacts() + } + assert artifacts["template:declared-template"].description == "From the manifest" + class TestListSorting: """Deterministic ordering: kind first (command/template/script), then name.""" @@ -385,6 +454,34 @@ def test_conflicting_shorthand_and_flag(self, spec_kit_project: Path): "template:speckit.constitution", kind="command" ) + @pytest.mark.parametrize( + "name, kind", + [ + ("../../outside", "template"), + ("/etc/passwd", "template"), + ("nested/name", "script"), + ("Upper-Case", "template"), + ("speckit.constitution", "template"), + ("command:template:foo", "command"), + ], + ) + def test_kind_flag_rejects_names_outside_the_grammar( + self, spec_kit_project: Path, name: str, kind: str + ): + """An explicit kind skips the inventory, so the name must be validated.""" + with pytest.raises(ArtifactNotFoundError): + ArtifactCatalog(spec_kit_project).get_artifact_info(name, kind=kind) + + def test_shorthand_rejects_names_outside_the_grammar(self, spec_kit_project: Path): + with pytest.raises(ArtifactNotFoundError): + ArtifactCatalog(spec_kit_project).get_artifact_info("template:../../outside") + + def test_kind_flag_accepts_a_valid_name(self, spec_kit_project: Path): + info = ArtifactCatalog(spec_kit_project).get_artifact_info( + "speckit.constitution", kind="command" + ) + assert info["kind"] == "command" + # --------------------------------------------------------------------------- # Skills exclusion diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py index 9937b83e09..a5131bf050 100644 --- a/tests/test_contribution_ids.py +++ b/tests/test_contribution_ids.py @@ -423,7 +423,9 @@ def test_project_override_layer_carries_sentinel_lookup_id(self, tmp_path): (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(l for l in layers if l["source"] == "project override") + 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" ) @@ -436,7 +438,7 @@ def test_core_layer_carries_core_lookup_id(self, tmp_path): resolver = PresetResolver(project) resolver.templates_dir = project / "templates" layers = resolver.collect_all_layers("spec-template", "template") - core_layer = next(l for l in layers if l["source"] == "core") + core_layer = next(layer for layer in layers if layer["source"] == "core") assert core_layer["lookupId"] == "core:_:template:spec-template" def test_preset_layer_lookup_id_matches_manifest_contribution_id(self, tmp_path): @@ -479,7 +481,9 @@ def test_preset_layer_lookup_id_matches_manifest_contribution_id(self, tmp_path) ) resolver = PresetResolver(project) layers = resolver.collect_all_layers("spec-template", "template") - preset_layer = next(l for l in layers if l["source"].startswith(pack_id)) + 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" From f2483b3cde9ba5dd3e62fe75678febf2bc097dc8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:03:26 +0000 Subject: [PATCH 29/49] Skip manifest contributions without a usable identifier Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 85163ee0e6..ded4658cc1 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -858,7 +858,10 @@ def _iter_pack_contributions( description = contribution.get("description", "") if not isinstance(description, str): description = "" - if contribution["id"] in lookup_ids(kind, name): + lookup_id = contribution.get("id") + if not isinstance(lookup_id, str) or not lookup_id: + continue + if lookup_id in lookup_ids(kind, name): yield kind, name, description # Convention fallback: a preset/extension file placed at the From 502b3857bde8fb251f4935c0d4b97031b58dff8b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:04:03 +0000 Subject: [PATCH 30/49] Hoist test-local imports to module scope in artifact/assets tests Assisted-by: GitHub Copilot (model: Claude Sonnet 4.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- tests/test_artifact_command.py | 25 ++++++------------------- tests/test_artifact_command_parity.py | 3 +-- tests/test_assets.py | 9 +-------- 3 files changed, 8 insertions(+), 29 deletions(-) diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index a17052180d..75db9f20de 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -22,7 +22,10 @@ ArtifactNotFoundError, ArtifactResolutionError, NotASpecKitProjectError, + _derive_manifest_path, + _preset_display_name, ) +from specify_cli.extensions import ExtensionRegistry ERROR_REGEX = re.compile( @@ -131,8 +134,6 @@ def test_core_script_variants_have_one_resolvable_logical_name( def test_excludes_disabled_and_unusable_manifest_contributions( self, spec_kit_project: Path ): - from specify_cli.extensions import ExtensionRegistry - extensions_dir = spec_kit_project / ".specify" / "extensions" for extension_id, artifact_name, enabled, file_name in ( ( @@ -783,8 +784,6 @@ class TestManifestPathPortability: """`_derive_manifest_path` must never leak an absolute host path.""" def test_preset_manifest_path_is_repo_relative(self, tmp_path: Path): - from specify_cli.artifacts import _derive_manifest_path - project_root = tmp_path / "proj" pack_dir = project_root / ".specify" / "presets" / "my-pack" pack_dir.mkdir(parents=True) @@ -800,8 +799,6 @@ def test_preset_manifest_path_is_repo_relative(self, tmp_path: Path): ) def test_extension_manifest_path_is_repo_relative(self, tmp_path: Path): - from specify_cli.artifacts import _derive_manifest_path - project_root = tmp_path / "proj" ext_dir = project_root / ".specify" / "extensions" / "my-ext" ext_dir.mkdir(parents=True) @@ -817,8 +814,6 @@ def test_extension_manifest_path_is_repo_relative(self, tmp_path: Path): ) def test_missing_manifest_file_is_none(self, tmp_path: Path): - from specify_cli.artifacts import _derive_manifest_path - project_root = tmp_path / "proj" pack_dir = project_root / ".specify" / "presets" / "my-pack" pack_dir.mkdir(parents=True) @@ -830,8 +825,6 @@ def test_missing_manifest_file_is_none(self, tmp_path: Path): assert _derive_manifest_path(layer, project_root) is None def test_core_and_project_layers_have_no_manifest(self, tmp_path: Path): - from specify_cli.artifacts import _derive_manifest_path - project_root = tmp_path / "proj" project_root.mkdir() @@ -861,8 +854,6 @@ class TestPresetDisplayName: """ def test_reads_validated_preset_name(self, tmp_path: Path): - from specify_cli.artifacts import _preset_display_name - pack_dir = tmp_path / "pack" pack_dir.mkdir() (pack_dir / "preset.yml").write_text(self._VALID_MANIFEST, encoding="utf-8") @@ -871,8 +862,6 @@ def test_reads_validated_preset_name(self, tmp_path: Path): 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.""" - from specify_cli.artifacts import _preset_display_name - pack_dir = tmp_path / "pack" pack_dir.mkdir() (pack_dir / "preset.yml").write_text("id: pack\nname: Flat Name\n", encoding="utf-8") @@ -880,8 +869,6 @@ def test_falls_back_to_pack_id_when_manifest_fails_validation(self, tmp_path: Pa assert _preset_display_name(pack_dir, "pack") == "pack" def test_falls_back_to_pack_id_without_manifest_file(self, tmp_path: Path): - from specify_cli.artifacts import _preset_display_name - pack_dir = tmp_path / "pack" pack_dir.mkdir() @@ -889,9 +876,9 @@ def test_falls_back_to_pack_id_without_manifest_file(self, tmp_path: Path): # --------------------------------------------------------------------------- -# Existing module-import placeholder retained for import safety. +# Import safety # --------------------------------------------------------------------------- -def test_module_imports(): - from specify_cli.artifacts import ArtifactCatalog # noqa: F401 +def test_public_api_is_importable_from_the_package_root(): + assert ArtifactCatalog.__module__ == "specify_cli.artifacts" diff --git a/tests/test_artifact_command_parity.py b/tests/test_artifact_command_parity.py index 5aa1435f3d..6218ef9374 100644 --- a/tests/test_artifact_command_parity.py +++ b/tests/test_artifact_command_parity.py @@ -15,6 +15,7 @@ import yaml from specify_cli.artifacts import ArtifactCatalog +from specify_cli.presets import PresetResolver def _install_preset(project_root: Path, pack_id: str, provides: dict, priority: int = 10) -> Path: @@ -92,8 +93,6 @@ class TestResolverParity: """The ``active: true`` row must be what :meth:`resolve_content` would pick.""" def test_active_layer_matches_resolver(self, spec_kit_project: Path): - from specify_cli.presets import PresetResolver - pack = _install_preset( spec_kit_project, "test-parity", diff --git a/tests/test_assets.py b/tests/test_assets.py index 1e2cf08122..b8149272d7 100644 --- a/tests/test_assets.py +++ b/tests/test_assets.py @@ -2,6 +2,7 @@ from __future__ import annotations +import specify_cli._assets as assets from specify_cli._assets import _locate_core_asset_dir @@ -11,8 +12,6 @@ class TestLocateCoreAssetDir: fallback, and the artifact command's core-baseline enumeration) shares.""" def test_prefers_wheel_core_pack_over_repo_checkout(self, tmp_path, monkeypatch): - import specify_cli._assets as assets - core_pack = tmp_path / "core_pack" (core_pack / "commands").mkdir(parents=True) repo_root = tmp_path / "repo" @@ -24,8 +23,6 @@ def test_prefers_wheel_core_pack_over_repo_checkout(self, tmp_path, monkeypatch) assert _locate_core_asset_dir("commands") == core_pack / "commands" def test_falls_back_to_repo_checkout_when_no_wheel_bundle(self, tmp_path, monkeypatch): - import specify_cli._assets as assets - repo_root = tmp_path / "repo" (repo_root / "templates" / "commands").mkdir(parents=True) (repo_root / "templates").mkdir(exist_ok=True) @@ -39,16 +36,12 @@ def test_falls_back_to_repo_checkout_when_no_wheel_bundle(self, tmp_path, monkey assert _locate_core_asset_dir("scripts") == repo_root / "scripts" def test_returns_none_when_directory_missing(self, tmp_path, monkeypatch): - import specify_cli._assets as assets - monkeypatch.setattr(assets, "_locate_core_pack", lambda: None) monkeypatch.setattr(assets, "_repo_root", lambda: tmp_path / "nonexistent") assert _locate_core_asset_dir("commands") is None def test_returns_none_for_unknown_subdir(self, tmp_path, monkeypatch): - import specify_cli._assets as assets - monkeypatch.setattr(assets, "_locate_core_pack", lambda: None) monkeypatch.setattr(assets, "_repo_root", lambda: tmp_path) From 0a7ea111a7faa14103f96abe4907f5c3b0a9cf76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:05:29 +0000 Subject: [PATCH 31/49] fix: resolve artifact inventory and validation review regressions Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 139 ++++++++++---------- tests/test_artifact_command.py | 176 ++++++++++++-------------- 2 files changed, 148 insertions(+), 167 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index ded4658cc1..c03849e809 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -128,7 +128,8 @@ def __init__(self) -> None: _TEMPLATE_SUFFIX = ".md" _SCRIPT_SUFFIX = ".sh" -_COMMAND_NAMESPACE = "speckit." +_COMMAND_NAME_RE = re.compile(r"[a-z0-9-]+(?:\.[a-z0-9-]+)+") +_TEMPLATE_OR_SCRIPT_NAME_RE = re.compile(r"[a-z0-9-]+") @dataclass(frozen=True) @@ -227,60 +228,75 @@ def _extract_script_description(text: str) -> str: return "" -def _core_command_logical_name(stem: str) -> str: - """Return the namespaced logical name for a core command file stem. - - Bundled core commands are stored unprefixed (``analyze.md``) and are - published as ``speckit.analyze``. A project-local file may already carry - the namespace (``.specify/templates/commands/speckit.analyze.md``), in - which case the prefix is preserved rather than doubled — the resolver - accepts the file under the logical name ``speckit.analyze``, so the - inventory has to publish that same name. - """ - return stem if stem.startswith(_COMMAND_NAMESPACE) else f"{_COMMAND_NAMESPACE}{stem}" - - def _enumerate_core_commands(project_root: Path | None = None) -> list[_CoreBaselineRow]: """Enumerate every command shipped in the core baseline. Names are surfaced with the ``speckit.`` prefix so they collide with preset/extension contributions in a stable way — this is what the id grammar ``command:speckit.constitution`` requires. - - The bundled baseline and the project-local core tree - (``.specify/templates/commands/``, resolver tier 4) are unioned; on a - logical-name collision the project-local file wins, matching the - resolver's own precedence. """ from ..extensions import CORE_COMMAND_NAMES # lazy: avoids circular import commands_dir = _core_asset_root("commands") project_commands_dir = _project_core_asset_root(project_root, "commands") - candidates: dict[str, Path] = {} + rows: list[_CoreBaselineRow] = [] + if commands_dir is None and project_commands_dir is None: + return rows + candidate_stems = set(CORE_COMMAND_NAMES) if commands_dir is not None: - for stem in sorted(CORE_COMMAND_NAMES): - path = commands_dir / f"{stem}{_TEMPLATE_SUFFIX}" - if path.is_file(): - candidates[_core_command_logical_name(stem)] = path + candidate_stems.update( + entry.stem + for entry in commands_dir.iterdir() + if entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX + ) if project_commands_dir is not None: - for entry in sorted(project_commands_dir.iterdir(), key=lambda p: p.name): - if entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX: - candidates[_core_command_logical_name(entry.stem)] = entry - rows: list[_CoreBaselineRow] = [] - for name in sorted(candidates): - path = candidates[name] + candidate_stems.update( + entry.stem + for entry in project_commands_dir.iterdir() + if entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX + ) + rows_by_name: dict[str, _CoreBaselineRow] = {} + for stem in sorted(candidate_stems): + logical_name = stem if stem.startswith("speckit.") else f"speckit.{stem}" + project_candidates = ( + ( + project_commands_dir / f"{stem}.md", + project_commands_dir / f"{logical_name}.md", + ) + if project_commands_dir is not None + else () + ) + bundled_candidates = ( + ( + commands_dir / f"{stem}.md", + commands_dir / f"{logical_name}.md", + ) + if commands_dir is not None + else () + ) + path = next( + ( + candidate + for candidate in (*project_candidates, *bundled_candidates) + if candidate.is_file() + ), + None, + ) + if path is None: + continue try: text = path.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): text = "" - rows.append( - _CoreBaselineRow( - name=name, - kind="command", - path=path, - description=_extract_frontmatter_description(text), - ) + if logical_name in rows_by_name: + continue + rows_by_name[logical_name] = _CoreBaselineRow( + name=logical_name, + kind="command", + path=path, + description=_extract_frontmatter_description(text), ) + rows.extend(rows_by_name[name] for name in sorted(rows_by_name)) return rows @@ -601,28 +617,17 @@ def _resolve_kind_hint(name: str, kind: ArtifactKind | None) -> tuple[str, Artif return name, kind -_COMMAND_NAME_RE = re.compile(r"[a-z0-9-]+(?:\.[a-z0-9-]+)+") -_SIMPLE_NAME_RE = re.compile(r"[a-z0-9-]+") - - -def _is_valid_artifact_name(name: str, kind: ArtifactKind) -> bool: - """Return True when ``name`` is a legal artifact name for ``kind``. - - Applies the same grammars ``specify preset resolve`` enforces — dotted - lowercase segments for commands, a single lowercase segment for templates - and scripts — plus the identifier-component rule that forbids ``:``. This - is the guard for the lookup path that skips the inventory (an explicit - ``--kind`` or a ``kind:name`` shorthand), where the name would otherwise - flow straight into the resolver's path joins and could both escape the - project tree (``../../outside``) and yield identifiers that violate the - colon-free grammar. - """ +def _validate_artifact_name(name: str, kind: ArtifactKind) -> str: + """Validate a candidate artifact name using resolver-compatible grammars.""" try: - validate_component(name, "artifact name") - except IdentifierComponentError: - return False - pattern = _COMMAND_NAME_RE if kind == "command" else _SIMPLE_NAME_RE - return pattern.fullmatch(name) is not None + validated = validate_component(name, f"{kind} name") + except IdentifierComponentError as exc: + raise ArtifactNotFoundError(name) from exc + + pattern = _COMMAND_NAME_RE if kind == "command" else _TEMPLATE_OR_SCRIPT_NAME_RE + if pattern.fullmatch(validated): + return validated + raise ArtifactNotFoundError(name) class ArtifactCatalog: @@ -707,19 +712,16 @@ def get_artifact_info( if len(matches) > 1: raise AmbiguousArtifactError(bare, [k for k, _ in matches]) resolved_kind = matches[0][0] - elif not _is_valid_artifact_name(bare, resolved_kind): - # A caller-supplied kind skips the inventory lookup, so the name - # has to be validated before it reaches the resolver. - raise ArtifactNotFoundError(name) - stack = _build_stack(self.project_root, resolved_kind, bare) + validated_name = _validate_artifact_name(bare, resolved_kind) + stack = _build_stack(self.project_root, resolved_kind, validated_name) if not stack: raise ArtifactNotFoundError(name) - description = self._describe(resolved_kind, bare) + description = self._describe(resolved_kind, validated_name) return { - "id": f"{resolved_kind}:{bare}", - "name": bare, + "id": f"{resolved_kind}:{validated_name}", + "name": validated_name, "kind": resolved_kind, "description": description, "stack": [layer.to_json_dict() for layer in stack], @@ -858,10 +860,7 @@ def _iter_pack_contributions( description = contribution.get("description", "") if not isinstance(description, str): description = "" - lookup_id = contribution.get("id") - if not isinstance(lookup_id, str) or not lookup_id: - continue - if lookup_id in lookup_ids(kind, name): + if contribution["id"] in lookup_ids(kind, name): yield kind, name, description # Convention fallback: a preset/extension file placed at the diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 75db9f20de..06b3d0c591 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -19,13 +19,11 @@ AmbiguousArtifactError, Artifact, ArtifactCatalog, + ArtifactKind, ArtifactNotFoundError, ArtifactResolutionError, NotASpecKitProjectError, - _derive_manifest_path, - _preset_display_name, ) -from specify_cli.extensions import ExtensionRegistry ERROR_REGEX = re.compile( @@ -62,11 +60,51 @@ def _install_preset(project_root: Path, pack_id: str, provides: dict, priority: """Drop a minimal preset onto disk and register it in the ``.registry`` file.""" pack_dir = project_root / ".specify" / "presets" / pack_id pack_dir.mkdir(parents=True) + 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 = { - "id": pack_id, - "version": "1.0.0", - "metadata": {"name": f"Test preset {pack_id}"}, - "provides": provides, + "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") registry_path = project_root / ".specify" / "presets" / ".registry" @@ -134,6 +172,8 @@ def test_core_script_variants_have_one_resolvable_logical_name( def test_excludes_disabled_and_unusable_manifest_contributions( self, spec_kit_project: Path ): + from specify_cli.extensions import ExtensionRegistry + extensions_dir = spec_kit_project / ".specify" / "extensions" for extension_id, artifact_name, enabled, file_name in ( ( @@ -222,74 +262,16 @@ def test_includes_project_local_core_assets(self, spec_kit_project: Path): "core:_:script:legacy-script" ) - def test_project_local_command_keeps_existing_namespace( - self, spec_kit_project: Path - ): - """A project-local ``speckit.*.md`` must not be published as ``speckit.speckit.*``.""" + def test_preserves_prefixed_project_local_command_names(self, spec_kit_project: Path): commands_dir = spec_kit_project / ".specify" / "templates" / "commands" - commands_dir.mkdir(parents=True) - (commands_dir / "speckit.local.md").write_text( - "---\ndescription: Local command\n---\n", encoding="utf-8" - ) - - catalog = ArtifactCatalog(spec_kit_project) - names = {row.name for row in catalog.list_artifacts() if row.kind == "command"} - assert "speckit.local" in names - assert "speckit.speckit.local" not in names - assert catalog.get_artifact_info("speckit.local")["stack"][0]["lookupId"] == ( - "core:_:command:speckit.local" - ) - - def test_manifest_declared_preset_contribution_uses_manifest_description( - self, spec_kit_project: Path - ): - """Declared entries come from ``PresetManifest.iter_contributions()``.""" - pack_dir = spec_kit_project / ".specify" / "presets" / "valid-pack" - pack_dir.mkdir(parents=True) - (pack_dir / "preset.yml").write_text( - yaml.safe_dump( - { - "schema_version": "1.0", - "preset": { - "id": "valid-pack", - "name": "Valid Pack", - "version": "1.0.0", - "description": "Fixture", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "template", - "name": "declared-template", - "description": "From the manifest", - "file": "templates/declared-template.md", - } - ] - }, - } - ), - encoding="utf-8", - ) - (pack_dir / "templates").mkdir() - (pack_dir / "templates" / "declared-template.md").write_text( - "body", encoding="utf-8" - ) - registry_path = spec_kit_project / ".specify" / "presets" / ".registry" - registry_path.write_text( - json.dumps( - { - "schema_version": "1.0", - "presets": {"valid-pack": {"version": "1.0.0", "priority": 10}}, - } - ), - encoding="utf-8", + commands_dir.mkdir() + (commands_dir / "speckit.local-prefixed.md").write_text( + "---\ndescription: Local prefixed command\n---\n", encoding="utf-8" ) - artifacts = { - row.id: row for row in ArtifactCatalog(spec_kit_project).list_artifacts() - } - assert artifacts["template:declared-template"].description == "From the manifest" + 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 class TestListSorting: @@ -456,33 +438,19 @@ def test_conflicting_shorthand_and_flag(self, spec_kit_project: Path): ) @pytest.mark.parametrize( - "name, kind", - [ - ("../../outside", "template"), - ("/etc/passwd", "template"), - ("nested/name", "script"), - ("Upper-Case", "template"), - ("speckit.constitution", "template"), - ("command:template:foo", "command"), - ], + ("kind", "name"), + ( + ("template", "../../outside"), + ("command", "template:foo"), + ("script", "script:name"), + ), ) - def test_kind_flag_rejects_names_outside_the_grammar( - self, spec_kit_project: Path, name: str, kind: str + def test_kind_hint_rejects_invalid_name_components( + self, spec_kit_project: Path, kind: ArtifactKind, name: str ): - """An explicit kind skips the inventory, so the name must be validated.""" with pytest.raises(ArtifactNotFoundError): ArtifactCatalog(spec_kit_project).get_artifact_info(name, kind=kind) - def test_shorthand_rejects_names_outside_the_grammar(self, spec_kit_project: Path): - with pytest.raises(ArtifactNotFoundError): - ArtifactCatalog(spec_kit_project).get_artifact_info("template:../../outside") - - def test_kind_flag_accepts_a_valid_name(self, spec_kit_project: Path): - info = ArtifactCatalog(spec_kit_project).get_artifact_info( - "speckit.constitution", kind="command" - ) - assert info["kind"] == "command" - # --------------------------------------------------------------------------- # Skills exclusion @@ -784,6 +752,8 @@ class TestManifestPathPortability: """`_derive_manifest_path` must never leak an absolute host path.""" def test_preset_manifest_path_is_repo_relative(self, tmp_path: Path): + from specify_cli.artifacts import _derive_manifest_path + project_root = tmp_path / "proj" pack_dir = project_root / ".specify" / "presets" / "my-pack" pack_dir.mkdir(parents=True) @@ -799,6 +769,8 @@ def test_preset_manifest_path_is_repo_relative(self, tmp_path: Path): ) def test_extension_manifest_path_is_repo_relative(self, tmp_path: Path): + from specify_cli.artifacts import _derive_manifest_path + project_root = tmp_path / "proj" ext_dir = project_root / ".specify" / "extensions" / "my-ext" ext_dir.mkdir(parents=True) @@ -814,6 +786,8 @@ def test_extension_manifest_path_is_repo_relative(self, tmp_path: Path): ) def test_missing_manifest_file_is_none(self, tmp_path: Path): + from specify_cli.artifacts import _derive_manifest_path + project_root = tmp_path / "proj" pack_dir = project_root / ".specify" / "presets" / "my-pack" pack_dir.mkdir(parents=True) @@ -825,6 +799,8 @@ def test_missing_manifest_file_is_none(self, tmp_path: Path): assert _derive_manifest_path(layer, project_root) is None def test_core_and_project_layers_have_no_manifest(self, tmp_path: Path): + from specify_cli.artifacts import _derive_manifest_path + project_root = tmp_path / "proj" project_root.mkdir() @@ -854,6 +830,8 @@ class TestPresetDisplayName: """ def test_reads_validated_preset_name(self, tmp_path: Path): + from specify_cli.artifacts import _preset_display_name + pack_dir = tmp_path / "pack" pack_dir.mkdir() (pack_dir / "preset.yml").write_text(self._VALID_MANIFEST, encoding="utf-8") @@ -862,6 +840,8 @@ def test_reads_validated_preset_name(self, tmp_path: Path): 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.""" + from specify_cli.artifacts import _preset_display_name + pack_dir = tmp_path / "pack" pack_dir.mkdir() (pack_dir / "preset.yml").write_text("id: pack\nname: Flat Name\n", encoding="utf-8") @@ -869,6 +849,8 @@ def test_falls_back_to_pack_id_when_manifest_fails_validation(self, tmp_path: Pa assert _preset_display_name(pack_dir, "pack") == "pack" def test_falls_back_to_pack_id_without_manifest_file(self, tmp_path: Path): + from specify_cli.artifacts import _preset_display_name + pack_dir = tmp_path / "pack" pack_dir.mkdir() @@ -876,9 +858,9 @@ def test_falls_back_to_pack_id_without_manifest_file(self, tmp_path: Path): # --------------------------------------------------------------------------- -# Import safety +# Existing module-import placeholder retained for import safety. # --------------------------------------------------------------------------- -def test_public_api_is_importable_from_the_package_root(): - assert ArtifactCatalog.__module__ == "specify_cli.artifacts" +def test_module_imports(): + from specify_cli.artifacts import ArtifactCatalog # noqa: F401 From 04535bee1b00a4dc55e01650d6319782ff1dac2f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:09:59 +0000 Subject: [PATCH 32/49] perf: avoid duplicate read in core command inventory Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index c03849e809..86f5fbe5bb 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -284,12 +284,12 @@ def _enumerate_core_commands(project_root: Path | None = None) -> list[_CoreBase ) if path is None: continue + if logical_name in rows_by_name: + continue try: text = path.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): text = "" - if logical_name in rows_by_name: - continue rows_by_name[logical_name] = _CoreBaselineRow( name=logical_name, kind="command", From d601a0cd70b1442fef28f349d2924be6d1be93f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:21:38 +0000 Subject: [PATCH 33/49] fix: classify dotted override-only artifacts as commands Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 9 ++++++++- tests/test_artifact_command.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 86f5fbe5bb..0c96ddd00c 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -882,6 +882,12 @@ def _iter_project_override_artifacts( reported as a command when some other layer already provides that command and as a template otherwise. That keeps a command override from also appearing as a second, spurious ``template:`` row. + + A dotted name (``speckit.local``) is a command name under the + resolver's own grammar (see ``_COMMAND_NAME_RE``) regardless of + whether any lower, non-project layer backs it, so it is classified + 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(): @@ -897,7 +903,8 @@ def _iter_project_override_artifacts( ) for layer in command_layers ) - yield ("command" if backed_by_command else "template"), name, "" + is_command = backed_by_command or bool(_COMMAND_NAME_RE.fullmatch(name)) + yield ("command" if is_command else "template"), name, "" scripts_dir = overrides_dir / "scripts" if not scripts_dir.is_dir(): return diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 06b3d0c591..6f3ea78999 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -716,6 +716,21 @@ def test_project_override_only_artifact_is_listed(self, spec_kit_project: Path): info = catalog.get_artifact_info("local-template") assert info["stack"][0]["layer"] == "project" + def test_dotted_override_only_artifact_is_a_command(self, spec_kit_project: Path): + overrides = spec_kit_project / ".specify" / "templates" / "overrides" + overrides.mkdir(parents=True) + (overrides / "speckit.local.md").write_text("body", encoding="utf-8") + + catalog = ArtifactCatalog(spec_kit_project) + ids = {row.id for row in catalog.list_artifacts()} + assert "command:speckit.local" in ids + assert "template:speckit.local" not in ids + with pytest.raises(ArtifactNotFoundError): + catalog.get_artifact_info("template:speckit.local") + info = catalog.get_artifact_info("command:speckit.local") + assert info["kind"] == "command" + assert info["stack"][0]["layer"] == "project" + def test_unregistered_preset_template_without_manifest(self, spec_kit_project: Path): pack_dir = _install_preset(spec_kit_project, "legacy-preset", provides={"templates": []}) preset_templates_dir = pack_dir / "templates" From 8716f8e01cf0c553f9680db04b0c6518144f9514 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:29:59 +0000 Subject: [PATCH 34/49] fix: accept single-segment artifact commands Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 6 ++- tests/test_artifact_command.py | 63 +++++++++++---------------- 2 files changed, 30 insertions(+), 39 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 0c96ddd00c..556823dc89 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -128,7 +128,7 @@ def __init__(self) -> None: _TEMPLATE_SUFFIX = ".md" _SCRIPT_SUFFIX = ".sh" -_COMMAND_NAME_RE = re.compile(r"[a-z0-9-]+(?:\.[a-z0-9-]+)+") +_COMMAND_NAME_RE = re.compile(r"[a-z0-9-]+(?:\.[a-z0-9-]+)*") _TEMPLATE_OR_SCRIPT_NAME_RE = re.compile(r"[a-z0-9-]+") @@ -903,7 +903,9 @@ def _iter_project_override_artifacts( ) for layer in command_layers ) - is_command = backed_by_command or bool(_COMMAND_NAME_RE.fullmatch(name)) + is_command = backed_by_command or ( + "." in name and bool(_COMMAND_NAME_RE.fullmatch(name)) + ) yield ("command" if is_command else "template"), name, "" scripts_dir = overrides_dir / "scripts" if not scripts_dir.is_dir(): diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 6f3ea78999..e82d61ea18 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -13,6 +13,7 @@ import pytest import yaml +from typer.testing import CliRunner from specify_cli import app from specify_cli.artifacts import ( @@ -23,7 +24,10 @@ ArtifactNotFoundError, ArtifactResolutionError, NotASpecKitProjectError, + _derive_manifest_path, + _preset_display_name, ) +from specify_cli.extensions import ExtensionRegistry ERROR_REGEX = re.compile( @@ -172,8 +176,6 @@ def test_core_script_variants_have_one_resolvable_logical_name( def test_excludes_disabled_and_unusable_manifest_contributions( self, spec_kit_project: Path ): - from specify_cli.extensions import ExtensionRegistry - extensions_dir = spec_kit_project / ".specify" / "extensions" for extension_id, artifact_name, enabled, file_name in ( ( @@ -473,8 +475,6 @@ def test_no_skills_in_list(self, spec_kit_project: Path): class TestCLI: def test_list_requires_json_flag(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): - from typer.testing import CliRunner - monkeypatch.chdir(spec_kit_project) runner = CliRunner() result = runner.invoke(app, ["artifact", "list"]) @@ -482,8 +482,6 @@ def test_list_requires_json_flag(self, spec_kit_project: Path, monkeypatch: pyte assert result.stdout == "" def test_list_json_emits_array(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): - from typer.testing import CliRunner - monkeypatch.chdir(spec_kit_project) runner = CliRunner() result = runner.invoke(app, ["artifact", "list", "--json"]) @@ -493,16 +491,12 @@ def test_list_json_emits_array(self, spec_kit_project: Path, monkeypatch: pytest assert result.stdout.endswith("\n") def test_list_json_is_pretty_printed(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): - from typer.testing import CliRunner - monkeypatch.chdir(spec_kit_project) runner = CliRunner() result = runner.invoke(app, ["artifact", "list", "--json"]) assert ' "id"' in result.stdout # 2-space indent visible def test_info_json_shape(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): - from typer.testing import CliRunner - monkeypatch.chdir(spec_kit_project) runner = CliRunner() result = runner.invoke(app, ["artifact", "info", "speckit.constitution", "--json"]) @@ -511,8 +505,6 @@ def test_info_json_shape(self, spec_kit_project: Path, monkeypatch: pytest.Monke assert set(payload.keys()) == {"id", "name", "kind", "description", "stack"} def test_info_unknown_error_envelope(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): - from typer.testing import CliRunner - monkeypatch.chdir(spec_kit_project) runner = CliRunner() result = runner.invoke(app, ["artifact", "info", "no.such.thing", "--json"]) @@ -525,8 +517,6 @@ def test_info_unknown_error_envelope(self, spec_kit_project: Path, monkeypatch: def test_info_corrupt_extension_registry_uses_json_error_envelope( self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch ): - from typer.testing import CliRunner - extensions_dir = spec_kit_project / ".specify" / "extensions" (extensions_dir / ".registry").write_text("{invalid", encoding="utf-8") monkeypatch.chdir(spec_kit_project) @@ -538,8 +528,6 @@ def test_info_corrupt_extension_registry_uses_json_error_envelope( assert json.loads(result.stderr) == {"error": "artifact resolution failed"} def test_not_a_project_error_envelope(self, non_project: Path, monkeypatch: pytest.MonkeyPatch): - from typer.testing import CliRunner - monkeypatch.chdir(non_project) runner = CliRunner() result = runner.invoke(app, ["artifact", "list", "--json"]) @@ -549,8 +537,6 @@ def test_not_a_project_error_envelope(self, non_project: Path, monkeypatch: pyte assert err["error"] == "not a Spec Kit project: no .specify/ directory found" def test_stdout_empty_on_error(self, non_project: Path, monkeypatch: pytest.MonkeyPatch): - from typer.testing import CliRunner - monkeypatch.chdir(non_project) runner = CliRunner() for argv in ( @@ -570,8 +556,6 @@ def test_invalid_init_dir_override_uses_json_error_envelope( monkeypatch: pytest.MonkeyPatch, override: str, ): - from typer.testing import CliRunner - monkeypatch.chdir(non_project) monkeypatch.setenv("SPECIFY_INIT_DIR", override) runner = CliRunner() @@ -589,8 +573,6 @@ def test_invalid_init_dir_override_uses_json_error_envelope( class TestUTF8NoBOM: def test_output_is_utf8_without_bom(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): - from typer.testing import CliRunner - monkeypatch.chdir(spec_kit_project) runner = CliRunner() result = runner.invoke(app, ["artifact", "list", "--json"]) @@ -628,6 +610,27 @@ def test_preset_command_uses_entry_type(self, spec_kit_project: Path): assert any(row.id == "command:speckit.constitution" for row in rows) assert ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution")["kind"] == "command" + def test_preset_single_segment_command_id_from_list_is_resolvable( + self, spec_kit_project: Path + ): + pack = _install_preset( + spec_kit_project, + "test-single-command", + {"commands": [{"name": "specify", "description": "single segment"}]}, + ) + (pack / "commands").mkdir() + (pack / "commands" / "specify.md").write_text( + "---\ndescription: single segment\n---\nbody", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + ids = {row.id for row in catalog.list_artifacts()} + + assert "command:specify" in ids + info = catalog.get_artifact_info("command:specify") + assert info["id"] == "command:specify" + assert catalog.get_artifact_info("specify", kind="command")["id"] == "command:specify" + def test_preset_replace_hides_core(self, spec_kit_project: Path): # Install a preset that replaces the constitution command. pack = _install_preset( @@ -767,8 +770,6 @@ class TestManifestPathPortability: """`_derive_manifest_path` must never leak an absolute host path.""" def test_preset_manifest_path_is_repo_relative(self, tmp_path: Path): - from specify_cli.artifacts import _derive_manifest_path - project_root = tmp_path / "proj" pack_dir = project_root / ".specify" / "presets" / "my-pack" pack_dir.mkdir(parents=True) @@ -784,8 +785,6 @@ def test_preset_manifest_path_is_repo_relative(self, tmp_path: Path): ) def test_extension_manifest_path_is_repo_relative(self, tmp_path: Path): - from specify_cli.artifacts import _derive_manifest_path - project_root = tmp_path / "proj" ext_dir = project_root / ".specify" / "extensions" / "my-ext" ext_dir.mkdir(parents=True) @@ -801,8 +800,6 @@ def test_extension_manifest_path_is_repo_relative(self, tmp_path: Path): ) def test_missing_manifest_file_is_none(self, tmp_path: Path): - from specify_cli.artifacts import _derive_manifest_path - project_root = tmp_path / "proj" pack_dir = project_root / ".specify" / "presets" / "my-pack" pack_dir.mkdir(parents=True) @@ -814,8 +811,6 @@ def test_missing_manifest_file_is_none(self, tmp_path: Path): assert _derive_manifest_path(layer, project_root) is None def test_core_and_project_layers_have_no_manifest(self, tmp_path: Path): - from specify_cli.artifacts import _derive_manifest_path - project_root = tmp_path / "proj" project_root.mkdir() @@ -845,8 +840,6 @@ class TestPresetDisplayName: """ def test_reads_validated_preset_name(self, tmp_path: Path): - from specify_cli.artifacts import _preset_display_name - pack_dir = tmp_path / "pack" pack_dir.mkdir() (pack_dir / "preset.yml").write_text(self._VALID_MANIFEST, encoding="utf-8") @@ -855,8 +848,6 @@ def test_reads_validated_preset_name(self, tmp_path: Path): 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.""" - from specify_cli.artifacts import _preset_display_name - pack_dir = tmp_path / "pack" pack_dir.mkdir() (pack_dir / "preset.yml").write_text("id: pack\nname: Flat Name\n", encoding="utf-8") @@ -864,8 +855,6 @@ def test_falls_back_to_pack_id_when_manifest_fails_validation(self, tmp_path: Pa assert _preset_display_name(pack_dir, "pack") == "pack" def test_falls_back_to_pack_id_without_manifest_file(self, tmp_path: Path): - from specify_cli.artifacts import _preset_display_name - pack_dir = tmp_path / "pack" pack_dir.mkdir() @@ -878,4 +867,4 @@ def test_falls_back_to_pack_id_without_manifest_file(self, tmp_path: Path): def test_module_imports(): - from specify_cli.artifacts import ArtifactCatalog # noqa: F401 + assert ArtifactCatalog is not None From 802067893ac9341524c16cd086f65dbdce83a1ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:31:41 +0000 Subject: [PATCH 35/49] fix: fail closed on corrupt artifact registries Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 12 ++++++++++++ src/specify_cli/artifacts/_commands.py | 4 +--- tests/test_artifact_command.py | 11 +++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 556823dc89..fa57703420 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -600,6 +600,17 @@ def _validate_project(project_root: Path) -> None: raise NotASpecKitProjectError() +def _validate_extension_registry(project_root: Path) -> None: + extensions_dir = project_root / ".specify" / "extensions" + if not extensions_dir.exists(): + return + + from ..extensions import ExtensionRegistry + + if ExtensionRegistry(extensions_dir).is_corrupt(): + raise ArtifactResolutionError() + + def _resolve_kind_hint(name: str, kind: ArtifactKind | None) -> tuple[str, ArtifactKind | None]: """Parse ``kind:name`` shorthand and reconcile it with an explicit ``--kind`` flag. @@ -651,6 +662,7 @@ def list_artifacts(self) -> list[Artifact]: they are integration-specific output, not a shipped asset family. """ _validate_project(self.project_root) + _validate_extension_registry(self.project_root) baseline = self._get_baseline() seen: dict[tuple[ArtifactKind, str], Artifact] = {} diff --git a/src/specify_cli/artifacts/_commands.py b/src/specify_cli/artifacts/_commands.py index 0f30655c03..ac7526db9e 100644 --- a/src/specify_cli/artifacts/_commands.py +++ b/src/specify_cli/artifacts/_commands.py @@ -20,11 +20,9 @@ import typer from . import ( - AmbiguousArtifactError, ArtifactCatalog, ArtifactError, ArtifactKind, - ArtifactNotFoundError, ArtifactResolutionError, NotASpecKitProjectError, ) @@ -140,7 +138,7 @@ def info_command( root = _resolve_project_root() catalog = ArtifactCatalog(root) payload = catalog.get_artifact_info(name, kind=resolved_kind) - except (ArtifactNotFoundError, AmbiguousArtifactError, NotASpecKitProjectError) as exc: + except ArtifactError as exc: _emit_error_and_exit(exc) return # pragma: no cover except PresetError: diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index e82d61ea18..ba3d1aed08 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -527,6 +527,17 @@ def test_info_corrupt_extension_registry_uses_json_error_envelope( 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() From 0c3ce793f0ff796fe132a0787665b20c49e0c7e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:40:42 +0000 Subject: [PATCH 36/49] fix: trust inventory for artifact info lookups Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/_identifier.py | 16 ++++++++++++++++ src/specify_cli/artifacts/__init__.py | 26 +++++++++----------------- tests/test_artifact_command.py | 12 ++++++++++++ 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py index 37e0baae0b..1d251ace1a 100644 --- a/src/specify_cli/_identifier.py +++ b/src/specify_cli/_identifier.py @@ -118,6 +118,22 @@ def layer_kind_from_lookup_id(lookup_id: str) -> str | None: return layer +def is_dotted_command_name(value: str) -> bool: + """Return ``True`` when ``value`` is a dotted command-style name. + + Command-style names allow lowercase alphanumerics and ``-`` in each segment + and require at least one ``.`` separator. + """ + if "." not in value: + return False + segments = value.split(".") + return all( + segment + and all((("0" <= char <= "9") or ("a" <= char <= "z") or char == "-") for char in segment) + for segment in segments + ) + + def canonical_json(value: Any) -> bytes: """Serialize ``value`` to a canonical UTF-8 JSON byte string. diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index fa57703420..26e3ad6edd 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -23,6 +23,7 @@ PROJECT_OVERRIDE_LAYER, IdentifierComponentError, derive_named_id, + is_dotted_command_name, layer_kind_from_lookup_id, validate_component, ) @@ -128,8 +129,6 @@ def __init__(self) -> None: _TEMPLATE_SUFFIX = ".md" _SCRIPT_SUFFIX = ".sh" -_COMMAND_NAME_RE = re.compile(r"[a-z0-9-]+(?:\.[a-z0-9-]+)*") -_TEMPLATE_OR_SCRIPT_NAME_RE = re.compile(r"[a-z0-9-]+") @dataclass(frozen=True) @@ -629,17 +628,12 @@ def _resolve_kind_hint(name: str, kind: ArtifactKind | None) -> tuple[str, Artif def _validate_artifact_name(name: str, kind: ArtifactKind) -> str: - """Validate a candidate artifact name using resolver-compatible grammars.""" + """Validate the structural identifier component constraints for ``name``.""" try: - validated = validate_component(name, f"{kind} name") + return validate_component(name, f"{kind} name") except IdentifierComponentError as exc: raise ArtifactNotFoundError(name) from exc - pattern = _COMMAND_NAME_RE if kind == "command" else _TEMPLATE_OR_SCRIPT_NAME_RE - if pattern.fullmatch(validated): - return validated - raise ArtifactNotFoundError(name) - class ArtifactCatalog: """Read-only view over one Spec Kit project's artifact inventory.""" @@ -726,6 +720,8 @@ def get_artifact_info( resolved_kind = matches[0][0] validated_name = _validate_artifact_name(bare, resolved_kind) + if not any(kind_name == resolved_kind for kind_name, _ in self._find_matches(validated_name)): + raise ArtifactNotFoundError(name) stack = _build_stack(self.project_root, resolved_kind, validated_name) if not stack: raise ArtifactNotFoundError(name) @@ -895,11 +891,9 @@ def _iter_project_override_artifacts( command and as a template otherwise. That keeps a command override from also appearing as a second, spurious ``template:`` row. - A dotted name (``speckit.local``) is a command name under the - resolver's own grammar (see ``_COMMAND_NAME_RE``) regardless of - whether any lower, non-project layer backs it, so it is classified - as a command even when the override is the only layer — matching - the exact ID ``preset resolve``/``artifact info`` accepts for it. + 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(): @@ -915,9 +909,7 @@ def _iter_project_override_artifacts( ) for layer in command_layers ) - is_command = backed_by_command or ( - "." in name and bool(_COMMAND_NAME_RE.fullmatch(name)) - ) + is_command = backed_by_command or is_dotted_command_name(name) yield ("command" if is_command else "template"), name, "" scripts_dir = overrides_dir / "scripts" if not scripts_dir.is_dir(): diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index ba3d1aed08..04e8efa50c 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -745,6 +745,18 @@ def test_dotted_override_only_artifact_is_a_command(self, spec_kit_project: Path 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 = _install_preset(spec_kit_project, "legacy-preset", provides={"templates": []}) preset_templates_dir = pack_dir / "templates" From 3c7e711f2b23f17fc0b29604f25551c4d90d9936 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:49:59 +0000 Subject: [PATCH 37/49] fix: validate registry before artifact info Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 1 + tests/test_artifact_command.py | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 26e3ad6edd..566bacb831 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -709,6 +709,7 @@ def get_artifact_info( * When no artifact matches, raises :class:`ArtifactNotFoundError`. """ _validate_project(self.project_root) + _validate_extension_registry(self.project_root) bare, resolved_kind = _resolve_kind_hint(name, kind) if resolved_kind is None: diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 04e8efa50c..3855a10392 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -411,6 +411,13 @@ def test_ambiguous_artifact_message(self, spec_kit_project: Path): def test_resolution_error_message(self): assert ArtifactResolutionError().message == "artifact resolution failed" + def test_info_rejects_corrupt_extension_registry(self, spec_kit_project: Path): + registry = spec_kit_project / ".specify" / "extensions" / ".registry" + registry.write_text("{invalid", encoding="utf-8") + + with pytest.raises(ArtifactResolutionError): + ArtifactCatalog(spec_kit_project).get_artifact_info("command:speckit.constitution") + class TestKindHint: def test_kind_flag_disambiguates(self, spec_kit_project: Path): From d8154635e763aa5e627b37e71fb2ff9eb8a8c7e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:16:10 +0000 Subject: [PATCH 38/49] fix: resolve artifact description by layer precedence, not enumeration order Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 121 ++++++++++++++++---------- tests/test_artifact_command.py | 72 +++++++++++++++ 2 files changed, 146 insertions(+), 47 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 566bacb831..b74a5e5956 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -654,42 +654,66 @@ def list_artifacts(self) -> list[Artifact]: Skills (``.github/skills/**/SKILL.md``) are intentionally excluded — they are integration-specific output, not a shipped asset family. + + Descriptions are picked from the highest-priority layer that has one, + not the first layer discovered — a core command that an active + preset overrides must report the preset's description, and two + competing packs must report the higher-precedence one's. Precedence + is decided by :meth:`PresetResolver.collect_all_layers`'s own + ordering (index 0 = winner), not by enumeration order here. """ _validate_project(self.project_root) _validate_extension_registry(self.project_root) baseline = self._get_baseline() - seen: dict[tuple[ArtifactKind, str], Artifact] = {} + from ..presets import PresetResolver # lazy: avoids circular import + + resolver = PresetResolver(self.project_root) + layers_cache: dict[tuple[ArtifactKind, str], list[dict[str, Any]]] = {} + + def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: + key = (kind, name) + if key not in layers_cache: + layers_cache[key] = resolver.collect_all_layers(name, kind) + return layers_cache[key] + + names: set[tuple[ArtifactKind, str]] = set() + descriptions_by_layer: dict[tuple[ArtifactKind, str], dict[str, str]] = {} for row in (*baseline.commands, *baseline.templates, *baseline.scripts): key = (row.kind, row.name) - if key not in seen: - seen[key] = Artifact( - id=f"{row.kind}:{row.name}", - name=row.name, - kind=row.kind, - description=row.description, - ) + names.add(key) + core_lookup_id = derive_named_id("core", "_", row.kind, row.name) + descriptions_by_layer.setdefault(key, {}).setdefault( + core_lookup_id, row.description + ) - for kind, name, description in self._iter_contribution_artifacts(): + for kind, name, description, lookup_id in self._iter_contribution_artifacts( + resolver, _layers_for + ): key = (kind, name) - if key not in seen: - seen[key] = Artifact( - id=f"{kind}:{name}", - name=name, - kind=kind, - description=description, - ) - elif description and not seen[key].description: - seen[key] = Artifact( - id=seen[key].id, - name=seen[key].name, - kind=seen[key].kind, - description=description, - ) + names.add(key) + layer_descriptions = descriptions_by_layer.setdefault(key, {}) + if lookup_id not in layer_descriptions or ( + description and not layer_descriptions[lookup_id] + ): + layer_descriptions[lookup_id] = description + + artifacts: list[Artifact] = [] + for kind, name in names: + layer_descriptions = descriptions_by_layer.get((kind, name), {}) + description = "" + for layer in _layers_for(kind, name): + candidate = layer_descriptions.get(layer["lookupId"], "") + if candidate: + description = candidate + break + artifacts.append( + Artifact(id=f"{kind}:{name}", name=name, kind=kind, description=description) + ) kind_order = {"command": 0, "template": 1, "script": 2} - return sorted(seen.values(), key=lambda a: (kind_order[a.kind], a.name)) + return sorted(artifacts, key=lambda a: (kind_order[a.kind], a.name)) # ------------------------------------------------------------------ info def get_artifact_info( @@ -761,8 +785,10 @@ def _describe(self, kind: ArtifactKind, name: str) -> str: def _iter_contribution_artifacts( self, - ) -> Iterable[tuple[ArtifactKind, str, str]]: - """Yield ``(kind, name, description)`` for resolver-visible contributions. + resolver: Any, + layers_for: Callable[[ArtifactKind, str], list[dict[str, Any]]], + ) -> Iterable[tuple[ArtifactKind, str, str, str]]: + """Yield ``(kind, name, description, lookup_id)`` for visible contributions. Covers the two ways a pack can contribute an artifact: @@ -780,10 +806,16 @@ def _iter_contribution_artifacts( ``PresetResolver._get_all_extensions_by_priority``), so those are folded in alongside the registered set. Either way, every yielded contribution is still checked against the resolver's own - ``collect_all_layers()`` output before being surfaced, so a disabled + ``collect_all_layers()`` output (via ``layers_for``, the cache shared + with :meth:`list_artifacts`) before being surfaced, so a disabled pack, an orphaned directory the resolver would not admit, or a declared-but-unusable entry cannot appear in the inventory. + The ``lookup_id`` is the same ``lookupId`` string + ``collect_all_layers()`` uses for this layer, so the caller can + resolve each artifact's description by precedence instead of + enumeration order. + Project-local overrides under ``.specify/templates/overrides`` are included too, so an artifact that exists only as an override is still listed. @@ -794,19 +826,10 @@ def _iter_contribution_artifacts( the second validation surface. """ from ..extensions import ExtensionManager, ExtensionManifest, ValidationError - from ..presets import PresetManager, PresetResolver # lazy: avoids circular import - - resolver = PresetResolver(self.project_root) - layers_by_artifact: dict[tuple[ArtifactKind, str], set[str]] = {} + from ..presets import PresetManager # lazy: avoids circular import def _lookup_ids(kind: ArtifactKind, name: str) -> set[str]: - key = (kind, name) - if key not in layers_by_artifact: - layers_by_artifact[key] = { - candidate["lookupId"] - for candidate in resolver.collect_all_layers(name, kind) - } - return layers_by_artifact[key] + return {layer["lookupId"] for layer in layers_for(kind, name)} # -- Presets: the registry is authoritative, no unregistered fallback. preset_manager = PresetManager(self.project_root) @@ -849,8 +872,8 @@ def _iter_pack_contributions( pack_dir: Path, layer: str, lookup_ids: Callable[[ArtifactKind, str], set[str]], - ) -> Iterable[tuple[ArtifactKind, str, str]]: - """Yield ``(kind, name, description)`` for one preset or extension pack. + ) -> Iterable[tuple[ArtifactKind, str, str, str]]: + """Yield ``(kind, name, description, lookup_id)`` for one pack. ``manifest`` is a validated ``PresetManifest``/``ExtensionManifest`` (or ``None`` if the pack has no usable manifest). Declared @@ -869,8 +892,9 @@ def _iter_pack_contributions( description = contribution.get("description", "") if not isinstance(description, str): description = "" - if contribution["id"] in lookup_ids(kind, name): - yield kind, name, description + lookup_id = contribution["id"] + if lookup_id in lookup_ids(kind, name): + yield kind, name, description, lookup_id # Convention fallback: a preset/extension file placed at the # conventional path resolves whether or not the manifest declares it, @@ -878,13 +902,13 @@ def _iter_pack_contributions( for kind, name in _iter_convention_contributions(pack_dir): lookup_id = derive_named_id(layer, pack_dir.name, kind, name) if lookup_id in lookup_ids(kind, name): - yield kind, name, "" + yield kind, name, "", lookup_id def _iter_project_override_artifacts( self, resolver: Any, - ) -> Iterable[tuple[ArtifactKind, str, str]]: - """Yield ``(kind, name, "")`` for project-local override files. + ) -> Iterable[tuple[ArtifactKind, str, str, str]]: + """Yield ``(kind, name, "", lookup_id)`` for project-local overrides. A root ``overrides/.md`` file is the override for both the ``template`` and the ``command`` lookup of ````, so it is @@ -911,13 +935,16 @@ def _iter_project_override_artifacts( for layer in command_layers ) is_command = backed_by_command or is_dotted_command_name(name) - yield ("command" if is_command else "template"), name, "" + kind: ArtifactKind = "command" if is_command else "template" + lookup_id = derive_named_id(PROJECT_OVERRIDE_LAYER, "_", kind, name) + yield kind, name, "", lookup_id scripts_dir = overrides_dir / "scripts" if not scripts_dir.is_dir(): return for entry in sorted(scripts_dir.iterdir(), key=lambda p: p.name): if entry.is_file() and entry.suffix == _SCRIPT_SUFFIX: - yield "script", entry.stem, "" + lookup_id = derive_named_id(PROJECT_OVERRIDE_LAYER, "_", "script", entry.stem) + yield "script", entry.stem, "", lookup_id _CONVENTION_SUBDIRS: tuple[tuple[str, ArtifactKind, str], ...] = ( diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 3855a10392..9373612842 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -275,6 +275,78 @@ def test_preserves_prefixed_project_local_command_names(self, spec_kit_project: assert "command:speckit.local-prefixed" in artifacts assert "command:speckit.speckit.local-prefixed" not in artifacts + 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.""" From dcdbe997d63101472155961eee6b258c5234eb13 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:18:45 +0000 Subject: [PATCH 39/49] fix: validate subdir before wheel bundle lookup in _locate_core_asset_dir Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/_assets.py | 6 +++--- tests/test_assets.py | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/_assets.py b/src/specify_cli/_assets.py index f77378b3fc..e19fc9816a 100644 --- a/src/specify_cli/_assets.py +++ b/src/specify_cli/_assets.py @@ -45,16 +45,16 @@ def _locate_core_asset_dir(subdir: str) -> Path | None: enumeration) cannot silently diverge on what "core" means on a given machine. """ + if subdir not in ("commands", "templates", "scripts"): + return None core = _locate_core_pack() if core is not None: candidate = core / subdir return candidate if candidate.is_dir() else None if subdir == "commands": candidate = _repo_root() / "templates" / "commands" - elif subdir in ("templates", "scripts"): + else: candidate = _repo_root() / subdir - else: # pragma: no cover — internal misuse - return None return candidate if candidate.is_dir() else None diff --git a/tests/test_assets.py b/tests/test_assets.py index b8149272d7..3e2a19f66c 100644 --- a/tests/test_assets.py +++ b/tests/test_assets.py @@ -46,3 +46,11 @@ def test_returns_none_for_unknown_subdir(self, tmp_path, monkeypatch): monkeypatch.setattr(assets, "_repo_root", lambda: tmp_path) assert _locate_core_asset_dir("bogus") is None + + def test_returns_none_for_unknown_subdir_with_wheel_bundle(self, tmp_path, monkeypatch): + core_pack = tmp_path / "core_pack" + (core_pack / "extensions").mkdir(parents=True) + + monkeypatch.setattr(assets, "_locate_core_pack", lambda: core_pack) + + assert _locate_core_asset_dir("extensions") is None From 211b99d158e1802210912d4fc2890799ffd3d0d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:32:11 +0000 Subject: [PATCH 40/49] fix: detect duplicate hooks after command canonicalization Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/extensions/__init__.py | 104 ++++++++++++++----------- tests/test_extensions.py | 24 ++++++ 2 files changed, 84 insertions(+), 44 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index f956b151e3..adfe3ea01d 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -416,7 +416,6 @@ def _validate(self): validate_component(hook_name, f"hook event name '{hook_name}'") except IdentifierComponentError as exc: raise ValidationError(str(exc)) from exc - event_entries: List[dict] = [] for entry in coerce_hook_entries(hook_config): if not isinstance(entry, dict): raise ValidationError( @@ -446,35 +445,6 @@ def _validate(self): f"Hook '{hook_name}' has invalid 'priority': " "must be >= 1" ) - event_entries.append(entry) - - # Reject two hook entries under the same (event, command) whose - # declared fields (with eventName/command stripped) canonicalize - # to the same byte string — those are semantically identical - # listeners with no way to address them separately. - by_command: Dict[str, List[tuple[int, dict]]] = {} - for idx, entry in enumerate(event_entries): - by_command.setdefault(entry["command"], []).append((idx, entry)) - for command_value, group in by_command.items(): - if len(group) < 2: - continue - seen_canonical: Dict[bytes, int] = {} - for idx, entry in group: - stripped = { - k: v - for k, v in entry.items() - if k not in ("eventName", "command") - } - key = canonical_json(stripped) - if key in seen_canonical: - first_idx = seen_canonical[key] - raise ValidationError( - f"Duplicate hook entries for event '{hook_name}' " - f"command '{command_value}': entries at positions " - f"{first_idx} and {idx} have byte-identical declared " - "fields and cannot be uniquely identified" - ) - seen_canonical[key] = idx # Validate commands; track renames so hook references can be rewritten. rename_map: Dict[str, str] = {} @@ -561,14 +531,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( @@ -590,12 +557,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( @@ -604,6 +570,56 @@ def _validate(self): f"The extension author should update the manifest." ) + # Reject two hook entries under the same (event, command) whose + # declared fields (with eventName/command stripped) canonicalize + # to the same byte string — those are semantically identical + # listeners with no way to address them separately. + if hooks: + for hook_name, hook_config in hooks.items(): + by_command: Dict[str, List[tuple[int, dict]]] = {} + for idx, entry in enumerate(coerce_hook_entries(hook_config)): + command_ref = entry.get("command") + if not isinstance(command_ref, str): + continue + command_value = self._canonicalize_command_ref( + command_ref, + ext["id"], + rename_map, + ) + by_command.setdefault(command_value, []).append((idx, entry)) + for command_value, group in by_command.items(): + if len(group) < 2: + continue + seen_canonical: Dict[bytes, int] = {} + for idx, entry in group: + stripped = { + k: v + for k, v in entry.items() + if k not in ("eventName", "command") + } + key = canonical_json(stripped) + if key in seen_canonical: + first_idx = seen_canonical[key] + raise ValidationError( + f"Duplicate hook entries for event '{hook_name}' " + f"command '{command_value}': entries at positions " + f"{first_idx} and {idx} have byte-identical declared " + "fields and cannot be uniquely identified" + ) + seen_canonical[key] = idx + + @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. diff --git a/tests/test_extensions.py b/tests/test_extensions.py index e0138e63ef..6c72645185 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -968,6 +968,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_detected_after_command_normalization( + self, + temp_dir, + valid_manifest_data, + ): + """Equivalent hook entries are rejected 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) + + with pytest.raises( + ValidationError, + match="Duplicate hook entries for event 'after_tasks' command 'speckit.test-ext.hello'", + ): + ExtensionManifest(manifest_path) + 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.""" From 747f20f6cc061db40fb510b4eb59500541823d19 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:33:15 +0000 Subject: [PATCH 41/49] fix: reuse normalized hook entries for duplicate detection Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/extensions/__init__.py | 69 ++++++++++++++------------ 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index adfe3ea01d..193df7879a 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -406,6 +406,7 @@ def _validate(self): # Validate hook values (if present). # Each event is a single mapping or a list of mappings. + hook_entries_by_event: Dict[str, List[dict]] = {} if hooks: for hook_name, hook_config in hooks.items(): if isinstance(hook_config, list) and not hook_config: @@ -416,6 +417,7 @@ def _validate(self): validate_component(hook_name, f"hook event name '{hook_name}'") except IdentifierComponentError as exc: raise ValidationError(str(exc)) from exc + event_entries: List[dict] = [] for entry in coerce_hook_entries(hook_config): if not isinstance(entry, dict): raise ValidationError( @@ -445,6 +447,8 @@ def _validate(self): f"Hook '{hook_name}' has invalid 'priority': " "must be >= 1" ) + event_entries.append(entry) + hook_entries_by_event[hook_name] = event_entries # Validate commands; track renames so hook references can be rewritten. rename_map: Dict[str, str] = {} @@ -574,39 +578,38 @@ def _validate(self): # declared fields (with eventName/command stripped) canonicalize # to the same byte string — those are semantically identical # listeners with no way to address them separately. - if hooks: - for hook_name, hook_config in hooks.items(): - by_command: Dict[str, List[tuple[int, dict]]] = {} - for idx, entry in enumerate(coerce_hook_entries(hook_config)): - command_ref = entry.get("command") - if not isinstance(command_ref, str): - continue - command_value = self._canonicalize_command_ref( - command_ref, - ext["id"], - rename_map, - ) - by_command.setdefault(command_value, []).append((idx, entry)) - for command_value, group in by_command.items(): - if len(group) < 2: - continue - seen_canonical: Dict[bytes, int] = {} - for idx, entry in group: - stripped = { - k: v - for k, v in entry.items() - if k not in ("eventName", "command") - } - key = canonical_json(stripped) - if key in seen_canonical: - first_idx = seen_canonical[key] - raise ValidationError( - f"Duplicate hook entries for event '{hook_name}' " - f"command '{command_value}': entries at positions " - f"{first_idx} and {idx} have byte-identical declared " - "fields and cannot be uniquely identified" - ) - seen_canonical[key] = idx + for hook_name, event_entries in hook_entries_by_event.items(): + by_command: Dict[str, List[tuple[int, dict]]] = {} + for idx, entry in enumerate(event_entries): + command_ref = entry.get("command") + if not isinstance(command_ref, str): + continue + command_value = self._canonicalize_command_ref( + command_ref, + ext["id"], + rename_map, + ) + by_command.setdefault(command_value, []).append((idx, entry)) + for command_value, group in by_command.items(): + if len(group) < 2: + continue + seen_canonical: Dict[bytes, int] = {} + for idx, entry in group: + stripped = { + k: v + for k, v in entry.items() + if k not in ("eventName", "command") + } + key = canonical_json(stripped) + if key in seen_canonical: + first_idx = seen_canonical[key] + raise ValidationError( + f"Duplicate hook entries for event '{hook_name}' " + f"command '{command_value}': entries at positions " + f"{first_idx} and {idx} have byte-identical declared " + "fields and cannot be uniquely identified" + ) + seen_canonical[key] = idx @staticmethod def _canonicalize_command_ref( From 7d52a5010c5efdc0f6e5e52105eac22806ea7abb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:42:24 +0000 Subject: [PATCH 42/49] fix: align core command candidate ordering Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 28 +++++++++++++-------------- src/specify_cli/presets/__init__.py | 16 +++++++++------ tests/test_artifact_command.py | 16 +++++++++++++++ 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index b74a5e5956..ca63e6f7e8 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -235,41 +235,39 @@ def _enumerate_core_commands(project_root: Path | None = None) -> list[_CoreBase grammar ``command:speckit.constitution`` requires. """ from ..extensions import CORE_COMMAND_NAMES # lazy: avoids circular import + from ..presets import PresetResolver commands_dir = _core_asset_root("commands") project_commands_dir = _project_core_asset_root(project_root, "commands") rows: list[_CoreBaselineRow] = [] if commands_dir is None and project_commands_dir is None: return rows - candidate_stems = set(CORE_COMMAND_NAMES) + logical_names = { + name if name.startswith("speckit.") else f"speckit.{name}" + for name in CORE_COMMAND_NAMES + } if commands_dir is not None: - candidate_stems.update( - entry.stem + logical_names.update( + entry.stem if entry.stem.startswith("speckit.") else f"speckit.{entry.stem}" for entry in commands_dir.iterdir() if entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX ) if project_commands_dir is not None: - candidate_stems.update( - entry.stem + logical_names.update( + entry.stem if entry.stem.startswith("speckit.") else f"speckit.{entry.stem}" for entry in project_commands_dir.iterdir() if entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX ) rows_by_name: dict[str, _CoreBaselineRow] = {} - for stem in sorted(candidate_stems): - logical_name = stem if stem.startswith("speckit.") else f"speckit.{stem}" + for logical_name in sorted(logical_names): + name_candidates = PresetResolver.core_name_candidates(logical_name) project_candidates = ( - ( - project_commands_dir / f"{stem}.md", - project_commands_dir / f"{logical_name}.md", - ) + tuple(project_commands_dir / f"{name}.md" for name in name_candidates) if project_commands_dir is not None else () ) bundled_candidates = ( - ( - commands_dir / f"{stem}.md", - commands_dir / f"{logical_name}.md", - ) + tuple(commands_dir / f"{name}.md" for name in name_candidates) if commands_dir is not None else () ) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index a2695c251e..98faabec65 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -5231,6 +5231,15 @@ def _core_stem(template_name: str) -> Optional[str]: return template_name[len("speckit."):] return None + @classmethod + def core_name_candidates(cls, logical_name: str) -> list[str]: + """Return exact-first filename candidates for a core logical name.""" + names = [logical_name] + stem = cls._core_stem(logical_name) + if stem and stem != logical_name: + names.append(stem) + return names + def resolve( self, template_name: str, @@ -5740,11 +5749,6 @@ def _find_bundled_core( except ImportError: return None - stem = self._core_stem(template_name) - names = [template_name] - if stem and stem != template_name: - names.append(stem) - if template_type == "template": base = _locate_core_asset_dir("templates") elif template_type == "command": @@ -5757,7 +5761,7 @@ def _find_bundled_core( if base is None: return None - for name in names: + for name in self.core_name_candidates(template_name): if template_type == "script": c = next( (path for path in script_variant_paths(base, name) if path.exists()), diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 9373612842..ae2f087181 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -28,6 +28,7 @@ _preset_display_name, ) from specify_cli.extensions import ExtensionRegistry +from specify_cli.presets import PresetResolver ERROR_REGEX = re.compile( @@ -275,6 +276,21 @@ def test_preserves_prefixed_project_local_command_names(self, spec_kit_project: 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 ): From 28214a2756e34c334303e33728fb419dbf679928 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:55:22 +0000 Subject: [PATCH 43/49] test: cover manifest-backed artifact parity Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- tests/conftest.py | 67 +++++++++++++++++++++ tests/test_artifact_command.py | 86 +++++---------------------- tests/test_artifact_command_parity.py | 59 ++++++++++-------- 3 files changed, 116 insertions(+), 96 deletions(-) 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 index ae2f087181..5b106d1261 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -28,7 +28,8 @@ _preset_display_name, ) from specify_cli.extensions import ExtensionRegistry -from specify_cli.presets import PresetResolver +from specify_cli.presets import PresetRegistry, PresetResolver +from tests.conftest import install_preset ERROR_REGEX = re.compile( @@ -61,67 +62,6 @@ def non_project(tmp_path: Path) -> Path: return root -def _install_preset(project_root: Path, pack_id: str, provides: dict, priority: int = 10) -> Path: - """Drop a minimal preset onto disk and register it in the ``.registry`` file.""" - pack_dir = project_root / ".specify" / "presets" / pack_id - pack_dir.mkdir(parents=True) - 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") - registry_path = project_root / ".specify" / "presets" / ".registry" - if registry_path.is_file(): - registry = json.loads(registry_path.read_text(encoding="utf-8")) - else: - registry = {"schema_version": "1.0.0", "presets": {}} - registry["presets"][pack_id] = {"priority": priority, "version": "1.0.0"} - registry_path.write_text(json.dumps(registry), encoding="utf-8") - return pack_dir - - # --------------------------------------------------------------------------- # Contract tests — matching artifact-list.schema.json # --------------------------------------------------------------------------- @@ -307,7 +247,7 @@ def test_active_preset_description_overrides_hidden_core_description( "---\ndescription: Core description\n---\n", encoding="utf-8" ) - pack = _install_preset( + pack = install_preset( spec_kit_project, "override-preset", { @@ -335,7 +275,7 @@ def test_higher_precedence_preset_description_wins(self, spec_kit_project: Path) not leak through just because it happens to be enumerated first alphabetically. """ - pack_low = _install_preset( + pack_low = install_preset( spec_kit_project, "aaa-low-priority-preset", {"templates": [{"name": "shared-artifact", "description": "Loser description"}]}, @@ -346,7 +286,7 @@ def test_higher_precedence_preset_description_wins(self, spec_kit_project: Path) "# Loser\n", encoding="utf-8" ) - pack_high = _install_preset( + pack_high = install_preset( spec_kit_project, "zzz-high-priority-preset", {"templates": [{"name": "shared-artifact", "description": "Winner description"}]}, @@ -477,7 +417,7 @@ def test_ambiguous_artifact_message(self, spec_kit_project: Path): # 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( + pack = install_preset( spec_kit_project, "test-ambig", { @@ -509,7 +449,7 @@ def test_info_rejects_corrupt_extension_registry(self, spec_kit_project: Path): class TestKindHint: def test_kind_flag_disambiguates(self, spec_kit_project: Path): - _install_preset( + install_preset( spec_kit_project, "test-kind", {"templates": [{"name": "dup", "description": "t"}], @@ -694,7 +634,7 @@ def test_output_is_utf8_without_bom(self, spec_kit_project: Path, monkeypatch: p class TestStackComposition: def test_preset_command_uses_entry_type(self, spec_kit_project: Path): - pack = _install_preset( + pack = install_preset( spec_kit_project, "test-command", { @@ -719,7 +659,7 @@ def test_preset_command_uses_entry_type(self, spec_kit_project: Path): def test_preset_single_segment_command_id_from_list_is_resolvable( self, spec_kit_project: Path ): - pack = _install_preset( + pack = install_preset( spec_kit_project, "test-single-command", {"commands": [{"name": "specify", "description": "single segment"}]}, @@ -739,7 +679,7 @@ def test_preset_single_segment_command_id_from_list_is_resolvable( def test_preset_replace_hides_core(self, spec_kit_project: Path): # Install a preset that replaces the constitution command. - pack = _install_preset( + pack = install_preset( spec_kit_project, "test-replace", {"commands": [{"name": "speckit.constitution", "description": "override"}]}, @@ -853,7 +793,11 @@ def test_malformed_dotted_override_is_not_forced_to_command( assert "command:speckit..local" not in ids def test_unregistered_preset_template_without_manifest(self, spec_kit_project: Path): - pack_dir = _install_preset(spec_kit_project, "legacy-preset", provides={"templates": []}) + 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( diff --git a/tests/test_artifact_command_parity.py b/tests/test_artifact_command_parity.py index 6218ef9374..9c23924169 100644 --- a/tests/test_artifact_command_parity.py +++ b/tests/test_artifact_command_parity.py @@ -12,30 +12,10 @@ from pathlib import Path import pytest -import yaml from specify_cli.artifacts import ArtifactCatalog from specify_cli.presets import PresetResolver - - -def _install_preset(project_root: Path, pack_id: str, provides: dict, priority: int = 10) -> Path: - pack_dir = project_root / ".specify" / "presets" / pack_id - pack_dir.mkdir(parents=True) - manifest = { - "id": pack_id, - "version": "1.0.0", - "metadata": {"name": f"Test preset {pack_id}"}, - "provides": provides, - } - (pack_dir / "preset.yml").write_text(yaml.safe_dump(manifest), encoding="utf-8") - registry_path = project_root / ".specify" / "presets" / ".registry" - if registry_path.is_file(): - registry = json.loads(registry_path.read_text(encoding="utf-8")) - else: - registry = {"schema_version": "1.0.0", "presets": {}} - registry["presets"][pack_id] = {"priority": priority, "version": "1.0.0"} - registry_path.write_text(json.dumps(registry), encoding="utf-8") - return pack_dir +from tests.conftest import install_preset @pytest.fixture @@ -53,7 +33,7 @@ class TestManifestPathIsPosix: """The ``manifestPath`` field MUST use forward slashes on every OS.""" def test_no_backslashes(self, spec_kit_project: Path): - pack = _install_preset( + pack = install_preset( spec_kit_project, "test-posix", {"commands": [{"name": "speckit.constitution", "description": "d"}]}, @@ -70,7 +50,7 @@ def test_no_backslashes(self, spec_kit_project: Path): assert "\\" not in path, f"backslash leak: {path!r}" def test_never_absolute(self, spec_kit_project: Path): - pack = _install_preset( + pack = install_preset( spec_kit_project, "test-rel", {"commands": [{"name": "speckit.constitution", "description": "d"}]}, @@ -93,7 +73,7 @@ class TestResolverParity: """The ``active: true`` row must be what :meth:`resolve_content` would pick.""" def test_active_layer_matches_resolver(self, spec_kit_project: Path): - pack = _install_preset( + pack = install_preset( spec_kit_project, "test-parity", {"commands": [{"name": "speckit.constitution", "description": "override"}]}, @@ -115,6 +95,36 @@ def test_active_layer_matches_resolver(self, spec_kit_project: Path): assert "body-from-preset" in winner assert active["layer"] == "preset" + 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["lookupId"] == "preset:test-manifest-parity:command:speckit.manifest-declared" + class TestJSONShape: """Reasserts JSON-envelope invariants at the whole-payload level.""" @@ -136,4 +146,3 @@ def test_terminated_by_single_newline(self, spec_kit_project: Path): def test_module_imports(): _ = ArtifactCatalog - From fe5348fa9c3a9967fe50b06306dbe27dd7d0f400 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:16:29 +0000 Subject: [PATCH 44/49] fix: align artifact IDs with resolver identity Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 26 ++++++++-------- src/specify_cli/presets/__init__.py | 8 +++++ tests/test_artifact_command.py | 44 +++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index ca63e6f7e8..e80e9e60e7 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -836,21 +836,16 @@ def _lookup_ids(kind: ArtifactKind, name: str) -> set[str]: pack_dir = preset_manager.presets_dir / pack_id manifest = preset_manager.get_pack(pack_id) yield from self._iter_pack_contributions( - manifest, pack_dir, "preset", _lookup_ids + manifest, pack_dir, "preset", pack_id, _lookup_ids ) - # -- Extensions: registered ids plus on-disk unregistered directories, - # mirroring PresetResolver._get_all_extensions_by_priority. + # -- 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) - registered_ext_ids = {e["id"] for e in ext_manager.list_installed()} - ext_ids = set(registered_ext_ids) - if ext_manager.extensions_dir.is_dir(): - ext_ids.update( - p.name for p in ext_manager.extensions_dir.iterdir() if p.is_dir() - ) - for ext_id in sorted(ext_ids): + for _priority, ext_id, metadata in resolver.iter_extensions_by_priority(): ext_dir = ext_manager.extensions_dir / ext_id - if ext_id in registered_ext_ids: + if metadata is not None: manifest = ext_manager.get_extension(ext_id) else: manifest_path = ext_dir / "extension.yml" @@ -860,7 +855,9 @@ def _lookup_ids(kind: ArtifactKind, name: str) -> set[str]: manifest = ExtensionManifest(manifest_path) except ValidationError: manifest = None - yield from self._iter_pack_contributions(manifest, ext_dir, "extension", _lookup_ids) + yield from self._iter_pack_contributions( + manifest, ext_dir, "extension", ext_id, _lookup_ids + ) yield from self._iter_project_override_artifacts(resolver) @@ -869,6 +866,7 @@ def _iter_pack_contributions( manifest: Any, pack_dir: Path, layer: str, + source_id: str, lookup_ids: Callable[[ArtifactKind, str], set[str]], ) -> Iterable[tuple[ArtifactKind, str, str, str]]: """Yield ``(kind, name, description, lookup_id)`` for one pack. @@ -890,7 +888,7 @@ def _iter_pack_contributions( description = contribution.get("description", "") if not isinstance(description, str): description = "" - lookup_id = contribution["id"] + lookup_id = derive_named_id(layer, source_id, kind, name) if lookup_id in lookup_ids(kind, name): yield kind, name, description, lookup_id @@ -898,7 +896,7 @@ def _iter_pack_contributions( # conventional path resolves whether or not the manifest declares it, # so it belongs in the inventory as well. for kind, name in _iter_convention_contributions(pack_dir): - lookup_id = derive_named_id(layer, pack_dir.name, kind, name) + lookup_id = derive_named_id(layer, source_id, kind, name) if lookup_id in lookup_ids(kind, name): yield kind, name, "", lookup_id diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 98faabec65..2a7e860011 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -5218,6 +5218,14 @@ def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: all_extensions.sort(key=lambda x: (x[0], x[1])) return all_extensions + def iter_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: + """Return extension directories in resolver lookup order. + + Each entry is ``(priority, ext_id, metadata_or_none)`` where ``ext_id`` + is always the on-disk directory name used in lookup identifiers. + """ + return self._get_all_extensions_by_priority() + @staticmethod def _core_stem(template_name: str) -> Optional[str]: """Extract the stem for core command lookup. diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 5b106d1261..af37e3b72d 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -173,6 +173,50 @@ def test_excludes_disabled_and_unusable_manifest_contributions( assert "disabled-template" not in names assert "missing-template" not in names + def test_unregistered_extension_uses_directory_id_for_lookup(self, spec_kit_project: Path): + ext_dir = spec_kit_project / ".specify" / "extensions" / "renamed" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + (ext_dir / "commands" / "actual.md").write_text( + "---\ndescription: Dir identity wins\n---\nbody\n", + encoding="utf-8", + ) + (ext_dir / "extension.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "extension": { + "id": "original", + "name": "Original Id", + "version": "1.0.0", + "description": "test", + "author": "test", + "repository": "https://example.com", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.2.0"}, + "provides": { + "commands": [ + { + "name": "speckit.original.hello", + "file": "commands/actual.md", + "description": "manifest declared command", + } + ] + }, + } + ), + encoding="utf-8", + ) + + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.original.hello") + assert info["stack"][0]["lookupId"] == "extension:renamed:command:speckit.original.hello" + assert ( + PresetResolver(spec_kit_project) + .collect_all_layers("speckit.original.hello", "command")[0]["lookupId"] + == "extension:renamed:command:speckit.original.hello" + ) + def test_includes_project_local_core_assets(self, spec_kit_project: Path): templates_dir = spec_kit_project / ".specify" / "templates" (templates_dir / "legacy-template.md").write_text( From bba794314cdf2000ae25077dd327c2b585767e42 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:20:38 +0000 Subject: [PATCH 45/49] fix: skip invalid local artifact name components Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 15 +++++++++++++++ tests/test_artifact_command.py | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index e80e9e60e7..9099128f6e 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -633,6 +633,15 @@ def _validate_artifact_name(name: str, kind: ArtifactKind) -> str: 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.""" @@ -679,6 +688,8 @@ def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: descriptions_by_layer: dict[tuple[ArtifactKind, str], dict[str, str]] = {} for row in (*baseline.commands, *baseline.templates, *baseline.scripts): + if not _is_valid_artifact_name_component(row.name, row.kind): + continue key = (row.kind, row.name) names.add(key) core_lookup_id = derive_named_id("core", "_", row.kind, row.name) @@ -923,6 +934,8 @@ def _iter_project_override_artifacts( if not entry.is_file() or entry.suffix != _TEMPLATE_SUFFIX: continue name = entry.stem + if not _is_valid_artifact_name_component(name, "template"): + continue command_layers = resolver.collect_all_layers(name, "command") backed_by_command = any( not str(layer.get("lookupId", "")).startswith( @@ -939,6 +952,8 @@ def _iter_project_override_artifacts( return for entry in sorted(scripts_dir.iterdir(), key=lambda p: p.name): if entry.is_file() and entry.suffix == _SCRIPT_SUFFIX: + if not _is_valid_artifact_name_component(entry.stem, "script"): + continue lookup_id = derive_named_id(PROJECT_OVERRIDE_LAYER, "_", "script", entry.stem) yield "script", entry.stem, "", lookup_id diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index af37e3b72d..27f10c18bd 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +import os import re from pathlib import Path @@ -249,6 +250,27 @@ def test_includes_project_local_core_assets(self, spec_kit_project: Path): "core:_:script:legacy-script" ) + @pytest.mark.skipif(os.name == "nt", reason="':' filenames are unsupported on Windows") + def test_skips_invalid_colon_names_in_project_local_inventory(self, spec_kit_project: Path): + templates_dir = spec_kit_project / ".specify" / "templates" + commands_dir = templates_dir / "commands" + scripts_dir = templates_dir / "scripts" + overrides_dir = templates_dir / "overrides" + override_scripts_dir = overrides_dir / "scripts" + commands_dir.mkdir(parents=True) + scripts_dir.mkdir(parents=True) + overrides_dir.mkdir(parents=True) + override_scripts_dir.mkdir(parents=True) + + (templates_dir / "bad:template.md").write_text("---\ndescription: bad\n---\n", encoding="utf-8") + (commands_dir / "bad:command.md").write_text("---\ndescription: bad\n---\n", encoding="utf-8") + (scripts_dir / "bad:script.sh").write_text("# bad\n", encoding="utf-8") + (overrides_dir / "bad:override.md").write_text("override", encoding="utf-8") + (override_scripts_dir / "bad:override-script.sh").write_text("# bad\n", encoding="utf-8") + + artifacts = ArtifactCatalog(spec_kit_project).list_artifacts() + assert all(":" not in artifact.name for artifact in artifacts) + def test_preserves_prefixed_project_local_command_names(self, spec_kit_project: Path): commands_dir = spec_kit_project / ".specify" / "templates" / "commands" commands_dir.mkdir() From 17dc23e7e4bb7ffde7408a36c01cc4037635cbbc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:22:56 +0000 Subject: [PATCH 46/49] fix: filter invalid local artifact IDs from inventory Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 9099128f6e..673f9f047c 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -934,7 +934,7 @@ def _iter_project_override_artifacts( if not entry.is_file() or entry.suffix != _TEMPLATE_SUFFIX: continue name = entry.stem - if not _is_valid_artifact_name_component(name, "template"): + if not _is_valid_artifact_name_component(name, "command"): continue command_layers = resolver.collect_all_layers(name, "command") backed_by_command = any( From 27ef85298bb1db40f957f9c90a46976e4cbbd8db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:26:15 +0000 Subject: [PATCH 47/49] fix: align artifact preset enumeration with resolver Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- extensions/EXTENSION-API-REFERENCE.md | 6 ++-- src/specify_cli/artifacts/__init__.py | 3 +- src/specify_cli/presets/__init__.py | 8 +++++ tests/test_artifact_command.py | 6 +++- tests/test_artifact_command_parity.py | 44 +++++++++++++++++++++++++++ 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index 475c3c8212..20048877e5 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -903,15 +903,15 @@ Project-local overrides in `.specify/templates/overrides/` are a resolver-only c `ExtensionManifest.iter_contributions()` yields dicts of the form `{layer, sourceId, kind, name, id, ...author-declared fields}`; each entry's `id` is the computed identifier. `ExtensionManifest.contribution_id(kind, name)` returns the id for a single lookup, or `None` if no contribution matches. `PresetManifest` exposes the same two methods. -`PresetResolver.collect_all_layers()` returns layer dicts that include a `lookupId` field for every layer type (`project override`, preset, extension, core, and bundled core). +`PresetResolver.collect_all_layers()` returns layer dicts that include a `lookupId` field for every layer type (`project override`, preset, extension, core, and bundled core). Resolver `lookupId` values identify the layer by the resolver's registry key or directory name, which can differ from the manifest-declared source id used by `iter_contributions()`. ### Determinism guarantees -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 any id. Copying an extension or preset to a different machine (or renaming its directory, or touching its files) does not change the identifiers it produces. +Manifest contribution identifier derivation reads only the in-memory declared manifest content. No filesystem paths, no `os.environ`, no timestamps, and no file-content hashes contribute to those manifest ids. Copying an extension or preset to a different machine (or touching its files) does not change the identifiers it produces. Resolver `lookupId` values are stack identifiers, not manifest contribution ids: for example, an unregistered extension's directory name is the resolver source id, so renaming that directory changes its `lookupId`. ### Opacity guidance -Identifiers are stable, but treat them as **opaque strings** in stored data (registries, cache files, external tooling). Parse them with the helpers in `specify_cli._identifier` (`derive_named_id`, `derive_hook_id`) rather than by string-splitting on `:` — the discriminator suffix and future grammar extensions may otherwise catch you out. +Identifiers are stable, but treat them as **opaque strings** in stored data (registries, cache files, external tooling). Do not parse them by string-splitting on `:` — the discriminator suffix and future grammar extensions may otherwise catch you out. If you only need to classify a stack entry's layer, use `layer_kind_from_lookup_id`; `derive_named_id` and `derive_hook_id` construct new identifiers rather than parsing existing ones. diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 673f9f047c..34f830ba48 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -842,8 +842,7 @@ def _lookup_ids(kind: ArtifactKind, name: str) -> set[str]: # -- Presets: the registry is authoritative, no unregistered fallback. preset_manager = PresetManager(self.project_root) - for entry in sorted(preset_manager.list_installed(), key=lambda e: e["id"]): - pack_id = entry["id"] + for pack_id, _metadata in resolver.iter_presets_by_priority(): pack_dir = preset_manager.presets_dir / pack_id manifest = preset_manager.get_pack(pack_id) yield from self._iter_pack_contributions( diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 2a7e860011..29416abb0b 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -5073,6 +5073,14 @@ def _get_all_presets_by_priority(self) -> List[tuple[str, dict]]: if self._is_safe_registry_id(pack_id) ] + def iter_presets_by_priority(self) -> List[tuple[str, dict]]: + """Return preset directories in resolver lookup order. + + Each entry is ``(pack_id, metadata)`` where ``pack_id`` is the registry + key/directory name used in lookup identifiers. + """ + return self._get_all_presets_by_priority() + def _manifest_declared_template( self, pack_dir: Path, template_name: str, template_type: str ) -> tuple[dict | None, Path | None]: diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 27f10c18bd..97f8790706 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -210,7 +210,11 @@ def test_unregistered_extension_uses_directory_id_for_lookup(self, spec_kit_proj encoding="utf-8", ) - info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.original.hello") + catalog = ArtifactCatalog(spec_kit_project) + assert "command:speckit.original.hello" in { + row.id for row in catalog.list_artifacts() + } + info = catalog.get_artifact_info("speckit.original.hello") assert info["stack"][0]["lookupId"] == "extension:renamed:command:speckit.original.hello" assert ( PresetResolver(spec_kit_project) diff --git a/tests/test_artifact_command_parity.py b/tests/test_artifact_command_parity.py index 9c23924169..b29f675698 100644 --- a/tests/test_artifact_command_parity.py +++ b/tests/test_artifact_command_parity.py @@ -12,6 +12,7 @@ from pathlib import Path import pytest +import yaml from specify_cli.artifacts import ArtifactCatalog from specify_cli.presets import PresetResolver @@ -125,6 +126,49 @@ def test_manifest_declared_artifact_matches_resolver(self, spec_kit_project: Pat assert winner == "body-from-manifest" assert active["lookupId"] == "preset:test-manifest-parity:command:speckit.manifest-declared" + def test_preset_manifest_id_mismatch_uses_registry_key(self, spec_kit_project: Path): + pack = install_preset( + spec_kit_project, + "renamed-preset", + { + "commands": [ + { + "name": "speckit.preset-renamed.hello", + "file": "commands/actual.md", + "description": "manifest contribution", + } + ] + }, + ) + manifest_path = pack / "preset.yml" + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + manifest["preset"]["id"] = "original-preset" + manifest_path.write_text(yaml.safe_dump(manifest), encoding="utf-8") + (pack / "commands").mkdir() + (pack / "commands" / "actual.md").write_text( + "body-from-renamed-preset", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + assert "command:speckit.preset-renamed.hello" in { + row.id for row in catalog.list_artifacts() + } + info = catalog.get_artifact_info("speckit.preset-renamed.hello") + active = next(layer for layer in info["stack"] if layer["active"]) + winner = PresetResolver(spec_kit_project).resolve_content( + "speckit.preset-renamed.hello", template_type="command" + ) + + assert winner == "body-from-renamed-preset" + assert active["lookupId"] == ( + "preset:renamed-preset:command:speckit.preset-renamed.hello" + ) + assert ( + PresetResolver(spec_kit_project) + .collect_all_layers("speckit.preset-renamed.hello", "command")[0]["lookupId"] + == "preset:renamed-preset:command:speckit.preset-renamed.hello" + ) + class TestJSONShape: """Reasserts JSON-envelope invariants at the whole-payload level.""" From 228d686e06a76f14d39791c81013daed86024a9a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:37:43 +0000 Subject: [PATCH 48/49] test: remove tautological artifact tests and strengthen id assertion Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- tests/test_artifact_command_parity.py | 92 ++------------------------- tests/test_assets.py | 8 --- tests/test_contribution_ids.py | 4 +- tests/test_extensions.py | 24 ------- 4 files changed, 8 insertions(+), 120 deletions(-) diff --git a/tests/test_artifact_command_parity.py b/tests/test_artifact_command_parity.py index b29f675698..e06f2a8bb2 100644 --- a/tests/test_artifact_command_parity.py +++ b/tests/test_artifact_command_parity.py @@ -1,14 +1,12 @@ -"""Cross-OS and resolver-parity tests for the `specify artifact` command group. +"""Resolver-parity tests for the `specify artifact` command group. -Focuses on invariants that either directly guard against OS-specific -regressions (POSIX-vs-Windows path separators, UTF-8 encoding) or verify -that the artifact output stays consistent with the underlying -:class:`~specify_cli.presets.PresetResolver`. +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 -import json from pathlib import Path import pytest @@ -30,72 +28,9 @@ def spec_kit_project(tmp_path: Path) -> Path: return root -class TestManifestPathIsPosix: - """The ``manifestPath`` field MUST use forward slashes on every OS.""" - - def test_no_backslashes(self, spec_kit_project: Path): - pack = install_preset( - spec_kit_project, - "test-posix", - {"commands": [{"name": "speckit.constitution", "description": "d"}]}, - ) - (pack / "commands").mkdir() - (pack / "commands" / "speckit.constitution.md").write_text( - "---\ndescription: d\n---\nbody", encoding="utf-8" - ) - info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") - for layer in info["stack"]: - path = layer["manifestPath"] - if path is None: - continue - assert "\\" not in path, f"backslash leak: {path!r}" - - def test_never_absolute(self, spec_kit_project: Path): - pack = install_preset( - spec_kit_project, - "test-rel", - {"commands": [{"name": "speckit.constitution", "description": "d"}]}, - ) - (pack / "commands").mkdir() - (pack / "commands" / "speckit.constitution.md").write_text( - "---\ndescription: d\n---\nbody", encoding="utf-8" - ) - info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") - for layer in info["stack"]: - path = layer["manifestPath"] - if path is None: - continue - assert not path.startswith("/"), f"leading slash: {path!r}" - # Windows drive letter check. - assert not (len(path) >= 2 and path[1] == ":"), f"drive letter: {path!r}" - - class TestResolverParity: """The ``active: true`` row must be what :meth:`resolve_content` would pick.""" - def test_active_layer_matches_resolver(self, spec_kit_project: Path): - pack = install_preset( - spec_kit_project, - "test-parity", - {"commands": [{"name": "speckit.constitution", "description": "override"}]}, - ) - (pack / "commands").mkdir() - (pack / "commands" / "speckit.constitution.md").write_text( - "---\ndescription: override\n---\nbody-from-preset", encoding="utf-8" - ) - - info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") - active = next(layer for layer in info["stack"] if layer["active"]) - - resolver = PresetResolver(spec_kit_project) - winner = resolver.resolve_content("speckit.constitution", template_type="command") - assert winner is not None - # The active row's layer classification must correspond to a real - # winning layer — if a preset override was installed and picked up - # by the resolver, active.layer must not be "core". - assert "body-from-preset" in winner - assert active["layer"] == "preset" - def test_manifest_declared_artifact_matches_resolver(self, spec_kit_project: Path): pack = install_preset( spec_kit_project, @@ -124,6 +59,7 @@ def test_manifest_declared_artifact_matches_resolver(self, spec_kit_project: Pat ) assert winner == "body-from-manifest" + assert active["layer"] == "preset" assert active["lookupId"] == "preset:test-manifest-parity:command:speckit.manifest-declared" def test_preset_manifest_id_mismatch_uses_registry_key(self, spec_kit_project: Path): @@ -170,23 +106,5 @@ def test_preset_manifest_id_mismatch_uses_registry_key(self, spec_kit_project: P ) -class TestJSONShape: - """Reasserts JSON-envelope invariants at the whole-payload level.""" - - def test_no_trailing_whitespace(self, spec_kit_project: Path): - catalog = ArtifactCatalog(spec_kit_project) - rows = [a.to_json_dict() for a in catalog.list_artifacts()] - payload = json.dumps(rows, indent=2, sort_keys=True) + "\n" - for line in payload.splitlines(): - assert line == line.rstrip(), f"trailing ws: {line!r}" - - def test_terminated_by_single_newline(self, spec_kit_project: Path): - catalog = ArtifactCatalog(spec_kit_project) - rows = [a.to_json_dict() for a in catalog.list_artifacts()] - payload = json.dumps(rows, indent=2, sort_keys=True) + "\n" - assert payload.endswith("\n") - assert not payload.endswith("\n\n") - - def test_module_imports(): _ = ArtifactCatalog diff --git a/tests/test_assets.py b/tests/test_assets.py index 3e2a19f66c..b8149272d7 100644 --- a/tests/test_assets.py +++ b/tests/test_assets.py @@ -46,11 +46,3 @@ def test_returns_none_for_unknown_subdir(self, tmp_path, monkeypatch): monkeypatch.setattr(assets, "_repo_root", lambda: tmp_path) assert _locate_core_asset_dir("bogus") is None - - def test_returns_none_for_unknown_subdir_with_wheel_bundle(self, tmp_path, monkeypatch): - core_pack = tmp_path / "core_pack" - (core_pack / "extensions").mkdir(parents=True) - - monkeypatch.setattr(assets, "_locate_core_pack", lambda: core_pack) - - assert _locate_core_asset_dir("extensions") is None diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py index a5131bf050..0895ccb037 100644 --- a/tests/test_contribution_ids.py +++ b/tests/test_contribution_ids.py @@ -439,7 +439,9 @@ def test_core_layer_carries_core_lookup_id(self, tmp_path): resolver.templates_dir = project / "templates" layers = resolver.collect_all_layers("spec-template", "template") core_layer = next(layer for layer in layers if layer["source"] == "core") - assert core_layer["lookupId"] == "core:_:template:spec-template" + assert core_layer["lookupId"] == derive_named_id( + "core", "_", "template", "spec-template" + ) def test_preset_layer_lookup_id_matches_manifest_contribution_id(self, tmp_path): project = _make_project(tmp_path) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 6c72645185..be81ebb1c3 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -306,30 +306,6 @@ def test_load_core_command_names_discovers_from_source_checkout(self, monkeypatc 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") - - # The shared resolver itself picks the bundle ahead of the source - # tree; here we just stand in for its already-resolved result. - monkeypatch.setattr( - ext, - "_locate_core_asset_dir", - lambda subdir: core_pack / "commands" if subdir == "commands" else None, - ) - - 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).""" From 6d9f7b23faa31a910f0016171ea031114a6559df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:46:39 +0000 Subject: [PATCH 49/49] fix: preserve documented hook duplicate semantics Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- extensions/EXTENSION-API-REFERENCE.md | 2 +- src/specify_cli/_identifier.py | 8 ++--- src/specify_cli/extensions/__init__.py | 42 -------------------------- tests/test_contribution_ids.py | 17 ----------- tests/test_extensions.py | 14 ++++----- 5 files changed, 10 insertions(+), 73 deletions(-) diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index 20048877e5..e26b3efa9c 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -889,7 +889,7 @@ When two or more hook entries within the same source share the same `(eventName, {layer}:{sourceId}:hook:{eventName}:{command}:{discriminator} ``` -The discriminator is the first 12 lowercase hex characters of `sha256(canonical_json(entry - {eventName, command}))`. Two hook entries with byte-identical declared fields (after removing `eventName` and `command`) are rejected at manifest load with a `ValidationError` naming both positions — there is no meaningful way to distinguish them at read time. +The discriminator is the first 12 lowercase hex characters of `sha256(canonical_json(entry - {eventName, command}))`. If two entries are byte-identical after removing `eventName` and `command`, they collapse under the existing per-event, per-command last-write-wins hook merge semantics. ### Reserved character diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py index 1d251ace1a..dec1fc2d91 100644 --- a/src/specify_cli/_identifier.py +++ b/src/specify_cli/_identifier.py @@ -26,9 +26,7 @@ hook in the same source shares the same ``(eventName, command)`` pair, and it is computed by SHA-256 of a canonical JSON serialization of the hook entry's declared fields (with ``eventName`` and ``command`` removed, since they already -appear in the identifier prefix). Two hook entries in the same source whose -declared fields produce byte-identical canonical JSON are rejected at manifest -load time — they are semantically identical listeners. +appear in the identifier prefix). The functions in this module are pure — inputs are strings or in-memory mappings parsed from a manifest, outputs are strings. None of them read from @@ -139,9 +137,7 @@ def canonical_json(value: Any) -> bytes: Mapping keys are sorted lexicographically at every depth, list order is preserved (author intent), whitespace is stripped, and non-ASCII characters - are emitted verbatim. This is the byte string that the hook discriminator - hashes and that the manifest loader uses to detect byte-identical duplicate - hook entries. + are emitted verbatim. This is the byte string the hook discriminator hashes. """ normalized = _normalize_for_canonical_json(value) return json.dumps( diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 193df7879a..0cae2dc296 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -30,7 +30,6 @@ from .._assets import _locate_core_asset_dir from .._identifier import ( IdentifierComponentError, - canonical_json, derive_hook_id, derive_named_id, validate_component, @@ -406,7 +405,6 @@ def _validate(self): # Validate hook values (if present). # Each event is a single mapping or a list of mappings. - hook_entries_by_event: Dict[str, List[dict]] = {} if hooks: for hook_name, hook_config in hooks.items(): if isinstance(hook_config, list) and not hook_config: @@ -417,7 +415,6 @@ def _validate(self): validate_component(hook_name, f"hook event name '{hook_name}'") except IdentifierComponentError as exc: raise ValidationError(str(exc)) from exc - event_entries: List[dict] = [] for entry in coerce_hook_entries(hook_config): if not isinstance(entry, dict): raise ValidationError( @@ -447,8 +444,6 @@ def _validate(self): f"Hook '{hook_name}' has invalid 'priority': " "must be >= 1" ) - event_entries.append(entry) - hook_entries_by_event[hook_name] = event_entries # Validate commands; track renames so hook references can be rewritten. rename_map: Dict[str, str] = {} @@ -574,43 +569,6 @@ def _validate(self): f"The extension author should update the manifest." ) - # Reject two hook entries under the same (event, command) whose - # declared fields (with eventName/command stripped) canonicalize - # to the same byte string — those are semantically identical - # listeners with no way to address them separately. - for hook_name, event_entries in hook_entries_by_event.items(): - by_command: Dict[str, List[tuple[int, dict]]] = {} - for idx, entry in enumerate(event_entries): - command_ref = entry.get("command") - if not isinstance(command_ref, str): - continue - command_value = self._canonicalize_command_ref( - command_ref, - ext["id"], - rename_map, - ) - by_command.setdefault(command_value, []).append((idx, entry)) - for command_value, group in by_command.items(): - if len(group) < 2: - continue - seen_canonical: Dict[bytes, int] = {} - for idx, entry in group: - stripped = { - k: v - for k, v in entry.items() - if k not in ("eventName", "command") - } - key = canonical_json(stripped) - if key in seen_canonical: - first_idx = seen_canonical[key] - raise ValidationError( - f"Duplicate hook entries for event '{hook_name}' " - f"command '{command_value}': entries at positions " - f"{first_idx} and {idx} have byte-identical declared " - "fields and cannot be uniquely identified" - ) - seen_canonical[key] = idx - @staticmethod def _canonicalize_command_ref( command_ref: str, diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py index 0895ccb037..99b2e042db 100644 --- a/tests/test_contribution_ids.py +++ b/tests/test_contribution_ids.py @@ -267,22 +267,6 @@ def test_discriminator_stable_under_reordering(self, tmp_path): } assert ids_a == ids_b - def test_byte_identical_declared_fields_rejected_at_load(self, tmp_path): - data = _extension_data( - hooks={ - "after_tasks": [ - {"command": "speckit.speckitgit.branch", "priority": 10}, - {"command": "speckit.speckitgit.branch", "priority": 10}, - ] - } - ) - with pytest.raises(ValidationError) as exc_info: - ExtensionManifest(_write_manifest(tmp_path, data, "extension.yml")) - message = str(exc_info.value) - assert "Duplicate hook entries" in message - assert "after_tasks" in message - assert "positions 0 and 1" in message - def test_hook_discriminator_helper_is_deterministic(self): payload = {"priority": 10, "optional": True, "prompt": "Run?"} a = hook_discriminator(payload) @@ -584,4 +568,3 @@ def test_no_id_written_to_preset_manifest_files(self, tmp_path): 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 be81ebb1c3..2307d5be79 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -944,12 +944,12 @@ 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_detected_after_command_normalization( + def test_duplicate_hook_entries_allowed_after_command_normalization( self, temp_dir, valid_manifest_data, ): - """Equivalent hook entries are rejected after command refs canonicalize.""" + """Equivalent hook entries are accepted after command refs canonicalize.""" import yaml valid_manifest_data["provides"]["commands"][0]["name"] = "speckit.hello" @@ -962,11 +962,11 @@ def test_duplicate_hook_entries_detected_after_command_normalization( with open(manifest_path, 'w', encoding="utf-8") as f: yaml.dump(valid_manifest_data, f) - with pytest.raises( - ValidationError, - match="Duplicate hook entries for event 'after_tasks' command 'speckit.test-ext.hello'", - ): - ExtensionManifest(manifest_path) + 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