From 3807522189ae466e096c0c43bd247dc66d25a9c3 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 21 Aug 2026 13:58:32 -0500 Subject: [PATCH 001/113] 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 9a441fe5334b2c9d03afcf7b574f53324c2764b9 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 21 Aug 2026 16:28:54 -0500 Subject: [PATCH 002/113] 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 c47371ebfd495ba9d8c277c8af97b4381f65bff6 Mon Sep 17 00:00:00 2001 From: nicolehaugen Date: Mon, 24 Aug 2026 10:14:36 -0500 Subject: [PATCH 003/113] 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 ea2636f364cf72baa2ffd4d374b8fc9f076fcfa7 Mon Sep 17 00:00:00 2001 From: nicolehaugen Date: Mon, 24 Aug 2026 10:15:51 -0500 Subject: [PATCH 004/113] 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 b1d6a7508d372acff1bd2a242c3dc6e4eb54a74f Mon Sep 17 00:00:00 2001 From: nicolehaugen Date: Mon, 24 Aug 2026 10:20:46 -0500 Subject: [PATCH 005/113] 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 424ca0f6b66d7354961276396de7e27fba8bcb28 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 006/113] 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 ac901818a37f9bac6b0e8ebb9b6c751b16684ab3 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 007/113] 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 ed2f3db52d196bfa79741c615d30137edc06e72d 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 008/113] 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 7eb1a5276e028a906a977b89f5c0ba6384f013d8 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 009/113] 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 70965d78382e09583dd5d00f1c059ec288916d9c Mon Sep 17 00:00:00 2001 From: nicolehaugen Date: Mon, 24 Aug 2026 10:46:37 -0500 Subject: [PATCH 010/113] 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 70a937e44dc3ac763dcdfa7f0a0d99758e0cc2b6 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 011/113] 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 df4afefe49b70ccd713bc8fcb5e2528e4fb54a1b 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 012/113] 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 028fcd83d1c1885f7dd7719fde0fda8e382b2224 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 013/113] 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 8e7cf835badbec7c2526311ae293bfe5c9adbdba 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 014/113] 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 fcb72b1872795ab93e4f3779a42e2e88421303dd 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 015/113] 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 69baa877742956605d34c89085f26ca2058be23a 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 016/113] 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 e4347b0d13a5106b2851cee8a7aa5e01b2d31d9a 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 017/113] 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 2c36d57e303569318534e98719619ea62a7c33bd 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 018/113] 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 f6eacd286d8bbffd7106e5e09f8ab6a4f15c8fb2 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 019/113] 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 f601efaa3bc82ab330612d2c799e99d14c9e301b 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 020/113] 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 5410f728c2223a5c58e5334413a811d44d881c5d 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 021/113] 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 9b68bac8ba0104d2686351821a9946455e0ccb1b 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 022/113] 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 f9ee35c22c2224f0d53c0b678bdae8126a2be931 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 023/113] 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 95bbd99a90d112d1064e856ed2297b16709d5330 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 024/113] 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 aacb49bec06d8f01b53114c2052c3438d3cd0eb7 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 025/113] 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 fef72da7746635c03e98dcddf520c1fb248ded3b 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 026/113] 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 442cd233480221f174518553f40e11147c60fd72 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 027/113] 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 ddadd5f594e36536933ad30ce2bae063f7283d90 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 028/113] 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 602f04245bff6071cd0a199282e5e225877abbab 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 029/113] 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 63237cf319dfc36c0544c6d10fd48db01873aef0 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 030/113] 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 67c107f5a36dcf09bba4b267e691e2f7f185b215 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 031/113] 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 251430232ce1ea65bbb459e274f7a683fcc47158 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 032/113] 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 f93f5266598908ef267dfc83bef1af157d6dd227 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 033/113] 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 5b08932cdfa2c484e2ac2617694bd8ccaa58854e 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 034/113] 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 355ed82e9921bcc54d32bd97d6c93dd0d2c15f0b 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 035/113] 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 d0f9f25465996e9bf4cd34537538926ab6123d8e 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 036/113] 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 b4de3175258fe52aefc6ab1751b146f70ede0c80 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 037/113] 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 4857201c180361448366bdb75995596eb1b79991 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 038/113] 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 a20502d51fc203be20e4a1b629925f14dcd64740 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 039/113] 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 a149714fabd2d00c5bed9237300c7e2a56a0efda 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 040/113] 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 9fd43d7e2e3808e9800d95d3ed1958ed9484f365 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 041/113] 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 4682021b8a1ae92d132d1307750ac8e7e1eafde1 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 042/113] 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 c816a290e7f76bc4625ac7c6111135d6b0d8b997 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 043/113] 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 60050a84b72968a8f158805ca8e1fd9ed317b153 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 044/113] 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 1c4842f9ce0c179e3a548e99bf14b7e012aab8fc 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 045/113] 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 a3293eba87804946f5b568328ccd5db655f48660 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 046/113] 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 03778618c4f5bc80119c58f23702d9f9f5cd52ce 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 047/113] 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 1739a098d2c6a35e8b99a90284c0c40025f41128 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 048/113] 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 866b3a5c546c2531748ab1c6004ff99ecb463adc 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 049/113] 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 From 467ddca81c7ee3f58a612116e871bb805657469a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:27:05 +0000 Subject: [PATCH 050/113] fix: dedupe hook contributions last-wins Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/_identifier.py | 87 +-------------------- src/specify_cli/extensions/__init__.py | 62 ++++++--------- tests/test_contribution_ids.py | 104 ++++--------------------- 3 files changed, 43 insertions(+), 210 deletions(-) diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py index dec1fc2d91..bcd0eb9d99 100644 --- a/src/specify_cli/_identifier.py +++ b/src/specify_cli/_identifier.py @@ -20,13 +20,7 @@ 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). + id = "{layer}:{sourceId}:hook:{eventName}:{command}" 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 @@ -38,9 +32,7 @@ from __future__ import annotations -import hashlib -import json -from typing import Any, Iterable, Mapping +from typing import Any PROJECT_OVERRIDE_LAYER = "project" @@ -54,9 +46,6 @@ 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.""" @@ -132,79 +121,11 @@ def is_dotted_command_name(value: str) -> bool: ) -def canonical_json(value: Any) -> bytes: - """Serialize ``value`` to a canonical UTF-8 JSON byte string. - - Mapping keys are sorted lexicographically at every depth, list order is - preserved (author intent), whitespace is stripped, and non-ASCII characters - are emitted verbatim. This is the byte string the hook discriminator hashes. - """ - normalized = _normalize_for_canonical_json(value) - return json.dumps( - normalized, - sort_keys=True, - ensure_ascii=False, - separators=(",", ":"), - ).encode("utf-8") - - -def _normalize_for_canonical_json(value: Any) -> Any: - if isinstance(value, Mapping): - return {str(k): _normalize_for_canonical_json(v) for k, v in value.items()} - if isinstance(value, (list, tuple)): - return [_normalize_for_canonical_json(v) for v in value] - return value - - -def _has_hook_sibling_collision( - event_name: str, - command: str, - siblings: Iterable[Mapping[str, Any]], -) -> bool: - """Return True when at least one sibling shares the same event/command pair. - - ``siblings`` is the full same-source hook entry list including the entry - whose identifier is being derived. A collision therefore means at least two - entries share the pair. - """ - seen = 0 - for entry in siblings: - if entry.get("eventName") == event_name and entry.get("command") == command: - seen += 1 - if seen >= 2: - return True - return False - - -def hook_discriminator(declared_fields: Mapping[str, Any]) -> str: - """Compute the 12-hex-char SHA-256 discriminator for a hook entry. - - ``declared_fields`` is the entry as parsed from the manifest with - ``eventName`` and ``command`` removed — those two values already appear in - the identifier prefix, so hashing them would only reflect information the - consumer can already read. - """ - return hashlib.sha256(canonical_json(declared_fields)).hexdigest()[:_DISCRIMINATOR_LENGTH] - - def derive_hook_id( layer: str, source_id: str, event_name: str, command: str, - siblings: Iterable[Mapping[str, Any]], - own_declared_fields: Mapping[str, Any], ) -> str: - """Build the identifier string for a hook contribution. - - The discriminator suffix is appended only when at least one sibling in the - same source shares the same ``(event_name, command)`` prefix. That keeps the - common case terse and the collision case unambiguous. ``siblings`` must - include every hook entry declared under this source (including the one - whose identifier is being derived); the function decides on its own whether - a collision exists. - """ - base = f"{layer}:{source_id}:hook:{event_name}:{command}" - if _has_hook_sibling_collision(event_name, command, siblings): - return f"{base}:{hook_discriminator(own_declared_fields)}" - return base + """Build the identifier string for a hook contribution.""" + return f"{layer}:{source_id}:hook:{event_name}:{command}" diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 0cae2dc296..24c9322d80 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -792,46 +792,30 @@ def iter_contributions(self) -> List[Dict[str, Any]]: ) 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 event_name, hook_config in (self.hooks or {}).items(): + deduped: Dict[str, dict] = {} 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) + if not isinstance(entry, dict): + continue + command_value = entry.get("command", "") + if command_value in deduped: + del deduped[command_value] + normalized = dict(entry) + normalized.setdefault("eventName", event_name) + deduped[command_value] = normalized + + for command_value, entry in deduped.items(): + enriched = dict(entry) + enriched.update( + layer="extension", + sourceId=source_id, + kind="hook", + name=f"{event_name}:{command_value}", + id=derive_hook_id( + "extension", source_id, event_name, command_value + ), + ) + contributions.append(enriched) return contributions diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py index 99b2e042db..815a3978e9 100644 --- a/tests/test_contribution_ids.py +++ b/tests/test_contribution_ids.py @@ -4,9 +4,9 @@ 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. +``layer x kind`` combination, hook deduplication, cross-process byte-stability, +path/mtime independence, and the additive-only shape guarantee for the +enriched contribution dicts. """ from __future__ import annotations @@ -27,10 +27,8 @@ from specify_cli._identifier import ( IdentifierComponentError, PROJECT_OVERRIDE_LAYER, - canonical_json, derive_hook_id, derive_named_id, - hook_discriminator, layer_kind_from_lookup_id, validate_component, ) @@ -143,11 +141,7 @@ def test_named_id_grammar(self, layer, source_id, kind, name, expected): ], ) 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 - ) + assert derive_hook_id(layer, source_id, event, command) == expected def test_named_id_stable_across_two_derivations(self): a = derive_named_id("preset", "speckit-core", "command", "speckit.plan") @@ -183,96 +177,30 @@ def test_unrecognized_or_malformed_returns_none(self, lookup_id): assert layer_kind_from_lookup_id(lookup_id) is None -# --------------------------------------------------------------------------- -# Canonical JSON -# --------------------------------------------------------------------------- - - -class TestCanonicalJson: - def test_sorts_mapping_keys_at_every_depth(self): - payload = {"z": 1, "a": {"y": 2, "x": [3, {"n": 4, "m": 5}]}} - assert canonical_json(payload) == b'{"a":{"x":[3,{"m":5,"n":4}],"y":2},"z":1}' - - def test_preserves_list_order(self): - assert canonical_json([3, 1, 2]) == b"[3,1,2]" - - def test_utf8_no_ensure_ascii(self): - assert canonical_json({"k": "café"}).decode("utf-8") == '{"k":"café"}' - - -# --------------------------------------------------------------------------- -# Hook discriminator behaviour -# --------------------------------------------------------------------------- - - -class TestHookDiscriminator: - def test_no_discriminator_when_unique(self, tmp_path): - data = _extension_data( - hooks={ - "before_specify": {"command": "speckit.speckitgit.branch"}, - } - ) - manifest = ExtensionManifest(_write_manifest(tmp_path, data, "extension.yml")) - hooks = [c for c in manifest.iter_contributions() if c["kind"] == "hook"] - assert len(hooks) == 1 - assert hooks[0]["id"] == "extension:speckit-git:hook:before_specify:speckit.speckitgit.branch" - - def test_discriminator_when_colliding(self, tmp_path): +class TestHookContributions: + def test_duplicate_commands_are_last_wins_and_move_to_end(self, tmp_path): data = _extension_data( hooks={ "before_plan": [ {"command": "speckit.speckitgit.branch", "priority": 10}, - {"command": "speckit.speckitgit.branch", "priority": 20}, + {"command": "speckit.speckitgit.status", "priority": 20}, + {"command": "speckit.speckitgit.branch", "priority": 30}, ] } ) manifest = ExtensionManifest(_write_manifest(tmp_path, data, "extension.yml")) hooks = [c for c in manifest.iter_contributions() if c["kind"] == "hook"] assert len(hooks) == 2 - 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}, + assert [(hook["command"], hook["priority"]) for hook in hooks] == [ + ("speckit.speckitgit.status", 20), + ("speckit.speckitgit.branch", 30), ] - 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") + assert hooks[-1]["id"] == ( + "extension:speckit-git:hook:before_plan:speckit.speckitgit.branch" ) - - ids_a = { - (h["command"], h.get("priority")): h["id"] - for h in manifest_a.iter_contributions() - if h["kind"] == "hook" - } - ids_b = { - (h["command"], h.get("priority")): h["id"] - for h in manifest_b.iter_contributions() - if h["kind"] == "hook" - } - assert ids_a == ids_b - - def test_hook_discriminator_helper_is_deterministic(self): - payload = {"priority": 10, "optional": True, "prompt": "Run?"} - a = hook_discriminator(payload) - b = hook_discriminator(dict(reversed(list(payload.items())))) - assert a == b - assert len(a) == 12 + assert manifest.contribution_id( + "hook", "before_plan:speckit.speckitgit.branch" + ) == hooks[-1]["id"] # --------------------------------------------------------------------------- From a2fc5866523936003bae890441410bf463f73585 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:28:00 +0000 Subject: [PATCH 051/113] docs: clarify hook identifier deduplication Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- extensions/EXTENSION-API-REFERENCE.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index e26b3efa9c..1e91f92b25 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -883,13 +883,9 @@ Hook contributions use a compound name-component built from the event and comman {layer}:{sourceId}:hook:{eventName}:{command} ``` -When two or more hook entries within the same source share the same `(eventName, command)` pair, a 12-hex-character discriminator is appended: - -```text -{layer}:{sourceId}:hook:{eventName}:{command}:{discriminator} -``` - -The discriminator is the first 12 lowercase hex characters of `sha256(canonical_json(entry - {eventName, command}))`. If two entries are byte-identical after removing `eventName` and `command`, they collapse under the existing per-event, per-command last-write-wins hook merge semantics. +Within a single event list, repeated `command` values collapse last-write-wins and +move to the end, so each surviving `(eventName, command)` pair has the same +identifier form above with no suffix. ### Reserved character From f9efe063af2948ed127a76da0b71b7dd1968aedc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:29:27 +0000 Subject: [PATCH 052/113] docs: remove hook discriminator references Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- docs/reference/presets.md | 2 +- extensions/EXTENSION-API-REFERENCE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/presets.md b/docs/reference/presets.md index 6f4a428908..3841579af2 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -222,7 +222,7 @@ Identifiers are computed on demand from author-declared manifest content and are `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. +For the full grammar, including the hook name-component convention and last-write-wins deduplication used by extensions, see the [Extension API Reference — Contribution Identifiers](../../extensions/EXTENSION-API-REFERENCE.md#contribution-identifiers) section. ## FAQ diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index 1e91f92b25..8a72486921 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -907,7 +907,7 @@ Manifest contribution identifier derivation reads only the in-memory declared ma ### Opacity guidance -Identifiers are stable, but treat them as **opaque strings** in stored data (registries, cache files, external tooling). Do not parse them by string-splitting on `:` — the discriminator suffix and future grammar extensions may otherwise catch you out. If you only need to classify a stack entry's layer, use `layer_kind_from_lookup_id`; `derive_named_id` and `derive_hook_id` construct new identifiers rather than parsing existing ones. +Identifiers are stable, but treat them as **opaque strings** in stored data (registries, cache files, external tooling). Do not parse them by string-splitting on `:` — hook ids contain a compound `{eventName}:{command}` component and future grammar extensions may otherwise catch you out. If you only need to classify a stack entry's layer, use `layer_kind_from_lookup_id`; `derive_named_id` and `derive_hook_id` construct new identifiers rather than parsing existing ones. From 9ae2682391b9c6f9cec5a43cbde36e86b1ae33b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:30:49 +0000 Subject: [PATCH 053/113] style: space identifier declarations Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/_identifier.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py index bcd0eb9d99..adc350bdc4 100644 --- a/src/specify_cli/_identifier.py +++ b/src/specify_cli/_identifier.py @@ -46,6 +46,7 @@ is the correct outcome for a layer with no originating manifest entry. """ + class IdentifierComponentError(ValueError): """Raised when a manifest component would break identifier grammar.""" From 7e0e7dc6573ec6786d36ba99dd5907e71b2c4fe6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:15:27 +0000 Subject: [PATCH 054/113] fix: address unresolved review feedback on PR #4305 - Validate preset-registry corruption in artifact catalog (fail closed with ArtifactResolutionError, mirroring the extension-registry check) and add ``PresetRegistry.is_corrupt`` in the shape of ``ExtensionRegistry.is_corrupt``. - ``_locate_core_asset_dir`` now falls through to the source checkout when a wheel bundle is present but missing the requested family subdirectory, matching the "wheel, then source" fallback pattern used by the sibling bundled-extension/workflow/preset resolvers. - Enforce the identifier component contract at the shared derivation boundary: ``derive_named_id`` / ``derive_hook_id`` now revalidate every input via ``validate_component`` so raw filesystem-derived names cannot produce non-round-trippable lookup ids. - Overwrite ``eventName`` in hook contributions from the containing hook key instead of ``setdefault`` so author-supplied fields cannot contradict the derived ``name`` / ``id`` metadata. - Manifest-declared preset and extension resolver layers now use the manifest's validated ``id:`` for the ``lookupId`` ``sourceId`` component, so the join to ``iter_contributions()`` stays direct when the installed directory was renamed. Convention-only contributions still fall back to the directory / registry key; directory identity is retained on the layer via ``source`` / ``extension_id`` / ``extension_dir``. - Artifact catalog reuses the manifest's own contribution ``id`` verbatim when yielding declared contributions so it stays consistent with the resolver. - Docs: clarify in ``docs/reference/presets.md`` and ``extensions/EXTENSION-API-REFERENCE.md`` that manifest contribution ``id`` and resolver ``lookupId`` share the same grammar but only join directly when the installed directory matches the manifest-declared ``id:``. - Restore the ``## File System Layout`` heading before the ``.specify/`` tree in ``extensions/EXTENSION-API-REFERENCE.md`` and add it to the ToC. - Use one consistent import style for ``specify_cli._assets`` in ``tests/test_assets.py`` (module import only) and update the existing test-suite entries whose behavior was locked to the resolver's old directory-key ``lookupId``. Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- docs/reference/presets.md | 2 +- extensions/EXTENSION-API-REFERENCE.md | 5 +- src/specify_cli/_assets.py | 8 ++- src/specify_cli/_identifier.py | 27 +++++++-- src/specify_cli/artifacts/__init__.py | 32 +++++++++- src/specify_cli/extensions/__init__.py | 8 ++- src/specify_cli/presets/__init__.py | 84 +++++++++++++++++++++++++- tests/test_artifact_command.py | 25 ++++++-- tests/test_artifact_command_parity.py | 9 ++- tests/test_assets.py | 33 +++++++--- 10 files changed, 206 insertions(+), 27 deletions(-) diff --git a/docs/reference/presets.md b/docs/reference/presets.md index 3841579af2..3ce8991d65 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -220,7 +220,7 @@ Every command, template, and script contributed by a preset (or an extension, or 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. +`PresetResolver.collect_all_layers()` returns layer dicts that each include a `lookupId` field that identifies the layer. For manifest-declared preset and extension layers, the `lookupId`'s `sourceId` component is the resolver's registry key or directory name, which can differ from the `id` used by `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` (the manifest-declared `id:` field). Both values follow the same grammar, but they only join directly when the installed directory name matches the manifest-declared id — so consumers should treat `lookupId` as the resolver's stack identity and use `layer_kind_from_lookup_id` / the manifest APIs when they need to reason about the originating contribution. 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 last-write-wins deduplication used by extensions, see the [Extension API Reference — Contribution Identifiers](../../extensions/EXTENSION-API-REFERENCE.md#contribution-identifiers) section. diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index 8a72486921..9a28284d96 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -11,6 +11,7 @@ Technical reference for Spec Kit extension system APIs and manifest schema. 5. [Hook System](#hook-system) 6. [CLI Commands](#cli-commands) 7. [Contribution Identifiers](#contribution-identifiers) +8. [File System Layout](#file-system-layout) --- @@ -862,7 +863,7 @@ satisfied = version_satisfies("1.2.3", ">=1.0.0,<2.0.0") # bool ## Contribution Identifiers -Every command, template, script, and hook contributed by an extension (or 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. +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 shares this grammar and identifies the layer's stack position. Manifest contribution `id` values and resolver `lookupId` values are **not always equal** — they only join directly when the installed directory name matches the manifest-declared `id:` (see [Determinism guarantees](#determinism-guarantees) below). Identifiers are **computed on demand from author-declared manifest content** and are **never persisted** to `.specify/` or to any cache file. ### Grammar @@ -909,7 +910,7 @@ Manifest contribution identifier derivation reads only the in-memory declared ma Identifiers are stable, but treat them as **opaque strings** in stored data (registries, cache files, external tooling). Do not parse them by string-splitting on `:` — hook ids contain a compound `{eventName}:{command}` component and future grammar extensions may otherwise catch you out. If you only need to classify a stack entry's layer, use `layer_kind_from_lookup_id`; `derive_named_id` and `derive_hook_id` construct new identifiers rather than parsing existing ones. - +## File System Layout ```text .specify/ diff --git a/src/specify_cli/_assets.py b/src/specify_cli/_assets.py index e19fc9816a..c27d9d4f8e 100644 --- a/src/specify_cli/_assets.py +++ b/src/specify_cli/_assets.py @@ -50,7 +50,13 @@ def _locate_core_asset_dir(subdir: str) -> Path | None: core = _locate_core_pack() if core is not None: candidate = core / subdir - return candidate if candidate.is_dir() else None + if candidate.is_dir(): + return candidate + # Fall through to the source checkout — a wheel bundle with a + # missing family subdir is treated the same as no bundle at all, + # matching the "wheel, then source" fallback pattern used by + # ``_locate_bundled_extension``/``_locate_bundled_workflow``/ + # ``_locate_bundled_preset`` below. if subdir == "commands": candidate = _repo_root() / "templates" / "commands" else: diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py index adc350bdc4..0fdbcc2abc 100644 --- a/src/specify_cli/_identifier.py +++ b/src/specify_cli/_identifier.py @@ -78,11 +78,20 @@ def validate_component(value: Any, field_label: str) -> str: 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. + Each component is revalidated with :func:`validate_component` before the + join. Manifest-load-time validators generally validate ahead of the join, + but resolver callers can pass raw filesystem-derived names (POSIX permits + ``:`` in filenames the way manifest validators do not), and every layer + dict downstream relies on ``lookupId`` being a round-trippable string that + :func:`layer_kind_from_lookup_id` can parse — so this is the shared + derivation boundary that must enforce the grammar. Callers passing raw + strings should either pre-validate or handle + :class:`IdentifierComponentError`. """ + validate_component(layer, "layer") + validate_component(source_id, "sourceId") + validate_component(kind, "kind") + validate_component(name, "name") return f"{layer}:{source_id}:{kind}:{name}" @@ -128,5 +137,13 @@ def derive_hook_id( event_name: str, command: str, ) -> str: - """Build the identifier string for a hook contribution.""" + """Build the identifier string for a hook contribution. + + Each component is revalidated with :func:`validate_component` — same + contract as :func:`derive_named_id`. + """ + validate_component(layer, "layer") + validate_component(source_id, "sourceId") + validate_component(event_name, "eventName") + validate_component(command, "command") return f"{layer}:{source_id}:hook:{event_name}:{command}" diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 34f830ba48..67351b41f9 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -608,6 +608,26 @@ def _validate_extension_registry(project_root: Path) -> None: raise ArtifactResolutionError() +def _validate_preset_registry(project_root: Path) -> None: + """Fail closed when the preset registry is present but unreadable. + + ``PresetRegistry._load`` normalizes malformed JSON to an empty mapping so + install/enable/disable flows keep working, but that same recovery would + silently drop every installed preset from the artifact inventory. Callers + that treat the inventory as authoritative must therefore refuse to run + against a corrupt registry — same fail-closed contract as + :func:`_validate_extension_registry`. + """ + presets_dir = project_root / ".specify" / "presets" + if not presets_dir.exists(): + return + + from ..presets import PresetRegistry + + if PresetRegistry(presets_dir).is_corrupt(): + raise ArtifactResolutionError() + + def _resolve_kind_hint(name: str, kind: ArtifactKind | None) -> tuple[str, ArtifactKind | None]: """Parse ``kind:name`` shorthand and reconcile it with an explicit ``--kind`` flag. @@ -671,6 +691,7 @@ def list_artifacts(self) -> list[Artifact]: """ _validate_project(self.project_root) _validate_extension_registry(self.project_root) + _validate_preset_registry(self.project_root) baseline = self._get_baseline() from ..presets import PresetResolver # lazy: avoids circular import @@ -743,6 +764,7 @@ def get_artifact_info( """ _validate_project(self.project_root) _validate_extension_registry(self.project_root) + _validate_preset_registry(self.project_root) bare, resolved_kind = _resolve_kind_hint(name, kind) if resolved_kind is None: @@ -898,7 +920,15 @@ def _iter_pack_contributions( description = contribution.get("description", "") if not isinstance(description, str): description = "" - lookup_id = derive_named_id(layer, source_id, kind, name) + # Use the manifest-computed id verbatim so the join with + # ``collect_all_layers()`` stays direct even when the + # installed directory (``source_id``) differs from the + # manifest's declared ``id:`` (renamed pack). The resolver's + # manifest-declared preset/extension layers derive their + # ``lookupId`` from ``manifest.id`` for the same reason. + 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, lookup_id diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 24c9322d80..ff4270c474 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -801,7 +801,13 @@ def iter_contributions(self) -> List[Dict[str, Any]]: if command_value in deduped: del deduped[command_value] normalized = dict(entry) - normalized.setdefault("eventName", event_name) + # Overwrite (not setdefault) so an author-supplied + # ``eventName`` cannot contradict the containing hook key — + # otherwise an entry under ``before_plan`` carrying + # ``eventName: after_plan`` would be emitted with metadata + # that disagrees with its ``name`` and ``id`` (both of which + # derive from the hook key below). + normalized["eventName"] = event_name deduped[command_value] = normalized for command_value, entry in deduped.items(): diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 29416abb0b..7e9c3cbf7a 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -639,6 +639,42 @@ def _save(self): with open(self.registry_path, 'w', encoding='utf-8') as f: json.dump(self.data, f, indent=2) + def is_corrupt(self) -> bool: + """Report whether an existing registry file is present but unreadable. + + ``_load`` deliberately recovers from a corrupt registry by normalizing + it to an empty mapping so install/enable/disable flows keep working. + Resolution paths (e.g. the artifact catalog), however, must fail + closed: a corrupt registry that normalizes to ``{}`` would otherwise + cause every installed preset to be silently dropped from the reported + inventory. This probe lets those callers distinguish "no registry" + (safe) from "registry exists but is invalid" (unsafe) without changing + recovery behavior. An absent registry returns ``False``; a directory, + broken or dangling symlink, non-regular file, unreadable file, + non-mapping root, or non-mapping ``presets`` value returns ``True``. + + Mirrors :meth:`ExtensionRegistry.is_corrupt` — the two registries have + the same corruption model, so both surfaces (artifact catalog, + extension enumeration) can share the same fail-closed pattern. + """ + # os.path.lexists (not Path.exists) so a dangling symlink is detected + # rather than followed to a non-existent target and mistaken for an + # absent registry. + if not os.path.lexists(self.registry_path): + return False + if not self.registry_path.is_file(): + return True + try: + with open(self.registry_path, "r", encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError, UnicodeDecodeError): + return True + if not isinstance(data, dict): + return True + if "presets" in data and not isinstance(data["presets"], dict): + return True + return False + def add(self, pack_id: str, metadata: dict): """Add preset to registry. @@ -5653,12 +5689,25 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: # strategy ("replace") when content is unreadable/invalid. pass version = metadata.get("version", "?") if metadata else "?" + # Manifest-declared entries derive their sourceId from the + # manifest's validated ``id:``, so ``lookupId`` joins + # directly to ``PresetManifest.iter_contributions()``'s + # ``id`` even when the installed directory (``pack_id``) + # was renamed. Convention-only contributions have no + # manifest to consult, so they fall back to the directory + # / registry key. The directory identity is still carried + # separately via ``source`` for path/provenance display. + source_id_for_lookup = pack_id + if entry is not None: + manifest = self._get_manifest(pack_dir) + if manifest is not None and isinstance(manifest.id, str) and manifest.id: + source_id_for_lookup = manifest.id layers.append({ "path": candidate, "source": f"{pack_id} v{version}", "strategy": strategy, "lookupId": derive_named_id( - "preset", pack_id, template_type, template_name + "preset", source_id_for_lookup, template_type, template_name ), }) @@ -5682,6 +5731,37 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: source = f"extension:{ext_id} v{version}" else: source = f"extension:{ext_id} (unregistered)" + # Manifest-declared entries use the manifest's validated ``id:`` + # for the lookupId's sourceId, so ``lookupId`` joins directly to + # ``ExtensionManifest.iter_contributions()``'s ``id`` even when + # the installed directory (``ext_id``) was renamed. Convention- + # only contributions have no manifest to consult and fall back + # to the directory identity. The directory identity is retained + # separately via ``extension_id`` / ``extension_dir`` for path + # / provenance lookup. + source_id_for_lookup = ext_id + if entry is not None: + ext_manifest_path = ext_dir / "extension.yml" + if ext_manifest_path.is_file(): + try: + from ..extensions import ( + ExtensionManifest, + ValidationError as ExtValidationError, + ) + ext_manifest = ExtensionManifest(ext_manifest_path) + if isinstance(ext_manifest.id, str) and ext_manifest.id: + source_id_for_lookup = ext_manifest.id + except ( + ExtValidationError, + yaml.YAMLError, + OSError, + TypeError, + AttributeError, + ): + # Fall back to the directory identity when the + # manifest can't be re-read — same recovery as + # ``_extension_manifest_declared_template``. + pass layers.append({ "path": candidate, "source": source, @@ -5689,7 +5769,7 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: "extension_id": ext_id, "extension_dir": ext_dir, "lookupId": derive_named_id( - "extension", ext_id, template_type, template_name + "extension", source_id_for_lookup, template_type, template_name ), }) diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 97f8790706..11212e23c4 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -174,12 +174,12 @@ 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): + def test_unregistered_extension_manifest_id_wins_for_lookup(self, spec_kit_project: Path): ext_dir = spec_kit_project / ".specify" / "extensions" / "renamed" ext_dir.mkdir() (ext_dir / "commands").mkdir() (ext_dir / "commands" / "actual.md").write_text( - "---\ndescription: Dir identity wins\n---\nbody\n", + "---\ndescription: Manifest identity wins\n---\nbody\n", encoding="utf-8", ) (ext_dir / "extension.yml").write_text( @@ -215,11 +215,14 @@ def test_unregistered_extension_uses_directory_id_for_lookup(self, spec_kit_proj 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" + # Manifest-declared entries use ``extension.id`` for the ``lookupId`` + # so the join to ``ExtensionManifest.iter_contributions()`` stays + # direct even when the installed directory (``renamed``) was renamed. + assert info["stack"][0]["lookupId"] == "extension:original:command:speckit.original.hello" assert ( PresetResolver(spec_kit_project) .collect_all_layers("speckit.original.hello", "command")[0]["lookupId"] - == "extension:renamed:command:speckit.original.hello" + == "extension:original:command:speckit.original.hello" ) def test_includes_project_local_core_assets(self, spec_kit_project: Path): @@ -516,6 +519,20 @@ def test_info_rejects_corrupt_extension_registry(self, spec_kit_project: Path): with pytest.raises(ArtifactResolutionError): ArtifactCatalog(spec_kit_project).get_artifact_info("command:speckit.constitution") + def test_info_rejects_corrupt_preset_registry(self, spec_kit_project: Path): + registry = spec_kit_project / ".specify" / "presets" / ".registry" + registry.write_text("{invalid", encoding="utf-8") + + with pytest.raises(ArtifactResolutionError): + ArtifactCatalog(spec_kit_project).get_artifact_info("command:speckit.constitution") + + def test_list_rejects_corrupt_preset_registry(self, spec_kit_project: Path): + registry = spec_kit_project / ".specify" / "presets" / ".registry" + registry.write_text("{invalid", encoding="utf-8") + + with pytest.raises(ArtifactResolutionError): + ArtifactCatalog(spec_kit_project).list_artifacts() + class TestKindHint: def test_kind_flag_disambiguates(self, spec_kit_project: Path): diff --git a/tests/test_artifact_command_parity.py b/tests/test_artifact_command_parity.py index e06f2a8bb2..078643eed3 100644 --- a/tests/test_artifact_command_parity.py +++ b/tests/test_artifact_command_parity.py @@ -62,7 +62,7 @@ def test_manifest_declared_artifact_matches_resolver(self, spec_kit_project: Pat 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): + def test_preset_manifest_id_mismatch_uses_manifest_id(self, spec_kit_project: Path): pack = install_preset( spec_kit_project, "renamed-preset", @@ -96,13 +96,16 @@ def test_preset_manifest_id_mismatch_uses_registry_key(self, spec_kit_project: P ) assert winner == "body-from-renamed-preset" + # Manifest-declared entries use the manifest's validated id, so the + # ``lookupId`` joins directly to ``PresetManifest.iter_contributions()`` + # regardless of the installed directory name. assert active["lookupId"] == ( - "preset:renamed-preset:command:speckit.preset-renamed.hello" + "preset:original-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" + == "preset:original-preset:command:speckit.preset-renamed.hello" ) diff --git a/tests/test_assets.py b/tests/test_assets.py index b8149272d7..7726c8a310 100644 --- a/tests/test_assets.py +++ b/tests/test_assets.py @@ -3,7 +3,6 @@ from __future__ import annotations import specify_cli._assets as assets -from specify_cli._assets import _locate_core_asset_dir class TestLocateCoreAssetDir: @@ -20,7 +19,7 @@ def test_prefers_wheel_core_pack_over_repo_checkout(self, tmp_path, monkeypatch) 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" + assert assets._locate_core_asset_dir("commands") == core_pack / "commands" def test_falls_back_to_repo_checkout_when_no_wheel_bundle(self, tmp_path, monkeypatch): repo_root = tmp_path / "repo" @@ -31,18 +30,38 @@ def test_falls_back_to_repo_checkout_when_no_wheel_bundle(self, tmp_path, monkey 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" + assert assets._locate_core_asset_dir("commands") == repo_root / "templates" / "commands" + assert assets._locate_core_asset_dir("templates") == repo_root / "templates" + assert assets._locate_core_asset_dir("scripts") == repo_root / "scripts" def test_returns_none_when_directory_missing(self, tmp_path, monkeypatch): monkeypatch.setattr(assets, "_locate_core_pack", lambda: None) monkeypatch.setattr(assets, "_repo_root", lambda: tmp_path / "nonexistent") - assert _locate_core_asset_dir("commands") is None + assert assets._locate_core_asset_dir("commands") is None def test_returns_none_for_unknown_subdir(self, tmp_path, monkeypatch): monkeypatch.setattr(assets, "_locate_core_pack", lambda: None) monkeypatch.setattr(assets, "_repo_root", lambda: tmp_path) - assert _locate_core_asset_dir("bogus") is None + assert assets._locate_core_asset_dir("bogus") is None + + def test_falls_back_to_repo_checkout_when_wheel_bundle_missing_subdir( + self, tmp_path, monkeypatch + ): + """A wheel bundle without the requested family subdir must not short-circuit + the source-checkout fallback, matching the "wheel, then source" pattern + used by ``_locate_bundled_extension``/``_locate_bundled_workflow``/ + ``_locate_bundled_preset``.""" + core_pack = tmp_path / "core_pack" + core_pack.mkdir() # bundle exists but has no "commands/" subdir + 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 ( + assets._locate_core_asset_dir("commands") + == repo_root / "templates" / "commands" + ) From e1a63ceddd55f633505c7a044d03380db0b3168e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:20:20 +0000 Subject: [PATCH 055/113] fix: keep on-disk preset/extension identity separate from lookupId The resolver now emits manifest.id in the ``lookupId``'s ``sourceId`` component for manifest-declared preset and extension layers, so code that had been extracting the on-disk directory name from ``lookupId`` (in ``_derive_manifest_path`` and ``_build_stack``) now points to the wrong path when the installed directory was renamed. Carry the directory identity as explicit ``preset_id`` / ``pack_dir`` keys on preset layer dicts (extension layers already carried ``extension_id`` / ``extension_dir``). Update ``_derive_manifest_path`` and ``_build_stack`` to prefer those explicit keys before falling back to ``lookupId`` parsing, so the display name and manifest path in the stack row keep tracking the actual on-disk directory. Extend the mismatch tests to lock down that ``presetId`` and ``manifestPath`` point to the renamed on-disk directory even when ``lookupId`` uses the manifest id. 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 | 59 +++++++++++++++++++-------- src/specify_cli/presets/__init__.py | 5 ++- tests/test_artifact_command.py | 7 ++++ tests/test_artifact_command_parity.py | 6 +++ 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 67351b41f9..903b2e7fe4 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -407,13 +407,13 @@ def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | No 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. + Since the resolver may set the ``lookupId``'s ``sourceId`` component to + the manifest-declared ``id:`` (which can differ from the on-disk directory + name for renamed packs), the on-disk directory is read from the layer's + explicit ``preset_id`` / ``pack_dir`` (preset layers) or + ``extension_id`` / ``extension_dir`` (extension layers) keys, falling + back to the ``lookupId`` ``sourceId`` only when those explicit keys are + absent. Uses ``as_posix()`` so the string is stable across Windows and POSIX — a caller comparing snapshots between operating systems gets the same value @@ -421,16 +421,34 @@ def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | No """ lookup_id = layer.get("lookupId", "") layer_kind = layer_kind_from_lookup_id(lookup_id) - if layer_kind not in ("preset", "extension"): + if layer_kind == "preset": + pack_dir = layer.get("pack_dir") + if isinstance(pack_dir, Path): + manifest_path = pack_dir / "preset.yml" + if not manifest_path.is_file(): + return None + try: + return manifest_path.relative_to(project_root).as_posix() + except ValueError: + return None + pack_id = layer.get("preset_id") or _extract_lookup_pack_id(lookup_id) + tier_dir, manifest_name = "presets", "preset.yml" + elif layer_kind == "extension": + ext_dir = layer.get("extension_dir") + if isinstance(ext_dir, Path): + manifest_path = ext_dir / "extension.yml" + if not manifest_path.is_file(): + return None + try: + return manifest_path.relative_to(project_root).as_posix() + except ValueError: + return None + pack_id = layer.get("extension_id") or _extract_lookup_pack_id(lookup_id) + tier_dir, manifest_name = "extensions", "extension.yml" + else: return None - pack_id = _extract_lookup_pack_id(lookup_id) if not pack_id: return None - tier_dir, manifest_name = ( - ("presets", "preset.yml") - if layer_kind == "preset" - else ("extensions", "extension.yml") - ) manifest_path = project_root / ".specify" / tier_dir / pack_id / manifest_name if not manifest_path.is_file(): return None @@ -560,8 +578,17 @@ def _build_stack( ) continue - pack_id = _extract_lookup_pack_id(lookup_id) or "" - pack_dir = project_root / ".specify" / "presets" / pack_id + # Preset layers carry the on-disk directory identity separately from + # ``lookupId`` (which may use the manifest-declared ``id:``): prefer + # the explicit ``preset_id`` / ``pack_dir`` keys before falling back + # to parsing ``lookupId``, so a renamed pack still resolves to the + # right on-disk directory for display-name and manifest-path lookup. + pack_id = layer.get("preset_id") or _extract_lookup_pack_id(lookup_id) or "" + pack_dir_layer = layer.get("pack_dir") + if isinstance(pack_dir_layer, Path): + pack_dir = pack_dir_layer + else: + pack_dir = project_root / ".specify" / "presets" / pack_id display = _preset_display_name(pack_dir, pack_id) if pack_id else pack_id manifest_path = _derive_manifest_path(layer, project_root) rows.append( diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 7e9c3cbf7a..094ee9d200 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -5696,7 +5696,8 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: # was renamed. Convention-only contributions have no # manifest to consult, so they fall back to the directory # / registry key. The directory identity is still carried - # separately via ``source`` for path/provenance display. + # separately via ``preset_id`` / ``pack_dir`` / ``source`` + # for on-disk path lookup and provenance display. source_id_for_lookup = pack_id if entry is not None: manifest = self._get_manifest(pack_dir) @@ -5706,6 +5707,8 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: "path": candidate, "source": f"{pack_id} v{version}", "strategy": strategy, + "preset_id": pack_id, + "pack_dir": pack_dir, "lookupId": derive_named_id( "preset", source_id_for_lookup, template_type, template_name ), diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 11212e23c4..ef252e4884 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -224,6 +224,13 @@ def test_unregistered_extension_manifest_id_wins_for_lookup(self, spec_kit_proje .collect_all_layers("speckit.original.hello", "command")[0]["lookupId"] == "extension:original:command:speckit.original.hello" ) + # The stack row's manifestPath must still reflect the actual on-disk + # extension directory (``renamed``), not the manifest id embedded in + # ``lookupId``. + assert ( + info["stack"][0]["manifestPath"] + == ".specify/extensions/renamed/extension.yml" + ) def test_includes_project_local_core_assets(self, spec_kit_project: Path): templates_dir = spec_kit_project / ".specify" / "templates" diff --git a/tests/test_artifact_command_parity.py b/tests/test_artifact_command_parity.py index 078643eed3..be2ba80d91 100644 --- a/tests/test_artifact_command_parity.py +++ b/tests/test_artifact_command_parity.py @@ -107,6 +107,12 @@ def test_preset_manifest_id_mismatch_uses_manifest_id(self, spec_kit_project: Pa .collect_all_layers("speckit.preset-renamed.hello", "command")[0]["lookupId"] == "preset:original-preset:command:speckit.preset-renamed.hello" ) + # The stack row's presetId / manifestPath must still reflect the + # actual on-disk directory (``renamed-preset``), not the manifest id + # embedded in ``lookupId`` — otherwise the display and manifest path + # would point to a non-existent location. + assert active["presetId"] == "renamed-preset" + assert active["manifestPath"] == ".specify/presets/renamed-preset/preset.yml" def test_module_imports(): From fd2b268fa1074bb234294963cd71e299ee0e8219 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:39:51 +0000 Subject: [PATCH 056/113] fix: remove stale lookupId parsing fallback and tighten malformed lookupId validation Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- docs/reference/presets.md | 2 +- extensions/EXTENSION-API-REFERENCE.md | 6 +-- src/specify_cli/_identifier.py | 18 ++++++- src/specify_cli/artifacts/__init__.py | 73 ++++++++++----------------- tests/test_artifact_command.py | 43 ++++++++++++++++ tests/test_contribution_ids.py | 9 ++++ 6 files changed, 100 insertions(+), 51 deletions(-) diff --git a/docs/reference/presets.md b/docs/reference/presets.md index 3ce8991d65..56b6e1ee4b 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -220,7 +220,7 @@ Every command, template, and script contributed by a preset (or an extension, or 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 that identifies the layer. For manifest-declared preset and extension layers, the `lookupId`'s `sourceId` component is the resolver's registry key or directory name, which can differ from the `id` used by `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` (the manifest-declared `id:` field). Both values follow the same grammar, but they only join directly when the installed directory name matches the manifest-declared id — so consumers should treat `lookupId` as the resolver's stack identity and use `layer_kind_from_lookup_id` / the manifest APIs when they need to reason about the originating contribution. 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. +`PresetResolver.collect_all_layers()` returns layer dicts that each include a `lookupId` field that identifies the layer. For manifest-declared preset and extension layers, the `lookupId`'s `sourceId` component is the manifest's validated `id:` field, so it joins directly to the `id` used by `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` even when the installed directory was renamed. Convention-only layers (undeclared in any manifest) have no manifest id to consult, so their `lookupId`'s `sourceId` falls back to the resolver's registry key or on-disk directory name instead — consumers should treat `lookupId` as the resolver's stack identity and use `layer_kind_from_lookup_id` / the manifest APIs when they need to reason about the originating contribution. 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 last-write-wins deduplication used by extensions, see the [Extension API Reference — Contribution Identifiers](../../extensions/EXTENSION-API-REFERENCE.md#contribution-identifiers) section. diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index 9a28284d96..c4be28a193 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -863,7 +863,7 @@ satisfied = version_satisfies("1.2.3", ">=1.0.0,<2.0.0") # bool ## Contribution Identifiers -Every command, template, script, and hook contributed by an extension (or 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 shares this grammar and identifies the layer's stack position. Manifest contribution `id` values and resolver `lookupId` values are **not always equal** — they only join directly when the installed directory name matches the manifest-declared `id:` (see [Determinism guarantees](#determinism-guarantees) below). Identifiers are **computed on demand from author-declared manifest content** and are **never persisted** to `.specify/` or to any cache file. +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 shares this grammar and identifies the layer's stack position. Manifest-declared preset and extension layers use the manifest's validated `id:` for `lookupId`'s `sourceId` component, so their `lookupId` joins directly to the matching `iter_contributions()` entry even after the installed directory is renamed; convention-only contributions have no manifest `id:` to consult and fall back to the on-disk directory / registry key instead (see [Determinism guarantees](#determinism-guarantees) below). Identifiers are **computed on demand from author-declared manifest content** and are **never persisted** to `.specify/` or to any cache file. ### Grammar @@ -900,11 +900,11 @@ 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). 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()`. +`PresetResolver.collect_all_layers()` returns layer dicts that include a `lookupId` field for every layer type (`project override`, preset, extension, core, and bundled core). Manifest-declared preset and extension layers use the manifest's validated `id:` as the `lookupId` source id, so it matches the id `iter_contributions()` yields for that same contribution. Convention-only layers (no manifest entry declares the contribution) have no manifest id to consult, so their `lookupId` falls back to the resolver's registry key or on-disk directory name. ### Determinism guarantees -Manifest contribution identifier derivation reads only the in-memory declared manifest content. No filesystem paths, no `os.environ`, no timestamps, and no file-content hashes contribute to those manifest ids. Copying an extension or preset to a different machine (or touching its files) does not change the identifiers it produces. Resolver `lookupId` values are stack identifiers, not manifest contribution ids: for example, an unregistered extension's directory name is the resolver source id, so renaming that directory changes its `lookupId`. +Manifest contribution identifier derivation reads only the in-memory declared manifest content. No filesystem paths, no `os.environ`, no timestamps, and no file-content hashes contribute to those manifest ids. Copying an extension or preset to a different machine (or touching its files) does not change the identifiers it produces. Manifest-declared resolver `lookupId` values share this stability — renaming the installed directory of a preset or extension that declares an `id:` does not change its `lookupId`. Only convention-only contributions (undeclared in any manifest) derive their `lookupId` from the on-disk directory name or registry key, so renaming that directory does change their `lookupId`. ### Opacity guidance diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py index 0fdbcc2abc..0bda1c2480 100644 --- a/src/specify_cli/_identifier.py +++ b/src/specify_cli/_identifier.py @@ -108,9 +108,23 @@ def layer_kind_from_lookup_id(lookup_id: str) -> str | None: This is the single place that knows the set of valid layer prefixes, so consumers can classify a lookupId without re-deriving the grammar via string-prefix checks of their own. + + Validates the complete shape, not just the presence of a layer prefix: + named contributions require exactly the four ``{layer}:{sourceId}:{kind}: + {name}`` components, and hook contributions require exactly the five + ``{layer}:{sourceId}:hook:{eventName}:{command}`` components, with every + component non-empty. A value such as ``"core:not-an-id"`` or ``"preset:x"`` + has a recognized layer prefix but the wrong number of components, so it is + malformed and returns ``None`` rather than being treated as authoritative. """ - layer, _, rest = lookup_id.partition(":") - if not rest or layer not in _LAYER_KINDS: + parts = lookup_id.split(":") + if len(parts) < 4 or any(not part for part in parts): + return None + layer = parts[0] + if layer not in _LAYER_KINDS: + return None + expected_len = 5 if parts[2] == "hook" else 4 + if len(parts) != expected_len: return None return layer diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 903b2e7fe4..2fc4750242 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -407,13 +407,14 @@ def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | No Only ``preset`` and ``extension`` layers have an on-disk manifest — core and project-override layers return ``None``. - Since the resolver may set the ``lookupId``'s ``sourceId`` component to - the manifest-declared ``id:`` (which can differ from the on-disk directory - name for renamed packs), the on-disk directory is read from the layer's - explicit ``preset_id`` / ``pack_dir`` (preset layers) or - ``extension_id`` / ``extension_dir`` (extension layers) keys, falling - back to the ``lookupId`` ``sourceId`` only when those explicit keys are - absent. + The resolver may set the ``lookupId``'s ``sourceId`` component to the + manifest-declared ``id:`` (which can differ from the on-disk directory + name for renamed packs), so ``lookupId`` is never parsed for the on-disk + directory here. The on-disk directory identity is read exclusively from + the layer's explicit provenance keys — ``preset_id`` / ``pack_dir`` for + preset layers, ``extension_id`` / ``extension_dir`` for extension + layers — which ``collect_all_layers()`` always sets alongside + ``lookupId``. Missing provenance keys mean no manifest path is available. Uses ``as_posix()`` so the string is stable across Windows and POSIX — a caller comparing snapshots between operating systems gets the same value @@ -423,36 +424,26 @@ def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | No layer_kind = layer_kind_from_lookup_id(lookup_id) if layer_kind == "preset": pack_dir = layer.get("pack_dir") - if isinstance(pack_dir, Path): - manifest_path = pack_dir / "preset.yml" - if not manifest_path.is_file(): - return None - try: - return manifest_path.relative_to(project_root).as_posix() - except ValueError: - return None - pack_id = layer.get("preset_id") or _extract_lookup_pack_id(lookup_id) + pack_id = layer.get("preset_id") tier_dir, manifest_name = "presets", "preset.yml" elif layer_kind == "extension": - ext_dir = layer.get("extension_dir") - if isinstance(ext_dir, Path): - manifest_path = ext_dir / "extension.yml" - if not manifest_path.is_file(): - return None - try: - return manifest_path.relative_to(project_root).as_posix() - except ValueError: - return None - pack_id = layer.get("extension_id") or _extract_lookup_pack_id(lookup_id) + pack_dir = layer.get("extension_dir") + pack_id = layer.get("extension_id") tier_dir, manifest_name = "extensions", "extension.yml" else: return None - if not pack_id: + if isinstance(pack_dir, Path): + manifest_path = pack_dir / manifest_name + elif pack_id: + manifest_path = project_root / ".specify" / tier_dir / pack_id / manifest_name + else: return None - manifest_path = project_root / ".specify" / tier_dir / pack_id / manifest_name if not manifest_path.is_file(): return None - return manifest_path.relative_to(project_root).as_posix() + try: + return manifest_path.relative_to(project_root).as_posix() + except ValueError: + return None def _preset_display_name(pack_dir: Path, pack_id: str) -> str: @@ -475,14 +466,6 @@ class ``PresetManager.list_installed()`` and ``specify preset list`` use — 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, @@ -579,11 +562,12 @@ def _build_stack( continue # Preset layers carry the on-disk directory identity separately from - # ``lookupId`` (which may use the manifest-declared ``id:``): prefer - # the explicit ``preset_id`` / ``pack_dir`` keys before falling back - # to parsing ``lookupId``, so a renamed pack still resolves to the - # right on-disk directory for display-name and manifest-path lookup. - pack_id = layer.get("preset_id") or _extract_lookup_pack_id(lookup_id) or "" + # ``lookupId`` (which may use the manifest-declared ``id:``): use the + # explicit ``preset_id`` / ``pack_dir`` keys ``collect_all_layers()`` + # always sets, never ``lookupId`` parsing, so a renamed pack still + # resolves to the right on-disk directory for display-name and + # manifest-path lookup. + pack_id = layer.get("preset_id") or "" pack_dir_layer = layer.get("pack_dir") if isinstance(pack_dir_layer, Path): pack_dir = pack_dir_layer @@ -994,9 +978,8 @@ def _iter_project_override_artifacts( continue command_layers = resolver.collect_all_layers(name, "command") backed_by_command = any( - not str(layer.get("lookupId", "")).startswith( - f"{PROJECT_OVERRIDE_LAYER}:" - ) + layer_kind_from_lookup_id(str(layer.get("lookupId", ""))) + != PROJECT_OVERRIDE_LAYER for layer in command_layers ) is_command = backed_by_command or is_dotted_command_name(name) diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index ef252e4884..5dfc97a4e5 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -934,6 +934,8 @@ def test_preset_manifest_path_is_repo_relative(self, tmp_path: Path): layer = { "lookupId": "preset:my-pack:template:spec-template", "path": pack_dir / "spec-template.md", + "preset_id": "my-pack", + "pack_dir": pack_dir, } assert ( _derive_manifest_path(layer, project_root) @@ -949,17 +951,58 @@ def test_extension_manifest_path_is_repo_relative(self, tmp_path: Path): layer = { "lookupId": "extension:my-ext:command:speckit.my-ext.go", "path": ext_dir / "commands" / "speckit.my-ext.go.md", + "extension_id": "my-ext", + "extension_dir": ext_dir, } assert ( _derive_manifest_path(layer, project_root) == ".specify/extensions/my-ext/extension.yml" ) + def test_renamed_pack_directory_wins_over_lookup_id_source(self, tmp_path: Path): + """The manifest path must track the on-disk directory, never a stale + directory guessed from ``lookupId``'s manifest-declared ``sourceId``.""" + project_root = tmp_path / "proj" + pack_dir = project_root / ".specify" / "presets" / "renamed-on-disk" + pack_dir.mkdir(parents=True) + (pack_dir / "preset.yml").write_text("id: original-manifest-id\n", encoding="utf-8") + # A stale directory matching the manifest id must not exist, so a + # lookupId-based guess would resolve to a nonexistent manifest. + stale_dir = project_root / ".specify" / "presets" / "original-manifest-id" + assert not stale_dir.exists() + + layer = { + "lookupId": "preset:original-manifest-id:template:spec-template", + "path": pack_dir / "spec-template.md", + "preset_id": "renamed-on-disk", + "pack_dir": pack_dir, + } + assert ( + _derive_manifest_path(layer, project_root) + == ".specify/presets/renamed-on-disk/preset.yml" + ) + def test_missing_manifest_file_is_none(self, tmp_path: Path): project_root = tmp_path / "proj" pack_dir = project_root / ".specify" / "presets" / "my-pack" pack_dir.mkdir(parents=True) + layer = { + "lookupId": "preset:my-pack:template:spec-template", + "path": pack_dir / "spec-template.md", + "preset_id": "my-pack", + "pack_dir": pack_dir, + } + assert _derive_manifest_path(layer, project_root) is None + + def test_missing_provenance_keys_is_none(self, tmp_path: Path): + """Without explicit ``preset_id``/``pack_dir``, no path is guessed from + ``lookupId`` — the caller gets ``None`` instead of a wrong path.""" + project_root = tmp_path / "proj" + pack_dir = project_root / ".specify" / "presets" / "my-pack" + pack_dir.mkdir(parents=True) + (pack_dir / "preset.yml").write_text("id: my-pack\n", encoding="utf-8") + layer = { "lookupId": "preset:my-pack:template:spec-template", "path": pack_dir / "spec-template.md", diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py index 815a3978e9..fcfc9639c4 100644 --- a/tests/test_contribution_ids.py +++ b/tests/test_contribution_ids.py @@ -159,6 +159,10 @@ class TestLayerKindFromLookupId: ("preset:speckit-core:template:spec-template", "preset"), ("extension:speckit-git:script:post-commit", "extension"), (f"{PROJECT_OVERRIDE_LAYER}:_:template:spec-template", PROJECT_OVERRIDE_LAYER), + ( + "extension:speckit-git:hook:before_specify:speckit.git.branch", + "extension", + ), ], ) def test_recognized_layer_prefixes(self, lookup_id, expected): @@ -171,6 +175,11 @@ def test_recognized_layer_prefixes(self, lookup_id, expected): "bogus:_:command:speckit.plan", "core", ":_:command:speckit.plan", + "core:not-an-id", + "preset:x", + "core:_:command", + "extension:speckit-git:hook:before_specify", + "core::command:speckit.plan", ], ) def test_unrecognized_or_malformed_returns_none(self, lookup_id): From 1c4319c0410af1915e61c7585fcde4e4f0e18cd6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:57:14 +0000 Subject: [PATCH 057/113] fix: route resolver core fallback through shared asset resolver, describe project overrides, document specify artifact Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- docs/reference/artifacts.md | 127 +++++++++++++++++++++++++ docs/reference/overview.md | 6 ++ docs/reference/presets.md | 2 +- docs/toc.yml | 2 + src/specify_cli/artifacts/__init__.py | 22 ++++- src/specify_cli/artifacts/_commands.py | 4 + src/specify_cli/presets/__init__.py | 63 ++++-------- tests/test_artifact_command.py | 36 +++++++ tests/test_presets.py | 25 +++++ 9 files changed, 239 insertions(+), 48 deletions(-) create mode 100644 docs/reference/artifacts.md diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md new file mode 100644 index 0000000000..fa9e530a34 --- /dev/null +++ b/docs/reference/artifacts.md @@ -0,0 +1,127 @@ +# Artifacts + +An **artifact** is any command, template, or script Spec Kit exposes in a project, regardless of which layer contributes it — the core baseline, an installed preset, an installed extension, or a project-local override in `.specify/templates/overrides/`. + +The `specify artifact` command group is the read-only introspection surface for that inventory. `specify preset resolve ` answers "which file wins for this preset-managed name?"; `specify artifact` answers "what exists at all, and what is the full composition stack behind it?" — including core-only artifacts that no preset touches. + +Both subcommands currently require `--json`. Omitting it exits with code `2` and prints a usage message on stderr; no stdout is produced. Text rendering is deliberately deferred so the JSON shapes below are the only contract, and adding a default text renderer later stays a non-breaking, additive change. + +## List Artifacts + +```bash +specify artifact list --json +``` + +| Option | Description | +| -------- | -------------------------------------------------------- | +| `--json` | Required. Emit the inventory as a JSON array on stdout. | + +Prints a flat inventory of every visible artifact — one row per `(kind, name)` pair — sorted by kind (`command`, then `template`, then `script`) and then by name. + +```json +[ + { + "id": "command:speckit.specify", + "name": "speckit.specify", + "kind": "command", + "description": "Create or update the feature specification." + }, + { + "id": "script:create-new-feature", + "name": "create-new-feature", + "kind": "script", + "description": "Create a new feature branch and spec directory." + } +] +``` + +| Field | Description | +| ------------- | ---------------------------------------------------------------------- | +| `id` | `{kind}:{name}` — the shorthand `artifact info` accepts as its argument | +| `name` | Logical artifact name (commands use the `speckit.` namespace) | +| `kind` | One of `command`, `template`, `script` | +| `description` | Description from the highest-precedence layer that declares one, else `""` | + +Core artifacts always appear, even when nothing overrides them. Descriptions come from the highest-priority layer that has one — a preset or project override that hides a core command reports its own description, not the hidden core text. Skills (`.github/skills/**/SKILL.md`) are excluded: they are integration-specific output, not a shipped asset family. + +## Artifact Info + +```bash +specify artifact info --json +``` + +| Option | Description | +| ---------------- | ------------------------------------------------------------------- | +| `--json` | Required. Emit the composition stack as a JSON object on stdout. | +| `--kind ` | Narrow the lookup to `command`, `template`, or `script` | + +`` accepts either a bare name (`speckit.specify`) or the `kind:name` shorthand (`command:speckit.specify`). When both the shorthand and `--kind` are supplied they must agree. + +```json +{ + "id": "command:speckit.specify", + "name": "speckit.specify", + "kind": "command", + "description": "Create or update the feature specification.", + "stack": [ + { + "layer": "preset", + "presetId": "compliance", + "presetName": "Compliance Preset", + "strategy": "replace", + "active": true, + "hidden": false, + "manifestPath": ".specify/presets/compliance/preset.yml", + "lookupId": "preset:compliance:command:speckit.specify" + }, + { + "layer": "core", + "presetId": null, + "presetName": null, + "strategy": "replace", + "active": false, + "hidden": true, + "manifestPath": null, + "lookupId": "core:_:command:speckit.specify" + } + ] +} +``` + +The top-level `id`, `name`, `kind`, and `description` fields match the corresponding row on `artifact list --json`. + +### Stack semantics + +`stack` is ordered by resolution precedence: index `0` is the layer that wins. Each row describes one contributing layer: + +| Field | Description | +| -------------- | -------------------------------------------------------------------------------- | +| `layer` | `project`, `preset`, `extension`, or `core` | +| `presetId` | Preset pack directory id; `null` on `core`, `project`, and `extension` rows | +| `presetName` | Preset display name when its manifest declares one, else the pack id; `null` when `presetId` is `null` | +| `strategy` | `replace`, `wrap`, `prepend`, or `append` | +| `active` | `true` only for index `0` — the layer whose content is served | +| `hidden` | `true` when a lower-index `replace` layer cuts this layer out of the composition | +| `manifestPath` | Project-relative path to the declaring manifest, or `null` when none applies | +| `lookupId` | Deterministic `{layer}:{sourceId}:{kind}:{name}` identifier for the layer | + +`active` and `hidden` are independent labels, not opposites. Composing strategies (`wrap`, `prepend`, `append`) keep lower layers in the composed output, so an inactive layer is not necessarily hidden: only layers below the first `replace` layer are marked `hidden`. Core rows appear as the base of the stack with `presetId`/`presetName`/`manifestPath` set to `null` and a `core:_:{kind}:{name}` lookup ID. + +Lookup IDs use the same grammar as [preset contribution identifiers](presets.md#contribution-identifiers), so a `lookupId` from this command joins directly to `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` for manifest-declared layers. Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID that intentionally matches no manifest contribution. + +## JSON Errors + +On failure, nothing is written to stdout. A single-key JSON envelope is written to stderr and the process exits with code `1`: + +```json +{ "error": "unknown artifact command:nope" } +``` + +| Message | Cause | +| --------------------------------------------------- | ---------------------------------------------------------------- | +| `not a Spec Kit project: no .specify/ directory found` | Run outside an initialized project | +| `unknown artifact ` | No artifact matches the requested name (and kind, when given) | +| `ambiguous artifact : matches kinds [...]` | The bare name matches more than one kind — re-run with `--kind` | +| `artifact resolution failed` | The preset/extension registries or a manifest could not be read | + +Exit code `2` is reserved for usage errors — a missing `--json` flag or an invalid `--kind` value — and emits a plain-text message on stderr rather than a JSON envelope. diff --git a/docs/reference/overview.md b/docs/reference/overview.md index 183ce84756..077eeb1d31 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -26,6 +26,12 @@ Presets customize how Spec Kit works — overriding command files, template file [Presets reference →](presets.md) +## Artifacts + +Artifacts are the commands, templates, and scripts a project exposes, whichever layer contributes them. The `specify artifact` command group is the read-only introspection surface over that inventory — a flat list of everything visible, plus the full composition stack behind any single entry, including which layer wins and which layers are hidden. + +[Artifacts reference →](artifacts.md) + ## Workflows Workflows automate multi-step Spec-Driven Development processes into repeatable sequences. They chain commands, prompts, shell steps, and human checkpoints together, with support for conditional logic, loops, fan-out/fan-in, and the ability to pause and resume from the exact point of interruption. diff --git a/docs/reference/presets.md b/docs/reference/presets.md index 56b6e1ee4b..382154f213 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -220,7 +220,7 @@ Every command, template, and script contributed by a preset (or an extension, or 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 that identifies the layer. For manifest-declared preset and extension layers, the `lookupId`'s `sourceId` component is the manifest's validated `id:` field, so it joins directly to the `id` used by `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` even when the installed directory was renamed. Convention-only layers (undeclared in any manifest) have no manifest id to consult, so their `lookupId`'s `sourceId` falls back to the resolver's registry key or on-disk directory name instead — consumers should treat `lookupId` as the resolver's stack identity and use `layer_kind_from_lookup_id` / the manifest APIs when they need to reason about the originating contribution. 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. +`PresetResolver.collect_all_layers()` returns layer dicts that each include a `lookupId` field that identifies the layer. For manifest-declared preset and extension layers, the `lookupId`'s `sourceId` component is the manifest's validated `id:` field, so it joins directly to the `id` used by `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` even when the installed directory was renamed. That join is guaranteed by the implementation, so consumers can key off `lookupId` directly rather than re-deriving the contribution id. Convention-only layers (undeclared in any manifest) have no manifest id to consult, so their `lookupId`'s `sourceId` falls back to the resolver's registry key or on-disk directory name instead; those layers have no manifest contribution to join to. Use `layer_kind_from_lookup_id` to tell the two cases apart rather than parsing the string yourself. Project-local overrides in `.specify/templates/overrides/` are a resolver-only concept — they carry a synthetic `project:_:{kind}:{name}` `lookupId` that intentionally does not match any manifest contribution. For the full grammar, including the hook name-component convention and last-write-wins deduplication used by extensions, see the [Extension API Reference — Contribution Identifiers](../../extensions/EXTENSION-API-REFERENCE.md#contribution-identifiers) section. diff --git a/docs/toc.yml b/docs/toc.yml index d2f1b2bd21..c0a4264547 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -41,6 +41,8 @@ href: reference/extensions.md - name: Presets href: reference/presets.md + - name: Artifacts + href: reference/artifacts.md - name: Workflows href: reference/workflows.md - name: Bundles diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 2fc4750242..d5014f90cf 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -227,6 +227,22 @@ def _extract_script_description(text: str) -> str: return "" +def _describe_artifact_file(path: Path, kind: ArtifactKind) -> str: + """Return the on-disk description for an artifact file, else ``""``. + + Routes to the same extractors the core baseline uses so a project + override reports its own metadata instead of inheriting the description + of the core/preset layer it hides. + """ + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return "" + if kind == "script": + return _extract_script_description(text) + return _extract_frontmatter_description(text) + + def _enumerate_core_commands(project_root: Path | None = None) -> list[_CoreBaselineRow]: """Enumerate every command shipped in the core baseline. @@ -955,7 +971,7 @@ def _iter_project_override_artifacts( self, resolver: Any, ) -> Iterable[tuple[ArtifactKind, str, str, str]]: - """Yield ``(kind, name, "", lookup_id)`` for project-local overrides. + """Yield ``(kind, name, description, lookup_id)`` for project overrides. A root ``overrides/.md`` file is the override for both the ``template`` and the ``command`` lookup of ````, so it is @@ -985,7 +1001,7 @@ def _iter_project_override_artifacts( is_command = backed_by_command or is_dotted_command_name(name) kind: ArtifactKind = "command" if is_command else "template" lookup_id = derive_named_id(PROJECT_OVERRIDE_LAYER, "_", kind, name) - yield kind, name, "", lookup_id + yield kind, name, _describe_artifact_file(entry, kind), lookup_id scripts_dir = overrides_dir / "scripts" if not scripts_dir.is_dir(): return @@ -994,7 +1010,7 @@ def _iter_project_override_artifacts( 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 + yield "script", entry.stem, _describe_artifact_file(entry, "script"), lookup_id _CONVENTION_SUBDIRS: tuple[tuple[str, ArtifactKind, str], ...] = ( diff --git a/src/specify_cli/artifacts/_commands.py b/src/specify_cli/artifacts/_commands.py index ac7526db9e..3804c08da4 100644 --- a/src/specify_cli/artifacts/_commands.py +++ b/src/specify_cli/artifacts/_commands.py @@ -7,6 +7,10 @@ Mirrors the shape used by ``src/specify_cli/presets/_commands.py`` and ``src/specify_cli/extensions/_commands.py``: a module-level Typer app plus a ``register(app)`` entry point invoked from ``src/specify_cli/__init__.py``. + +The user-facing contract for both subcommands — the ``list``/``info`` JSON +shapes, stack semantics (``active``/``hidden``, core rows, lookup IDs), and +the JSON error envelope — is documented in ``docs/reference/artifacts.md``. """ from __future__ import annotations diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 094ee9d200..c8438d9227 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -5113,7 +5113,12 @@ 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. + key / on-disk directory name. That key identifies *where* the pack + lives — it is used for lookup and provenance, and as the ``sourceId`` + of convention-only contribution IDs. Manifest-declared layers instead + take their ``lookupId`` ``sourceId`` from ``PresetManifest.id`` so the + ID joins directly to the manifest's own contributions even when the + installed directory was renamed. """ return self._get_all_presets_by_priority() @@ -5266,7 +5271,12 @@ 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. + is always the on-disk directory name. That name identifies *where* the + extension lives — it is used for lookup and provenance, and as the + ``sourceId`` of convention-only contribution IDs. Manifest-declared + layers instead take their ``lookupId`` ``sourceId`` from + ``ExtensionManifest.id`` so the ID joins directly to the manifest's own + contributions even when the installed directory was renamed. """ return self._get_all_extensions_by_priority() @@ -5416,48 +5426,13 @@ def resolve( # Priority 5: Bundled core_pack (wheel install) or repo-root templates # (source-checkout / editable install). This is the canonical home for # speckit's built-in command/template files and must always be checked - # so that strategy:wrap presets can locate {CORE_TEMPLATE}. - from specify_cli import _locate_core_pack, _repo_root # local import to avoid cycles - _core_pack = _locate_core_pack() - if _core_pack is not None: - # Wheel install path - if template_type == "template": - candidate = _core_pack / "templates" / f"{template_name}.md" - elif template_type == "command": - candidate = _core_pack / "commands" / f"{template_name}.md" - if not candidate.exists(): - stem = self._core_stem(template_name) - if stem: - candidate = _core_pack / "commands" / f"{stem}.md" - elif template_type == "script": - candidate = 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 is not None and candidate.exists(): - return candidate - else: - # Source-checkout / editable install: templates live at repo root - repo_root = _repo_root() - if template_type == "template": - candidate = repo_root / "templates" / f"{template_name}.md" - elif template_type == "command": - candidate = repo_root / "templates" / "commands" / f"{template_name}.md" - if not candidate.exists(): - stem = self._core_stem(template_name) - if stem: - candidate = repo_root / "templates" / "commands" / f"{stem}.md" - elif template_type == "script": - candidate = 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 is not None and candidate.exists(): - return candidate + # so that strategy:wrap presets can locate {CORE_TEMPLATE}. Delegated + # to the shared core asset resolver via ``_find_bundled_core`` so this + # tier and ``collect_all_layers()`` never disagree about what "core" + # means on this machine. + bundled = self._find_bundled_core(template_name, template_type, ext) + if bundled is not None: + return bundled return None diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 5dfc97a4e5..f68416a5c5 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -859,6 +859,42 @@ 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_project_override_reports_its_own_description(self, spec_kit_project: Path): + """An override's frontmatter/comment metadata wins over the hidden layer.""" + commands_dir = spec_kit_project / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True) + (commands_dir / "speckit.constitution.md").write_text( + "---\ndescription: Core description\n---\n", encoding="utf-8" + ) + overrides = spec_kit_project / ".specify" / "templates" / "overrides" + (overrides / "scripts").mkdir(parents=True) + (overrides / "speckit.constitution.md").write_text( + "---\ndescription: Override description\n---\n", encoding="utf-8" + ) + (overrides / "scripts" / "local-script.sh").write_text( + "#!/bin/sh\n# Override script description\n", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + rows = {row.id: row.description for row in catalog.list_artifacts()} + assert rows["command:speckit.constitution"] == "Override description" + assert rows["script:local-script"] == "Override script description" + + def test_project_override_without_metadata_falls_back(self, spec_kit_project: Path): + """A metadata-free override still reports the hidden layer's description.""" + commands_dir = spec_kit_project / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True) + (commands_dir / "speckit.constitution.md").write_text( + "---\ndescription: Core description\n---\n", encoding="utf-8" + ) + overrides = spec_kit_project / ".specify" / "templates" / "overrides" + overrides.mkdir(parents=True) + (overrides / "speckit.constitution.md").write_text("body\n", encoding="utf-8") + + catalog = ArtifactCatalog(spec_kit_project) + rows = {row.id: row.description for row in catalog.list_artifacts()} + assert rows["command:speckit.constitution"] == "Core description" + def test_dotted_override_only_artifact_is_a_command(self, spec_kit_project: Path): overrides = spec_kit_project / ".specify" / "templates" / "overrides" overrides.mkdir(parents=True) diff --git a/tests/test_presets.py b/tests/test_presets.py index 92576eb44d..ac43fa507c 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1173,6 +1173,31 @@ def test_resolve_nonexistent(self, project_dir): result = resolver.resolve("nonexistent-template") assert result is None + def test_core_fallback_uses_shared_asset_resolver(self, project_dir, monkeypatch): + """resolve() tier 5 and collect_all_layers() must agree on "core". + + Regression test: the tier-5 branch used to read ``core_pack//`` + directly, so a wheel bundle missing ``scripts/`` made ``resolve()`` + return nothing while ``collect_all_layers()`` fell back to the source + checkout via ``_locate_core_asset_dir``. + """ + import specify_cli._assets as assets + + core_pack = project_dir.parent / "core_pack" + (core_pack / "commands").mkdir(parents=True) # bundle exists, no scripts/ + repo_root = project_dir.parent / "repo" + (repo_root / "scripts" / "bash").mkdir(parents=True) + script = repo_root / "scripts" / "bash" / "core-only.sh" + script.write_text("#!/bin/sh\n", encoding="utf-8") + + monkeypatch.setattr(assets, "_locate_core_pack", lambda: core_pack) + monkeypatch.setattr(assets, "_repo_root", lambda: repo_root) + + resolver = PresetResolver(project_dir) + assert resolver.resolve("core-only", "script") == script + layers = resolver.collect_all_layers("core-only", "script") + assert [layer["path"] for layer in layers] == [script] + def test_resolver_ignores_traversing_registry_ids(self, project_dir): """Registry IDs cannot escape preset or extension install roots.""" for registry_dir, registry_key, outside_name in ( From f7b549fc39b3420445d1b77f72ffcf3b66a571ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:59:50 +0000 Subject: [PATCH 058/113] refactor: drop redundant derive_named_id import-visibility assignment Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index d5014f90cf..d86000f48d 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -1050,5 +1050,3 @@ def _iter_convention_contributions(pack_dir: Path) -> Iterable[tuple[ArtifactKin "StackLayer", "Strategy", ] - -_ = derive_named_id # keep the import edge visible for tooling From 083af4c64794aed621ef58529eb3f31cdea337c8 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Mon, 24 Aug 2026 22:05:14 -0500 Subject: [PATCH 059/113] fix: align artifact inventory and lookup ID validation Address the latest review feedback for root-level legacy templates and unsupported lookup ID kinds. Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- src/specify_cli/_identifier.py | 3 +++ src/specify_cli/artifacts/__init__.py | 14 +++++++++++--- src/specify_cli/presets/__init__.py | 2 ++ tests/test_artifact_command.py | 17 +++++++++++++++++ tests/test_contribution_ids.py | 1 + 5 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py index 0bda1c2480..6637217238 100644 --- a/src/specify_cli/_identifier.py +++ b/src/specify_cli/_identifier.py @@ -96,6 +96,7 @@ def derive_named_id(layer: str, source_id: str, kind: str, name: str) -> str: _LAYER_KINDS = frozenset({"core", PROJECT_OVERRIDE_LAYER, "preset", "extension"}) +_CONTRIBUTION_KINDS = frozenset({"command", "template", "script", "hook"}) def layer_kind_from_lookup_id(lookup_id: str) -> str | None: @@ -123,6 +124,8 @@ def layer_kind_from_lookup_id(lookup_id: str) -> str | None: layer = parts[0] if layer not in _LAYER_KINDS: return None + if parts[2] not in _CONTRIBUTION_KINDS: + return None expected_len = 5 if parts[2] == "hook" else 4 if len(parts) != expected_len: return None diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 2fc4750242..39acf49af0 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -1007,9 +1007,9 @@ def _iter_project_override_artifacts( 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. + Templates are also accepted at the pack root for legacy compatibility, + matching the resolver's ``templates/``-then-root lookup order. README files + are packaging metadata rather than artifacts and are excluded consistently. """ for subdir, kind, suffix in _CONVENTION_SUBDIRS: candidate_dir = pack_dir / subdir @@ -1018,6 +1018,14 @@ def _iter_convention_contributions(pack_dir: Path) -> Iterable[tuple[ArtifactKin 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 + for entry in sorted(pack_dir.iterdir(), key=lambda p: p.name): + if ( + entry.is_file() + and entry.suffix == _TEMPLATE_SUFFIX + and entry.stem.lower() != "readme" + and ":" not in entry.stem + ): + yield "template", entry.stem __all__ = [ diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 094ee9d200..2527d09de3 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -5619,6 +5619,8 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: if subdir: candidate = base_dir / subdir / f"{template_name}{ext}" else: + if template_name.lower() == "readme": + continue candidate = base_dir / f"{template_name}{ext}" if candidate.exists(): return candidate diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 5dfc97a4e5..15ac6df40c 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -264,6 +264,23 @@ def test_includes_project_local_core_assets(self, spec_kit_project: Path): "core:_:script:legacy-script" ) + def test_includes_root_level_pack_template_but_excludes_readme( + self, spec_kit_project: Path + ): + extension_dir = spec_kit_project / ".specify" / "extensions" / "legacy" + extension_dir.mkdir() + (extension_dir / "legacy-root.md").write_text( + "---\ndescription: Legacy root template\n---\n", + encoding="utf-8", + ) + (extension_dir / "README.md").write_text("# Packaging notes\n", encoding="utf-8") + + catalog = ArtifactCatalog(spec_kit_project) + names = {row.name for row in catalog.list_artifacts()} + + assert "legacy-root" in names + assert "README" not in names + @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" diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py index fcfc9639c4..fb3aa69c34 100644 --- a/tests/test_contribution_ids.py +++ b/tests/test_contribution_ids.py @@ -180,6 +180,7 @@ def test_recognized_layer_prefixes(self, lookup_id, expected): "core:_:command", "extension:speckit-git:hook:before_specify", "core::command:speckit.plan", + "core:_:bogus:speckit.plan", ], ) def test_unrecognized_or_malformed_returns_none(self, lookup_id): From bd77448123fb5215126a7022275f5d560120c99d Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Mon, 24 Aug 2026 22:08:59 -0500 Subject: [PATCH 060/113] fix: fail closed on malformed artifact registries Treat missing registry collection keys as corruption and map filesystem read failures to the artifact JSON error envelope. Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- src/specify_cli/artifacts/_commands.py | 4 ++-- src/specify_cli/extensions/__init__.py | 2 +- src/specify_cli/presets/__init__.py | 2 +- tests/test_artifact_command.py | 13 +++++++++++++ 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/artifacts/_commands.py b/src/specify_cli/artifacts/_commands.py index ac7526db9e..93ba8684b5 100644 --- a/src/specify_cli/artifacts/_commands.py +++ b/src/specify_cli/artifacts/_commands.py @@ -99,7 +99,7 @@ def list_command( except ArtifactError as exc: _emit_error_and_exit(exc) return # pragma: no cover — _emit_error_and_exit raises - except PresetError: + except (OSError, PresetError): _emit_error_and_exit(ArtifactResolutionError()) return # pragma: no cover — _emit_error_and_exit raises @@ -141,7 +141,7 @@ def info_command( except ArtifactError as exc: _emit_error_and_exit(exc) return # pragma: no cover - except PresetError: + except (OSError, PresetError): _emit_error_and_exit(ArtifactResolutionError()) return # pragma: no cover diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index ff4270c474..e4eb23d7fb 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -920,7 +920,7 @@ def is_corrupt(self) -> bool: return True if not isinstance(data, dict): return True - if "extensions" in data and not isinstance(data["extensions"], dict): + if "extensions" not in data or not isinstance(data["extensions"], dict): return True return False diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 2527d09de3..fea130edce 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -671,7 +671,7 @@ def is_corrupt(self) -> bool: return True if not isinstance(data, dict): return True - if "presets" in data and not isinstance(data["presets"], dict): + if "presets" not in data or not isinstance(data["presets"], dict): return True return False diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 15ac6df40c..9c7a1aff78 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -281,6 +281,19 @@ def test_includes_root_level_pack_template_but_excludes_readme( assert "legacy-root" in names assert "README" not in names + @pytest.mark.parametrize("registry_dir, registry_name", [ + ("extensions", "extensions"), + ("presets", "presets"), + ]) + def test_registry_missing_collection_key_is_corrupt( + self, spec_kit_project: Path, registry_dir: str, registry_name: str + ): + registry_path = spec_kit_project / ".specify" / registry_dir / ".registry" + registry_path.write_text('{"schema_version": "1.0"}', encoding="utf-8") + + with pytest.raises(ArtifactResolutionError): + ArtifactCatalog(spec_kit_project).list_artifacts() + @pytest.mark.skipif(os.name == "nt", reason="':' filenames are unsupported on Windows") def test_skips_invalid_colon_names_in_project_local_inventory(self, spec_kit_project: Path): templates_dir = spec_kit_project / ".specify" / "templates" From 436f394b1a9730945281b876a63a7b9ff89e7664 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Mon, 24 Aug 2026 22:11:13 -0500 Subject: [PATCH 061/113] fix: preserve convention artifact descriptions Use the existing artifact description extractors for convention-based preset and extension files. Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- src/specify_cli/artifacts/__init__.py | 14 ++++++++------ tests/test_artifact_command.py | 3 +++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 6959bbb467..b493def6ce 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -962,10 +962,10 @@ 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. - for kind, name in _iter_convention_contributions(pack_dir): + for kind, name, path in _iter_convention_contributions(pack_dir): lookup_id = derive_named_id(layer, source_id, kind, name) if lookup_id in lookup_ids(kind, name): - yield kind, name, "", lookup_id + yield kind, name, _describe_artifact_file(path, kind), lookup_id def _iter_project_override_artifacts( self, @@ -1020,8 +1020,10 @@ def _iter_project_override_artifacts( ) -def _iter_convention_contributions(pack_dir: Path) -> Iterable[tuple[ArtifactKind, str]]: - """Yield ``(kind, name)`` for files an extension exposes by convention. +def _iter_convention_contributions( + pack_dir: Path, +) -> Iterable[tuple[ArtifactKind, str, Path]]: + """Yield ``(kind, name, path)`` for files exposed by convention. Templates are also accepted at the pack root for legacy compatibility, matching the resolver's ``templates/``-then-root lookup order. README files @@ -1033,7 +1035,7 @@ def _iter_convention_contributions(pack_dir: Path) -> Iterable[tuple[ArtifactKin 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 + yield kind, entry.stem, entry for entry in sorted(pack_dir.iterdir(), key=lambda p: p.name): if ( entry.is_file() @@ -1041,7 +1043,7 @@ def _iter_convention_contributions(pack_dir: Path) -> Iterable[tuple[ArtifactKin and entry.stem.lower() != "readme" and ":" not in entry.stem ): - yield "template", entry.stem + yield "template", entry.stem, entry __all__ = [ diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 42445c4cf8..562eb5bb88 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -280,6 +280,9 @@ def test_includes_root_level_pack_template_but_excludes_readme( assert "legacy-root" in names assert "README" not in names + assert next( + row for row in catalog.list_artifacts() if row.name == "legacy-root" + ).description == "Legacy root template" @pytest.mark.parametrize("registry_dir, registry_name", [ ("extensions", "extensions"), From 74162477b37d8a80d7120251764cd68905f0a9be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:01:21 +0000 Subject: [PATCH 062/113] fix: align artifact override resolution Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 31 +++++++++++++++------------ src/specify_cli/presets/__init__.py | 4 ++++ tests/test_artifact_command.py | 17 +++++++++++++++ tests/test_presets.py | 11 ++++++++++ 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index b493def6ce..4988eee106 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -974,10 +974,9 @@ def _iter_project_override_artifacts( """Yield ``(kind, name, description, lookup_id)`` for project overrides. A root ``overrides/.md`` file is the override for both the - ``template`` and the ``command`` lookup of ````, so it is - reported as a command when some other layer already provides that - command and as a template otherwise. That keeps a command override - from also appearing as a second, spurious ``template:`` row. + ``template`` and the ``command`` lookup of ````. It is reported + for every kind backed by another layer; the fallback heuristic is used + only when the override is the sole layer. A dotted name (``speckit.local``) is treated as a command even when the override is the only layer — matching the exact ID @@ -992,16 +991,20 @@ def _iter_project_override_artifacts( name = entry.stem if not _is_valid_artifact_name_component(name, "command"): continue - command_layers = resolver.collect_all_layers(name, "command") - backed_by_command = any( - layer_kind_from_lookup_id(str(layer.get("lookupId", ""))) - != PROJECT_OVERRIDE_LAYER - for layer in command_layers - ) - is_command = backed_by_command or is_dotted_command_name(name) - kind: ArtifactKind = "command" if is_command else "template" - lookup_id = derive_named_id(PROJECT_OVERRIDE_LAYER, "_", kind, name) - yield kind, name, _describe_artifact_file(entry, kind), lookup_id + backed_kinds: list[ArtifactKind] = [] + for kind in ("command", "template"): + layers = resolver.collect_all_layers(name, kind) + if any( + layer_kind_from_lookup_id(str(layer.get("lookupId", ""))) + != PROJECT_OVERRIDE_LAYER + for layer in layers + ): + backed_kinds.append(kind) + if not backed_kinds: + backed_kinds.append("command" if is_dotted_command_name(name) else "template") + for kind in backed_kinds: + lookup_id = derive_named_id(PROJECT_OVERRIDE_LAYER, "_", kind, name) + yield kind, name, _describe_artifact_file(entry, kind), lookup_id scripts_dir = overrides_dir / "scripts" if not scripts_dir.is_dir(): return diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index d1c47af6c4..f20a29268a 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -5373,6 +5373,8 @@ def resolve( if subdir: candidate = pack_dir / subdir / f"{template_name}{ext}" else: + if template_name.lower() == "readme": + continue candidate = pack_dir / f"{template_name}{ext}" if candidate.exists(): return candidate @@ -5396,6 +5398,8 @@ def resolve( if subdir: candidate = ext_dir / subdir / f"{template_name}{ext}" else: + if template_name.lower() == "readme": + continue candidate = ext_dir / f"{template_name}{ext}" if candidate.exists(): return candidate diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 562eb5bb88..96d9b556b0 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -928,6 +928,23 @@ def test_project_override_without_metadata_falls_back(self, spec_kit_project: Pa rows = {row.id: row.description for row in catalog.list_artifacts()} assert rows["command:speckit.constitution"] == "Core description" + def test_project_override_describes_both_backed_kinds(self, spec_kit_project: Path): + commands_dir = spec_kit_project / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True) + (commands_dir / "shared.md").write_text("command\n", encoding="utf-8") + templates_dir = spec_kit_project / ".specify" / "templates" + (templates_dir / "shared.md").write_text("template\n", encoding="utf-8") + overrides = templates_dir / "overrides" + overrides.mkdir(parents=True) + (overrides / "shared.md").write_text( + "---\ndescription: Shared override\n---\n", encoding="utf-8" + ) + + rows = {row.id: row.description for row in ArtifactCatalog(spec_kit_project).list_artifacts()} + + assert rows["command:shared"] == "Shared override" + assert rows["template:shared"] == "Shared override" + def test_dotted_override_only_artifact_is_a_command(self, spec_kit_project: Path): overrides = spec_kit_project / ".specify" / "templates" / "overrides" overrides.mkdir(parents=True) diff --git a/tests/test_presets.py b/tests/test_presets.py index ac43fa507c..4899ab5df0 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -11970,6 +11970,17 @@ def test_extension_template_convention_lookup_unaffected_when_undeclared(self, p assert layers, "expected convention-based lookup to still find the template" assert layers[0]["path"] == tmpl_dir / "legacy-template.md" + @pytest.mark.parametrize("pack_kind", ["preset", "extension"]) + def test_root_readme_is_not_resolved_as_template(self, project_dir, pack_kind): + pack_dir = project_dir / ".specify" / f"{pack_kind}s" / "legacy" + pack_dir.mkdir(parents=True) + (pack_dir / "README.md").write_text("packaging notes\n") + + resolver = PresetResolver(project_dir) + + assert resolver.resolve("README", "template") is None + assert resolver.collect_all_layers("README", "template") == [] + def test_extension_manifest_wins_over_stale_conventional_file(self, project_dir): """A declared entry is authoritative even when a stale file also sits at the conventional path (templates/.md) — the manifest must win, From 4b8b381a600c7ae1d306441cfd05e0d67cfeb0f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:23:04 +0000 Subject: [PATCH 063/113] Refactor artifact inventory candidates Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 463 ++++++++++---------------- 1 file changed, 167 insertions(+), 296 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 4988eee106..d12fcafaf2 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -22,7 +22,6 @@ from .._identifier import ( PROJECT_OVERRIDE_LAYER, IdentifierComponentError, - derive_named_id, is_dotted_command_name, layer_kind_from_lookup_id, validate_component, @@ -123,22 +122,10 @@ def __init__(self) -> None: super().__init__(self.message) -# --------------------------------------------------------------------------- -# Core-baseline enumeration -# --------------------------------------------------------------------------- - _TEMPLATE_SUFFIX = ".md" _SCRIPT_SUFFIX = ".sh" -@dataclass(frozen=True) -class _CoreBaselineRow: - name: str - kind: ArtifactKind - path: Path - description: str - - def _core_asset_root(subdir: str) -> Path | None: """Return the on-disk directory holding a family of core assets, or None. @@ -164,6 +151,10 @@ def _project_core_asset_root(project_root: Path | None, subdir: str) -> Path | N return candidate if candidate.is_dir() else None +def _core_command_logical_name(stem: str) -> str: + return stem if stem.startswith("speckit.") else f"speckit.{stem}" + + def _extract_frontmatter_description(text: str) -> str: """Return the ``description`` value from YAML frontmatter, else ``""``. @@ -230,7 +221,7 @@ def _extract_script_description(text: str) -> str: def _describe_artifact_file(path: Path, kind: ArtifactKind) -> str: """Return the on-disk description for an artifact file, else ``""``. - Routes to the same extractors the core baseline uses so a project + Routes to the same extractors the inventory uses so a project override reports its own metadata instead of inheriting the description of the core/preset layer it hides. """ @@ -243,174 +234,6 @@ def _describe_artifact_file(path: Path, kind: ArtifactKind) -> str: return _extract_frontmatter_description(text) -def _enumerate_core_commands(project_root: Path | None = None) -> list[_CoreBaselineRow]: - """Enumerate every command shipped in the core baseline. - - Names are surfaced with the ``speckit.`` prefix so they collide with - preset/extension contributions in a stable way — this is what the id - grammar ``command:speckit.constitution`` requires. - """ - from ..extensions import CORE_COMMAND_NAMES # lazy: avoids circular import - from ..presets import PresetResolver - - commands_dir = _core_asset_root("commands") - project_commands_dir = _project_core_asset_root(project_root, "commands") - rows: list[_CoreBaselineRow] = [] - if commands_dir is None and project_commands_dir is None: - return rows - logical_names = { - name if name.startswith("speckit.") else f"speckit.{name}" - for name in CORE_COMMAND_NAMES - } - if commands_dir is not None: - logical_names.update( - entry.stem if entry.stem.startswith("speckit.") else f"speckit.{entry.stem}" - for entry in commands_dir.iterdir() - if entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX - ) - if project_commands_dir is not None: - logical_names.update( - entry.stem if entry.stem.startswith("speckit.") else f"speckit.{entry.stem}" - for entry in project_commands_dir.iterdir() - if entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX - ) - rows_by_name: dict[str, _CoreBaselineRow] = {} - for logical_name in sorted(logical_names): - name_candidates = PresetResolver.core_name_candidates(logical_name) - project_candidates = ( - tuple(project_commands_dir / f"{name}.md" for name in name_candidates) - if project_commands_dir is not None - else () - ) - bundled_candidates = ( - tuple(commands_dir / f"{name}.md" for name in name_candidates) - if commands_dir is not None - else () - ) - path = next( - ( - candidate - for candidate in (*project_candidates, *bundled_candidates) - if candidate.is_file() - ), - None, - ) - if path is None: - continue - if logical_name in rows_by_name: - continue - try: - text = path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - text = "" - rows_by_name[logical_name] = _CoreBaselineRow( - name=logical_name, - kind="command", - path=path, - description=_extract_frontmatter_description(text), - ) - rows.extend(rows_by_name[name] for name in sorted(rows_by_name)) - return rows - - -def _enumerate_core_templates(project_root: Path | None = None) -> list[_CoreBaselineRow]: - templates_dir = _core_asset_root("templates") - project_templates_dir = _project_core_asset_root(project_root, "templates") - rows: list[_CoreBaselineRow] = [] - seen: set[str] = set() - for directory in (project_templates_dir, templates_dir): - if directory is None: - continue - for entry in sorted(directory.iterdir(), key=lambda p: p.name): - if ( - not entry.is_file() - or entry.suffix != _TEMPLATE_SUFFIX - or entry.stem in seen - ): - continue - seen.add(entry.stem) - try: - text = entry.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - text = "" - rows.append( - _CoreBaselineRow( - name=entry.stem, - kind="template", - path=entry, - description=_extract_frontmatter_description(text), - ) - ) - return rows - - -def _enumerate_core_scripts(project_root: Path | None = None) -> list[_CoreBaselineRow]: - scripts_dir = _core_asset_root("scripts") - project_scripts_dir = _project_core_asset_root(project_root, "scripts") - rows: list[_CoreBaselineRow] = [] - seen: dict[str, _CoreBaselineRow] = {} - for directory in (project_scripts_dir, scripts_dir): - if directory is None: - continue - for entry in sorted(directory.glob(f"*{_SCRIPT_SUFFIX}"), key=lambda p: p.name): - if entry.stem not in seen: - seen[entry.stem] = _core_script_row(entry, entry.stem) - for runtime_dir in sorted(directory.iterdir(), key=lambda p: p.name): - if not runtime_dir.is_dir(): - continue - for entry in sorted(runtime_dir.iterdir(), key=lambda p: p.name): - if not entry.is_file(): - continue - name = canonical_script_name(entry) - if name is not None and name not in seen: - seen[name] = _core_script_row(entry, name) - rows.extend(sorted(seen.values(), key=lambda r: r.name)) - return rows - - -def _core_script_row(path: Path, name: str) -> _CoreBaselineRow: - try: - text = path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - text = "" - return _CoreBaselineRow( - name=name, - kind="script", - path=path, - description=_extract_script_description(text), - ) - - -@dataclass(frozen=True) -class CoreBaseline: - """The union of the three core enumerators, indexed for O(1) lookup.""" - - commands: tuple[_CoreBaselineRow, ...] - templates: tuple[_CoreBaselineRow, ...] - scripts: tuple[_CoreBaselineRow, ...] - - @classmethod - def load(cls, project_root: Path | None = None) -> "CoreBaseline": - return cls( - commands=tuple(_enumerate_core_commands(project_root)), - templates=tuple(_enumerate_core_templates(project_root)), - scripts=tuple(_enumerate_core_scripts(project_root)), - ) - - def by_kind(self, kind: ArtifactKind) -> tuple[_CoreBaselineRow, ...]: - return { - "command": self.commands, - "template": self.templates, - "script": self.scripts, - }[kind] - - def find(self, kind: ArtifactKind, name: str) -> _CoreBaselineRow | None: - for row in self.by_kind(kind): - if row.name == name: - return row - return None - - # --------------------------------------------------------------------------- # Resolver-adaptation helpers # --------------------------------------------------------------------------- @@ -495,7 +318,7 @@ def _build_stack( ``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). + (no preset, no extension, no core asset). """ from ..presets import PresetResolver # lazy: avoids circular import @@ -694,7 +517,6 @@ class ArtifactCatalog: def __init__(self, project_root: Path) -> None: self.project_root = project_root - self._baseline: CoreBaseline | None = None # ------------------------------------------------------------------ list def list_artifacts(self) -> list[Artifact]: @@ -703,8 +525,8 @@ def list_artifacts(self) -> list[Artifact]: 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. + a fresh install with no presets, no extensions, and no core assets is + still a valid Spec Kit project. Skills (``.github/skills/**/SKILL.md``) are intentionally excluded — they are integration-specific output, not a shipped asset family. @@ -719,7 +541,6 @@ def list_artifacts(self) -> list[Artifact]: _validate_project(self.project_root) _validate_extension_registry(self.project_root) _validate_preset_registry(self.project_root) - baseline = self._get_baseline() from ..presets import PresetResolver # lazy: avoids circular import @@ -733,35 +554,18 @@ def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: return layers_cache[key] names: set[tuple[ArtifactKind, str]] = set() - descriptions_by_layer: dict[tuple[ArtifactKind, str], dict[str, str]] = {} - - for row in (*baseline.commands, *baseline.templates, *baseline.scripts): - if not _is_valid_artifact_name_component(row.name, row.kind): - continue - key = (row.kind, row.name) - names.add(key) - core_lookup_id = derive_named_id("core", "_", row.kind, row.name) - descriptions_by_layer.setdefault(key, {}).setdefault( - core_lookup_id, row.description - ) - - for kind, name, description, lookup_id in self._iter_contribution_artifacts( - resolver, _layers_for - ): + for kind, name in self._iter_candidate_artifacts(resolver, _layers_for): key = (kind, name) - names.add(key) - layer_descriptions = descriptions_by_layer.setdefault(key, {}) - if lookup_id not in layer_descriptions or ( - description and not layer_descriptions[lookup_id] - ): - layer_descriptions[lookup_id] = description + if not _is_valid_artifact_name_component(name, kind): + continue + if _layers_for(kind, name): + names.add(key) 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"], "") + candidate = self._describe_layer(layer, kind, name) if candidate: description = candidate break @@ -819,11 +623,6 @@ def get_artifact_info( } # -------------------------------------------------------------- internals - def _get_baseline(self) -> CoreBaseline: - if self._baseline is None: - self._baseline = CoreBaseline.load(self.project_root) - return self._baseline - def _find_matches(self, name: str) -> list[tuple[ArtifactKind, str]]: """Return every (kind, name) pair whose name matches exactly.""" artifacts = self.list_artifacts() @@ -841,14 +640,14 @@ def _describe(self, kind: ArtifactKind, name: str) -> str: return artifact.description return "" - def _iter_contribution_artifacts( + def _iter_candidate_artifacts( self, resolver: Any, layers_for: Callable[[ArtifactKind, str], list[dict[str, Any]]], - ) -> Iterable[tuple[ArtifactKind, str, str, str]]: - """Yield ``(kind, name, description, lookup_id)`` for visible contributions. + ) -> Iterable[tuple[ArtifactKind, str]]: + """Yield candidate ``(kind, name)`` pairs from every resolver tier. - Covers the two ways a pack can contribute an artifact: + Covers the ways a pack can contribute an artifact: * manifest-declared entries (``preset.yml`` / ``extension.yml``), read via each manifest class's own ``iter_contributions()`` rather than @@ -856,23 +655,12 @@ def _iter_contribution_artifacts( * convention-placed extension files (``commands/``, ``templates/``, ``scripts/``) that the resolver picks up even without a manifest. - Presets are enumerated through ``PresetManager.list_installed()`` — - presets have no unregistered-directory fallback in the resolver (see - ``PresetResolver._get_all_presets_by_priority``), so the registry is - the complete set. Extensions additionally admit unregistered - directories at implicit priority 10 (see - ``PresetResolver._get_all_extensions_by_priority``), so those are - folded in alongside the registered set. Either way, every yielded - contribution is still checked against the resolver's own - ``collect_all_layers()`` output (via ``layers_for``, the cache shared - with :meth:`list_artifacts`) before being surfaced, so a disabled - pack, an orphaned directory the resolver would not admit, or a - declared-but-unusable entry cannot appear in the inventory. - - The ``lookup_id`` is the same ``lookupId`` string - ``collect_all_layers()`` uses for this layer, so the caller can - resolve each artifact's description by precedence instead of - enumeration order. + Presets and extensions are enumerated through the resolver's public + ``iter_*_by_priority()`` helpers, so the candidate set follows the same + install/enable/priority rules as resolution. Project overrides and + resolver-compatible core asset paths are included only as candidate + names; :meth:`PresetResolver.collect_all_layers` remains the source of + truth for which candidates are actually present and which layer wins. Project-local overrides under ``.specify/templates/overrides`` are included too, so an artifact that exists only as an override is still @@ -886,24 +674,19 @@ def _iter_contribution_artifacts( from ..extensions import ExtensionManager, ExtensionManifest, ValidationError from ..presets import PresetManager # lazy: avoids circular import - def _lookup_ids(kind: ArtifactKind, name: str) -> set[str]: - return {layer["lookupId"] for layer in layers_for(kind, name)} - # -- Presets: the registry is authoritative, no unregistered fallback. preset_manager = PresetManager(self.project_root) for pack_id, _metadata in resolver.iter_presets_by_priority(): pack_dir = preset_manager.presets_dir / pack_id manifest = preset_manager.get_pack(pack_id) - yield from self._iter_pack_contributions( - manifest, pack_dir, "preset", pack_id, _lookup_ids - ) + yield from self._iter_pack_candidates(manifest, pack_dir) # -- Extensions: use the resolver's own extension enumeration order and # identity (directory name), including safe-id and corrupt-registry # handling from PresetResolver.iter_extensions_by_priority(). ext_manager = ExtensionManager(self.project_root) for _priority, ext_id, metadata in resolver.iter_extensions_by_priority(): - ext_dir = ext_manager.extensions_dir / ext_id + ext_dir = resolver.extensions_dir / ext_id if metadata is not None: manifest = ext_manager.get_extension(ext_id) else: @@ -912,66 +695,34 @@ def _lookup_ids(kind: ArtifactKind, name: str) -> set[str]: if manifest_path.is_file(): try: manifest = ExtensionManifest(manifest_path) - except ValidationError: + except (ValidationError, OSError, TypeError, AttributeError): manifest = None - yield from self._iter_pack_contributions( - manifest, ext_dir, "extension", ext_id, _lookup_ids - ) + yield from self._iter_pack_candidates(manifest, ext_dir) - yield from self._iter_project_override_artifacts(resolver) + yield from self._iter_project_override_candidates(resolver, layers_for) + yield from self._iter_core_candidates() @staticmethod - def _iter_pack_contributions( + def _iter_pack_candidates( manifest: Any, pack_dir: Path, - layer: str, - source_id: str, - lookup_ids: Callable[[ArtifactKind, str], set[str]], - ) -> Iterable[tuple[ArtifactKind, str, str, str]]: - """Yield ``(kind, name, description, lookup_id)`` for one pack. - - ``manifest`` is a validated ``PresetManifest``/``ExtensionManifest`` - (or ``None`` if the pack has no usable manifest). Declared - contributions come from the manifest's own ``iter_contributions()``; - convention-placed files are scanned separately since they exist - whether or not any manifest declares them. - """ + ) -> Iterable[tuple[ArtifactKind, str]]: + """Yield manifest-declared and convention-based candidate names.""" if manifest is not None: for contribution in manifest.iter_contributions(): kind = contribution.get("kind") name = contribution.get("name") - if kind 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 = "" - # Use the manifest-computed id verbatim so the join with - # ``collect_all_layers()`` stays direct even when the - # installed directory (``source_id``) differs from the - # manifest's declared ``id:`` (renamed pack). The resolver's - # manifest-declared preset/extension layers derive their - # ``lookupId`` from ``manifest.id`` for the same reason. - 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, lookup_id - - # Convention fallback: a preset/extension file placed at the - # conventional path resolves whether or not the manifest declares it, - # so it belongs in the inventory as well. - for kind, name, path in _iter_convention_contributions(pack_dir): - lookup_id = derive_named_id(layer, source_id, kind, name) - if lookup_id in lookup_ids(kind, name): - yield kind, name, _describe_artifact_file(path, kind), lookup_id - - def _iter_project_override_artifacts( + if kind in ("command", "template", "script") and isinstance(name, str): + yield kind, name + + yield from ((kind, name) for kind, name, _path in _iter_convention_contributions(pack_dir)) + + def _iter_project_override_candidates( self, resolver: Any, - ) -> Iterable[tuple[ArtifactKind, str, str, str]]: - """Yield ``(kind, name, description, lookup_id)`` for project overrides. + layers_for: Callable[[ArtifactKind, str], list[dict[str, Any]]], + ) -> Iterable[tuple[ArtifactKind, str]]: + """Yield candidate ``(kind, name)`` pairs for project overrides. A root ``overrides/.md`` file is the override for both the ``template`` and the ``command`` lookup of ````. It is reported @@ -1003,8 +754,7 @@ def _iter_project_override_artifacts( if not backed_kinds: backed_kinds.append("command" if is_dotted_command_name(name) else "template") for kind in backed_kinds: - lookup_id = derive_named_id(PROJECT_OVERRIDE_LAYER, "_", kind, name) - yield kind, name, _describe_artifact_file(entry, kind), lookup_id + yield kind, name scripts_dir = overrides_dir / "scripts" if not scripts_dir.is_dir(): return @@ -1012,8 +762,130 @@ def _iter_project_override_artifacts( 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, _describe_artifact_file(entry, "script"), lookup_id + yield "script", entry.stem + + def _iter_core_candidates(self) -> Iterable[tuple[ArtifactKind, str]]: + """Yield candidate names from resolver-compatible core asset paths.""" + from ..extensions import CORE_COMMAND_NAMES # lazy: avoids circular import + from ..presets import PresetResolver + + project_commands_dir = _project_core_asset_root(self.project_root, "commands") + bundled_commands_dir = _core_asset_root("commands") + command_dirs = tuple( + directory + for directory in (project_commands_dir, bundled_commands_dir) + if directory is not None + ) + command_names = {_core_command_logical_name(name) for name in CORE_COMMAND_NAMES} + for directory in command_dirs: + for entry in sorted(directory.iterdir(), key=lambda p: p.name): + if entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX: + command_names.add(_core_command_logical_name(entry.stem)) + for name in sorted(command_names): + if any( + (directory / f"{candidate}.md").is_file() + for directory in command_dirs + for candidate in PresetResolver.core_name_candidates(name) + ): + yield "command", name + + for directory in ( + _project_core_asset_root(self.project_root, "templates"), + _core_asset_root("templates"), + ): + if directory is None: + continue + for entry in sorted(directory.iterdir(), key=lambda p: p.name): + if entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX: + yield "template", entry.stem + + seen_scripts: set[str] = set() + for directory in ( + _project_core_asset_root(self.project_root, "scripts"), + _core_asset_root("scripts"), + ): + if directory is None: + continue + for entry in sorted(directory.glob(f"*{_SCRIPT_SUFFIX}"), key=lambda p: p.name): + if entry.stem not in seen_scripts: + seen_scripts.add(entry.stem) + yield "script", entry.stem + for runtime_dir in sorted(directory.iterdir(), key=lambda p: p.name): + if not runtime_dir.is_dir(): + continue + for entry in sorted(runtime_dir.iterdir(), key=lambda p: p.name): + if not entry.is_file(): + continue + name = canonical_script_name(entry) + if name is not None and name not in seen_scripts: + seen_scripts.add(name) + yield "script", name + + def _describe_layer( + self, + layer: dict[str, Any], + kind: ArtifactKind, + name: str, + ) -> str: + """Return manifest metadata or on-disk metadata for one resolver layer.""" + manifest_description = self._manifest_description_for_layer(layer, kind, name) + if manifest_description: + return manifest_description + path = layer.get("path") + if isinstance(path, Path): + return _describe_artifact_file(path, kind) + return "" + + def _manifest_description_for_layer( + self, + layer: dict[str, Any], + kind: ArtifactKind, + name: str, + ) -> str: + lookup_id = layer.get("lookupId", "") + layer_kind = layer_kind_from_lookup_id(lookup_id) + manifest = None + if layer_kind == "preset": + pack_dir = layer.get("pack_dir") + if not isinstance(pack_dir, Path): + preset_id = layer.get("preset_id") + if not preset_id: + return "" + pack_dir = self.project_root / ".specify" / "presets" / preset_id + manifest_path = pack_dir / "preset.yml" + if manifest_path.is_file(): + try: + from ..presets import PresetManifest, PresetValidationError + + manifest = PresetManifest(manifest_path) + except (PresetValidationError, OSError, TypeError, AttributeError): + manifest = None + elif layer_kind == "extension": + ext_dir = layer.get("extension_dir") + if not isinstance(ext_dir, Path): + extension_id = layer.get("extension_id") + if not extension_id: + return "" + ext_dir = self.project_root / ".specify" / "extensions" / extension_id + manifest_path = ext_dir / "extension.yml" + if manifest_path.is_file(): + try: + from ..extensions import ExtensionManifest, ValidationError + + manifest = ExtensionManifest(manifest_path) + except (ValidationError, OSError, TypeError, AttributeError): + manifest = None + if manifest is None: + return "" + for contribution in manifest.iter_contributions(): + if ( + contribution.get("id") == lookup_id + and contribution.get("kind") == kind + and contribution.get("name") == name + ): + description = contribution.get("description", "") + return description if isinstance(description, str) else "" + return "" _CONVENTION_SUBDIRS: tuple[tuple[str, ArtifactKind, str], ...] = ( @@ -1057,7 +929,6 @@ def _iter_convention_contributions( "ArtifactKind", "ArtifactNotFoundError", "ArtifactResolutionError", - "CoreBaseline", "LayerName", "NotASpecKitProjectError", "StackLayer", From 19beb04e2904f84cc093c0e6d0c3ab779003835f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:24:56 +0000 Subject: [PATCH 064/113] Address artifact inventory review Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 56 ++++++++++++++++++++------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index d12fcafaf2..da0c2ccf42 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -562,10 +562,11 @@ def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: names.add(key) artifacts: list[Artifact] = [] + manifest_cache: dict[Path, Any | None] = {} for kind, name in names: description = "" for layer in _layers_for(kind, name): - candidate = self._describe_layer(layer, kind, name) + candidate = self._describe_layer(layer, kind, name, manifest_cache) if candidate: description = candidate break @@ -712,7 +713,12 @@ def _iter_pack_candidates( for contribution in manifest.iter_contributions(): kind = contribution.get("kind") name = contribution.get("name") - if kind in ("command", "template", "script") and isinstance(name, str): + if ( + kind in ("command", "template", "script") + and isinstance(name, str) + and name + and ":" not in name + ): yield kind, name yield from ((kind, name) for kind, name, _path in _iter_convention_contributions(pack_dir)) @@ -826,9 +832,12 @@ def _describe_layer( layer: dict[str, Any], kind: ArtifactKind, name: str, + manifest_cache: dict[Path, Any | None], ) -> str: """Return manifest metadata or on-disk metadata for one resolver layer.""" - manifest_description = self._manifest_description_for_layer(layer, kind, name) + manifest_description = self._manifest_description_for_layer( + layer, kind, name, manifest_cache + ) if manifest_description: return manifest_description path = layer.get("path") @@ -841,6 +850,7 @@ def _manifest_description_for_layer( layer: dict[str, Any], kind: ArtifactKind, name: str, + manifest_cache: dict[Path, Any | None], ) -> str: lookup_id = layer.get("lookupId", "") layer_kind = layer_kind_from_lookup_id(lookup_id) @@ -854,12 +864,20 @@ def _manifest_description_for_layer( pack_dir = self.project_root / ".specify" / "presets" / preset_id manifest_path = pack_dir / "preset.yml" if manifest_path.is_file(): - try: - from ..presets import PresetManifest, PresetValidationError - - manifest = PresetManifest(manifest_path) - except (PresetValidationError, OSError, TypeError, AttributeError): - manifest = None + if manifest_path not in manifest_cache: + try: + from ..presets import PresetManifest, PresetValidationError + + manifest_cache[manifest_path] = PresetManifest(manifest_path) + except ( + PresetValidationError, + yaml.YAMLError, + OSError, + TypeError, + AttributeError, + ): + manifest_cache[manifest_path] = None + manifest = manifest_cache[manifest_path] elif layer_kind == "extension": ext_dir = layer.get("extension_dir") if not isinstance(ext_dir, Path): @@ -869,12 +887,20 @@ def _manifest_description_for_layer( ext_dir = self.project_root / ".specify" / "extensions" / extension_id manifest_path = ext_dir / "extension.yml" if manifest_path.is_file(): - try: - from ..extensions import ExtensionManifest, ValidationError - - manifest = ExtensionManifest(manifest_path) - except (ValidationError, OSError, TypeError, AttributeError): - manifest = None + if manifest_path not in manifest_cache: + try: + from ..extensions import ExtensionManifest, ValidationError + + manifest_cache[manifest_path] = ExtensionManifest(manifest_path) + except ( + ValidationError, + yaml.YAMLError, + OSError, + TypeError, + AttributeError, + ): + manifest_cache[manifest_path] = None + manifest = manifest_cache[manifest_path] if manifest is None: return "" for contribution in manifest.iter_contributions(): From dd9ef6480e69962539055f13594826acef5a678a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:24:56 +0000 Subject: [PATCH 065/113] Address artifact inventory review Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index da0c2ccf42..b61fd500db 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, Callable, Iterable, Literal +from typing import Any, Iterable, Literal import yaml @@ -554,7 +554,7 @@ def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: return layers_cache[key] names: set[tuple[ArtifactKind, str]] = set() - for kind, name in self._iter_candidate_artifacts(resolver, _layers_for): + for kind, name in self._iter_candidate_artifacts(resolver): key = (kind, name) if not _is_valid_artifact_name_component(name, kind): continue @@ -644,7 +644,6 @@ def _describe(self, kind: ArtifactKind, name: str) -> str: def _iter_candidate_artifacts( self, resolver: Any, - layers_for: Callable[[ArtifactKind, str], list[dict[str, Any]]], ) -> Iterable[tuple[ArtifactKind, str]]: """Yield candidate ``(kind, name)`` pairs from every resolver tier. @@ -700,7 +699,7 @@ def _iter_candidate_artifacts( manifest = None yield from self._iter_pack_candidates(manifest, ext_dir) - yield from self._iter_project_override_candidates(resolver, layers_for) + yield from self._iter_project_override_candidates(resolver) yield from self._iter_core_candidates() @staticmethod @@ -726,7 +725,6 @@ def _iter_pack_candidates( def _iter_project_override_candidates( self, resolver: Any, - layers_for: Callable[[ArtifactKind, str], list[dict[str, Any]]], ) -> Iterable[tuple[ArtifactKind, str]]: """Yield candidate ``(kind, name)`` pairs for project overrides. From 8c29bfe6afa9d90a426f0e27ad0b923bf9353da5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:24:56 +0000 Subject: [PATCH 066/113] Address artifact inventory review Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 42 ++++++++++++++++++++------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index b61fd500db..16b57cb6ed 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -563,10 +563,13 @@ def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: artifacts: list[Artifact] = [] manifest_cache: dict[Path, Any | None] = {} + manifest_description_cache: dict[Path, dict[tuple[str, str, str], str]] = {} for kind, name in names: description = "" for layer in _layers_for(kind, name): - candidate = self._describe_layer(layer, kind, name, manifest_cache) + candidate = self._describe_layer( + layer, kind, name, manifest_cache, manifest_description_cache + ) if candidate: description = candidate break @@ -793,6 +796,7 @@ def _iter_core_candidates(self) -> Iterable[tuple[ArtifactKind, str]]: ): yield "command", name + seen_templates: set[str] = set() for directory in ( _project_core_asset_root(self.project_root, "templates"), _core_asset_root("templates"), @@ -800,7 +804,12 @@ def _iter_core_candidates(self) -> Iterable[tuple[ArtifactKind, str]]: if directory is None: continue for entry in sorted(directory.iterdir(), key=lambda p: p.name): - if entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX: + if ( + entry.is_file() + and entry.suffix == _TEMPLATE_SUFFIX + and entry.stem not in seen_templates + ): + seen_templates.add(entry.stem) yield "template", entry.stem seen_scripts: set[str] = set() @@ -831,10 +840,11 @@ def _describe_layer( kind: ArtifactKind, name: str, manifest_cache: dict[Path, Any | None], + manifest_description_cache: dict[Path, dict[tuple[str, str, str], str]], ) -> str: """Return manifest metadata or on-disk metadata for one resolver layer.""" manifest_description = self._manifest_description_for_layer( - layer, kind, name, manifest_cache + layer, kind, name, manifest_cache, manifest_description_cache ) if manifest_description: return manifest_description @@ -849,6 +859,7 @@ def _manifest_description_for_layer( kind: ArtifactKind, name: str, manifest_cache: dict[Path, Any | None], + manifest_description_cache: dict[Path, dict[tuple[str, str, str], str]], ) -> str: lookup_id = layer.get("lookupId", "") layer_kind = layer_kind_from_lookup_id(lookup_id) @@ -901,15 +912,24 @@ def _manifest_description_for_layer( manifest = manifest_cache[manifest_path] if manifest is None: return "" - for contribution in manifest.iter_contributions(): - if ( - contribution.get("id") == lookup_id - and contribution.get("kind") == kind - and contribution.get("name") == name - ): + if manifest_path not in manifest_description_cache: + descriptions: dict[tuple[str, str, str], str] = {} + for contribution in manifest.iter_contributions(): + contribution_id = contribution.get("id") + contribution_kind = contribution.get("kind") + contribution_name = contribution.get("name") description = contribution.get("description", "") - return description if isinstance(description, str) else "" - return "" + if ( + isinstance(contribution_id, str) + and isinstance(contribution_kind, str) + and isinstance(contribution_name, str) + and isinstance(description, str) + ): + descriptions[ + (contribution_kind, contribution_name, contribution_id) + ] = description + manifest_description_cache[manifest_path] = descriptions + return manifest_description_cache[manifest_path].get((kind, name, lookup_id), "") _CONVENTION_SUBDIRS: tuple[tuple[str, ArtifactKind, str], ...] = ( From 86780140bcbe10204a4331f31ac195d719c68ddb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:45:14 +0000 Subject: [PATCH 067/113] source-agnostic artifact IDs; built-in tier recognized by exclusion, never by name. Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- docs/reference/artifacts.md | 15 +++-- extensions/EXTENSION-API-REFERENCE.md | 8 +-- src/specify_cli/_identifier.py | 35 +++++++--- src/specify_cli/artifacts/__init__.py | 92 +++++++++++++-------------- src/specify_cli/presets/__init__.py | 14 ++-- tests/test_artifact_command.py | 52 ++++++++------- tests/test_contribution_ids.py | 22 ++++--- tests/test_presets.py | 16 ++--- 8 files changed, 135 insertions(+), 119 deletions(-) diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md index fa9e530a34..2816b5d0dd 100644 --- a/docs/reference/artifacts.md +++ b/docs/reference/artifacts.md @@ -66,6 +66,7 @@ specify artifact info --json "stack": [ { "layer": "preset", + "sourceId": "compliance", "presetId": "compliance", "presetName": "Compliance Preset", "strategy": "replace", @@ -75,14 +76,15 @@ specify artifact info --json "lookupId": "preset:compliance:command:speckit.specify" }, { - "layer": "core", + "layer": null, + "sourceId": null, "presetId": null, "presetName": null, "strategy": "replace", "active": false, "hidden": true, "manifestPath": null, - "lookupId": "core:_:command:speckit.specify" + "lookupId": null } ] } @@ -96,16 +98,17 @@ The top-level `id`, `name`, `kind`, and `description` fields match the correspon | Field | Description | | -------------- | -------------------------------------------------------------------------------- | -| `layer` | `project`, `preset`, `extension`, or `core` | -| `presetId` | Preset pack directory id; `null` on `core`, `project`, and `extension` rows | +| `layer` | `project`, `preset`, or `extension`; `null` for built-in layers | +| `sourceId` | Source component of `lookupId`, or `null` when the layer has no provenance | +| `presetId` | Preset pack directory id; `null` on built-in, `project`, and `extension` rows | | `presetName` | Preset display name when its manifest declares one, else the pack id; `null` when `presetId` is `null` | | `strategy` | `replace`, `wrap`, `prepend`, or `append` | | `active` | `true` only for index `0` — the layer whose content is served | | `hidden` | `true` when a lower-index `replace` layer cuts this layer out of the composition | | `manifestPath` | Project-relative path to the declaring manifest, or `null` when none applies | -| `lookupId` | Deterministic `{layer}:{sourceId}:{kind}:{name}` identifier for the layer | +| `lookupId` | Deterministic `{layer}:{sourceId}:{kind}:{name}` identifier, or `null` for built-in layers | -`active` and `hidden` are independent labels, not opposites. Composing strategies (`wrap`, `prepend`, `append`) keep lower layers in the composed output, so an inactive layer is not necessarily hidden: only layers below the first `replace` layer are marked `hidden`. Core rows appear as the base of the stack with `presetId`/`presetName`/`manifestPath` set to `null` and a `core:_:{kind}:{name}` lookup ID. +`active` and `hidden` are independent labels, not opposites. Composing strategies (`wrap`, `prepend`, `append`) keep lower layers in the composed output, so an inactive layer is not necessarily hidden: only layers below the first `replace` layer are marked `hidden`. Built-in rows have no provenance: `layer`, `sourceId`, and `lookupId` are `null`. Lookup IDs use the same grammar as [preset contribution identifiers](presets.md#contribution-identifiers), so a `lookupId` from this command joins directly to `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` for manifest-declared layers. Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID that intentionally matches no manifest contribution. diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index c4be28a193..9e2555390d 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -863,7 +863,7 @@ satisfied = version_satisfies("1.2.3", ">=1.0.0,<2.0.0") # bool ## Contribution Identifiers -Every command, template, script, and hook contributed by an extension (or 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 shares this grammar and identifies the layer's stack position. Manifest-declared preset and extension layers use the manifest's validated `id:` for `lookupId`'s `sourceId` component, so their `lookupId` joins directly to the matching `iter_contributions()` entry even after the installed directory is renamed; convention-only contributions have no manifest `id:` to consult and fall back to the on-disk directory / registry key instead (see [Determinism guarantees](#determinism-guarantees) below). Identifiers are **computed on demand from author-declared manifest content** and are **never persisted** to `.specify/` or to any cache file. +Every command, template, script, and hook contributed by an extension or preset is addressable at read time by a deterministic opaque identifier. Resolved artifact-stack layers carry a matching `lookupId` field when they have provenance. Manifest-declared preset and extension layers use the manifest's validated `id:` for `lookupId`'s `sourceId` component, so their `lookupId` joins directly to the matching `iter_contributions()` entry even after the installed directory is renamed; convention-only contributions have no manifest `id:` to consult and fall back to the on-disk directory / registry key instead (see [Determinism guarantees](#determinism-guarantees) below). Identifiers are **computed on demand from author-declared manifest content** and are **never persisted** to `.specify/` or to any cache file. ### Grammar @@ -873,9 +873,9 @@ Named contributions (commands, templates, scripts) follow: {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`. +- `layer` is one of `preset` or `extension`. +- `sourceId` is the preset pack id for `preset`, or the extension id for `extension`. +- `kind` is one of `command`, `template`, or `script`. - `name` is the contribution's declared `name` field. Hook contributions use a compound name-component built from the event and command: diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py index 6637217238..7193dc05f7 100644 --- a/src/specify_cli/_identifier.py +++ b/src/specify_cli/_identifier.py @@ -9,19 +9,22 @@ across machines, project locations, and reinstalls, and what lets consumers use them as stable join keys. -Grammar for named contributions (commands, templates, scripts):: +Grammar for manifest-backed 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"} + layer ∈ {"project", "preset", "extension"} + sourceId = "_" when layer == "project"; the preset or extension id otherwise + kind ∈ {"command", "template", "script"} name = the contribution's declared ``name`` Hook identifiers use ``{eventName}:{command}`` as the name component:: id = "{layer}:{sourceId}:hook:{eventName}:{command}" +Built-in artifacts have no layer or lookup identifier. Their public identifier +is source-agnostic: ``"{kind}:{name}"``. + The functions in this module are pure — inputs are strings or in-memory mappings parsed from a manifest, outputs are strings. None of them read from disk, look at ``os.environ``, call ``datetime``, or hash file contents. That @@ -88,6 +91,10 @@ def derive_named_id(layer: str, source_id: str, kind: str, name: str) -> str: strings should either pre-validate or handle :class:`IdentifierComponentError`. """ + if layer not in _LAYER_KINDS: + raise IdentifierComponentError(f"Invalid layer '{layer}'") + if kind not in _NAMED_CONTRIBUTION_KINDS: + raise IdentifierComponentError(f"Invalid named contribution kind '{kind}'") validate_component(layer, "layer") validate_component(source_id, "sourceId") validate_component(kind, "kind") @@ -95,8 +102,14 @@ 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"}) +_LAYER_KINDS = frozenset({PROJECT_OVERRIDE_LAYER, "preset", "extension"}) _CONTRIBUTION_KINDS = frozenset({"command", "template", "script", "hook"}) +_NAMED_CONTRIBUTION_KINDS = _CONTRIBUTION_KINDS - {"hook"} + + +def derive_public_id(kind: str, name: str) -> str: + """Build the source-agnostic public identifier for an artifact.""" + return f"{kind}:{name}" def layer_kind_from_lookup_id(lookup_id: str) -> str | None: @@ -104,8 +117,8 @@ def layer_kind_from_lookup_id(lookup_id: str) -> str | 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. + module docstring), including :data:`PROJECT_OVERRIDE_LAYER` for + project-local override layers. This is the single place that knows the set of valid layer prefixes, so consumers can classify a lookupId without re-deriving the grammar via string-prefix checks of their own. @@ -114,9 +127,9 @@ def layer_kind_from_lookup_id(lookup_id: str) -> str | None: named contributions require exactly the four ``{layer}:{sourceId}:{kind}: {name}`` components, and hook contributions require exactly the five ``{layer}:{sourceId}:hook:{eventName}:{command}`` components, with every - component non-empty. A value such as ``"core:not-an-id"`` or ``"preset:x"`` - has a recognized layer prefix but the wrong number of components, so it is - malformed and returns ``None`` rather than being treated as authoritative. + component non-empty. A value such as ``"preset:x"`` has a recognized layer + prefix but the wrong number of components, so it is malformed and returns + ``None`` rather than being treated as authoritative. """ parts = lookup_id.split(":") if len(parts) < 4 or any(not part for part in parts): @@ -159,6 +172,8 @@ def derive_hook_id( Each component is revalidated with :func:`validate_component` — same contract as :func:`derive_named_id`. """ + if layer not in _LAYER_KINDS: + raise IdentifierComponentError(f"Invalid layer '{layer}'") validate_component(layer, "layer") validate_component(source_id, "sourceId") validate_component(event_name, "eventName") diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 16b57cb6ed..4df4c4c0ce 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -22,6 +22,7 @@ from .._identifier import ( PROJECT_OVERRIDE_LAYER, IdentifierComponentError, + derive_public_id, is_dotted_command_name, layer_kind_from_lookup_id, validate_component, @@ -33,7 +34,7 @@ # --------------------------------------------------------------------------- ArtifactKind = Literal["command", "template", "script"] -LayerName = Literal["project", "preset", "extension", "core"] +LayerName = Literal["project", "preset", "extension"] Strategy = Literal["replace", "wrap", "prepend", "append"] @@ -59,18 +60,20 @@ def to_json_dict(self) -> dict[str, Any]: class StackLayer: """One row inside the ``stack`` array returned by ``get_artifact_info()``.""" - layer: LayerName + layer: LayerName | None + sourceId: str | None presetId: str | None presetName: str | None strategy: Strategy active: bool hidden: bool manifestPath: str | None - lookupId: str + lookupId: str | None def to_json_dict(self) -> dict[str, Any]: return { "layer": self.layer, + "sourceId": self.sourceId, "presetId": self.presetId, "presetName": self.presetName, "strategy": self.strategy, @@ -335,8 +338,7 @@ def _build_stack( rows: list[StackLayer] = [] for idx, layer in enumerate(raw): - lookup_id = layer.get("lookupId", "") - source = str(layer.get("source", "")) + lookup_id = layer.get("lookupId") strategy = layer["strategy"] active = idx == 0 @@ -345,15 +347,14 @@ def _build_stack( else: hidden = idx > first_replace_idx - # Layer classification: the lookupId prefix is the resolver's own - # grammar (see layer_kind_from_lookup_id) and is authoritative; the - # source-string check only guards against a malformed lookupId. - layer_kind = layer_kind_from_lookup_id(lookup_id) + layer_kind = layer_kind_from_lookup_id(lookup_id) if isinstance(lookup_id, str) else None + source_id = lookup_id.split(":", 2)[1] if layer_kind else None - if layer_kind == "core" or (layer_kind is None and source.startswith("core")): + if layer_kind == PROJECT_OVERRIDE_LAYER: rows.append( StackLayer( - layer="core", + layer="project", + sourceId=source_id, presetId=None, presetName=None, strategy=strategy, @@ -365,37 +366,35 @@ def _build_stack( ) continue - if layer_kind == PROJECT_OVERRIDE_LAYER or ( - layer_kind is None and source == "project override" - ): + if layer_kind == "extension": + manifest_path = _derive_manifest_path(layer, project_root) rows.append( StackLayer( - layer="project", + layer="extension", + sourceId=source_id, presetId=None, presetName=None, strategy=strategy, active=active, hidden=hidden, - manifestPath=None, + manifestPath=manifest_path, lookupId=lookup_id, ) ) continue - if layer_kind == "extension" or ( - layer_kind is None and source.startswith("extension:") - ): - manifest_path = _derive_manifest_path(layer, project_root) + if layer_kind != "preset": rows.append( StackLayer( - layer="extension", + layer=None, + sourceId=None, presetId=None, presetName=None, strategy=strategy, active=active, hidden=hidden, - manifestPath=manifest_path, - lookupId=lookup_id, + manifestPath=None, + lookupId=None, ) ) continue @@ -417,6 +416,7 @@ def _build_stack( rows.append( StackLayer( layer="preset", + sourceId=source_id, presetId=pack_id or None, presetName=display or None, strategy=strategy, @@ -546,6 +546,7 @@ def list_artifacts(self) -> list[Artifact]: resolver = PresetResolver(self.project_root) layers_cache: dict[tuple[ArtifactKind, str], list[dict[str, Any]]] = {} + resolved_cache: dict[tuple[ArtifactKind, str], bool] = {} def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: key = (kind, name) @@ -553,12 +554,18 @@ def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: layers_cache[key] = resolver.collect_all_layers(name, kind) return layers_cache[key] + def _is_resolved(kind: ArtifactKind, name: str) -> bool: + key = (kind, name) + if key not in resolved_cache: + resolved_cache[key] = resolver.resolve_content(name, kind) is not None + return resolved_cache[key] + names: set[tuple[ArtifactKind, str]] = set() for kind, name in self._iter_candidate_artifacts(resolver): key = (kind, name) if not _is_valid_artifact_name_component(name, kind): continue - if _layers_for(kind, name): + if _layers_for(kind, name) and _is_resolved(kind, name): names.add(key) artifacts: list[Artifact] = [] @@ -574,7 +581,7 @@ def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: description = candidate break artifacts.append( - Artifact(id=f"{kind}:{name}", name=name, kind=kind, description=description) + Artifact(id=derive_public_id(kind, name), name=name, kind=kind, description=description) ) kind_order = {"command": 0, "template": 1, "script": 2} @@ -602,8 +609,9 @@ def get_artifact_info( _validate_preset_registry(self.project_root) bare, resolved_kind = _resolve_kind_hint(name, kind) + inventory = self.list_artifacts() if resolved_kind is None: - matches = self._find_matches(bare) + matches = [(artifact.kind, artifact.name) for artifact in inventory if artifact.name == bare] if not matches: raise ArtifactNotFoundError(name) if len(matches) > 1: @@ -611,39 +619,29 @@ 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)): + artifact = next( + ( + item + for item in inventory + if item.kind == resolved_kind and item.name == validated_name + ), + None, + ) + if artifact is None: raise ArtifactNotFoundError(name) stack = _build_stack(self.project_root, resolved_kind, validated_name) if not stack: raise ArtifactNotFoundError(name) - description = self._describe(resolved_kind, validated_name) return { - "id": f"{resolved_kind}:{validated_name}", + "id": derive_public_id(resolved_kind, validated_name), "name": validated_name, "kind": resolved_kind, - "description": description, + "description": artifact.description, "stack": [layer.to_json_dict() for layer in stack], } # -------------------------------------------------------------- internals - 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_candidate_artifacts( self, resolver: Any, diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index f20a29268a..9dfb6d336a 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -1861,7 +1861,7 @@ def record_written(written: Dict[str, List[str]]) -> None: if not registered: # Top layer is a non-preset source (extension, core, or # project override). Register directly from the layer path. - source = layers[0]["source"] + source = layers[0].get("source") or "" extension_id = None written: Dict[str, List[str]] = {} if source.startswith("extension:"): @@ -1985,7 +1985,7 @@ def record_written(written: Dict[str, List[str]]) -> None: shared_composed.mkdir(parents=True, exist_ok=True) composed_file = shared_composed / f"{cmd_name}.md" composed_file.write_text(composed, encoding="utf-8") - source = layers[0]["source"] + source = layers[0].get("source") or "" if source.startswith("extension:"): source_id = source.split(":", 1)[1].split(" ", 1)[0] else: @@ -5784,11 +5784,8 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: if core: layers.append({ "path": core, - "source": "core", + "source": None, "strategy": "replace", - "lookupId": derive_named_id( - "core", "_", template_type, template_name - ), }) else: # Priority 5: Bundled core_pack (wheel install) or repo-root @@ -5797,11 +5794,8 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: if bundled: layers.append({ "path": bundled, - "source": "core (bundled)", + "source": None, "strategy": "replace", - "lookupId": derive_named_id( - "core", "_", template_type, template_name - ), }) return layers diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 96d9b556b0..b03b688dd7 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -113,7 +113,9 @@ def test_core_script_variants_have_one_resolvable_logical_name( } for script in scripts: info = catalog.get_artifact_info(script.id) - assert info["stack"][-1]["lookupId"] == f"core:_:script:{script.name}" + assert info["stack"][-1]["layer"] is None + assert info["stack"][-1]["sourceId"] is None + assert info["stack"][-1]["lookupId"] is None def test_excludes_disabled_and_unusable_manifest_contributions( self, spec_kit_project: Path @@ -254,15 +256,11 @@ def test_includes_project_local_core_assets(self, spec_kit_project: Path): 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" - ) + for name in ("speckit.local-command", "legacy-template", "legacy-script"): + layer = catalog.get_artifact_info(name)["stack"][0] + assert layer["layer"] is None + assert layer["sourceId"] is None + assert layer["lookupId"] is None def test_includes_root_level_pack_template_but_excludes_readme( self, spec_kit_project: Path @@ -470,14 +468,15 @@ def test_active_is_index_zero(self, spec_kit_project: Path): for layer in info["stack"][1:]: assert layer["active"] is False - def test_core_row_shape(self, spec_kit_project: Path): + def test_builtin_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"]) + builtin = next(layer for layer in info["stack"] if layer["layer"] is None) + assert builtin["sourceId"] is None + assert builtin["presetId"] is None + assert builtin["presetName"] is None + assert builtin["manifestPath"] is None + assert builtin["strategy"] == "replace" + assert builtin["lookupId"] is None def test_project_override_row_shape(self, spec_kit_project: Path): overrides = spec_kit_project / ".specify" / "templates" / "overrides" @@ -491,13 +490,18 @@ def test_project_override_row_shape(self, spec_kit_project: Path): assert project["presetName"] is None assert project["manifestPath"] is None assert project["strategy"] == "replace" + assert project["sourceId"] == "_" assert re.match(r"^project:_:(command|template|script):[^:]+$", project["lookupId"]) def test_lookup_id_grammar(self, spec_kit_project: Path): info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") for layer in info["stack"]: + if layer["lookupId"] is None: + assert layer["layer"] is None + assert layer["sourceId"] is None + continue assert re.match( - r"^(project|preset|extension|core):[^:]+:(command|template|script):[^:]+(:[0-9a-f]{12})?$", + r"^(project|preset|extension):[^:]+:(command|template|script):[^:]+(:[0-9a-f]{12})?$", layer["lookupId"], ) @@ -820,9 +824,9 @@ def test_preset_replace_hides_core(self, spec_kit_project: Path): 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: + # If a lower built-in layer exists it must be hidden. + built_in_rows = [layer for layer in stack if layer["layer"] is None] + for row in built_in_rows: assert row["hidden"] is True @@ -1095,13 +1099,13 @@ def test_missing_provenance_keys_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): + def test_builtin_and_project_layers_have_no_manifest(self, tmp_path: Path): project_root = tmp_path / "proj" project_root.mkdir() - core_layer = {"lookupId": "core:_:template:spec-template"} + builtin_layer = {} project_layer = {"lookupId": "project:_:template:spec-template"} - assert _derive_manifest_path(core_layer, project_root) is None + assert _derive_manifest_path(builtin_layer, project_root) is None assert _derive_manifest_path(project_layer, project_root) is None diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py index fb3aa69c34..2472aa4684 100644 --- a/tests/test_contribution_ids.py +++ b/tests/test_contribution_ids.py @@ -29,6 +29,7 @@ PROJECT_OVERRIDE_LAYER, derive_hook_id, derive_named_id, + derive_public_id, layer_kind_from_lookup_id, validate_component, ) @@ -118,9 +119,9 @@ class TestIdentifierDerivation: @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"), + ("project", "_", "command", "speckit.constitution", "project:_:command:speckit.constitution"), + ("project", "_", "template", "spec-template", "project:_:template:spec-template"), + ("project", "_", "script", "setup-plan", "project:_:script:setup-plan"), ("preset", "speckit-core", "command", "speckit.plan", "preset:speckit-core:command:speckit.plan"), ("preset", "speckit-core", "template", "spec-template", "preset:speckit-core:template:spec-template"), ("preset", "speckit-core", "script", "setup-plan", "preset:speckit-core:script:setup-plan"), @@ -135,7 +136,7 @@ def test_named_id_grammar(self, 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"), + ("project", "_", "before_specify", "speckit.constitution", "project:_: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"), ], @@ -148,6 +149,9 @@ def test_named_id_stable_across_two_derivations(self): b = derive_named_id("preset", "speckit-core", "command", "speckit.plan") assert a == b + def test_public_id_is_source_agnostic(self): + assert derive_public_id("command", "speckit.plan") == "command:speckit.plan" + class TestLayerKindFromLookupId: """``layer_kind_from_lookup_id`` extracts the layer segment of a lookupId.""" @@ -155,7 +159,6 @@ class TestLayerKindFromLookupId: @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), @@ -173,6 +176,7 @@ def test_recognized_layer_prefixes(self, lookup_id, expected): [ "", "bogus:_:command:speckit.plan", + "core:_:command:speckit.plan", "core", ":_:command:speckit.plan", "core:not-an-id", @@ -352,7 +356,7 @@ def test_project_override_layer_carries_sentinel_lookup_id(self, tmp_path): PROJECT_OVERRIDE_LAYER, "_", "template", "spec-template" ) - def test_core_layer_carries_core_lookup_id(self, tmp_path): + def test_builtin_layer_has_no_provenance(self, tmp_path): project = _make_project(tmp_path) (project / "templates" / "spec-template.md").write_text("core", encoding="utf-8") # PresetResolver reads templates from a bundled/repo path — point the @@ -360,10 +364,8 @@ 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(layer for layer in layers if layer["source"] == "core") - assert core_layer["lookupId"] == derive_named_id( - "core", "_", "template", "spec-template" - ) + builtin_layer = next(layer for layer in layers if "lookupId" not in layer) + assert builtin_layer["source"] is None def test_preset_layer_lookup_id_matches_manifest_contribution_id(self, tmp_path): project = _make_project(tmp_path) diff --git a/tests/test_presets.py b/tests/test_presets.py index 4899ab5df0..54fa5d382f 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1704,7 +1704,7 @@ def test_collect_all_layers_finds_bundled_core_without_specify_commands( resolver = PresetResolver(project_dir) layers = resolver.collect_all_layers("speckit.implement", "command") assert layers, "expected a bundled core base layer to be found" - assert layers[-1]["source"] == "core (bundled)" + assert layers[-1]["source"] is None assert layers[-1]["path"].parts[-2:] == ("commands", "implement.md") def test_resolve_command_falls_back_to_bundled_core(self, project_dir): @@ -12879,7 +12879,7 @@ def test_single_core_layer(self, project_dir): resolver = PresetResolver(project_dir) layers = resolver.collect_all_layers("spec-template") assert len(layers) == 1 - assert layers[0]["source"] == "core" + assert layers[0]["source"] is None assert layers[0]["strategy"] == "replace" def test_layers_include_presets(self, project_dir, temp_dir, valid_pack_data): @@ -12894,7 +12894,7 @@ def test_layers_include_presets(self, project_dir, temp_dir, valid_pack_data): assert len(layers) == 2 # Highest priority first assert "test-pack" in layers[0]["source"] - assert layers[1]["source"] == "core" + assert layers[1]["source"] is None def test_layers_order_matches_priority(self, project_dir, temp_dir, valid_pack_data): """Test that layers are ordered by priority (highest first).""" @@ -12915,7 +12915,7 @@ def test_layers_order_matches_priority(self, project_dir, temp_dir, valid_pack_d assert len(layers) == 3 # pack-hi, pack-lo, core assert "pack-hi" in layers[0]["source"] assert "pack-lo" in layers[1]["source"] - assert layers[2]["source"] == "core" + assert layers[2]["source"] is None def test_layers_read_strategy_from_manifest(self, project_dir, temp_dir, valid_pack_data): """Test that layers read strategy from preset manifest.""" @@ -12978,7 +12978,7 @@ def test_collect_all_layers_finds_powershell_only_core_script(self, project_dir) ) assert len(layers) == 1 assert layers[0]["path"] == path - assert layers[0]["source"] == "core" + assert layers[0]["source"] is None def test_resolve_finds_python_only_core_script(self, project_dir): """Only the underscored .py variant exists — the hyphenated logical @@ -13002,7 +13002,7 @@ def test_collect_all_layers_finds_python_only_core_script(self, project_dir): ) assert len(layers) == 1 assert layers[0]["path"] == path - assert layers[0]["source"] == "core" + assert layers[0]["source"] is None def test_resolve_finds_legacy_flat_core_script(self, project_dir): """The legacy flat .specify/templates/scripts/.sh layout still @@ -13027,7 +13027,7 @@ def test_collect_all_layers_finds_legacy_flat_core_script(self, project_dir): ) assert len(layers) == 1 assert layers[0]["path"] == path - assert layers[0]["source"] == "core" + assert layers[0]["source"] is None class TestRemoveReconciliation: @@ -14094,7 +14094,7 @@ def test_wrap_composes_over_core_constitution(self, project_dir): assert len(layers) >= 2, "expected preset wrap layer plus a core base" assert layers[0]["strategy"] == "wrap" assert any("constitution-sync" in str(layer["path"]) for layer in layers) - assert layers[-1]["source"] == "core (bundled)" + assert layers[-1]["source"] is None def test_resolved_content_embeds_core_and_sync_pass(self, project_dir): """resolve_content substitutes {CORE_TEMPLATE} so the effective command From 05eaf722f7ad0a7605bb2798d7a7c72d2ab7ded3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:46:16 +0000 Subject: [PATCH 068/113] fix: reject malformed artifact layer provenance Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/_identifier.py | 9 ++++----- src/specify_cli/artifacts/__init__.py | 4 +++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py index 7193dc05f7..facb29de98 100644 --- a/src/specify_cli/_identifier.py +++ b/src/specify_cli/_identifier.py @@ -49,6 +49,10 @@ is the correct outcome for a layer with no originating manifest entry. """ +_LAYER_KINDS = frozenset({PROJECT_OVERRIDE_LAYER, "preset", "extension"}) +_CONTRIBUTION_KINDS = frozenset({"command", "template", "script", "hook"}) +_NAMED_CONTRIBUTION_KINDS = _CONTRIBUTION_KINDS - {"hook"} + class IdentifierComponentError(ValueError): """Raised when a manifest component would break identifier grammar.""" @@ -102,11 +106,6 @@ def derive_named_id(layer: str, source_id: str, kind: str, name: str) -> str: return f"{layer}:{source_id}:{kind}:{name}" -_LAYER_KINDS = frozenset({PROJECT_OVERRIDE_LAYER, "preset", "extension"}) -_CONTRIBUTION_KINDS = frozenset({"command", "template", "script", "hook"}) -_NAMED_CONTRIBUTION_KINDS = _CONTRIBUTION_KINDS - {"hook"} - - def derive_public_id(kind: str, name: str) -> str: """Build the source-agnostic public identifier for an artifact.""" return f"{kind}:{name}" diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 4df4c4c0ce..ca7f53babd 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -349,6 +349,8 @@ def _build_stack( layer_kind = layer_kind_from_lookup_id(lookup_id) if isinstance(lookup_id, str) else None source_id = lookup_id.split(":", 2)[1] if layer_kind else None + if lookup_id is not None and layer_kind is None: + raise ArtifactResolutionError() if layer_kind == PROJECT_OVERRIDE_LAYER: rows.append( @@ -383,7 +385,7 @@ def _build_stack( ) continue - if layer_kind != "preset": + if layer_kind is None: rows.append( StackLayer( layer=None, From b6f44812a110e9ccef3e978a0b67cbe5026a88ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:56:30 +0000 Subject: [PATCH 069/113] Tighten artifact provenance handling Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- docs/reference/artifacts.md | 8 ++--- docs/reference/presets.md | 8 ++--- extensions/EXTENSION-API-REFERENCE.md | 4 +-- src/specify_cli/_identifier.py | 8 +++-- src/specify_cli/artifacts/__init__.py | 46 +++++++++++++++++--------- src/specify_cli/artifacts/_commands.py | 2 +- tests/test_artifact_command.py | 29 ++++++++++++++++ tests/test_contribution_ids.py | 21 +++++++++++- 8 files changed, 97 insertions(+), 29 deletions(-) diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md index 2816b5d0dd..d82fa558c7 100644 --- a/docs/reference/artifacts.md +++ b/docs/reference/artifacts.md @@ -1,8 +1,8 @@ # Artifacts -An **artifact** is any command, template, or script Spec Kit exposes in a project, regardless of which layer contributes it — the core baseline, an installed preset, an installed extension, or a project-local override in `.specify/templates/overrides/`. +An **artifact** is any command, template, or script Spec Kit exposes in a project, regardless of which layer contributes it — built-in assets, an installed preset, an installed extension, or a project-local override in `.specify/templates/overrides/`. -The `specify artifact` command group is the read-only introspection surface for that inventory. `specify preset resolve ` answers "which file wins for this preset-managed name?"; `specify artifact` answers "what exists at all, and what is the full composition stack behind it?" — including core-only artifacts that no preset touches. +The `specify artifact` command group is the read-only introspection surface for that inventory. `specify preset resolve ` answers "which file wins for this preset-managed name?"; `specify artifact` answers "what exists at all, and what is the full composition stack behind it?" — including built-in artifacts that no preset touches. Both subcommands currently require `--json`. Omitting it exits with code `2` and prints a usage message on stderr; no stdout is produced. Text rendering is deliberately deferred so the JSON shapes below are the only contract, and adding a default text renderer later stays a non-breaking, additive change. @@ -42,7 +42,7 @@ Prints a flat inventory of every visible artifact — one row per `(kind, name)` | `kind` | One of `command`, `template`, `script` | | `description` | Description from the highest-precedence layer that declares one, else `""` | -Core artifacts always appear, even when nothing overrides them. Descriptions come from the highest-priority layer that has one — a preset or project override that hides a core command reports its own description, not the hidden core text. Skills (`.github/skills/**/SKILL.md`) are excluded: they are integration-specific output, not a shipped asset family. +Built-in artifacts always appear, even when nothing overrides them. Descriptions come from the highest-priority layer that has one — a preset or project override that hides a built-in command reports its own description, not the hidden built-in text. Skills (`.github/skills/**/SKILL.md`) are excluded: they are integration-specific output, not a shipped asset family. ## Artifact Info @@ -125,6 +125,6 @@ On failure, nothing is written to stdout. A single-key JSON envelope is written | `not a Spec Kit project: no .specify/ directory found` | Run outside an initialized project | | `unknown artifact ` | No artifact matches the requested name (and kind, when given) | | `ambiguous artifact : matches kinds [...]` | The bare name matches more than one kind — re-run with `--kind` | -| `artifact resolution failed` | The preset/extension registries or a manifest could not be read | +| `artifact resolution failed` | The preset/extension registries could not be read, or artifact content could not be composed | Exit code `2` is reserved for usage errors — a missing `--json` flag or an invalid `--kind` value — and emits a plain-text message on stderr rather than a JSON envelope. diff --git a/docs/reference/presets.md b/docs/reference/presets.md index 382154f213..97c95b5edd 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -207,20 +207,20 @@ For any file that both provide, `compliance` wins (priority 5 < 10). For files o ## 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: +Every command, template, and script contributed by a preset or extension is addressable at read time by a deterministic opaque identifier of the form: ```text {layer}:{sourceId}:{kind}:{name} ``` -- `layer` is one of `core`, `preset`, or `extension`. -- `sourceId` is `_` for `core`, the preset pack id for `preset`, or the extension id for `extension`. +- `layer` is one of `preset` or `extension`. +- `sourceId` is the preset pack id for `preset`, or the extension id for `extension`. - `kind` is one of `command`, `template`, or `script`. - `name` is the entry's declared `name` field. Identifiers are computed on demand from author-declared manifest content and are never persisted to `.specify/` or any cache. Copying a preset to another machine (or touching its files) does not change the identifiers it produces. -`PresetResolver.collect_all_layers()` returns layer dicts that each include a `lookupId` field that identifies the layer. For manifest-declared preset and extension layers, the `lookupId`'s `sourceId` component is the manifest's validated `id:` field, so it joins directly to the `id` used by `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` even when the installed directory was renamed. That join is guaranteed by the implementation, so consumers can key off `lookupId` directly rather than re-deriving the contribution id. Convention-only layers (undeclared in any manifest) have no manifest id to consult, so their `lookupId`'s `sourceId` falls back to the resolver's registry key or on-disk directory name instead; those layers have no manifest contribution to join to. Use `layer_kind_from_lookup_id` to tell the two cases apart rather than parsing the string yourself. Project-local overrides in `.specify/templates/overrides/` are a resolver-only concept — they carry a synthetic `project:_:{kind}:{name}` `lookupId` that intentionally does not match any manifest contribution. +`PresetResolver.collect_all_layers()` returns layer dicts that include a `lookupId` field for preset, extension, and project-override layers. For manifest-declared preset and extension layers, the `lookupId`'s `sourceId` component is the manifest's validated `id:` field, so it joins directly to the `id` used by `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` even when the installed directory was renamed. That join is guaranteed by the implementation, so consumers can key off `lookupId` directly rather than re-deriving the contribution id. Convention-only layers (undeclared in any manifest) have no manifest id to consult, so their `lookupId`'s `sourceId` falls back to the resolver's registry key or on-disk directory name instead; those layers have no manifest contribution to join to. Built-in fallback layers omit `lookupId`. Use `layer_kind_from_lookup_id` to classify lookup IDs rather than parsing the string yourself. Project-local overrides in `.specify/templates/overrides/` are a resolver-only concept — they carry a synthetic `project:_:{kind}:{name}` `lookupId` that intentionally does not match any manifest contribution. For the full grammar, including the hook name-component convention and last-write-wins deduplication used by extensions, see the [Extension API Reference — Contribution Identifiers](../../extensions/EXTENSION-API-REFERENCE.md#contribution-identifiers) section. diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index 9e2555390d..4e159368e3 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -892,7 +892,7 @@ identifier form above with no suffix. `:` 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 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. @@ -900,7 +900,7 @@ 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). Manifest-declared preset and extension layers use the manifest's validated `id:` as the `lookupId` source id, so it matches the id `iter_contributions()` yields for that same contribution. Convention-only layers (no manifest entry declares the contribution) have no manifest id to consult, so their `lookupId` falls back to the resolver's registry key or on-disk directory name. +`PresetResolver.collect_all_layers()` returns layer dicts that include a `lookupId` field for project overrides, preset contributions, and extension contributions. Manifest-declared preset and extension layers use the manifest's validated `id:` as the `lookupId` source id, so it matches the id `iter_contributions()` yields for that same contribution. Convention-only layers (no manifest entry declares the contribution) have no manifest id to consult, so their `lookupId` falls back to the resolver's registry key or on-disk directory name. Built-in fallback layers omit `lookupId`. ### Determinism guarantees diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py index facb29de98..25f89e8081 100644 --- a/src/specify_cli/_identifier.py +++ b/src/specify_cli/_identifier.py @@ -9,7 +9,7 @@ across machines, project locations, and reinstalls, and what lets consumers use them as stable join keys. -Grammar for manifest-backed named contributions (commands, templates, scripts):: +Grammar for provenance-backed named contributions (commands, templates, scripts):: id = "{layer}:{sourceId}:{kind}:{name}" @@ -108,6 +108,10 @@ def derive_named_id(layer: str, source_id: str, kind: str, name: str) -> str: def derive_public_id(kind: str, name: str) -> str: """Build the source-agnostic public identifier for an artifact.""" + if kind not in _NAMED_CONTRIBUTION_KINDS: + raise IdentifierComponentError(f"Invalid public artifact kind '{kind}'") + validate_component(kind, "kind") + validate_component(name, "name") return f"{kind}:{name}" @@ -171,7 +175,7 @@ def derive_hook_id( Each component is revalidated with :func:`validate_component` — same contract as :func:`derive_named_id`. """ - if layer not in _LAYER_KINDS: + if layer not in _LAYER_KINDS - {PROJECT_OVERRIDE_LAYER}: raise IdentifierComponentError(f"Invalid layer '{layer}'") validate_component(layer, "layer") validate_component(source_id, "sourceId") diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index ca7f53babd..169db0c552 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -321,13 +321,16 @@ def _build_stack( ``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 asset). + (no preset, no extension, no built-in asset). """ - from ..presets import PresetResolver # lazy: avoids circular import + from ..presets import PresetError, PresetResolver # lazy: avoids circular import resolver = PresetResolver(project_root) template_type = kind - raw = resolver.collect_all_layers(name, template_type) + try: + raw = resolver.collect_all_layers(name, template_type) + except (OSError, PresetError) as exc: + raise ArtifactResolutionError() from exc if not raw: return [] @@ -347,7 +350,11 @@ def _build_stack( else: hidden = idx > first_replace_idx - layer_kind = layer_kind_from_lookup_id(lookup_id) if isinstance(lookup_id, str) else None + layer_kind = ( + layer_kind_from_lookup_id(lookup_id) + if isinstance(lookup_id, str) + else None + ) source_id = lookup_id.split(":", 2)[1] if layer_kind else None if lookup_id is not None and layer_kind is None: raise ArtifactResolutionError() @@ -527,14 +534,14 @@ def list_artifacts(self) -> list[Artifact]: Sort order is deterministic — first by ``kind`` in the fixed ``["command", "template", "script"]`` order, then by ``name``. Returns an empty list when no artifacts are found rather than raising; - a fresh install with no presets, no extensions, and no core assets is + a fresh install with no presets, no extensions, and no built-in assets is still a valid Spec Kit project. Skills (``.github/skills/**/SKILL.md``) are intentionally excluded — they are integration-specific output, not a shipped asset family. Descriptions are picked from the highest-priority layer that has one, - not the first layer discovered — a core command that an active + not the first layer discovered — a built-in command that an active preset overrides must report the preset's description, and two competing packs must report the higher-precedence one's. Precedence is decided by :meth:`PresetResolver.collect_all_layers`'s own @@ -544,7 +551,7 @@ def list_artifacts(self) -> list[Artifact]: _validate_extension_registry(self.project_root) _validate_preset_registry(self.project_root) - from ..presets import PresetResolver # lazy: avoids circular import + from ..presets import PresetError, PresetResolver # lazy: avoids circular import resolver = PresetResolver(self.project_root) layers_cache: dict[tuple[ArtifactKind, str], list[dict[str, Any]]] = {} @@ -553,22 +560,31 @@ def list_artifacts(self) -> list[Artifact]: 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) + try: + layers_cache[key] = resolver.collect_all_layers(name, kind) + except (OSError, PresetError) as exc: + raise ArtifactResolutionError() from exc return layers_cache[key] def _is_resolved(kind: ArtifactKind, name: str) -> bool: key = (kind, name) if key not in resolved_cache: - resolved_cache[key] = resolver.resolve_content(name, kind) is not None + try: + resolved_cache[key] = resolver.resolve_content(name, kind) is not None + except (OSError, PresetError) as exc: + raise ArtifactResolutionError() from exc return resolved_cache[key] names: set[tuple[ArtifactKind, str]] = set() - for kind, name in self._iter_candidate_artifacts(resolver): - key = (kind, name) - if not _is_valid_artifact_name_component(name, kind): - continue - if _layers_for(kind, name) and _is_resolved(kind, name): - names.add(key) + try: + for kind, name in self._iter_candidate_artifacts(resolver): + key = (kind, name) + if not _is_valid_artifact_name_component(name, kind): + continue + if _layers_for(kind, name) and _is_resolved(kind, name): + names.add(key) + except (OSError, PresetError) as exc: + raise ArtifactResolutionError() from exc artifacts: list[Artifact] = [] manifest_cache: dict[Path, Any | None] = {} diff --git a/src/specify_cli/artifacts/_commands.py b/src/specify_cli/artifacts/_commands.py index 269e193b50..72342124fe 100644 --- a/src/specify_cli/artifacts/_commands.py +++ b/src/specify_cli/artifacts/_commands.py @@ -9,7 +9,7 @@ ``register(app)`` entry point invoked from ``src/specify_cli/__init__.py``. The user-facing contract for both subcommands — the ``list``/``info`` JSON -shapes, stack semantics (``active``/``hidden``, core rows, lookup IDs), and +shapes, stack semantics (``active``/``hidden``, built-in rows, lookup IDs), and the JSON error envelope — is documented in ``docs/reference/artifacts.md``. """ diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index b03b688dd7..a957295d46 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -808,6 +808,35 @@ def test_preset_single_segment_command_id_from_list_is_resolvable( assert info["id"] == "command:specify" assert catalog.get_artifact_info("specify", kind="command")["id"] == "command:specify" + def test_append_only_candidate_without_base_is_not_listed( + self, spec_kit_project: Path + ): + pack = install_preset( + spec_kit_project, + "append-only", + { + "templates": [ + { + "type": "template", + "name": "append-only-template", + "strategy": "append", + } + ] + }, + ) + (pack / "templates").mkdir() + (pack / "templates" / "append-only-template.md").write_text( + "append", encoding="utf-8" + ) + + catalog = ArtifactCatalog(spec_kit_project) + + assert "template:append-only-template" not in { + row.id for row in catalog.list_artifacts() + } + with pytest.raises(ArtifactNotFoundError): + catalog.get_artifact_info("append-only-template", kind="template") + def test_preset_replace_hides_core(self, spec_kit_project: Path): # Install a preset that replaces the constitution command. pack = install_preset( diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py index 2472aa4684..2654e02bde 100644 --- a/tests/test_contribution_ids.py +++ b/tests/test_contribution_ids.py @@ -136,7 +136,6 @@ def test_named_id_grammar(self, layer, source_id, kind, name, expected): @pytest.mark.parametrize( "layer, source_id, event, command, expected", [ - ("project", "_", "before_specify", "speckit.constitution", "project:_: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"), ], @@ -152,6 +151,26 @@ def test_named_id_stable_across_two_derivations(self): def test_public_id_is_source_agnostic(self): assert derive_public_id("command", "speckit.plan") == "command:speckit.plan" + @pytest.mark.parametrize( + "args", + [ + ("core", "_", "command", "speckit.plan"), + ("preset", "speckit-core", "hook", "before_plan:speckit.plan"), + ("unknown", "source", "command", "speckit.plan"), + ], + ) + def test_named_id_rejects_invalid_layer_or_kind(self, args): + with pytest.raises(IdentifierComponentError): + derive_named_id(*args) + + def test_public_id_rejects_non_artifact_kind(self): + with pytest.raises(IdentifierComponentError): + derive_public_id("hook", "before_plan:speckit.plan") + + def test_hook_id_rejects_project_layer(self): + with pytest.raises(IdentifierComponentError): + derive_hook_id(PROJECT_OVERRIDE_LAYER, "_", "before_plan", "speckit.plan") + class TestLayerKindFromLookupId: """``layer_kind_from_lookup_id`` extracts the layer segment of a lookupId.""" From cea47c78901e8f8081fdcc965dc1d75991ceae4a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:00:38 +0000 Subject: [PATCH 070/113] Refactor shared asset directory lookup Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/_assets.py | 43 +++++++++--------------- src/specify_cli/artifacts/__init__.py | 19 +++-------- src/specify_cli/events.py | 15 +++------ src/specify_cli/extensions/__init__.py | 6 ++-- src/specify_cli/integrations/base.py | 23 ++----------- src/specify_cli/presets/__init__.py | 20 ++++++------ tests/test_assets.py | 45 +++++++++++++------------- tests/test_extensions.py | 6 ++-- tests/test_presets.py | 6 ++-- 9 files changed, 69 insertions(+), 114 deletions(-) diff --git a/src/specify_cli/_assets.py b/src/specify_cli/_assets.py index c27d9d4f8e..9afee1138d 100644 --- a/src/specify_cli/_assets.py +++ b/src/specify_cli/_assets.py @@ -32,36 +32,25 @@ 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. +def _locate_shared_asset_dir(subdir: str) -> Path | None: + """Return an asset directory from the wheel bundle or source checkout. + + Checks ``core_pack//`` first. In a source checkout, commands live + under ``templates/commands/`` and the other asset families use ``/``. """ - if subdir not in ("commands", "templates", "scripts"): - return None - core = _locate_core_pack() - if core is not None: - candidate = core / subdir + package_dir = Path(__file__).resolve().parent + source_dir = ( + _repo_root() / "templates" / "commands" + if subdir == "commands" + else _repo_root() / subdir + ) + for candidate in [ + package_dir / "core_pack" / subdir, + source_dir, + ]: if candidate.is_dir(): return candidate - # Fall through to the source checkout — a wheel bundle with a - # missing family subdir is treated the same as no bundle at all, - # matching the "wheel, then source" fallback pattern used by - # ``_locate_bundled_extension``/``_locate_bundled_workflow``/ - # ``_locate_bundled_preset`` below. - if subdir == "commands": - candidate = _repo_root() / "templates" / "commands" - else: - candidate = _repo_root() / subdir - return candidate if candidate.is_dir() else None + return None def _locate_bundled_extension(extension_id: str) -> Path | None: diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 169db0c552..99cf275b03 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -18,7 +18,7 @@ import yaml -from .._assets import _locate_core_asset_dir +from .._assets import _locate_shared_asset_dir from .._identifier import ( PROJECT_OVERRIDE_LAYER, IdentifierComponentError, @@ -129,17 +129,6 @@ def __init__(self) -> None: _SCRIPT_SUFFIX = ".sh" -def _core_asset_root(subdir: str) -> Path | None: - """Return the on-disk directory holding a family of core assets, or None. - - Delegates to :func:`_locate_core_asset_dir`, the single shared resolver - also used by :func:`_load_core_command_names` and - :meth:`PresetResolver._find_bundled_core`, so all three code paths agree - on what "core" means on this machine instead of each re-deriving it. - """ - return _locate_core_asset_dir(subdir) - - def _project_core_asset_root(project_root: Path | None, subdir: str) -> Path | None: """Return the project-local core directory for an asset family, if present.""" if project_root is None: @@ -793,7 +782,7 @@ def _iter_core_candidates(self) -> Iterable[tuple[ArtifactKind, str]]: from ..presets import PresetResolver project_commands_dir = _project_core_asset_root(self.project_root, "commands") - bundled_commands_dir = _core_asset_root("commands") + bundled_commands_dir = _locate_shared_asset_dir("commands") command_dirs = tuple( directory for directory in (project_commands_dir, bundled_commands_dir) @@ -815,7 +804,7 @@ def _iter_core_candidates(self) -> Iterable[tuple[ArtifactKind, str]]: seen_templates: set[str] = set() for directory in ( _project_core_asset_root(self.project_root, "templates"), - _core_asset_root("templates"), + _locate_shared_asset_dir("templates"), ): if directory is None: continue @@ -831,7 +820,7 @@ def _iter_core_candidates(self) -> Iterable[tuple[ArtifactKind, str]]: seen_scripts: set[str] = set() for directory in ( _project_core_asset_root(self.project_root, "scripts"), - _core_asset_root("scripts"), + _locate_shared_asset_dir("scripts"), ): if directory is None: continue diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 83da04d4fb..4096e45a69 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -551,17 +551,12 @@ def _find_command_template(command_name: str, project_root: Path) -> tuple[Path # templates/commands). The previous bespoke inspect.getfile() math # pointed at core_pack/templates/commands, which never exists in a # wheel build (force-include maps templates/commands -> core_pack/commands). - from ._assets import _locate_core_pack, _repo_root - core_pack = _locate_core_pack() - candidate_dirs = [ - core_pack / "commands" if core_pack is not None else None, - _repo_root() / "templates" / "commands", - ] + from ._assets import _locate_shared_asset_dir + + commands_dir = _locate_shared_asset_dir("commands") stem = command_name.replace("speckit.", "").replace("spec.", "") - for candidate_dir in candidate_dirs: - if candidate_dir is None or not candidate_dir.is_dir(): - continue - candidate = candidate_dir / f"{stem}.md" + if commands_dir is not None: + candidate = commands_dir / f"{stem}.md" if candidate.exists(): return candidate, None diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index e4eb23d7fb..af370b05f7 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_asset_dir +from .._assets import _locate_shared_asset_dir from .._identifier import ( IdentifierComponentError, derive_hook_id, @@ -88,7 +88,7 @@ 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 :func:`_locate_core_asset_dir` — the same + Path resolution is delegated to :func:`_locate_shared_asset_dir` — the same resolver ``PresetResolver._find_bundled_core`` and the artifact command's core-baseline enumeration use — rather than bespoke ``Path(__file__)`` arithmetic. Hand-counted ``.parent`` chains silently broke discovery once @@ -99,7 +99,7 @@ def _load_core_command_names() -> frozenset[str]: The shared resolver is anchored to the package root, so discovery survives future module moves. """ - commands_dir = _locate_core_asset_dir("commands") + commands_dir = _locate_shared_asset_dir("commands") if commands_dir is not None: command_names = { command_file.stem diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 27c43582b0..5c0a808808 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -27,6 +27,7 @@ import yaml +from .._assets import _locate_shared_asset_dir from .._invocation_style import get_invocation_prefix, is_dollar_skills_agent from .._toml_string import escape_toml_basic as _escape_toml_basic from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control @@ -435,16 +436,7 @@ def shared_commands_dir(self) -> Path | None: ``templates/commands/`` (source checkout). Returns ``None`` if neither exists. """ - import inspect - - pkg_dir = Path(inspect.getfile(IntegrationBase)).resolve().parent.parent - for candidate in [ - pkg_dir / "core_pack" / "commands", - pkg_dir.parent.parent / "templates" / "commands", - ]: - if candidate.is_dir(): - return candidate - return None + return _locate_shared_asset_dir("commands") def shared_templates_dir(self) -> Path | None: """Return path to the shared page templates directory. @@ -452,16 +444,7 @@ def shared_templates_dir(self) -> Path | None: Contains ``vscode-settings.json``, ``spec-template.md``, etc. Checks ``core_pack/templates/`` then ``templates/``. """ - import inspect - - pkg_dir = Path(inspect.getfile(IntegrationBase)).resolve().parent.parent - for candidate in [ - pkg_dir / "core_pack" / "templates", - pkg_dir.parent.parent / "templates", - ]: - if candidate.is_dir(): - return candidate - return None + return _locate_shared_asset_dir("templates") def list_command_templates(self) -> list[Path]: """Return ordered list of command template files from the shared directory.""" diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 9dfb6d336a..e224a885bc 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -3496,13 +3496,11 @@ def _unregister_skills_in_dir( and restore_from_bundled_core and extension_restore is None ): - from .. import _locate_core_pack, _repo_root + from .._assets import _locate_shared_asset_dir - _core_pack = _locate_core_pack() - if _core_pack is not None: - core_file = _core_pack / "commands" / f"{short_name}.md" - else: - core_file = _repo_root() / "templates" / "commands" / f"{short_name}.md" + commands_dir = _locate_shared_asset_dir("commands") + if commands_dir is not None: + core_file = commands_dir / f"{short_name}.md" if not core_file.exists(): core_file = None @@ -5813,22 +5811,22 @@ def _find_bundled_core( ``.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 + ``_locate_shared_asset_dir`` resolver — the same one the artifact command's core-baseline enumeration and the extensions module's core-command-name discovery use — so all three code paths agree on what "core" means on this machine. """ try: - from specify_cli._assets import _locate_core_asset_dir + from specify_cli._assets import _locate_shared_asset_dir except ImportError: return None if template_type == "template": - base = _locate_core_asset_dir("templates") + base = _locate_shared_asset_dir("templates") elif template_type == "command": - base = _locate_core_asset_dir("commands") + base = _locate_shared_asset_dir("commands") elif template_type == "script": - base = _locate_core_asset_dir("scripts") + base = _locate_shared_asset_dir("scripts") else: base = None diff --git a/tests/test_assets.py b/tests/test_assets.py index 7726c8a310..da79b81a4c 100644 --- a/tests/test_assets.py +++ b/tests/test_assets.py @@ -5,46 +5,44 @@ import specify_cli._assets as assets -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.""" +class TestLocateSharedAssetDir: + """Tests for the shared wheel-then-source asset directory lookup.""" def test_prefers_wheel_core_pack_over_repo_checkout(self, tmp_path, monkeypatch): - core_pack = tmp_path / "core_pack" + package_dir = tmp_path / "site-packages" / "specify_cli" + core_pack = package_dir / "core_pack" (core_pack / "commands").mkdir(parents=True) repo_root = tmp_path / "repo" (repo_root / "templates" / "commands").mkdir(parents=True) - monkeypatch.setattr(assets, "_locate_core_pack", lambda: core_pack) + monkeypatch.setattr(assets, "__file__", str(package_dir / "_assets.py")) monkeypatch.setattr(assets, "_repo_root", lambda: repo_root) - assert assets._locate_core_asset_dir("commands") == core_pack / "commands" + assert assets._locate_shared_asset_dir("commands") == core_pack / "commands" def test_falls_back_to_repo_checkout_when_no_wheel_bundle(self, tmp_path, monkeypatch): + package_dir = tmp_path / "site-packages" / "specify_cli" repo_root = tmp_path / "repo" (repo_root / "templates" / "commands").mkdir(parents=True) (repo_root / "templates").mkdir(exist_ok=True) (repo_root / "scripts").mkdir(parents=True, exist_ok=True) - monkeypatch.setattr(assets, "_locate_core_pack", lambda: None) + monkeypatch.setattr(assets, "__file__", str(package_dir / "_assets.py")) monkeypatch.setattr(assets, "_repo_root", lambda: repo_root) - assert assets._locate_core_asset_dir("commands") == repo_root / "templates" / "commands" - assert assets._locate_core_asset_dir("templates") == repo_root / "templates" - assert assets._locate_core_asset_dir("scripts") == repo_root / "scripts" + assert ( + assets._locate_shared_asset_dir("commands") + == repo_root / "templates" / "commands" + ) + assert assets._locate_shared_asset_dir("templates") == repo_root / "templates" + assert assets._locate_shared_asset_dir("scripts") == repo_root / "scripts" def test_returns_none_when_directory_missing(self, tmp_path, monkeypatch): - monkeypatch.setattr(assets, "_locate_core_pack", lambda: None) + package_dir = tmp_path / "site-packages" / "specify_cli" + monkeypatch.setattr(assets, "__file__", str(package_dir / "_assets.py")) monkeypatch.setattr(assets, "_repo_root", lambda: tmp_path / "nonexistent") - assert assets._locate_core_asset_dir("commands") is None - - def test_returns_none_for_unknown_subdir(self, tmp_path, monkeypatch): - monkeypatch.setattr(assets, "_locate_core_pack", lambda: None) - monkeypatch.setattr(assets, "_repo_root", lambda: tmp_path) - - assert assets._locate_core_asset_dir("bogus") is None + assert assets._locate_shared_asset_dir("commands") is None def test_falls_back_to_repo_checkout_when_wheel_bundle_missing_subdir( self, tmp_path, monkeypatch @@ -53,15 +51,16 @@ def test_falls_back_to_repo_checkout_when_wheel_bundle_missing_subdir( the source-checkout fallback, matching the "wheel, then source" pattern used by ``_locate_bundled_extension``/``_locate_bundled_workflow``/ ``_locate_bundled_preset``.""" - core_pack = tmp_path / "core_pack" - core_pack.mkdir() # bundle exists but has no "commands/" subdir + package_dir = tmp_path / "site-packages" / "specify_cli" + core_pack = package_dir / "core_pack" + core_pack.mkdir(parents=True) # bundle exists but has no "commands/" subdir repo_root = tmp_path / "repo" (repo_root / "templates" / "commands").mkdir(parents=True) - monkeypatch.setattr(assets, "_locate_core_pack", lambda: core_pack) + monkeypatch.setattr(assets, "__file__", str(package_dir / "_assets.py")) monkeypatch.setattr(assets, "_repo_root", lambda: repo_root) assert ( - assets._locate_core_asset_dir("commands") + assets._locate_shared_asset_dir("commands") == repo_root / "templates" / "commands" ) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 2307d5be79..bc1d9d9569 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -276,7 +276,7 @@ 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 the shared ``_locate_core_asset_dir`` resolver + from a dead one. Point the shared ``_locate_shared_asset_dir`` resolver at a temp tree with *different* command names: the old off-by-one path math read nothing and returned the baked-in fallback; the fixed loader returns the temp stems. @@ -297,7 +297,7 @@ def test_load_core_command_names_discovers_from_source_checkout(self, monkeypatc # No wheel bundle in this scenario; force the source-checkout path. monkeypatch.setattr( ext, - "_locate_core_asset_dir", + "_locate_shared_asset_dir", lambda subdir: commands if subdir == "commands" else None, ) @@ -315,7 +315,7 @@ def test_load_core_command_names_falls_back_when_nothing_found(self, monkeypatch ) import specify_cli.extensions as ext - monkeypatch.setattr(ext, "_locate_core_asset_dir", lambda subdir: None) + monkeypatch.setattr(ext, "_locate_shared_asset_dir", lambda subdir: None) assert _load_core_command_names() == _FALLBACK_CORE_COMMAND_NAMES diff --git a/tests/test_presets.py b/tests/test_presets.py index 54fa5d382f..60662a8cae 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1179,7 +1179,7 @@ def test_core_fallback_uses_shared_asset_resolver(self, project_dir, monkeypatch Regression test: the tier-5 branch used to read ``core_pack//`` directly, so a wheel bundle missing ``scripts/`` made ``resolve()`` return nothing while ``collect_all_layers()`` fell back to the source - checkout via ``_locate_core_asset_dir``. + checkout via ``_locate_shared_asset_dir``. """ import specify_cli._assets as assets @@ -1190,7 +1190,9 @@ def test_core_fallback_uses_shared_asset_resolver(self, project_dir, monkeypatch script = repo_root / "scripts" / "bash" / "core-only.sh" script.write_text("#!/bin/sh\n", encoding="utf-8") - monkeypatch.setattr(assets, "_locate_core_pack", lambda: core_pack) + monkeypatch.setattr( + assets, "__file__", str(project_dir.parent / "_assets.py") + ) monkeypatch.setattr(assets, "_repo_root", lambda: repo_root) resolver = PresetResolver(project_dir) From 2f3ec0268a6abd58ee5380bab2a3b5fc0d25b23c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:01:38 +0000 Subject: [PATCH 071/113] Document shared asset families Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/_assets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/specify_cli/_assets.py b/src/specify_cli/_assets.py index 9afee1138d..f6f20469f1 100644 --- a/src/specify_cli/_assets.py +++ b/src/specify_cli/_assets.py @@ -35,6 +35,7 @@ def _repo_root() -> Path: def _locate_shared_asset_dir(subdir: str) -> Path | None: """Return an asset directory from the wheel bundle or source checkout. + ``subdir`` is ``"commands"``, ``"templates"``, or ``"scripts"``. Checks ``core_pack//`` first. In a source checkout, commands live under ``templates/commands/`` and the other asset families use ``/``. """ From 38ce9f6416395e2d2087e372ded95dc74c7fd1df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:02:40 +0000 Subject: [PATCH 072/113] Avoid full artifact content scans Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 29 +++++++++++++++++---------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 99cf275b03..bc2582378d 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -544,7 +544,6 @@ def list_artifacts(self) -> list[Artifact]: resolver = PresetResolver(self.project_root) layers_cache: dict[tuple[ArtifactKind, str], list[dict[str, Any]]] = {} - resolved_cache: dict[tuple[ArtifactKind, str], bool] = {} def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: key = (kind, name) @@ -555,14 +554,8 @@ def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: raise ArtifactResolutionError() from exc return layers_cache[key] - def _is_resolved(kind: ArtifactKind, name: str) -> bool: - key = (kind, name) - if key not in resolved_cache: - try: - resolved_cache[key] = resolver.resolve_content(name, kind) is not None - except (OSError, PresetError) as exc: - raise ArtifactResolutionError() from exc - return resolved_cache[key] + def _has_replace_base(layers: list[dict[str, Any]]) -> bool: + return any(layer.get("strategy") == "replace" for layer in layers) names: set[tuple[ArtifactKind, str]] = set() try: @@ -570,7 +563,8 @@ def _is_resolved(kind: ArtifactKind, name: str) -> bool: key = (kind, name) if not _is_valid_artifact_name_component(name, kind): continue - if _layers_for(kind, name) and _is_resolved(kind, name): + layers = _layers_for(kind, name) + if layers and _has_replace_base(layers): names.add(key) except (OSError, PresetError) as exc: raise ArtifactResolutionError() from exc @@ -616,9 +610,15 @@ def get_artifact_info( _validate_preset_registry(self.project_root) bare, resolved_kind = _resolve_kind_hint(name, kind) + from ..presets import PresetError, PresetResolver # lazy: avoids circular import + inventory = self.list_artifacts() if resolved_kind is None: - matches = [(artifact.kind, artifact.name) for artifact in inventory if artifact.name == bare] + matches = [ + (artifact.kind, artifact.name) + for artifact in inventory + if artifact.name == bare + ] if not matches: raise ArtifactNotFoundError(name) if len(matches) > 1: @@ -636,6 +636,13 @@ def get_artifact_info( ) if artifact is None: raise ArtifactNotFoundError(name) + try: + if PresetResolver(self.project_root).resolve_content( + validated_name, resolved_kind + ) is None: + raise ArtifactNotFoundError(name) + except (OSError, PresetError) as exc: + raise ArtifactResolutionError() from exc stack = _build_stack(self.project_root, resolved_kind, validated_name) if not stack: raise ArtifactNotFoundError(name) From f8810741687d7b4bf3c8bfec1133ec2aafb21e59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:04:30 +0000 Subject: [PATCH 073/113] Clarify artifact resolution guard Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous) 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 bc2582378d..a7679f76bf 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -554,7 +554,7 @@ def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: raise ArtifactResolutionError() from exc return layers_cache[key] - def _has_replace_base(layers: list[dict[str, Any]]) -> bool: + def _has_any_replace_layer(layers: list[dict[str, Any]]) -> bool: return any(layer.get("strategy") == "replace" for layer in layers) names: set[tuple[ArtifactKind, str]] = set() @@ -564,7 +564,7 @@ def _has_replace_base(layers: list[dict[str, Any]]) -> bool: if not _is_valid_artifact_name_component(name, kind): continue layers = _layers_for(kind, name) - if layers and _has_replace_base(layers): + if layers and _has_any_replace_layer(layers): names.add(key) except (OSError, PresetError) as exc: raise ArtifactResolutionError() from exc From 9c7355035e85fb4339ecbbb1cc389046806c0d55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:04:52 +0000 Subject: [PATCH 074/113] Restore resolver core provenance Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/_identifier.py | 14 +++++----- src/specify_cli/artifacts/__init__.py | 37 ++++++++++++++++++++------- src/specify_cli/presets/__init__.py | 10 ++++++-- tests/test_artifact_command.py | 16 ++++++++++++ tests/test_contribution_ids.py | 15 ++++++----- tests/test_presets.py | 27 +++++++++++-------- 6 files changed, 85 insertions(+), 34 deletions(-) diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py index 25f89e8081..e548fbd52a 100644 --- a/src/specify_cli/_identifier.py +++ b/src/specify_cli/_identifier.py @@ -22,8 +22,10 @@ id = "{layer}:{sourceId}:hook:{eventName}:{command}" -Built-in artifacts have no layer or lookup identifier. Their public identifier -is source-agnostic: ``"{kind}:{name}"``. +Built-in artifacts have no public layer or lookup identifier. Their public +identifier is source-agnostic: ``"{kind}:{name}"``. The pre-existing resolver +still uses ``core:_:...`` lookup IDs internally; consumers that expose public +artifact data translate those IDs at their boundary. 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 @@ -49,7 +51,7 @@ is the correct outcome for a layer with no originating manifest entry. """ -_LAYER_KINDS = frozenset({PROJECT_OVERRIDE_LAYER, "preset", "extension"}) +_LAYER_KINDS = frozenset({"core", PROJECT_OVERRIDE_LAYER, "preset", "extension"}) _CONTRIBUTION_KINDS = frozenset({"command", "template", "script", "hook"}) _NAMED_CONTRIBUTION_KINDS = _CONTRIBUTION_KINDS - {"hook"} @@ -120,8 +122,8 @@ def layer_kind_from_lookup_id(lookup_id: str) -> str | None: ``lookupId`` values on resolved stack layers follow the same ``"{layer}:..."`` grammar as manifest-contribution ``id`` values (see - module docstring), including :data:`PROJECT_OVERRIDE_LAYER` for - project-local override layers. + module docstring), including ``core`` for built-in layers and + :data:`PROJECT_OVERRIDE_LAYER` for project-local override layers. This is the single place that knows the set of valid layer prefixes, so consumers can classify a lookupId without re-deriving the grammar via string-prefix checks of their own. @@ -175,7 +177,7 @@ def derive_hook_id( Each component is revalidated with :func:`validate_component` — same contract as :func:`derive_named_id`. """ - if layer not in _LAYER_KINDS - {PROJECT_OVERRIDE_LAYER}: + if layer not in {"preset", "extension"}: raise IdentifierComponentError(f"Invalid layer '{layer}'") validate_component(layer, "layer") validate_component(source_id, "sourceId") diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index a7679f76bf..bc963c9508 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -231,6 +231,33 @@ def _describe_artifact_file(path: Path, kind: ArtifactKind) -> str: # --------------------------------------------------------------------------- +def _public_layer_shape( + resolver_layer: dict[str, Any], +) -> tuple[LayerName | None, str | None, str | None]: + """Translate resolver provenance into the public layer identity triple. + + The resolver preserves its pre-existing built-in identity with + ``source == "core"`` and a ``core:_:`` lookup ID. Public artifact output + omits that tier's identity while retaining preset, extension, and project + override identities unchanged. + """ + lookup_id = resolver_layer.get("lookupId") + if ( + resolver_layer.get("source") == "core" + and isinstance(lookup_id, str) + and lookup_id.startswith("core:_:") + ): + return None, None, None + if lookup_id is None: + return None, None, None + if not isinstance(lookup_id, str): + raise ArtifactResolutionError() + layer_kind = layer_kind_from_lookup_id(lookup_id) + if layer_kind not in ("project", "preset", "extension"): + raise ArtifactResolutionError() + return layer_kind, lookup_id.split(":", 2)[1], lookup_id + + def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | None: """Return a repo-relative POSIX path to the manifest declaring this layer. @@ -330,7 +357,6 @@ def _build_stack( rows: list[StackLayer] = [] for idx, layer in enumerate(raw): - lookup_id = layer.get("lookupId") strategy = layer["strategy"] active = idx == 0 @@ -339,14 +365,7 @@ def _build_stack( else: hidden = idx > first_replace_idx - layer_kind = ( - layer_kind_from_lookup_id(lookup_id) - if isinstance(lookup_id, str) - else None - ) - source_id = lookup_id.split(":", 2)[1] if layer_kind else None - if lookup_id is not None and layer_kind is None: - raise ArtifactResolutionError() + layer_kind, source_id, lookup_id = _public_layer_shape(layer) if layer_kind == PROJECT_OVERRIDE_LAYER: rows.append( diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index e224a885bc..6d124adedc 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -5782,8 +5782,11 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: if core: layers.append({ "path": core, - "source": None, + "source": "core", "strategy": "replace", + "lookupId": derive_named_id( + "core", "_", template_type, template_name + ), }) else: # Priority 5: Bundled core_pack (wheel install) or repo-root @@ -5792,8 +5795,11 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: if bundled: layers.append({ "path": bundled, - "source": None, + "source": "core", "strategy": "replace", + "lookupId": derive_named_id( + "core", "_", template_type, template_name + ), }) return layers diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index a957295d46..6dffe6b3f2 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -27,6 +27,7 @@ NotASpecKitProjectError, _derive_manifest_path, _preset_display_name, + _public_layer_shape, ) from specify_cli.extensions import ExtensionRegistry from specify_cli.presets import PresetRegistry, PresetResolver @@ -469,7 +470,14 @@ def test_active_is_index_zero(self, spec_kit_project: Path): assert layer["active"] is False def test_builtin_row_shape(self, spec_kit_project: Path): + resolver_layer = PresetResolver(spec_kit_project).collect_all_layers( + "speckit.constitution", "command" + )[-1] + assert resolver_layer["source"] == "core" + assert resolver_layer["lookupId"] == "core:_:command:speckit.constitution" + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + assert info["id"] == "command:speckit.constitution" builtin = next(layer for layer in info["stack"] if layer["layer"] is None) assert builtin["sourceId"] is None assert builtin["presetId"] is None @@ -478,6 +486,14 @@ def test_builtin_row_shape(self, spec_kit_project: Path): assert builtin["strategy"] == "replace" assert builtin["lookupId"] is None + def test_public_layer_shape_preserves_non_core_identity(self): + assert _public_layer_shape( + { + "source": "preset:foo v1", + "lookupId": "preset:foo:template:spec-template", + } + ) == ("preset", "foo", "preset:foo:template:spec-template") + def test_project_override_row_shape(self, spec_kit_project: Path): overrides = spec_kit_project / ".specify" / "templates" / "overrides" overrides.mkdir() diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py index 2654e02bde..9a7e2209c9 100644 --- a/tests/test_contribution_ids.py +++ b/tests/test_contribution_ids.py @@ -122,6 +122,7 @@ class TestIdentifierDerivation: ("project", "_", "command", "speckit.constitution", "project:_:command:speckit.constitution"), ("project", "_", "template", "spec-template", "project:_:template:spec-template"), ("project", "_", "script", "setup-plan", "project:_:script:setup-plan"), + ("core", "_", "command", "speckit.plan", "core:_:command:speckit.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"), @@ -154,7 +155,6 @@ def test_public_id_is_source_agnostic(self): @pytest.mark.parametrize( "args", [ - ("core", "_", "command", "speckit.plan"), ("preset", "speckit-core", "hook", "before_plan:speckit.plan"), ("unknown", "source", "command", "speckit.plan"), ], @@ -167,9 +167,10 @@ def test_public_id_rejects_non_artifact_kind(self): with pytest.raises(IdentifierComponentError): derive_public_id("hook", "before_plan:speckit.plan") - def test_hook_id_rejects_project_layer(self): + @pytest.mark.parametrize("layer", [PROJECT_OVERRIDE_LAYER, "core"]) + def test_hook_id_rejects_non_manifest_layer(self, layer): with pytest.raises(IdentifierComponentError): - derive_hook_id(PROJECT_OVERRIDE_LAYER, "_", "before_plan", "speckit.plan") + derive_hook_id(layer, "_", "before_plan", "speckit.plan") class TestLayerKindFromLookupId: @@ -178,6 +179,7 @@ class TestLayerKindFromLookupId: @pytest.mark.parametrize( "lookup_id, expected", [ + ("core:_:command:speckit.plan", "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), @@ -195,7 +197,6 @@ def test_recognized_layer_prefixes(self, lookup_id, expected): [ "", "bogus:_:command:speckit.plan", - "core:_:command:speckit.plan", "core", ":_:command:speckit.plan", "core:not-an-id", @@ -375,7 +376,7 @@ def test_project_override_layer_carries_sentinel_lookup_id(self, tmp_path): PROJECT_OVERRIDE_LAYER, "_", "template", "spec-template" ) - def test_builtin_layer_has_no_provenance(self, tmp_path): + def test_builtin_layer_preserves_resolver_provenance(self, tmp_path): project = _make_project(tmp_path) (project / "templates" / "spec-template.md").write_text("core", encoding="utf-8") # PresetResolver reads templates from a bundled/repo path — point the @@ -383,8 +384,8 @@ def test_builtin_layer_has_no_provenance(self, tmp_path): resolver = PresetResolver(project) resolver.templates_dir = project / "templates" layers = resolver.collect_all_layers("spec-template", "template") - builtin_layer = next(layer for layer in layers if "lookupId" not in layer) - assert builtin_layer["source"] is None + builtin_layer = next(layer for layer in layers if layer["source"] == "core") + assert builtin_layer["lookupId"] == "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_presets.py b/tests/test_presets.py index 60662a8cae..db839835aa 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1706,7 +1706,8 @@ def test_collect_all_layers_finds_bundled_core_without_specify_commands( resolver = PresetResolver(project_dir) layers = resolver.collect_all_layers("speckit.implement", "command") assert layers, "expected a bundled core base layer to be found" - assert layers[-1]["source"] is None + assert layers[-1]["source"] == "core" + assert layers[-1]["lookupId"] == "core:_:command:speckit.implement" assert layers[-1]["path"].parts[-2:] == ("commands", "implement.md") def test_resolve_command_falls_back_to_bundled_core(self, project_dir): @@ -12881,7 +12882,8 @@ def test_single_core_layer(self, project_dir): resolver = PresetResolver(project_dir) layers = resolver.collect_all_layers("spec-template") assert len(layers) == 1 - assert layers[0]["source"] is None + assert layers[0]["source"] == "core" + assert layers[0]["lookupId"] == "core:_:template:spec-template" assert layers[0]["strategy"] == "replace" def test_layers_include_presets(self, project_dir, temp_dir, valid_pack_data): @@ -12896,7 +12898,7 @@ def test_layers_include_presets(self, project_dir, temp_dir, valid_pack_data): assert len(layers) == 2 # Highest priority first assert "test-pack" in layers[0]["source"] - assert layers[1]["source"] is None + assert layers[1]["source"] == "core" def test_layers_order_matches_priority(self, project_dir, temp_dir, valid_pack_data): """Test that layers are ordered by priority (highest first).""" @@ -12917,7 +12919,7 @@ def test_layers_order_matches_priority(self, project_dir, temp_dir, valid_pack_d assert len(layers) == 3 # pack-hi, pack-lo, core assert "pack-hi" in layers[0]["source"] assert "pack-lo" in layers[1]["source"] - assert layers[2]["source"] is None + assert layers[2]["source"] == "core" def test_layers_read_strategy_from_manifest(self, project_dir, temp_dir, valid_pack_data): """Test that layers read strategy from preset manifest.""" @@ -12980,7 +12982,7 @@ def test_collect_all_layers_finds_powershell_only_core_script(self, project_dir) ) assert len(layers) == 1 assert layers[0]["path"] == path - assert layers[0]["source"] is None + 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 @@ -13004,7 +13006,7 @@ def test_collect_all_layers_finds_python_only_core_script(self, project_dir): ) assert len(layers) == 1 assert layers[0]["path"] == path - assert layers[0]["source"] is None + 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 @@ -13029,7 +13031,7 @@ def test_collect_all_layers_finds_legacy_flat_core_script(self, project_dir): ) assert len(layers) == 1 assert layers[0]["path"] == path - assert layers[0]["source"] is None + assert layers[0]["source"] == "core" class TestRemoveReconciliation: @@ -13416,7 +13418,10 @@ def test_seeds_from_core_when_no_preset(self, project_dir): memory = project_dir / ".specify" / "memory" / "constitution.md" assert memory.exists() assert "[PROJECT_NAME]" in memory.read_text() - assert (memory.parent / ".constitution-template.json").exists() + provenance = json.loads( + (memory.parent / ".constitution-template.json").read_text() + ) + assert provenance["source"] == "core" def test_seeds_from_preset_when_installed(self, project_dir): from specify_cli.commands.init import ensure_constitution_from_template @@ -13863,7 +13868,9 @@ def test_resolve_accepts_dotted_command_name(self, project_dir): ) assert result.exit_code == 0, (result.output, result.exception) - assert "constitution.md" in "".join(strip_ansi(result.output).split()) + output = " ".join(strip_ansi(result.output).split()) + assert "constitution.md" in "".join(output.split()) + assert "top layer from: core" in output def test_resolve_rejects_empty_command_segments(self, project_dir): """Dotted command identifiers cannot contain empty path-like segments.""" @@ -14096,7 +14103,7 @@ def test_wrap_composes_over_core_constitution(self, project_dir): assert len(layers) >= 2, "expected preset wrap layer plus a core base" assert layers[0]["strategy"] == "wrap" assert any("constitution-sync" in str(layer["path"]) for layer in layers) - assert layers[-1]["source"] is None + assert layers[-1]["source"] == "core" def test_resolved_content_embeds_core_and_sync_pass(self, project_dir): """resolve_content substitutes {CORE_TEMPLATE} so the effective command From ef7fca30da45f4749a1c22a9eab4a2830057d786 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:08:27 +0000 Subject: [PATCH 075/113] Reuse artifact inventory layers Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/_identifier.py | 12 +-- src/specify_cli/artifacts/__init__.py | 139 +++++++++++++++----------- 2 files changed, 87 insertions(+), 64 deletions(-) diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py index e548fbd52a..76fa95a5a5 100644 --- a/src/specify_cli/_identifier.py +++ b/src/specify_cli/_identifier.py @@ -97,22 +97,22 @@ def derive_named_id(layer: str, source_id: str, kind: str, name: str) -> str: strings should either pre-validate or handle :class:`IdentifierComponentError`. """ + validate_component(layer, "layer") + validate_component(source_id, "sourceId") + validate_component(kind, "kind") if layer not in _LAYER_KINDS: raise IdentifierComponentError(f"Invalid layer '{layer}'") if kind not in _NAMED_CONTRIBUTION_KINDS: raise IdentifierComponentError(f"Invalid named contribution kind '{kind}'") - validate_component(layer, "layer") - validate_component(source_id, "sourceId") - validate_component(kind, "kind") validate_component(name, "name") return f"{layer}:{source_id}:{kind}:{name}" def derive_public_id(kind: str, name: str) -> str: """Build the source-agnostic public identifier for an artifact.""" + validate_component(kind, "kind") if kind not in _NAMED_CONTRIBUTION_KINDS: raise IdentifierComponentError(f"Invalid public artifact kind '{kind}'") - validate_component(kind, "kind") validate_component(name, "name") return f"{kind}:{name}" @@ -177,10 +177,10 @@ def derive_hook_id( Each component is revalidated with :func:`validate_component` — same contract as :func:`derive_named_id`. """ - if layer not in {"preset", "extension"}: - raise IdentifierComponentError(f"Invalid layer '{layer}'") validate_component(layer, "layer") validate_component(source_id, "sourceId") + if layer not in {"preset", "extension"}: + raise IdentifierComponentError(f"Invalid layer '{layer}'") validate_component(event_name, "eventName") validate_component(command, "command") return f"{layer}:{source_id}:hook:{event_name}:{command}" diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index bc963c9508..ad8de0855a 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -328,6 +328,7 @@ def _build_stack( project_root: Path, kind: ArtifactKind, name: str, + raw_layers: list[dict[str, Any]] | None = None, ) -> list[StackLayer]: """Build the ordered stack for a single artifact. @@ -341,12 +342,15 @@ def _build_stack( """ from ..presets import PresetError, PresetResolver # lazy: avoids circular import - resolver = PresetResolver(project_root) template_type = kind - try: - raw = resolver.collect_all_layers(name, template_type) - except (OSError, PresetError) as exc: - raise ArtifactResolutionError() from exc + if raw_layers is None: + resolver = PresetResolver(project_root) + try: + raw = resolver.collect_all_layers(name, template_type) + except (OSError, PresetError) as exc: + raise ArtifactResolutionError() from exc + else: + raw = raw_layers if not raw: return [] @@ -555,57 +559,8 @@ def list_artifacts(self) -> list[Artifact]: 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) - _validate_preset_registry(self.project_root) - - from ..presets import PresetError, PresetResolver # lazy: avoids circular import - - resolver = PresetResolver(self.project_root) - layers_cache: dict[tuple[ArtifactKind, str], list[dict[str, Any]]] = {} - - def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: - key = (kind, name) - if key not in layers_cache: - try: - layers_cache[key] = resolver.collect_all_layers(name, kind) - except (OSError, PresetError) as exc: - raise ArtifactResolutionError() from exc - return layers_cache[key] - - def _has_any_replace_layer(layers: list[dict[str, Any]]) -> bool: - return any(layer.get("strategy") == "replace" for layer in layers) - - names: set[tuple[ArtifactKind, str]] = set() - try: - for kind, name in self._iter_candidate_artifacts(resolver): - key = (kind, name) - if not _is_valid_artifact_name_component(name, kind): - continue - layers = _layers_for(kind, name) - if layers and _has_any_replace_layer(layers): - names.add(key) - except (OSError, PresetError) as exc: - raise ArtifactResolutionError() from exc - - artifacts: list[Artifact] = [] - manifest_cache: dict[Path, Any | None] = {} - manifest_description_cache: dict[Path, dict[tuple[str, str, str], str]] = {} - for kind, name in names: - description = "" - for layer in _layers_for(kind, name): - candidate = self._describe_layer( - layer, kind, name, manifest_cache, manifest_description_cache - ) - if candidate: - description = candidate - break - artifacts.append( - Artifact(id=derive_public_id(kind, name), name=name, kind=kind, description=description) - ) - - kind_order = {"command": 0, "template": 1, "script": 2} - return sorted(artifacts, key=lambda a: (kind_order[a.kind], a.name)) + artifacts, _layers_cache = self._collect_inventory() + return artifacts # ------------------------------------------------------------------ info def get_artifact_info( @@ -631,7 +586,7 @@ def get_artifact_info( from ..presets import PresetError, PresetResolver # lazy: avoids circular import - inventory = self.list_artifacts() + inventory, layers_cache = self._collect_inventory() if resolved_kind is None: matches = [ (artifact.kind, artifact.name) @@ -662,7 +617,12 @@ def get_artifact_info( raise ArtifactNotFoundError(name) except (OSError, PresetError) as exc: raise ArtifactResolutionError() from exc - stack = _build_stack(self.project_root, resolved_kind, validated_name) + stack = _build_stack( + self.project_root, + resolved_kind, + validated_name, + raw_layers=layers_cache.get((resolved_kind, validated_name)), + ) if not stack: raise ArtifactNotFoundError(name) @@ -675,6 +635,69 @@ def get_artifact_info( } # -------------------------------------------------------------- internals + def _collect_inventory( + self, + ) -> tuple[ + list[Artifact], + dict[tuple[ArtifactKind, str], list[dict[str, Any]]], + ]: + _validate_project(self.project_root) + _validate_extension_registry(self.project_root) + _validate_preset_registry(self.project_root) + + from ..presets import PresetError, PresetResolver # lazy: avoids circular import + + resolver = PresetResolver(self.project_root) + layers_cache: dict[tuple[ArtifactKind, str], list[dict[str, Any]]] = {} + + def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: + key = (kind, name) + if key not in layers_cache: + try: + layers_cache[key] = resolver.collect_all_layers(name, kind) + except (OSError, PresetError) as exc: + raise ArtifactResolutionError() from exc + return layers_cache[key] + + def _has_any_replace_layer(layers: list[dict[str, Any]]) -> bool: + return any(layer.get("strategy") == "replace" for layer in layers) + + names: set[tuple[ArtifactKind, str]] = set() + try: + for kind, name in self._iter_candidate_artifacts(resolver): + key = (kind, name) + if not _is_valid_artifact_name_component(name, kind): + continue + layers = _layers_for(kind, name) + if layers and _has_any_replace_layer(layers): + names.add(key) + except (OSError, PresetError) as exc: + raise ArtifactResolutionError() from exc + + artifacts: list[Artifact] = [] + manifest_cache: dict[Path, Any | None] = {} + manifest_description_cache: dict[Path, dict[tuple[str, str, str], str]] = {} + for kind, name in names: + description = "" + for layer in _layers_for(kind, name): + candidate = self._describe_layer( + layer, kind, name, manifest_cache, manifest_description_cache + ) + if candidate: + description = candidate + break + artifacts.append( + Artifact( + id=derive_public_id(kind, name), + name=name, + kind=kind, + description=description, + ) + ) + + kind_order = {"command": 0, "template": 1, "script": 2} + return sorted(artifacts, key=lambda a: (kind_order[a.kind], a.name)), layers_cache + def _iter_candidate_artifacts( self, resolver: Any, From 2b8ce698ca79c5993ae874dd905d45074de05884 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:08:30 +0000 Subject: [PATCH 076/113] Simplify preset resolve assertion Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- tests/test_presets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_presets.py b/tests/test_presets.py index db839835aa..10f84361e1 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -13869,7 +13869,7 @@ def test_resolve_accepts_dotted_command_name(self, project_dir): assert result.exit_code == 0, (result.output, result.exception) output = " ".join(strip_ansi(result.output).split()) - assert "constitution.md" in "".join(output.split()) + assert "constitution.md" in output assert "top layer from: core" in output def test_resolve_rejects_empty_command_segments(self, project_dir): From 73832c7ca1acc98dcfcdb3c2936eec73217ef8ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:19:58 +0000 Subject: [PATCH 077/113] Restore source-agnostic artifact provenance Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/_identifier.py | 16 +++++++--------- src/specify_cli/artifacts/__init__.py | 12 ++---------- src/specify_cli/presets/__init__.py | 6 ------ tests/test_artifact_command.py | 2 +- tests/test_contribution_ids.py | 6 +++--- tests/test_presets.py | 4 ++-- 6 files changed, 15 insertions(+), 31 deletions(-) diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py index 76fa95a5a5..8bc2fe7b5b 100644 --- a/src/specify_cli/_identifier.py +++ b/src/specify_cli/_identifier.py @@ -1,9 +1,9 @@ """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 +extension manifest carries a computed opaque ``id`` string, and provenance-backed +layers of a resolved artifact stack carry a matching ``lookupId``. The identifier +value is derived only from author-declared manifest data — it never depends on file contents, timestamps, archive hashes, installation directory paths, install-time random values, or list positions. That is what makes identifiers portable across machines, project locations, and reinstalls, and what lets consumers use @@ -23,9 +23,7 @@ id = "{layer}:{sourceId}:hook:{eventName}:{command}" Built-in artifacts have no public layer or lookup identifier. Their public -identifier is source-agnostic: ``"{kind}:{name}"``. The pre-existing resolver -still uses ``core:_:...`` lookup IDs internally; consumers that expose public -artifact data translate those IDs at their boundary. +identifier is source-agnostic: ``"{kind}:{name}"``. The functions in this module are pure — inputs are strings or in-memory mappings parsed from a manifest, outputs are strings. None of them read from @@ -51,7 +49,7 @@ is the correct outcome for a layer with no originating manifest entry. """ -_LAYER_KINDS = frozenset({"core", PROJECT_OVERRIDE_LAYER, "preset", "extension"}) +_LAYER_KINDS = frozenset({PROJECT_OVERRIDE_LAYER, "preset", "extension"}) _CONTRIBUTION_KINDS = frozenset({"command", "template", "script", "hook"}) _NAMED_CONTRIBUTION_KINDS = _CONTRIBUTION_KINDS - {"hook"} @@ -122,8 +120,8 @@ def layer_kind_from_lookup_id(lookup_id: str) -> str | None: ``lookupId`` values on resolved stack layers follow the same ``"{layer}:..."`` grammar as manifest-contribution ``id`` values (see - module docstring), including ``core`` for built-in layers and - :data:`PROJECT_OVERRIDE_LAYER` for project-local override layers. + module docstring), including :data:`PROJECT_OVERRIDE_LAYER` for project-local + override layers. This is the single place that knows the set of valid layer prefixes, so consumers can classify a lookupId without re-deriving the grammar via string-prefix checks of their own. diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index ad8de0855a..67570b6304 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -236,18 +236,10 @@ def _public_layer_shape( ) -> tuple[LayerName | None, str | None, str | None]: """Translate resolver provenance into the public layer identity triple. - The resolver preserves its pre-existing built-in identity with - ``source == "core"`` and a ``core:_:`` lookup ID. Public artifact output - omits that tier's identity while retaining preset, extension, and project - override identities unchanged. + Layers without a lookup identifier have no public provenance. Preset, + extension, and project override identities are retained unchanged. """ lookup_id = resolver_layer.get("lookupId") - if ( - resolver_layer.get("source") == "core" - and isinstance(lookup_id, str) - and lookup_id.startswith("core:_:") - ): - return None, None, None if lookup_id is None: return None, None, None if not isinstance(lookup_id, str): diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 6d124adedc..c9adc54710 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -5784,9 +5784,6 @@ 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 @@ -5797,9 +5794,6 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: "path": bundled, "source": "core", "strategy": "replace", - "lookupId": derive_named_id( - "core", "_", template_type, template_name - ), }) return layers diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 6dffe6b3f2..66a4cfdf0e 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -474,7 +474,7 @@ def test_builtin_row_shape(self, spec_kit_project: Path): "speckit.constitution", "command" )[-1] assert resolver_layer["source"] == "core" - assert resolver_layer["lookupId"] == "core:_:command:speckit.constitution" + assert "lookupId" not in resolver_layer info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") assert info["id"] == "command:speckit.constitution" diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py index 9a7e2209c9..2cf16ef9d8 100644 --- a/tests/test_contribution_ids.py +++ b/tests/test_contribution_ids.py @@ -122,7 +122,6 @@ class TestIdentifierDerivation: ("project", "_", "command", "speckit.constitution", "project:_:command:speckit.constitution"), ("project", "_", "template", "spec-template", "project:_:template:spec-template"), ("project", "_", "script", "setup-plan", "project:_:script:setup-plan"), - ("core", "_", "command", "speckit.plan", "core:_:command:speckit.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"), @@ -157,6 +156,7 @@ def test_public_id_is_source_agnostic(self): [ ("preset", "speckit-core", "hook", "before_plan:speckit.plan"), ("unknown", "source", "command", "speckit.plan"), + ("core", "_", "command", "speckit.plan"), ], ) def test_named_id_rejects_invalid_layer_or_kind(self, args): @@ -179,7 +179,6 @@ class TestLayerKindFromLookupId: @pytest.mark.parametrize( "lookup_id, expected", [ - ("core:_:command:speckit.plan", "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), @@ -197,6 +196,7 @@ def test_recognized_layer_prefixes(self, lookup_id, expected): [ "", "bogus:_:command:speckit.plan", + "core:_:command:speckit.plan", "core", ":_:command:speckit.plan", "core:not-an-id", @@ -385,7 +385,7 @@ def test_builtin_layer_preserves_resolver_provenance(self, tmp_path): resolver.templates_dir = project / "templates" layers = resolver.collect_all_layers("spec-template", "template") builtin_layer = next(layer for layer in layers if layer["source"] == "core") - assert builtin_layer["lookupId"] == "core:_:template:spec-template" + assert "lookupId" not in builtin_layer def test_preset_layer_lookup_id_matches_manifest_contribution_id(self, tmp_path): project = _make_project(tmp_path) diff --git a/tests/test_presets.py b/tests/test_presets.py index 10f84361e1..ec6d2773c9 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1707,7 +1707,7 @@ def test_collect_all_layers_finds_bundled_core_without_specify_commands( layers = resolver.collect_all_layers("speckit.implement", "command") assert layers, "expected a bundled core base layer to be found" assert layers[-1]["source"] == "core" - assert layers[-1]["lookupId"] == "core:_:command:speckit.implement" + assert "lookupId" not in layers[-1] assert layers[-1]["path"].parts[-2:] == ("commands", "implement.md") def test_resolve_command_falls_back_to_bundled_core(self, project_dir): @@ -12883,7 +12883,7 @@ def test_single_core_layer(self, project_dir): layers = resolver.collect_all_layers("spec-template") assert len(layers) == 1 assert layers[0]["source"] == "core" - assert layers[0]["lookupId"] == "core:_:template:spec-template" + assert "lookupId" not in layers[0] assert layers[0]["strategy"] == "replace" def test_layers_include_presets(self, project_dir, temp_dir, valid_pack_data): From bf8aeb3578e57c46e4b0b9b34d731657357cb830 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:31:30 +0000 Subject: [PATCH 078/113] artifact catalog: `id` is the source-agnostic round-trip key; `info` accepts `id`; docs and issue #4212 updated. Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- docs/reference/artifacts.md | 7 +++- docs/reference/presets.md | 2 + extensions/EXTENSION-API-REFERENCE.md | 12 ++++++ src/specify_cli/artifacts/__init__.py | 19 ++++++++- tests/test_artifact_command.py | 60 +++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 3 deletions(-) diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md index d82fa558c7..8f55676261 100644 --- a/docs/reference/artifacts.md +++ b/docs/reference/artifacts.md @@ -65,6 +65,7 @@ specify artifact info --json "description": "Create or update the feature specification.", "stack": [ { + "id": "command:speckit.specify", "layer": "preset", "sourceId": "compliance", "presetId": "compliance", @@ -76,6 +77,7 @@ specify artifact info --json "lookupId": "preset:compliance:command:speckit.specify" }, { + "id": "command:speckit.specify", "layer": null, "sourceId": null, "presetId": null, @@ -98,6 +100,7 @@ The top-level `id`, `name`, `kind`, and `description` fields match the correspon | Field | Description | | -------------- | -------------------------------------------------------------------------------- | +| `id` | `{kind}:{name}` — the source-agnostic round-trip key, identical on every row of the same artifact's stack | | `layer` | `project`, `preset`, or `extension`; `null` for built-in layers | | `sourceId` | Source component of `lookupId`, or `null` when the layer has no provenance | | `presetId` | Preset pack directory id; `null` on built-in, `project`, and `extension` rows | @@ -108,9 +111,9 @@ The top-level `id`, `name`, `kind`, and `description` fields match the correspon | `manifestPath` | Project-relative path to the declaring manifest, or `null` when none applies | | `lookupId` | Deterministic `{layer}:{sourceId}:{kind}:{name}` identifier, or `null` for built-in layers | -`active` and `hidden` are independent labels, not opposites. Composing strategies (`wrap`, `prepend`, `append`) keep lower layers in the composed output, so an inactive layer is not necessarily hidden: only layers below the first `replace` layer are marked `hidden`. Built-in rows have no provenance: `layer`, `sourceId`, and `lookupId` are `null`. +`active` and `hidden` are independent labels, not opposites. Composing strategies (`wrap`, `prepend`, `append`) keep lower layers in the composed output, so an inactive layer is not necessarily hidden: only layers below the first `replace` layer are marked `hidden`. Built-in rows have no provenance: `layer`, `sourceId`, and `lookupId` are `null` — but `id` is always populated, even on built-in rows. `id` is the round-trip key: `specify artifact info` accepts it as input (for example, `specify artifact info command:speckit.specify --json`), and it resolves the same artifact whether the caller passes the bare name or the `id`. -Lookup IDs use the same grammar as [preset contribution identifiers](presets.md#contribution-identifiers), so a `lookupId` from this command joins directly to `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` for manifest-declared layers. Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID that intentionally matches no manifest contribution. +Lookup IDs use the same grammar as [preset contribution identifiers](presets.md#contribution-identifiers), so a `lookupId` from this command joins directly to `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` for manifest-declared layers. Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID that intentionally matches no manifest contribution. `lookupId` is manifest-backed layer provenance, not the round-trip key — use `id` for that. ## JSON Errors diff --git a/docs/reference/presets.md b/docs/reference/presets.md index 97c95b5edd..9889c92dbd 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -222,6 +222,8 @@ Identifiers are computed on demand from author-declared manifest content and are `PresetResolver.collect_all_layers()` returns layer dicts that include a `lookupId` field for preset, extension, and project-override layers. For manifest-declared preset and extension layers, the `lookupId`'s `sourceId` component is the manifest's validated `id:` field, so it joins directly to the `id` used by `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` even when the installed directory was renamed. That join is guaranteed by the implementation, so consumers can key off `lookupId` directly rather than re-deriving the contribution id. Convention-only layers (undeclared in any manifest) have no manifest id to consult, so their `lookupId`'s `sourceId` falls back to the resolver's registry key or on-disk directory name instead; those layers have no manifest contribution to join to. Built-in fallback layers omit `lookupId`. Use `layer_kind_from_lookup_id` to classify lookup IDs rather than parsing the string yourself. Project-local overrides in `.specify/templates/overrides/` are a resolver-only concept — they carry a synthetic `project:_:{kind}:{name}` `lookupId` that intentionally does not match any manifest contribution. +The `id` field (shape `kind:name`) is the stable round-trip key for every artifact and is accepted as input by `specify artifact info`. The `lookupId` field carries manifest-backed layer provenance and is present only for artifacts contributed by presets, extensions, or project overrides. Built-in-tier artifacts have no `lookupId`; use `id` to round-trip them. For example, given a stack row for a built-in artifact with only `id` populated, the round-trip is `specify artifact info command:speckit.plan --json`, which resolves the same artifact as `specify artifact info speckit.plan --json`. + For the full grammar, including the hook name-component convention and last-write-wins deduplication used by extensions, see the [Extension API Reference — Contribution Identifiers](../../extensions/EXTENSION-API-REFERENCE.md#contribution-identifiers) section. ## FAQ diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index 4e159368e3..60ba428d5b 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -902,6 +902,18 @@ Project-local overrides in `.specify/templates/overrides/` are a resolver-only c `PresetResolver.collect_all_layers()` returns layer dicts that include a `lookupId` field for project overrides, preset contributions, and extension contributions. Manifest-declared preset and extension layers use the manifest's validated `id:` as the `lookupId` source id, so it matches the id `iter_contributions()` yields for that same contribution. Convention-only layers (no manifest entry declares the contribution) have no manifest id to consult, so their `lookupId` falls back to the resolver's registry key or on-disk directory name. Built-in fallback layers omit `lookupId`. +### Round-trip via the public `id` + +The `id` field (shape `kind:name`) is the stable round-trip key for every artifact and is accepted as input by `specify artifact info`. The `lookupId` field carries manifest-backed layer provenance and is present only for artifacts contributed by presets, extensions, or project overrides. Built-in-tier artifacts have no `lookupId`; use `id` to round-trip them. + +For example, given a `specify artifact list --json` / `specify artifact info` stack row for a built-in artifact — which has only `id` populated (`layer`, `sourceId`, and `lookupId` are `null`) — the round-trip is: + +```bash +specify artifact info command:speckit.plan --json +``` + +This resolves the same artifact as `specify artifact info speckit.plan --json`, because `id` (not `lookupId`) is the source-agnostic identifier every artifact carries. + ### Determinism guarantees Manifest contribution identifier derivation reads only the in-memory declared manifest content. No filesystem paths, no `os.environ`, no timestamps, and no file-content hashes contribute to those manifest ids. Copying an extension or preset to a different machine (or touching its files) does not change the identifiers it produces. Manifest-declared resolver `lookupId` values share this stability — renaming the installed directory of a preset or extension that declares an `id:` does not change its `lookupId`. Only convention-only contributions (undeclared in any manifest) derive their `lookupId` from the on-disk directory name or registry key, so renaming that directory does change their `lookupId`. diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 67570b6304..24bfdc91fb 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -58,8 +58,19 @@ def to_json_dict(self) -> dict[str, Any]: @dataclass(frozen=True) class StackLayer: - """One row inside the ``stack`` array returned by ``get_artifact_info()``.""" + """One row inside the ``stack`` array returned by ``get_artifact_info()``. + + ``id`` is the source-agnostic round-trip key (``f"{kind}:{name}"``) for the + artifact this stack row belongs to — every row in a given stack carries + the same ``id``, matching the top-level ``id`` on the ``info`` payload and + the corresponding row's ``id`` on ``artifact list``. It is populated for + every row, including built-in-tier rows that have no ``lookupId``. + ``lookupId`` is separate, manifest-backed layer provenance: it is only + present when the row has a specific preset/extension/project-override + layer to point at, and is ``None`` for the built-in tier. + """ + id: str layer: LayerName | None sourceId: str | None presetId: str | None @@ -72,6 +83,7 @@ class StackLayer: def to_json_dict(self) -> dict[str, Any]: return { + "id": self.id, "layer": self.layer, "sourceId": self.sourceId, "presetId": self.presetId, @@ -351,6 +363,7 @@ def _build_stack( None, ) + public_id = derive_public_id(kind, name) rows: list[StackLayer] = [] for idx, layer in enumerate(raw): strategy = layer["strategy"] @@ -366,6 +379,7 @@ def _build_stack( if layer_kind == PROJECT_OVERRIDE_LAYER: rows.append( StackLayer( + id=public_id, layer="project", sourceId=source_id, presetId=None, @@ -383,6 +397,7 @@ def _build_stack( manifest_path = _derive_manifest_path(layer, project_root) rows.append( StackLayer( + id=public_id, layer="extension", sourceId=source_id, presetId=None, @@ -399,6 +414,7 @@ def _build_stack( if layer_kind is None: rows.append( StackLayer( + id=public_id, layer=None, sourceId=None, presetId=None, @@ -428,6 +444,7 @@ def _build_stack( manifest_path = _derive_manifest_path(layer, project_root) rows.append( StackLayer( + id=public_id, layer="preset", sourceId=source_id, presetId=pack_id or None, diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 66a4cfdf0e..70865655c0 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -526,6 +526,21 @@ def test_id_matches_list(self, spec_kit_project: Path): info = cat.get_artifact_info("speckit.constitution") assert info["id"] == "command:speckit.constitution" + def test_every_stack_row_carries_id(self, spec_kit_project: Path): + """Every stack row carries a non-null ``id``, including built-in rows. + + ``id`` is the source-agnostic round-trip key; it does not depend on + the row having a ``lookupId`` (manifest-backed layer provenance). + """ + overrides = spec_kit_project / ".specify" / "templates" / "overrides" + overrides.mkdir() + (overrides / "speckit.constitution.md").write_text("override", encoding="utf-8") + + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + assert len(info["stack"]) >= 2 + for layer in info["stack"]: + assert layer["id"] == "command:speckit.constitution" + # --------------------------------------------------------------------------- # Error conditions — pinned strings for the artifact-error contract @@ -635,6 +650,40 @@ def test_kind_hint_rejects_invalid_name_components( with pytest.raises(ArtifactNotFoundError): ArtifactCatalog(spec_kit_project).get_artifact_info(name, kind=kind) + def test_id_form_round_trips_to_same_artifact(self, spec_kit_project: Path): + """``artifact info`` accepts the public ``id`` form (``kind:name``). + + Given either the bare name or its ``id``, the resolved artifact is + the same — ``id`` is the source-agnostic round-trip key. + """ + cat = ArtifactCatalog(spec_kit_project) + by_bare = cat.get_artifact_info("speckit.plan") + by_id = cat.get_artifact_info("command:speckit.plan") + assert by_id == by_bare + + def test_id_form_resolves_template_despite_same_named_command( + self, spec_kit_project: Path + ): + """``kind:name`` disambiguates when a command shares a template's name.""" + pack_dir = install_preset( + spec_kit_project, + "collide-pack", + {"commands": [{"name": "spec-template", "description": "cmd"}]}, + ) + (pack_dir / "commands").mkdir(parents=True, exist_ok=True) + (pack_dir / "commands" / "spec-template.md").write_text( + "colliding command body", encoding="utf-8" + ) + + # Sanity check: without a kind hint, the bare name is ambiguous + # because both a command and a template named "spec-template" exist. + with pytest.raises(AmbiguousArtifactError): + ArtifactCatalog(spec_kit_project).get_artifact_info("spec-template") + + info = ArtifactCatalog(spec_kit_project).get_artifact_info("template:spec-template") + assert info["kind"] == "template" + assert info["id"] == "template:spec-template" + # --------------------------------------------------------------------------- # Skills exclusion @@ -686,6 +735,17 @@ def test_info_json_shape(self, spec_kit_project: Path, monkeypatch: pytest.Monke payload = json.loads(result.stdout) assert set(payload.keys()) == {"id", "name", "kind", "description", "stack"} + def test_info_accepts_id_form_on_cli( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + by_bare = runner.invoke(app, ["artifact", "info", "speckit.plan", "--json"]) + by_id = runner.invoke(app, ["artifact", "info", "command:speckit.plan", "--json"]) + assert by_bare.exit_code == 0, by_bare.stderr + assert by_id.exit_code == 0, by_id.stderr + assert json.loads(by_id.stdout) == json.loads(by_bare.stdout) + def test_info_unknown_error_envelope(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.chdir(spec_kit_project) runner = CliRunner() From d7babbb56c774e4cc1f81df19dd01f5672ffa33a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:42:18 +0000 Subject: [PATCH 079/113] fix: keep layer_kind_from_lookup_id and derive_hook_id in agreement on hook layers Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/_identifier.py | 11 +++++++++-- tests/test_contribution_ids.py | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py index 8bc2fe7b5b..21e87b3bfc 100644 --- a/src/specify_cli/_identifier.py +++ b/src/specify_cli/_identifier.py @@ -52,6 +52,7 @@ _LAYER_KINDS = frozenset({PROJECT_OVERRIDE_LAYER, "preset", "extension"}) _CONTRIBUTION_KINDS = frozenset({"command", "template", "script", "hook"}) _NAMED_CONTRIBUTION_KINDS = _CONTRIBUTION_KINDS - {"hook"} +_HOOK_LAYERS = frozenset({"preset", "extension"}) class IdentifierComponentError(ValueError): @@ -132,7 +133,11 @@ def layer_kind_from_lookup_id(lookup_id: str) -> str | None: ``{layer}:{sourceId}:hook:{eventName}:{command}`` components, with every component non-empty. A value such as ``"preset:x"`` has a recognized layer prefix but the wrong number of components, so it is malformed and returns - ``None`` rather than being treated as authoritative. + ``None`` rather than being treated as authoritative. Hook IDs are only + valid on preset/extension layers (see :data:`_HOOK_LAYERS`); a value such + as ``"project:_:hook:some-event:some-command"`` is rejected even though it + otherwise has the right shape, matching :func:`derive_hook_id`'s refusal + to build hook IDs for other layers. """ parts = lookup_id.split(":") if len(parts) < 4 or any(not part for part in parts): @@ -145,6 +150,8 @@ def layer_kind_from_lookup_id(lookup_id: str) -> str | None: expected_len = 5 if parts[2] == "hook" else 4 if len(parts) != expected_len: return None + if parts[2] == "hook" and layer not in _HOOK_LAYERS: + return None return layer @@ -177,7 +184,7 @@ def derive_hook_id( """ validate_component(layer, "layer") validate_component(source_id, "sourceId") - if layer not in {"preset", "extension"}: + if layer not in _HOOK_LAYERS: raise IdentifierComponentError(f"Invalid layer '{layer}'") validate_component(event_name, "eventName") validate_component(command, "command") diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py index 2cf16ef9d8..dab93db674 100644 --- a/tests/test_contribution_ids.py +++ b/tests/test_contribution_ids.py @@ -205,6 +205,7 @@ def test_recognized_layer_prefixes(self, lookup_id, expected): "extension:speckit-git:hook:before_specify", "core::command:speckit.plan", "core:_:bogus:speckit.plan", + "project:_:hook:some-event:some-command", ], ) def test_unrecognized_or_malformed_returns_none(self, lookup_id): From 6efff92504b0db1d433f731a204798a51af1eb63 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:08:14 +0000 Subject: [PATCH 080/113] artifact: reuse shared project resolver, rename handlers, dedupe validation Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 7 +++--- src/specify_cli/artifacts/_commands.py | 34 +++++++++++++++----------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 24bfdc91fb..7bf96c05b7 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -588,13 +588,14 @@ def get_artifact_info( :class:`AmbiguousArtifactError`. * When no artifact matches, raises :class:`ArtifactNotFoundError`. """ - _validate_project(self.project_root) - _validate_extension_registry(self.project_root) - _validate_preset_registry(self.project_root) bare, resolved_kind = _resolve_kind_hint(name, kind) from ..presets import PresetError, PresetResolver # lazy: avoids circular import + # Project and registry validation happens once, inside + # ``_collect_inventory`` below — the same chokepoint ``list_artifacts`` + # uses — so both public methods fail closed identically instead of + # each re-implementing the checks. inventory, layers_cache = self._collect_inventory() if resolved_kind is None: matches = [ diff --git a/src/specify_cli/artifacts/_commands.py b/src/specify_cli/artifacts/_commands.py index 72342124fe..5ff1fa9d0a 100644 --- a/src/specify_cli/artifacts/_commands.py +++ b/src/specify_cli/artifacts/_commands.py @@ -15,8 +15,9 @@ from __future__ import annotations +import contextlib +import io import json -import os import sys from pathlib import Path from typing import Optional @@ -42,18 +43,23 @@ def _resolve_project_root() -> Path: """Return the project root without emitting Rich output on failure. - The stdout of ``specify artifact list --json`` and ``specify artifact - info --json`` is a strict JSON envelope; any incidental Rich - output would corrupt it. The shared ``_resolve_init_dir_override`` emits - Rich errors for invalid overrides, so validate the override quietly here - and raise the module-local :class:`NotASpecKitProjectError` for the shared - error handler to serialize. + Delegates to :func:`specify_cli._require_specify_project` — the same + resolution chokepoint every other project-scoped subcommand (``preset``, + ``extension``, ``workflow``, ...) uses, including its ``SPECIFY_INIT_DIR`` + override handling. That helper prints Rich error output and raises + ``typer.Exit`` on failure, which would corrupt the strict JSON envelope + ``specify artifact list --json`` and ``specify artifact info --json`` + emit on stdout/stderr. The Rich output is suppressed here and the + failure is re-raised as the module-local :class:`NotASpecKitProjectError` + for the shared error handler to serialize instead. """ - 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 + from .. import _require_specify_project # lazy: avoids circular import + + with contextlib.redirect_stderr(io.StringIO()): + try: + return _require_specify_project() + except typer.Exit: + raise NotASpecKitProjectError() from None def _emit_error_and_exit(exc: ArtifactError) -> None: @@ -87,7 +93,7 @@ def _require_json_flag(json_flag: bool) -> None: @artifact_app.command("list") -def list_command( +def artifact_list( json_flag: bool = typer.Option( False, "--json", @@ -112,7 +118,7 @@ def list_command( @artifact_app.command("info") -def info_command( +def artifact_info( name: str = typer.Argument(..., help="Artifact name, optionally 'kind:name'."), json_flag: bool = typer.Option( False, From bd483da8d89232da56aafe151d44d3a8f871cdac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:21:28 +0000 Subject: [PATCH 081/113] fix: align artifact info existence and resolver naming Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 11 +---------- src/specify_cli/presets/__init__.py | 14 +++++++++++--- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 7bf96c05b7..0d009a8d73 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -590,8 +590,6 @@ def get_artifact_info( """ bare, resolved_kind = _resolve_kind_hint(name, kind) - from ..presets import PresetError, PresetResolver # lazy: avoids circular import - # Project and registry validation happens once, inside # ``_collect_inventory`` below — the same chokepoint ``list_artifacts`` # uses — so both public methods fail closed identically instead of @@ -620,13 +618,6 @@ def get_artifact_info( ) if artifact is None: raise ArtifactNotFoundError(name) - try: - if PresetResolver(self.project_root).resolve_content( - validated_name, resolved_kind - ) is None: - raise ArtifactNotFoundError(name) - except (OSError, PresetError) as exc: - raise ArtifactResolutionError() from exc stack = _build_stack( self.project_root, resolved_kind, @@ -856,7 +847,7 @@ def _iter_core_candidates(self) -> Iterable[tuple[ArtifactKind, str]]: if any( (directory / f"{candidate}.md").is_file() for directory in command_dirs - for candidate in PresetResolver.core_name_candidates(name) + for candidate in PresetResolver.name_candidates(name) ): yield "command", name diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index c9adc54710..3664625fba 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -5292,8 +5292,16 @@ def _core_stem(template_name: str) -> Optional[str]: return None @classmethod - def core_name_candidates(cls, logical_name: str) -> list[str]: - """Return exact-first filename candidates for a core logical name.""" + def name_candidates(cls, logical_name: str) -> list[str]: + """Return exact-first filename candidates for a ``speckit.`` logical name. + + Given a logical name like ``speckit.plan``, returns + ``["speckit.plan", "plan"]`` so callers can try the fully-qualified + filename first and then fall back to the bare stem. + + Names that do not follow the ``speckit.`` convention return a + single-element list containing the original name. + """ names = [logical_name] stem = cls._core_stem(logical_name) if stem and stem != logical_name: @@ -5833,7 +5841,7 @@ def _find_bundled_core( if base is None: return None - for name in self.core_name_candidates(template_name): + for name in self.name_candidates(template_name): if template_type == "script": c = next( (path for path in script_variant_paths(base, name) if path.exists()), From e67b1cd427643ba7e9522a70881d2f2458a179d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:30:25 +0000 Subject: [PATCH 082/113] artifact: reuse PresetResolver.templates_dir in _project_core_asset_root Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 0d009a8d73..1fba17fe07 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -142,16 +142,16 @@ def __init__(self) -> 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.""" + """Return the project-local built-in-tier 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 + if subdir not in {"commands", "scripts", "templates"}: + return None # pragma: no cover — internal misuse + from ..presets import PresetResolver # lazy: avoids circular import + + candidate = PresetResolver(project_root).templates_dir + if subdir != "templates": + candidate = candidate / subdir return candidate if candidate.is_dir() else None From e7bdc62f4b87dca270bbdbc5e43f450f200d46e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:09:23 +0000 Subject: [PATCH 083/113] fix: guard stale registry entries in artifact convention discovery Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 2 ++ tests/test_artifact_command.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 1fba17fe07..7470f28e5e 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -1010,6 +1010,8 @@ def _iter_convention_contributions( for entry in sorted(candidate_dir.iterdir(), key=lambda p: p.name): if entry.is_file() and entry.suffix == suffix and ":" not in entry.stem: yield kind, entry.stem, entry + if not pack_dir.is_dir(): + return for entry in sorted(pack_dir.iterdir(), key=lambda p: p.name): if ( entry.is_file() diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 70865655c0..6f90d98f6c 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -10,6 +10,7 @@ import json import os import re +import shutil from pathlib import Path import pytest @@ -1102,6 +1103,23 @@ def test_unregistered_preset_template_without_manifest(self, spec_kit_project: P "preset:legacy-preset:template:legacy-preset-template" ) + def test_stale_registry_entry_with_missing_pack_dir_is_skipped( + self, spec_kit_project: Path + ): + pack_dir = spec_kit_project / ".specify" / "presets" / "removed-preset" + pack_dir.mkdir() + PresetRegistry(pack_dir.parent).add( + "removed-preset", {"priority": 10, "version": "1.0.0"} + ) + shutil.rmtree(pack_dir) + + catalog = ArtifactCatalog(spec_kit_project) + # Should not raise FileNotFoundError despite the registry entry + # pointing at a directory that no longer exists on disk; the stale + # preset contributes no artifacts. + ids = {row.id for row in catalog.list_artifacts()} + assert not any("removed-preset" in artifact_id for artifact_id in ids) + def test_command_override_is_not_duplicated_as_template(self, spec_kit_project: Path): ext_dir = spec_kit_project / ".specify" / "extensions" / "legacy" / "commands" ext_dir.mkdir(parents=True) From 9977faab18c919b189bd8ab454cf3e1a68249689 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:47:30 +0000 Subject: [PATCH 084/113] fix: include stack in artifact list json Assisted-by: GitHub Copilot (model: GPT-5 Codex, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/artifacts/__init__.py | 16 ++++++++++++++++ src/specify_cli/artifacts/_commands.py | 2 +- tests/test_artifact_command.py | 17 +++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 7470f28e5e..f32bdb854e 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -571,6 +571,22 @@ def list_artifacts(self) -> list[Artifact]: artifacts, _layers_cache = self._collect_inventory() return artifacts + def list_artifacts_with_stack(self) -> list[dict[str, Any]]: + """Return list rows enriched with each artifact's full composition stack.""" + artifacts, layers_cache = self._collect_inventory() + rows: list[dict[str, Any]] = [] + for artifact in artifacts: + stack = _build_stack( + self.project_root, + artifact.kind, + artifact.name, + raw_layers=layers_cache.get((artifact.kind, artifact.name)), + ) + row = artifact.to_json_dict() + row["stack"] = [layer.to_json_dict() for layer in stack] + rows.append(row) + return rows + # ------------------------------------------------------------------ info def get_artifact_info( self, diff --git a/src/specify_cli/artifacts/_commands.py b/src/specify_cli/artifacts/_commands.py index 5ff1fa9d0a..2c19b6166b 100644 --- a/src/specify_cli/artifacts/_commands.py +++ b/src/specify_cli/artifacts/_commands.py @@ -105,7 +105,7 @@ def artifact_list( try: root = _resolve_project_root() catalog = ArtifactCatalog(root) - rows = [artifact.to_json_dict() for artifact in catalog.list_artifacts()] + rows = catalog.list_artifacts_with_stack() except ArtifactError as exc: _emit_error_and_exit(exc) return # pragma: no cover — _emit_error_and_exit raises diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 6f90d98f6c..44b5d24ba9 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -722,6 +722,23 @@ def test_list_json_emits_array(self, spec_kit_project: Path, monkeypatch: pytest assert isinstance(payload, list) assert result.stdout.endswith("\n") + def test_list_json_rows_include_stack(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert payload, "expected at least one artifact" + + row = payload[0] + assert set(row.keys()) == {"id", "name", "kind", "description", "stack"} + assert isinstance(row["stack"], list) + + info_result = runner.invoke(app, ["artifact", "info", row["id"], "--json"]) + assert info_result.exit_code == 0, info_result.stderr + info = json.loads(info_result.stdout) + assert row["stack"] == info["stack"] + def test_list_json_is_pretty_printed(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.chdir(spec_kit_project) runner = CliRunner() From d9d1ef7626a569f758ff4e713e139cc07ec8b065 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:09:08 +0000 Subject: [PATCH 085/113] docs: document artifact list stack records Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- docs/reference/artifacts.md | 47 ++++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md index 8f55676261..fba74efa81 100644 --- a/docs/reference/artifacts.md +++ b/docs/reference/artifacts.md @@ -16,7 +16,7 @@ specify artifact list --json | -------- | -------------------------------------------------------- | | `--json` | Required. Emit the inventory as a JSON array on stdout. | -Prints a flat inventory of every visible artifact — one row per `(kind, name)` pair — sorted by kind (`command`, then `template`, then `script`) and then by name. +Prints the full inventory of every visible artifact — one row per `(kind, name)` pair, including its composition `stack` — sorted by kind (`command`, then `template`, then `script`) and then by name. ```json [ @@ -24,23 +24,52 @@ Prints a flat inventory of every visible artifact — one row per `(kind, name)` "id": "command:speckit.specify", "name": "speckit.specify", "kind": "command", - "description": "Create or update the feature specification." + "description": "Create or update the feature specification.", + "stack": [ + { + "id": "command:speckit.specify", + "layer": null, + "sourceId": null, + "presetId": null, + "presetName": null, + "strategy": "replace", + "active": true, + "hidden": false, + "manifestPath": null, + "lookupId": null + } + ] }, { "id": "script:create-new-feature", "name": "create-new-feature", "kind": "script", - "description": "Create a new feature branch and spec directory." + "description": "Create a new feature branch and spec directory.", + "stack": [ + { + "id": "script:create-new-feature", + "layer": null, + "sourceId": null, + "presetId": null, + "presetName": null, + "strategy": "replace", + "active": true, + "hidden": false, + "manifestPath": null, + "lookupId": null + } + ] } ] ``` -| Field | Description | -| ------------- | ---------------------------------------------------------------------- | -| `id` | `{kind}:{name}` — the shorthand `artifact info` accepts as its argument | -| `name` | Logical artifact name (commands use the `speckit.` namespace) | -| `kind` | One of `command`, `template`, `script` | +| Field | Description | +| ------------- | ------------------------------------------------------------------------- | +| `id` | `{kind}:{name}` — the shorthand `artifact info` accepts as its argument | +| `name` | Logical artifact name (commands use the `speckit.` namespace) | +| `kind` | One of `command`, `template`, `script` | | `description` | Description from the highest-precedence layer that declares one, else `""` | +| `stack` | Composition stack for this artifact, using the same row shape as `artifact info` | Built-in artifacts always appear, even when nothing overrides them. Descriptions come from the highest-priority layer that has one — a preset or project override that hides a built-in command reports its own description, not the hidden built-in text. Skills (`.github/skills/**/SKILL.md`) are excluded: they are integration-specific output, not a shipped asset family. @@ -92,7 +121,7 @@ specify artifact info --json } ``` -The top-level `id`, `name`, `kind`, and `description` fields match the corresponding row on `artifact list --json`. +The top-level `id`, `name`, `kind`, `description`, and `stack` fields match the corresponding row on `artifact list --json`. ### Stack semantics From 0ba48000593ef6095b92acd313330a8dec3a9dc8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:28:43 +0000 Subject: [PATCH 086/113] feat: add artifact layer source paths Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- CHANGELOG.md | 6 + docs/reference/artifacts.md | 15 ++- src/specify_cli/artifacts/__init__.py | 160 ++++++++++++++++++++++++++ tests/test_artifact_command.py | 98 ++++++++++++++++ 4 files changed, 274 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca3dde8da5..1da288cc51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## Unreleased + +### Changed + +- feat(artifacts): include `sourcePath` on artifact stack layers for installed preset and extension contributions. + ## [1.0.1] - 2026-08-21 ### Changed diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md index fba74efa81..6d32997c5a 100644 --- a/docs/reference/artifacts.md +++ b/docs/reference/artifacts.md @@ -36,7 +36,8 @@ Prints the full inventory of every visible artifact — one row per `(kind, name "active": true, "hidden": false, "manifestPath": null, - "lookupId": null + "lookupId": null, + "sourcePath": null } ] }, @@ -56,7 +57,8 @@ Prints the full inventory of every visible artifact — one row per `(kind, name "active": true, "hidden": false, "manifestPath": null, - "lookupId": null + "lookupId": null, + "sourcePath": null } ] } @@ -103,7 +105,8 @@ specify artifact info --json "active": true, "hidden": false, "manifestPath": ".specify/presets/compliance/preset.yml", - "lookupId": "preset:compliance:command:speckit.specify" + "lookupId": "preset:compliance:command:speckit.specify", + "sourcePath": ".github/skills/speckit-specify/SKILL.md" }, { "id": "command:speckit.specify", @@ -115,7 +118,8 @@ specify artifact info --json "active": false, "hidden": true, "manifestPath": null, - "lookupId": null + "lookupId": null, + "sourcePath": null } ] } @@ -139,10 +143,11 @@ The top-level `id`, `name`, `kind`, `description`, and `stack` fields match the | `hidden` | `true` when a lower-index `replace` layer cuts this layer out of the composition | | `manifestPath` | Project-relative path to the declaring manifest, or `null` when none applies | | `lookupId` | Deterministic `{layer}:{sourceId}:{kind}:{name}` identifier, or `null` for built-in layers | +| `sourcePath` | Project-relative POSIX path to the concrete file backing the layer, or `null` for built-in/synthetic layers | `active` and `hidden` are independent labels, not opposites. Composing strategies (`wrap`, `prepend`, `append`) keep lower layers in the composed output, so an inactive layer is not necessarily hidden: only layers below the first `replace` layer are marked `hidden`. Built-in rows have no provenance: `layer`, `sourceId`, and `lookupId` are `null` — but `id` is always populated, even on built-in rows. `id` is the round-trip key: `specify artifact info` accepts it as input (for example, `specify artifact info command:speckit.specify --json`), and it resolves the same artifact whether the caller passes the bare name or the `id`. -Lookup IDs use the same grammar as [preset contribution identifiers](presets.md#contribution-identifiers), so a `lookupId` from this command joins directly to `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` for manifest-declared layers. Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID that intentionally matches no manifest contribution. `lookupId` is manifest-backed layer provenance, not the round-trip key — use `id` for that. +Lookup IDs use the same grammar as [preset contribution identifiers](presets.md#contribution-identifiers), so a `lookupId` from this command joins directly to `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` for manifest-declared layers. Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID that intentionally matches no manifest contribution. `lookupId` is manifest-backed layer provenance, not the round-trip key — use `id` for that. `sourcePath` is populated only when the layer maps to a concrete installed preset/extension file or a tracked agent materialization; core, project-override, and other synthetic rows report `null`. ## JSON Errors diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index f32bdb854e..d4f41d7a12 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -80,6 +80,7 @@ class StackLayer: hidden: bool manifestPath: str | None lookupId: str | None + sourcePath: str | None def to_json_dict(self) -> dict[str, Any]: return { @@ -93,6 +94,7 @@ def to_json_dict(self) -> dict[str, Any]: "hidden": self.hidden, "manifestPath": self.manifestPath, "lookupId": self.lookupId, + "sourcePath": self.sourcePath, } @@ -308,6 +310,159 @@ def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | No return None +def _repo_relative_existing_file(project_root: Path, path: Path) -> str | None: + """Return *path* relative to the project root when it is an existing file.""" + if not path.is_file(): + return None + try: + return path.relative_to(project_root).as_posix() + except ValueError: + return None + + +def _is_safe_path_component(value: str) -> bool: + """Return true when *value* is a single non-traversing path component.""" + if not value or value in (".", ".."): + return False + path = Path(value) + return not path.is_absolute() and len(path.parts) == 1 and path.name == value + + +def _materialized_command_source_path( + project_root: Path, + metadata: dict[str, Any] | None, + name: str, + *, + source: Literal["preset", "extension"], +) -> str | None: + """Return the tracked agent output path for an installed command layer.""" + if not isinstance(metadata, dict): + return None + + try: + from ..agents import CommandRegistrar + except ImportError: + return None + + registrar = CommandRegistrar() + + registered_commands = metadata.get("registered_commands") + if isinstance(registered_commands, dict): + for agent_name in sorted(registered_commands): + cmd_names = registered_commands.get(agent_name) + if not isinstance(agent_name, str) or not isinstance(cmd_names, list): + continue + if name not in cmd_names: + continue + agent_config = registrar.AGENT_CONFIGS.get(agent_name) + if agent_config is None: + continue + output_name = registrar._compute_output_name(agent_name, name, agent_config) + command_path = ( + registrar._resolve_agent_dir(agent_name, agent_config, project_root) + / f"{output_name}{agent_config['extension']}" + ) + rel = _repo_relative_existing_file(project_root, command_path) + if rel is not None: + return rel + + registered_skills = metadata.get("registered_skills") + if source == "preset": + skill_names_by_agent = registered_skills if isinstance(registered_skills, dict) else {} + else: + skill_names_by_agent = { + agent_name: registered_skills + for agent_name in sorted(registrar.AGENT_CONFIGS) + if isinstance(registered_skills, list) + } + + expected_skill_names: set[str] | None = None + if source == "extension": + try: + from ..extensions import ExtensionManager + + expected_skill_names = {ExtensionManager._skill_name_for_command(name)} + except ImportError: + expected_skill_names = None + elif isinstance(skill_names_by_agent, dict): + try: + from ..presets import PresetManager + + expected_skill_names = set(PresetManager._skill_names_for_command(name)) + except ImportError: + expected_skill_names = None + + if isinstance(skill_names_by_agent, dict): + from .. import _get_skills_dir as _project_skills_dir + + for agent_name in sorted(skill_names_by_agent): + skill_names = skill_names_by_agent.get(agent_name) + if not isinstance(agent_name, str) or not isinstance(skill_names, list): + continue + agent_config = registrar.AGENT_CONFIGS.get(agent_name) + if agent_config is None: + continue + if agent_config.get("extension") == "/SKILL.md": + skills_dir = registrar._resolve_agent_dir(agent_name, agent_config, project_root) + else: + skills_dir = _project_skills_dir(project_root, agent_name) + for skill_name in sorted( + n for n in skill_names if isinstance(n, str) and _is_safe_path_component(n) + ): + if expected_skill_names is not None and skill_name not in expected_skill_names: + continue + skill_path = skills_dir / skill_name / "SKILL.md" + rel = _repo_relative_existing_file(project_root, skill_path) + if rel is not None: + return rel + + return None + + +def _derive_source_path( + layer: dict[str, Any], + project_root: Path, + kind: ArtifactKind, + name: str, +) -> str | None: + """Return the repo-relative concrete file backing a preset/extension layer.""" + lookup_id = layer.get("lookupId", "") + layer_kind = layer_kind_from_lookup_id(lookup_id) + if layer_kind == "preset": + pack_id = layer.get("preset_id") + if not isinstance(pack_id, str) or not pack_id: + return None + from ..presets import PresetRegistry + + metadata = PresetRegistry(project_root / ".specify" / "presets").get(pack_id) + if kind == "command": + materialized = _materialized_command_source_path( + project_root, metadata, name, source="preset" + ) + if materialized is not None: + return materialized + elif layer_kind == "extension": + extension_id = layer.get("extension_id") + if not isinstance(extension_id, str) or not extension_id: + return None + from ..extensions import ExtensionRegistry + + metadata = ExtensionRegistry(project_root / ".specify" / "extensions").get(extension_id) + if kind == "command": + materialized = _materialized_command_source_path( + project_root, metadata, name, source="extension" + ) + if materialized is not None: + return materialized + else: + return None + + path = layer.get("path") + if isinstance(path, Path): + return _repo_relative_existing_file(project_root, path) + return None + + def _preset_display_name(pack_dir: Path, pack_id: str) -> str: """Return the preset's human-friendly name from ``preset.yml``, or ``pack_id``. @@ -375,6 +530,7 @@ def _build_stack( hidden = idx > first_replace_idx layer_kind, source_id, lookup_id = _public_layer_shape(layer) + source_path = _derive_source_path(layer, project_root, kind, name) if layer_kind == PROJECT_OVERRIDE_LAYER: rows.append( @@ -389,6 +545,7 @@ def _build_stack( hidden=hidden, manifestPath=None, lookupId=lookup_id, + sourcePath=source_path, ) ) continue @@ -407,6 +564,7 @@ def _build_stack( hidden=hidden, manifestPath=manifest_path, lookupId=lookup_id, + sourcePath=source_path, ) ) continue @@ -424,6 +582,7 @@ def _build_stack( hidden=hidden, manifestPath=None, lookupId=None, + sourcePath=source_path, ) ) continue @@ -454,6 +613,7 @@ def _build_stack( hidden=hidden, manifestPath=manifest_path, lookupId=lookup_id, + sourcePath=source_path, ) ) return rows diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 44b5d24ba9..9dfb624e21 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -118,6 +118,7 @@ def test_core_script_variants_have_one_resolvable_logical_name( assert info["stack"][-1]["layer"] is None assert info["stack"][-1]["sourceId"] is None assert info["stack"][-1]["lookupId"] is None + assert info["stack"][-1]["sourcePath"] is None def test_excludes_disabled_and_unusable_manifest_contributions( self, spec_kit_project: Path @@ -263,6 +264,7 @@ def test_includes_project_local_core_assets(self, spec_kit_project: Path): assert layer["layer"] is None assert layer["sourceId"] is None assert layer["lookupId"] is None + assert layer["sourcePath"] is None def test_includes_root_level_pack_template_but_excludes_readme( self, spec_kit_project: Path @@ -486,6 +488,7 @@ def test_builtin_row_shape(self, spec_kit_project: Path): assert builtin["manifestPath"] is None assert builtin["strategy"] == "replace" assert builtin["lookupId"] is None + assert builtin["sourcePath"] is None def test_public_layer_shape_preserves_non_core_identity(self): assert _public_layer_shape( @@ -506,6 +509,7 @@ def test_project_override_row_shape(self, spec_kit_project: Path): assert project["presetId"] is None assert project["presetName"] is None assert project["manifestPath"] is None + assert project["sourcePath"] is None assert project["strategy"] == "replace" assert project["sourceId"] == "_" assert re.match(r"^project:_:(command|template|script):[^:]+$", project["lookupId"]) @@ -739,6 +743,100 @@ def test_list_json_rows_include_stack(self, spec_kit_project: Path, monkeypatch: info = json.loads(info_result.stdout) assert row["stack"] == info["stack"] + def test_list_json_stack_source_path_contract( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + preset_pack = install_preset( + spec_kit_project, + "compliance", + { + "commands": [ + { + "name": "speckit.compliance.plan", + "file": "commands/speckit.compliance.plan.md", + "description": "Compliance plan", + } + ] + }, + ) + (preset_pack / "commands").mkdir() + (preset_pack / "commands" / "speckit.compliance.plan.md").write_text( + "---\ndescription: Compliance plan\n---\nbody\n", encoding="utf-8" + ) + PresetRegistry(spec_kit_project / ".specify" / "presets").update( + "compliance", + { + "registered_skills": { + "copilot": ["speckit-compliance-plan"], + } + }, + ) + skill_file = ( + spec_kit_project + / ".github" + / "skills" + / "speckit-compliance-plan" + / "SKILL.md" + ) + skill_file.parent.mkdir(parents=True) + skill_file.write_text("---\nname: speckit-compliance-plan\n---\n", encoding="utf-8") + + extension_dir = spec_kit_project / ".specify" / "extensions" / "quality" + (extension_dir / "templates").mkdir(parents=True) + (extension_dir / "templates" / "checklist.md").write_text( + "---\ndescription: Extension checklist\n---\n", encoding="utf-8" + ) + (extension_dir / "extension.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "extension": { + "id": "quality", + "name": "Quality", + "version": "1.0.0", + "description": "test", + "author": "test", + "repository": "https://example.com", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.2.0"}, + "provides": { + "templates": [ + { + "name": "checklist", + "file": "templates/checklist.md", + "description": "Extension checklist", + } + ] + }, + } + ), + encoding="utf-8", + ) + ExtensionRegistry(spec_kit_project / ".specify" / "extensions").add( + "quality", {"version": "1.0.0", "enabled": True} + ) + + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + + non_null_source_paths: set[str] = set() + for row in payload: + for layer in row["stack"]: + assert "sourcePath" in layer + source_path = layer["sourcePath"] + if source_path is None: + continue + assert isinstance(source_path, str) + assert (spec_kit_project / source_path).is_file() + non_null_source_paths.add(source_path) + + assert ".github/skills/speckit-compliance-plan/SKILL.md" in non_null_source_paths + assert ".specify/extensions/quality/templates/checklist.md" in non_null_source_paths + def test_list_json_is_pretty_printed(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.chdir(spec_kit_project) runner = CliRunner() From ec76b9f2c3d577a4c5a67176dbe2c3ed00cb6a5e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:29:44 +0000 Subject: [PATCH 087/113] docs: clarify artifact sourcePath provenance Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- CHANGELOG.md | 2 +- src/specify_cli/artifacts/__init__.py | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1da288cc51..86ed306b9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ## Unreleased -### Changed +### Added - feat(artifacts): include `sourcePath` on artifact stack layers for installed preset and extension contributions. diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index d4f41d7a12..4c125211bb 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -425,7 +425,14 @@ def _derive_source_path( kind: ArtifactKind, name: str, ) -> str | None: - """Return the repo-relative concrete file backing a preset/extension layer.""" + """Return the repo-relative concrete file backing a preset/extension layer. + + ``layer`` is one raw ``PresetResolver.collect_all_layers()`` row. Preset + and extension rows carry explicit on-disk provenance keys + (``preset_id``/``pack_dir`` or ``extension_id``/``extension_dir``) + alongside ``lookupId``; core and project rows intentionally do not produce + a source path here. + """ lookup_id = layer.get("lookupId", "") layer_kind = layer_kind_from_lookup_id(lookup_id) if layer_kind == "preset": From 48605fd84298d7a9d62bac0034da4926a84c2fba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:30:37 +0000 Subject: [PATCH 088/113] refactor: clarify sourcePath derivation flow 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 | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 4c125211bb..fa9cf57510 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -369,12 +369,15 @@ def _materialized_command_source_path( registered_skills = metadata.get("registered_skills") if source == "preset": skill_names_by_agent = registered_skills if isinstance(registered_skills, dict) else {} - else: + elif isinstance(registered_skills, list): + # Extension registries store skills as a flat list, unlike presets' + # per-agent map. Probe every known agent's project-local skills + # directory and return the first extant tracked file. skill_names_by_agent = { - agent_name: registered_skills - for agent_name in sorted(registrar.AGENT_CONFIGS) - if isinstance(registered_skills, list) + agent_name: registered_skills for agent_name in sorted(registrar.AGENT_CONFIGS) } + else: + skill_names_by_agent = {} expected_skill_names: set[str] | None = None if source == "extension": @@ -462,8 +465,12 @@ def _derive_source_path( if materialized is not None: return materialized else: + # Core and project-override rows are built-in/synthetic from the public + # artifact contract's perspective, so their sourcePath stays null. return None + # Non-command preset/extension layers, and command layers without a tracked + # materialized agent output, report the installed pack file the resolver used. path = layer.get("path") if isinstance(path, Path): return _repo_relative_existing_file(project_root, path) From f83e95bfd4885dbab168266888944b849e0fffa5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:31:33 +0000 Subject: [PATCH 089/113] refactor: document artifact source path fallback 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 | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index fa9cf57510..c7d0f40cb7 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -350,7 +350,7 @@ def _materialized_command_source_path( if isinstance(registered_commands, dict): for agent_name in sorted(registered_commands): cmd_names = registered_commands.get(agent_name) - if not isinstance(agent_name, str) or not isinstance(cmd_names, list): + if not isinstance(cmd_names, list): continue if name not in cmd_names: continue @@ -387,7 +387,7 @@ def _materialized_command_source_path( expected_skill_names = {ExtensionManager._skill_name_for_command(name)} except ImportError: expected_skill_names = None - elif isinstance(skill_names_by_agent, dict): + else: try: from ..presets import PresetManager @@ -470,7 +470,8 @@ def _derive_source_path( return None # Non-command preset/extension layers, and command layers without a tracked - # materialized agent output, report the installed pack file the resolver used. + # materialized agent output, report the installed pack file from the raw + # PresetResolver.collect_all_layers() row's concrete ``path`` key. path = layer.get("path") if isinstance(path, Path): return _repo_relative_existing_file(project_root, path) From b76371200080c8bec2851c79e47f621df0fe29cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:32:38 +0000 Subject: [PATCH 090/113] refactor: expose registrar output path helper Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/agents.py | 22 ++++++++++++++++++++++ src/specify_cli/artifacts/__init__.py | 12 +++++++----- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index dede50e0b1..e46b431880 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -1049,6 +1049,28 @@ def _resolve_agent_dir( return legacy_dir return agent_dir + def resolve_agent_dir(self, agent_name: str, project_root: Path) -> Optional[Path]: + """Return the configured output directory for *agent_name*, if known.""" + self._ensure_configs() + agent_config = self.AGENT_CONFIGS.get(agent_name) + if agent_config is None: + return None + return self._resolve_agent_dir(agent_name, agent_config, project_root) + + def resolve_command_output_path( + self, agent_name: str, cmd_name: str, project_root: Path + ) -> Optional[Path]: + """Return the command/skill output path this registrar uses for a command.""" + self._ensure_configs() + agent_config = self.AGENT_CONFIGS.get(agent_name) + if agent_config is None: + return None + output_name = self._compute_output_name(agent_name, cmd_name, agent_config) + return ( + self._resolve_agent_dir(agent_name, agent_config, project_root) + / f"{output_name}{agent_config['extension']}" + ) + def register_commands_for_all_agents( self, commands: List[Dict[str, Any]], diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index c7d0f40cb7..58edf8758d 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -357,11 +357,11 @@ def _materialized_command_source_path( agent_config = registrar.AGENT_CONFIGS.get(agent_name) if agent_config is None: continue - output_name = registrar._compute_output_name(agent_name, name, agent_config) - command_path = ( - registrar._resolve_agent_dir(agent_name, agent_config, project_root) - / f"{output_name}{agent_config['extension']}" + command_path = registrar.resolve_command_output_path( + agent_name, name, project_root ) + if command_path is None: + continue rel = _repo_relative_existing_file(project_root, command_path) if rel is not None: return rel @@ -406,9 +406,11 @@ def _materialized_command_source_path( if agent_config is None: continue if agent_config.get("extension") == "/SKILL.md": - skills_dir = registrar._resolve_agent_dir(agent_name, agent_config, project_root) + skills_dir = registrar.resolve_agent_dir(agent_name, project_root) else: skills_dir = _project_skills_dir(project_root, agent_name) + if skills_dir is None: + continue for skill_name in sorted( n for n in skill_names if isinstance(n, str) and _is_safe_path_component(n) ): From c45c4bf5ce066b65dc084269e8b0deebb38beab2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:33:31 +0000 Subject: [PATCH 091/113] refactor: centralize registrar skill output check Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- src/specify_cli/agents.py | 6 ++++++ src/specify_cli/artifacts/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index e46b431880..6721001d1b 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -1057,6 +1057,12 @@ def resolve_agent_dir(self, agent_name: str, project_root: Path) -> Optional[Pat return None return self._resolve_agent_dir(agent_name, agent_config, project_root) + def uses_skill_output(self, agent_name: str) -> bool: + """Return true when *agent_name* writes commands as ``SKILL.md`` files.""" + self._ensure_configs() + agent_config = self.AGENT_CONFIGS.get(agent_name) + return bool(agent_config and agent_config.get("extension") == "/SKILL.md") + def resolve_command_output_path( self, agent_name: str, cmd_name: str, project_root: Path ) -> Optional[Path]: diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 58edf8758d..d963770ab0 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -405,7 +405,7 @@ def _materialized_command_source_path( agent_config = registrar.AGENT_CONFIGS.get(agent_name) if agent_config is None: continue - if agent_config.get("extension") == "/SKILL.md": + if registrar.uses_skill_output(agent_name): skills_dir = registrar.resolve_agent_dir(agent_name, project_root) else: skills_dir = _project_skills_dir(project_root, agent_name) From 63ec27281de83f0eae0fae308018a675dd4438ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:08:10 +0000 Subject: [PATCH 092/113] fix: only use materialized command output for the active stack row 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 | 18 ++++-- tests/test_artifact_command.py | 80 +++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index d963770ab0..1c2fbc8ba7 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -429,6 +429,8 @@ def _derive_source_path( project_root: Path, kind: ArtifactKind, name: str, + *, + active: bool, ) -> str | None: """Return the repo-relative concrete file backing a preset/extension layer. @@ -437,6 +439,11 @@ def _derive_source_path( (``preset_id``/``pack_dir`` or ``extension_id``/``extension_dir``) alongside ``lookupId``; core and project rows intentionally do not produce a source path here. + + The tracked materialized agent output is shared by every stack row that + contributed the same command name, so it only reflects the winning + (``active``) row's content. Lower ``replace``/``merge`` rows must report + their own installed pack file instead of that shared output. """ lookup_id = layer.get("lookupId", "") layer_kind = layer_kind_from_lookup_id(lookup_id) @@ -447,7 +454,7 @@ def _derive_source_path( from ..presets import PresetRegistry metadata = PresetRegistry(project_root / ".specify" / "presets").get(pack_id) - if kind == "command": + if kind == "command" and active: materialized = _materialized_command_source_path( project_root, metadata, name, source="preset" ) @@ -460,7 +467,7 @@ def _derive_source_path( from ..extensions import ExtensionRegistry metadata = ExtensionRegistry(project_root / ".specify" / "extensions").get(extension_id) - if kind == "command": + if kind == "command" and active: materialized = _materialized_command_source_path( project_root, metadata, name, source="extension" ) @@ -471,8 +478,9 @@ def _derive_source_path( # artifact contract's perspective, so their sourcePath stays null. return None - # Non-command preset/extension layers, and command layers without a tracked - # materialized agent output, report the installed pack file from the raw + # Non-active command layers, non-command preset/extension layers, and + # active command layers without a tracked materialized agent output all + # report the installed pack file from the raw # PresetResolver.collect_all_layers() row's concrete ``path`` key. path = layer.get("path") if isinstance(path, Path): @@ -547,7 +555,7 @@ def _build_stack( hidden = idx > first_replace_idx layer_kind, source_id, lookup_id = _public_layer_shape(layer) - source_path = _derive_source_path(layer, project_root, kind, name) + source_path = _derive_source_path(layer, project_root, kind, name, active=active) if layer_kind == PROJECT_OVERRIDE_LAYER: rows.append( diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 9dfb624e21..9cac1f7985 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -743,6 +743,86 @@ def test_list_json_rows_include_stack(self, spec_kit_project: Path, monkeypatch: info = json.loads(info_result.stdout) assert row["stack"] == info["stack"] + def test_hidden_command_layer_source_path_is_own_pack_file( + self, spec_kit_project: Path + ): + """A hidden (non-active) command row must not report the winner's + shared materialized agent output as its ``sourcePath``. + + Both presets below register the same command name and the same + agent skill name, so the tracked materialized output is a single + shared file. Only the active (winning) row may report that shared + file; the hidden loser row must report its own installed pack file. + """ + pack_low = install_preset( + spec_kit_project, + "aaa-low-priority-preset", + { + "commands": [ + { + "name": "speckit.compliance.plan", + "file": "commands/speckit.compliance.plan.md", + "description": "Loser", + } + ] + }, + priority=20, + ) + (pack_low / "commands").mkdir() + (pack_low / "commands" / "speckit.compliance.plan.md").write_text( + "---\ndescription: Loser\n---\nloser body\n", encoding="utf-8" + ) + PresetRegistry(spec_kit_project / ".specify" / "presets").update( + "aaa-low-priority-preset", + {"registered_skills": {"copilot": ["speckit-compliance-plan"]}}, + ) + + pack_high = install_preset( + spec_kit_project, + "zzz-high-priority-preset", + { + "commands": [ + { + "name": "speckit.compliance.plan", + "file": "commands/speckit.compliance.plan.md", + "description": "Winner", + } + ] + }, + priority=5, + ) + (pack_high / "commands").mkdir() + (pack_high / "commands" / "speckit.compliance.plan.md").write_text( + "---\ndescription: Winner\n---\nwinner body\n", encoding="utf-8" + ) + PresetRegistry(spec_kit_project / ".specify" / "presets").update( + "zzz-high-priority-preset", + {"registered_skills": {"copilot": ["speckit-compliance-plan"]}}, + ) + + skill_file = ( + spec_kit_project + / ".github" + / "skills" + / "speckit-compliance-plan" + / "SKILL.md" + ) + skill_file.parent.mkdir(parents=True) + skill_file.write_text("---\nname: speckit-compliance-plan\n---\n", encoding="utf-8") + + info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.compliance.plan") + stack = info["stack"] + assert stack[0]["active"] is True + assert stack[0]["sourcePath"] == ".github/skills/speckit-compliance-plan/SKILL.md" + + hidden_rows = [layer for layer in stack if layer["active"] is False] + assert hidden_rows + for row in hidden_rows: + assert row["sourcePath"] != stack[0]["sourcePath"] + assert row["sourcePath"] == ( + ".specify/presets/aaa-low-priority-preset/commands/speckit.compliance.plan.md" + ) + def test_list_json_stack_source_path_contract( self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch ): From 26f7fb0534c080c6fdeafacfa52e57ef5e54dab7 Mon Sep 17 00:00:00 2001 From: nicolehaugen Date: Tue, 8 Sep 2026 17:42:18 -0500 Subject: [PATCH 093/113] docs(artifacts): cross-link contribution identifier grammar Add a direct link from docs/reference/artifacts.md to the contribution-identifiers section of the extension API reference next to the existing presets.md link, so readers of the artifact CLI reference can find the id/lookupId grammar without re-deriving it here. Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/artifacts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md index 6d32997c5a..965b45f2f8 100644 --- a/docs/reference/artifacts.md +++ b/docs/reference/artifacts.md @@ -147,7 +147,7 @@ The top-level `id`, `name`, `kind`, `description`, and `stack` fields match the `active` and `hidden` are independent labels, not opposites. Composing strategies (`wrap`, `prepend`, `append`) keep lower layers in the composed output, so an inactive layer is not necessarily hidden: only layers below the first `replace` layer are marked `hidden`. Built-in rows have no provenance: `layer`, `sourceId`, and `lookupId` are `null` — but `id` is always populated, even on built-in rows. `id` is the round-trip key: `specify artifact info` accepts it as input (for example, `specify artifact info command:speckit.specify --json`), and it resolves the same artifact whether the caller passes the bare name or the `id`. -Lookup IDs use the same grammar as [preset contribution identifiers](presets.md#contribution-identifiers), so a `lookupId` from this command joins directly to `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` for manifest-declared layers. Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID that intentionally matches no manifest contribution. `lookupId` is manifest-backed layer provenance, not the round-trip key — use `id` for that. `sourcePath` is populated only when the layer maps to a concrete installed preset/extension file or a tracked agent materialization; core, project-override, and other synthetic rows report `null`. +Lookup IDs use the same grammar as [preset contribution identifiers](presets.md#contribution-identifiers) and [extension contribution identifiers](../../extensions/EXTENSION-API-REFERENCE.md#contribution-identifiers), so a `lookupId` from this command joins directly to `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` for manifest-declared layers. Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID that intentionally matches no manifest contribution. `lookupId` is manifest-backed layer provenance, not the round-trip key — use `id` for that. `sourcePath` is populated only when the layer maps to a concrete installed preset/extension file or a tracked agent materialization; core, project-override, and other synthetic rows report `null`. ## JSON Errors From 64f6a65cbfabc42dcb8c1a66c031efaba7430cb3 Mon Sep 17 00:00:00 2001 From: nicolehaugen Date: Tue, 8 Sep 2026 17:42:32 -0500 Subject: [PATCH 094/113] fix(artifacts): enforce identifier grammar and gate manifestPath on declared contributions Three related correctness fixes for the artifact-stack pipeline surfaced during PR #4305 review: 1. _identifier.py: add source_id_from_lookup_id() helper mirroring layer_kind_from_lookup_id, and enforce the project/'_' sentinel in derive_named_id (project layer requires source_id == '_'; preset/extension layers reject '_'). Swap the split(':', 2)[1] call in artifacts/__init__.py to use the new helper so consumers no longer parse identifier grammar directly. 2. presets/__init__.py: thread a manifest_declared flag through collect_all_layers so downstream consumers can distinguish manifest-declared contributions from convention-only fallbacks. 3. artifacts/__init__.py: _derive_manifest_path returns None when the layer is not manifest-declared, so a stack row for a convention-only contribution no longer falsely reports a manifestPath pointing at a manifest that does not declare it. Tests: compact param-based coverage for source_id_from_lookup_id and derive_named_id sentinel rules; one preset + one extension test proving lookupId uses the manifest's validated id when it differs from the on-disk directory name; one end-to-end extension test proving a convention-only contribution reports manifestPath: null. Existing TestManifestPathPortability fixtures updated to set manifest_declared: True. Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/_identifier.py | 31 +++++++ src/specify_cli/artifacts/__init__.py | 18 +++- src/specify_cli/presets/__init__.py | 2 + tests/test_artifact_command.py | 29 +++++++ tests/test_contribution_ids.py | 113 ++++++++++++++++++++++++++ 5 files changed, 192 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py index 21e87b3bfc..6cb01529a7 100644 --- a/src/specify_cli/_identifier.py +++ b/src/specify_cli/_identifier.py @@ -95,6 +95,15 @@ def derive_named_id(layer: str, source_id: str, kind: str, name: str) -> str: derivation boundary that must enforce the grammar. Callers passing raw strings should either pre-validate or handle :class:`IdentifierComponentError`. + + The project-layer sentinel is also enforced here: the project layer uses + ``sourceId == "_"`` (see :data:`PROJECT_OVERRIDE_LAYER`) and no other + value; the preset and extension layers never use ``"_"``, which is + reserved for the project layer. Callers that mix these up would produce a + lookupId :func:`layer_kind_from_lookup_id` still parses but that no + manifest ever emits — a silent join-key mismatch. Rejecting the mix here + keeps the grammar's sentinel contract enforced at the single derivation + boundary rather than in each caller. """ validate_component(layer, "layer") validate_component(source_id, "sourceId") @@ -104,6 +113,14 @@ def derive_named_id(layer: str, source_id: str, kind: str, name: str) -> str: if kind not in _NAMED_CONTRIBUTION_KINDS: raise IdentifierComponentError(f"Invalid named contribution kind '{kind}'") validate_component(name, "name") + if layer == PROJECT_OVERRIDE_LAYER and source_id != "_": + raise IdentifierComponentError( + f"Invalid sourceId '{source_id}': project layer requires '_'" + ) + if layer in {"preset", "extension"} and source_id == "_": + raise IdentifierComponentError( + "Invalid sourceId '_': reserved for project layer" + ) return f"{layer}:{source_id}:{kind}:{name}" @@ -171,6 +188,20 @@ def is_dotted_command_name(value: str) -> bool: ) +def source_id_from_lookup_id(lookup_id: str) -> str | None: + """Return the sourceId segment of a resolved-stack ``lookupId``, or ``None``. + + Returns ``None`` for any value that :func:`layer_kind_from_lookup_id` + would reject — same validation, same grammar, single source of truth. + Consumers must not ``.split(":")`` a ``lookupId`` themselves: the + grammar's segmentation lives in this module, and any caller doing its + own split leaks the layout across the codebase. + """ + if layer_kind_from_lookup_id(lookup_id) is None: + return None + return lookup_id.split(":", 2)[1] + + def derive_hook_id( layer: str, source_id: str, diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 1c2fbc8ba7..ec617e5eee 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -25,6 +25,7 @@ derive_public_id, is_dotted_command_name, layer_kind_from_lookup_id, + source_id_from_lookup_id, validate_component, ) from .._script_variants import canonical_script_name @@ -261,7 +262,10 @@ def _public_layer_shape( layer_kind = layer_kind_from_lookup_id(lookup_id) if layer_kind not in ("project", "preset", "extension"): raise ArtifactResolutionError() - return layer_kind, lookup_id.split(":", 2)[1], lookup_id + source_id = source_id_from_lookup_id(lookup_id) + if source_id is None: + raise ArtifactResolutionError() + return layer_kind, source_id, lookup_id def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | None: @@ -280,10 +284,22 @@ def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | No layers — which ``collect_all_layers()`` always sets alongside ``lookupId``. Missing provenance keys mean no manifest path is available. + Convention-only contributions are surfaced by the resolver even when the + pack's manifest does not declare them — the manifest file exists on disk + but does not list the artifact in ``provides``. Reporting the manifest + path in that case would be a false positive: consumers joining on the + reported path would find no matching contribution. ``collect_all_layers`` + sets ``manifest_declared=True`` on layers that came from a manifest + ``provides`` entry, so those layers alone report a manifest path; a layer + without that flag falls through to ``None`` even when the manifest file + exists on disk. + 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 not layer.get("manifest_declared"): + return None lookup_id = layer.get("lookupId", "") layer_kind = layer_kind_from_lookup_id(lookup_id) if layer_kind == "preset": diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index c9eaafeab4..1d0dca25fb 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -5997,6 +5997,7 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: "strategy": strategy, "preset_id": pack_id, "pack_dir": pack_dir, + "manifest_declared": entry is not None, "lookupId": derive_named_id( "preset", source_id_for_lookup, template_type, template_name ), @@ -6059,6 +6060,7 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: "strategy": "replace", "extension_id": ext_id, "extension_dir": ext_dir, + "manifest_declared": entry is not None, "lookupId": derive_named_id( "extension", source_id_for_lookup, template_type, template_name ), diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 9cac1f7985..b135de4b50 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -1344,6 +1344,7 @@ def test_preset_manifest_path_is_repo_relative(self, tmp_path: Path): "path": pack_dir / "spec-template.md", "preset_id": "my-pack", "pack_dir": pack_dir, + "manifest_declared": True, } assert ( _derive_manifest_path(layer, project_root) @@ -1361,6 +1362,7 @@ def test_extension_manifest_path_is_repo_relative(self, tmp_path: Path): "path": ext_dir / "commands" / "speckit.my-ext.go.md", "extension_id": "my-ext", "extension_dir": ext_dir, + "manifest_declared": True, } assert ( _derive_manifest_path(layer, project_root) @@ -1384,6 +1386,7 @@ def test_renamed_pack_directory_wins_over_lookup_id_source(self, tmp_path: Path) "path": pack_dir / "spec-template.md", "preset_id": "renamed-on-disk", "pack_dir": pack_dir, + "manifest_declared": True, } assert ( _derive_manifest_path(layer, project_root) @@ -1400,6 +1403,7 @@ def test_missing_manifest_file_is_none(self, tmp_path: Path): "path": pack_dir / "spec-template.md", "preset_id": "my-pack", "pack_dir": pack_dir, + "manifest_declared": True, } assert _derive_manifest_path(layer, project_root) is None @@ -1414,6 +1418,7 @@ def test_missing_provenance_keys_is_none(self, tmp_path: Path): layer = { "lookupId": "preset:my-pack:template:spec-template", "path": pack_dir / "spec-template.md", + "manifest_declared": True, } assert _derive_manifest_path(layer, project_root) is None @@ -1426,6 +1431,30 @@ def test_builtin_and_project_layers_have_no_manifest(self, tmp_path: Path): assert _derive_manifest_path(builtin_layer, project_root) is None assert _derive_manifest_path(project_layer, project_root) is None + def test_convention_only_extension_layer_reports_no_manifest_path( + self, tmp_path: Path + ): + """A contribution the manifest does not declare in ``provides`` — a + "convention-only" contribution — must NOT report the manifest as its + source, even when the manifest file exists on disk. Joining on the + reported path would find no matching contribution. One extension test + covers both the preset and extension branches: ``_derive_manifest_path`` + gates on the layer's ``manifest_declared`` flag before dispatching by + layer kind.""" + project_root = tmp_path / "proj" + ext_dir = project_root / ".specify" / "extensions" / "foo" + ext_dir.mkdir(parents=True) + (ext_dir / "extension.yml").write_text("id: foo\n", encoding="utf-8") + + layer = { + "lookupId": "extension:foo:command:speckit.baz", + "path": ext_dir / "commands" / "baz.md", + "extension_id": "foo", + "extension_dir": ext_dir, + "manifest_declared": False, + } + assert _derive_manifest_path(layer, project_root) is None + class TestPresetDisplayName: """`_preset_display_name` delegates to the validated `PresetManifest.name`.""" diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py index dab93db674..a5c907020a 100644 --- a/tests/test_contribution_ids.py +++ b/tests/test_contribution_ids.py @@ -31,6 +31,7 @@ derive_named_id, derive_public_id, layer_kind_from_lookup_id, + source_id_from_lookup_id, validate_component, ) from specify_cli.extensions import ExtensionManifest, ValidationError @@ -529,3 +530,115 @@ 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 + + +# --------------------------------------------------------------------------- +# `_identifier.py` review-round nits — sourceId accessor + derive_named_id +# sentinel enforcement. Consumers must not ``.split(":")`` a lookupId +# themselves, and the ``project``/``_`` pairing is enforced at the single +# derivation boundary rather than at each caller. +# --------------------------------------------------------------------------- + + +class TestSourceIdFromLookupId: + @pytest.mark.parametrize( + "lookup_id, expected", + [ + ("preset:speckit-core:command:speckit.plan", "speckit-core"), + ( + "extension:speckit-git:hook:before_specify:speckit.git.branch", + "speckit-git", + ), + ("", None), + ("preset:foo", None), + ("unknown:foo:command:bar", None), + ("project:_:hook:evt:cmd", None), + ], + ) + def test_extracts_source_id_or_none(self, lookup_id, expected): + assert source_id_from_lookup_id(lookup_id) == expected + + +class TestDeriveNamedIdSentinel: + @pytest.mark.parametrize( + "layer, source_id", + [ + (PROJECT_OVERRIDE_LAYER, "other"), + ("preset", "_"), + ("extension", "_"), + ], + ) + def test_rejects_invalid_layer_source_pairs(self, layer, source_id): + with pytest.raises(IdentifierComponentError): + derive_named_id(layer, source_id, "command", "n") + + def test_project_layer_accepts_underscore_source(self): + assert ( + derive_named_id(PROJECT_OVERRIDE_LAYER, "_", "command", "n") + == f"{PROJECT_OVERRIDE_LAYER}:_:command:n" + ) + + +# --------------------------------------------------------------------------- +# Manifest-declared id wins over installed-directory name — one preset test +# and one extension test because the two branches of ``collect_all_layers`` +# could diverge independently. Each proves ``lookupId`` on the resolved layer +# equals the manifest contribution ``id``. +# --------------------------------------------------------------------------- + + +def _write_registry(project: Path, tier: str, pack_id: str) -> None: + registry = { + "schema_version": "1.0", + tier: {pack_id: {"version": "1.0.0", "priority": 10, "enabled": True}}, + } + (project / ".specify" / tier / ".registry").write_text( + json.dumps(registry), encoding="utf-8" + ) + + +class TestManifestIdWinsOverDirectoryName: + def test_preset_lookup_id_uses_manifest_id_when_directory_renamed(self, tmp_path): + project = _make_project(tmp_path) + dir_name, manifest_id = "renamed-preset", "original-preset" + pack_dir = project / ".specify" / "presets" / dir_name + (pack_dir / "templates").mkdir(parents=True) + (pack_dir / "templates" / "spec-template.md").write_text("p", encoding="utf-8") + data = _preset_data(manifest_id) + data["provides"] = { + "templates": [ + {"type": "template", "name": "spec-template", "file": "templates/spec-template.md"} + ] + } + _write_manifest(pack_dir, data, "preset.yml") + _write_registry(project, "presets", dir_name) + + layers = PresetResolver(project).collect_all_layers("spec-template", "template") + layer = next(L for L in layers if L["source"].startswith(dir_name)) + manifest = PresetManifest(pack_dir / "preset.yml") + assert layer["lookupId"] == manifest.contribution_id("template", "spec-template") + assert layer["lookupId"] == f"preset:{manifest_id}:template:spec-template" + + def test_extension_lookup_id_uses_manifest_id_when_directory_renamed(self, tmp_path): + project = _make_project(tmp_path) + dir_name, manifest_id = "renamed-ext", "original-ext" + # Extension commands are auto-namespaced under speckit. + namespaced = f"speckit.{manifest_id}.branch" + ext_dir = project / ".specify" / "extensions" / dir_name + (ext_dir / "commands").mkdir(parents=True) + (ext_dir / "commands" / "branch.md").write_text("e", encoding="utf-8") + data = _extension_data(manifest_id, with_templates=False, with_scripts=False) + data["provides"]["commands"] = [ + {"name": "speckit.branch", "file": "commands/branch.md", "description": "F"} + ] + _write_manifest(ext_dir, data, "extension.yml") + _write_registry(project, "extensions", dir_name) + + layers = PresetResolver(project).collect_all_layers(namespaced, "command") + layer = next(L for L in layers if L.get("extension_id") == dir_name) + manifest = ExtensionManifest(ext_dir / "extension.yml") + assert layer["lookupId"] == manifest.contribution_id("command", namespaced) + assert layer["lookupId"] == f"extension:{manifest_id}:command:{namespaced}" + + + From b2dbb7a27f46cdb31c48c846f2e553be078a28fa Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Tue, 8 Sep 2026 18:03:38 -0500 Subject: [PATCH 095/113] chore(tests): remove trailing blank lines Assisted-by: GitHub Copilot (autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- tests/test_contribution_ids.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py index a5c907020a..4660ee7193 100644 --- a/tests/test_contribution_ids.py +++ b/tests/test_contribution_ids.py @@ -639,6 +639,3 @@ def test_extension_lookup_id_uses_manifest_id_when_directory_renamed(self, tmp_p manifest = ExtensionManifest(ext_dir / "extension.yml") assert layer["lookupId"] == manifest.contribution_id("command", namespaced) assert layer["lookupId"] == f"extension:{manifest_id}:command:{namespaced}" - - - From 842aa8792b9d135600aded6cace73eb7dcd187fe Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Tue, 8 Sep 2026 18:37:09 -0500 Subject: [PATCH 096/113] fix(presets): reuse parsed extension manifest identity Carry the validated extension manifest ID out of the manifest-first resolution helper so collect_all_layers does not re-read the manifest and fall back to a directory-based lookupId after a transient second-read failure. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- src/specify_cli/presets/__init__.py | 72 +++++++++++++---------------- tests/test_contribution_ids.py | 21 +++++++-- 2 files changed, 49 insertions(+), 44 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 1d0dca25fb..6f5231239e 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -5456,16 +5456,20 @@ def _manifest_declared_template( def _extension_manifest_declared_template( self, ext_dir: Path, template_name: str, template_type: str - ) -> tuple[dict | None, Path | None]: + ) -> tuple[dict | None, Path | None, str | None]: """Resolve an extension's manifest-declared command/template/script entry and usable file. - Mirrors ``_manifest_declared_template`` (for presets): returns ``(entry, candidate)`` - where ``entry`` is the matching ``provides.`` mapping, or ``None`` if the - extension has no (valid) manifest or doesn't declare this ``(name, type)``. - ``candidate`` is the declared ``file:`` resolved under ``ext_dir`` IFF it is a - regular file that stays within ``ext_dir`` (guards against path traversal via a - malformed manifest, mirroring ``resolve_extension_command_via_manifest``); - ``None`` otherwise. + Mirrors ``_manifest_declared_template`` (for presets): returns + ``(entry, candidate, manifest_id)`` where ``entry`` is the matching + ``provides.`` mapping, or ``None`` if the extension has no + (valid) manifest or doesn't declare this ``(name, type)``. + ``candidate`` is the declared ``file:`` resolved under ``ext_dir`` IFF + it is a regular file that stays within ``ext_dir`` (guards against path + traversal via a malformed manifest, mirroring + ``resolve_extension_command_via_manifest``); ``None`` otherwise. + ``manifest_id`` comes from the same successful parse that produced + ``entry``, so callers never need a second fallible read to derive the + contribution identity. The manifest is authoritative: when ``entry`` is not ``None`` but ``candidate`` is ``None``, callers must NOT fall back to convention-based lookup — that would mask @@ -5474,16 +5478,16 @@ def _extension_manifest_declared_template( diverge (the divergence flagged in review on #4012). """ if template_type not in ("command", "template", "script"): - return None, None + return None, None, None ext_manifest_path = ext_dir / "extension.yml" if not ext_manifest_path.exists(): - return None, None + return None, None, None from ..extensions import ExtensionManifest, ValidationError as ExtValidationError try: ext_manifest = ExtensionManifest(ext_manifest_path) except (ExtValidationError, yaml.YAMLError, OSError, TypeError, AttributeError): - return None, None + return None, None, None if template_type == "command": entries = ext_manifest.commands elif template_type == "template": @@ -5495,10 +5499,10 @@ def _extension_manifest_declared_template( continue file_rel = entry.get("file") if not file_rel: - return entry, None + return entry, None, ext_manifest.id rel_path = Path(file_rel) if rel_path.is_absolute(): - return entry, None + return entry, None, ext_manifest.id candidate = ext_dir / rel_path try: # Resolve only for the containment check, not for the @@ -5508,9 +5512,13 @@ def _extension_manifest_declared_template( # lookup returns for the same directory. candidate.resolve().relative_to(ext_dir.resolve()) # raises ValueError if outside except (OSError, ValueError): - return entry, None - return entry, (candidate if candidate.is_file() else None) - return None, None + return entry, None, ext_manifest.id + return ( + entry, + candidate if candidate.is_file() else None, + ext_manifest.id, + ) + return None, None, None def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: """Build unified list of registered and unregistered extensions sorted by priority. @@ -5694,8 +5702,10 @@ def resolve( # The extension manifest is authoritative, same as preset manifests # above: check it before convention-based lookup so a declared entry # at a non-conventional path wins over a stale conventional file. - entry, manifest_candidate = self._extension_manifest_declared_template( - ext_dir, template_name, template_type + entry, manifest_candidate, _manifest_id = ( + self._extension_manifest_declared_template( + ext_dir, template_name, template_type + ) ) if manifest_candidate is not None: return manifest_candidate @@ -6012,7 +6022,7 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: # above: check it before convention-based lookup so a declared entry # at a non-conventional path wins over a stale conventional file, and # a declared-but-missing file isn't silently masked by convention. - entry, candidate = self._extension_manifest_declared_template( + entry, candidate, manifest_id = self._extension_manifest_declared_template( ext_dir, template_name, template_type ) if entry is None: @@ -6032,28 +6042,8 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: # separately via ``extension_id`` / ``extension_dir`` for path # / provenance lookup. source_id_for_lookup = ext_id - if entry is not None: - ext_manifest_path = ext_dir / "extension.yml" - if ext_manifest_path.is_file(): - try: - from ..extensions import ( - ExtensionManifest, - ValidationError as ExtValidationError, - ) - ext_manifest = ExtensionManifest(ext_manifest_path) - if isinstance(ext_manifest.id, str) and ext_manifest.id: - source_id_for_lookup = ext_manifest.id - except ( - ExtValidationError, - yaml.YAMLError, - OSError, - TypeError, - AttributeError, - ): - # Fall back to the directory identity when the - # manifest can't be re-read — same recovery as - # ``_extension_manifest_declared_template``. - pass + if entry is not None and manifest_id is not None: + source_id_for_lookup = manifest_id layers.append({ "path": candidate, "source": source, diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py index 4660ee7193..9e3e23b2ac 100644 --- a/tests/test_contribution_ids.py +++ b/tests/test_contribution_ids.py @@ -619,7 +619,9 @@ def test_preset_lookup_id_uses_manifest_id_when_directory_renamed(self, tmp_path assert layer["lookupId"] == manifest.contribution_id("template", "spec-template") assert layer["lookupId"] == f"preset:{manifest_id}:template:spec-template" - def test_extension_lookup_id_uses_manifest_id_when_directory_renamed(self, tmp_path): + def test_extension_lookup_id_uses_manifest_id_when_directory_renamed( + self, tmp_path, monkeypatch + ): project = _make_project(tmp_path) dir_name, manifest_id = "renamed-ext", "original-ext" # Extension commands are auto-namespaced under speckit. @@ -631,11 +633,24 @@ def test_extension_lookup_id_uses_manifest_id_when_directory_renamed(self, tmp_p data["provides"]["commands"] = [ {"name": "speckit.branch", "file": "commands/branch.md", "description": "F"} ] - _write_manifest(ext_dir, data, "extension.yml") + manifest_path = _write_manifest(ext_dir, data, "extension.yml") _write_registry(project, "extensions", dir_name) + manifest = ExtensionManifest(manifest_path) + read_count = 0 + + def read_manifest_once(path): + nonlocal read_count + read_count += 1 + if read_count > 1: + raise OSError("simulated transient second-read failure") + return ExtensionManifest(path) + + monkeypatch.setattr( + "specify_cli.extensions.ExtensionManifest", read_manifest_once + ) layers = PresetResolver(project).collect_all_layers(namespaced, "command") layer = next(L for L in layers if L.get("extension_id") == dir_name) - manifest = ExtensionManifest(ext_dir / "extension.yml") + assert read_count == 1 assert layer["lookupId"] == manifest.contribution_id("command", namespaced) assert layer["lookupId"] == f"extension:{manifest_id}:command:{namespaced}" From 778fc06a5d3759b73c21c64708c4de492648f168 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Tue, 8 Sep 2026 21:21:31 -0500 Subject: [PATCH 097/113] fix(identifiers): enforce layer source sentinel when parsing Share the project underscore sentinel rule across named and hook constructors and lookupId parsing so malformed project and provider provenance is rejected consistently. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- src/specify_cli/_identifier.py | 21 +++++++++++++++++++-- tests/test_contribution_ids.py | 7 +++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py index 6cb01529a7..2177614950 100644 --- a/src/specify_cli/_identifier.py +++ b/src/specify_cli/_identifier.py @@ -59,6 +59,13 @@ class IdentifierComponentError(ValueError): """Raised when a manifest component would break identifier grammar.""" +def _source_id_matches_layer(layer: str, source_id: str) -> bool: + """Return whether ``source_id`` satisfies the layer sentinel contract.""" + if layer == PROJECT_OVERRIDE_LAYER: + return source_id == "_" + return layer in {"preset", "extension"} and source_id != "_" + + def validate_component(value: Any, field_label: str) -> str: """Return ``value`` unchanged if it is a non-empty ``:``-free string. @@ -113,11 +120,15 @@ def derive_named_id(layer: str, source_id: str, kind: str, name: str) -> str: if kind not in _NAMED_CONTRIBUTION_KINDS: raise IdentifierComponentError(f"Invalid named contribution kind '{kind}'") validate_component(name, "name") - if layer == PROJECT_OVERRIDE_LAYER and source_id != "_": + if layer == PROJECT_OVERRIDE_LAYER and not _source_id_matches_layer( + layer, source_id + ): raise IdentifierComponentError( f"Invalid sourceId '{source_id}': project layer requires '_'" ) - if layer in {"preset", "extension"} and source_id == "_": + if layer in {"preset", "extension"} and not _source_id_matches_layer( + layer, source_id + ): raise IdentifierComponentError( "Invalid sourceId '_': reserved for project layer" ) @@ -162,6 +173,8 @@ def layer_kind_from_lookup_id(lookup_id: str) -> str | None: layer = parts[0] if layer not in _LAYER_KINDS: return None + if not _source_id_matches_layer(layer, parts[1]): + return None if parts[2] not in _CONTRIBUTION_KINDS: return None expected_len = 5 if parts[2] == "hook" else 4 @@ -217,6 +230,10 @@ def derive_hook_id( validate_component(source_id, "sourceId") if layer not in _HOOK_LAYERS: raise IdentifierComponentError(f"Invalid layer '{layer}'") + if not _source_id_matches_layer(layer, source_id): + raise IdentifierComponentError( + "Invalid sourceId '_': reserved for project layer" + ) validate_component(event_name, "eventName") validate_component(command, "command") return f"{layer}:{source_id}:hook:{event_name}:{command}" diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py index 9e3e23b2ac..02b65e0d8b 100644 --- a/tests/test_contribution_ids.py +++ b/tests/test_contribution_ids.py @@ -553,6 +553,9 @@ class TestSourceIdFromLookupId: ("preset:foo", None), ("unknown:foo:command:bar", None), ("project:_:hook:evt:cmd", None), + ("project:foo:command:bar", None), + ("preset:_:command:bar", None), + ("extension:_:hook:before_plan:speckit.plan", None), ], ) def test_extracts_source_id_or_none(self, lookup_id, expected): @@ -578,6 +581,10 @@ def test_project_layer_accepts_underscore_source(self): == f"{PROJECT_OVERRIDE_LAYER}:_:command:n" ) + def test_hook_rejects_project_source_sentinel(self): + with pytest.raises(IdentifierComponentError): + derive_hook_id("extension", "_", "before_plan", "speckit.plan") + # --------------------------------------------------------------------------- # Manifest-declared id wins over installed-directory name — one preset test From 3f3206af99624526094fbfa05d82ecd64993ba4d Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Tue, 8 Sep 2026 21:27:51 -0500 Subject: [PATCH 098/113] docs(identifiers): clarify built-in provenance contract Document that built-in artifact layers omit lookupId and round-trip through their source-agnostic public kind:name ID, while project overrides retain a synthetic stack identity. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- src/specify_cli/_identifier.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py index 2177614950..4783d6f382 100644 --- a/src/specify_cli/_identifier.py +++ b/src/specify_cli/_identifier.py @@ -43,10 +43,12 @@ 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. +its ``lookupId`` uses this label so provenance-backed non-built-in layers have +a stable stack identity. 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. Built-in layers +carry no ``lookupId`` and round-trip through their source-agnostic public +``kind:name`` artifact ID instead. """ _LAYER_KINDS = frozenset({PROJECT_OVERRIDE_LAYER, "preset", "extension"}) From c9b23e8be87faa1dcab945d0dcd2a5f32c726d05 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Tue, 8 Sep 2026 21:39:05 -0500 Subject: [PATCH 099/113] fix(artifacts): preserve preset registry fallback Remove the artifact-specific preset corruption guard and retain Spec Kit's existing behavior of treating malformed preset registry data as an empty registry. Keep extension registry validation unchanged. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- docs/reference/artifacts.md | 2 +- src/specify_cli/artifacts/__init__.py | 21 ---------------- src/specify_cli/presets/__init__.py | 36 --------------------------- tests/test_artifact_command.py | 27 ++++++++------------ 4 files changed, 11 insertions(+), 75 deletions(-) diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md index 965b45f2f8..046d44dd07 100644 --- a/docs/reference/artifacts.md +++ b/docs/reference/artifacts.md @@ -162,6 +162,6 @@ On failure, nothing is written to stdout. A single-key JSON envelope is written | `not a Spec Kit project: no .specify/ directory found` | Run outside an initialized project | | `unknown artifact ` | No artifact matches the requested name (and kind, when given) | | `ambiguous artifact : matches kinds [...]` | The bare name matches more than one kind — re-run with `--kind` | -| `artifact resolution failed` | The preset/extension registries could not be read, or artifact content could not be composed | +| `artifact resolution failed` | The extension registry could not be read, or artifact content could not be composed | Exit code `2` is reserved for usage errors — a missing `--json` flag or an invalid `--kind` value — and emits a plain-text message on stderr rather than a JSON envelope. diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index ec617e5eee..9755917bfe 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -689,26 +689,6 @@ def _validate_extension_registry(project_root: Path) -> None: raise ArtifactResolutionError() -def _validate_preset_registry(project_root: Path) -> None: - """Fail closed when the preset registry is present but unreadable. - - ``PresetRegistry._load`` normalizes malformed JSON to an empty mapping so - install/enable/disable flows keep working, but that same recovery would - silently drop every installed preset from the artifact inventory. Callers - that treat the inventory as authoritative must therefore refuse to run - against a corrupt registry — same fail-closed contract as - :func:`_validate_extension_registry`. - """ - presets_dir = project_root / ".specify" / "presets" - if not presets_dir.exists(): - return - - from ..presets import PresetRegistry - - if PresetRegistry(presets_dir).is_corrupt(): - raise ArtifactResolutionError() - - def _resolve_kind_hint(name: str, kind: ArtifactKind | None) -> tuple[str, ArtifactKind | None]: """Parse ``kind:name`` shorthand and reconcile it with an explicit ``--kind`` flag. @@ -861,7 +841,6 @@ def _collect_inventory( ]: _validate_project(self.project_root) _validate_extension_registry(self.project_root) - _validate_preset_registry(self.project_root) from ..presets import PresetError, PresetResolver # lazy: avoids circular import diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 6f5231239e..005cacab9e 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -771,42 +771,6 @@ def _save(self): with open(self.registry_path, 'w', encoding='utf-8') as f: json.dump(self.data, f, indent=2) - def is_corrupt(self) -> bool: - """Report whether an existing registry file is present but unreadable. - - ``_load`` deliberately recovers from a corrupt registry by normalizing - it to an empty mapping so install/enable/disable flows keep working. - Resolution paths (e.g. the artifact catalog), however, must fail - closed: a corrupt registry that normalizes to ``{}`` would otherwise - cause every installed preset to be silently dropped from the reported - inventory. This probe lets those callers distinguish "no registry" - (safe) from "registry exists but is invalid" (unsafe) without changing - recovery behavior. An absent registry returns ``False``; a directory, - broken or dangling symlink, non-regular file, unreadable file, - non-mapping root, or non-mapping ``presets`` value returns ``True``. - - Mirrors :meth:`ExtensionRegistry.is_corrupt` — the two registries have - the same corruption model, so both surfaces (artifact catalog, - extension enumeration) can share the same fail-closed pattern. - """ - # os.path.lexists (not Path.exists) so a dangling symlink is detected - # rather than followed to a non-existent target and mistaken for an - # absent registry. - if not os.path.lexists(self.registry_path): - return False - if not self.registry_path.is_file(): - return True - try: - with open(self.registry_path, "r", encoding="utf-8") as f: - data = json.load(f) - except (json.JSONDecodeError, OSError, UnicodeDecodeError): - return True - if not isinstance(data, dict): - return True - if "presets" not in data or not isinstance(data["presets"], dict): - return True - return False - def add(self, pack_id: str, metadata: dict): """Add preset to registry. diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index b135de4b50..484765d98d 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -286,14 +286,10 @@ def test_includes_root_level_pack_template_but_excludes_readme( row for row in catalog.list_artifacts() if row.name == "legacy-root" ).description == "Legacy root template" - @pytest.mark.parametrize("registry_dir, registry_name", [ - ("extensions", "extensions"), - ("presets", "presets"), - ]) - def test_registry_missing_collection_key_is_corrupt( - self, spec_kit_project: Path, registry_dir: str, registry_name: str + def test_extension_registry_missing_collection_key_is_corrupt( + self, spec_kit_project: Path ): - registry_path = spec_kit_project / ".specify" / registry_dir / ".registry" + registry_path = spec_kit_project / ".specify" / "extensions" / ".registry" registry_path.write_text('{"schema_version": "1.0"}', encoding="utf-8") with pytest.raises(ArtifactResolutionError): @@ -599,19 +595,16 @@ def test_info_rejects_corrupt_extension_registry(self, spec_kit_project: Path): with pytest.raises(ArtifactResolutionError): ArtifactCatalog(spec_kit_project).get_artifact_info("command:speckit.constitution") - def test_info_rejects_corrupt_preset_registry(self, spec_kit_project: Path): - registry = spec_kit_project / ".specify" / "presets" / ".registry" - registry.write_text("{invalid", encoding="utf-8") - - with pytest.raises(ArtifactResolutionError): - ArtifactCatalog(spec_kit_project).get_artifact_info("command:speckit.constitution") - - def test_list_rejects_corrupt_preset_registry(self, spec_kit_project: Path): + def test_corrupt_preset_registry_uses_empty_registry_fallback(self, spec_kit_project: Path): registry = spec_kit_project / ".specify" / "presets" / ".registry" registry.write_text("{invalid", encoding="utf-8") - with pytest.raises(ArtifactResolutionError): - ArtifactCatalog(spec_kit_project).list_artifacts() + catalog = ArtifactCatalog(spec_kit_project) + assert any(row.id == "command:speckit.constitution" for row in catalog.list_artifacts()) + assert ( + catalog.get_artifact_info("command:speckit.constitution")["id"] + == "command:speckit.constitution" + ) class TestKindHint: From 4eefb4bbb264e818fa734552b095adf6710cc3fa Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Tue, 8 Sep 2026 21:39:32 -0500 Subject: [PATCH 100/113] test(artifacts): drop preset corruption fallback coverage Do not establish a new artifact-specific contract test for the preset registry's pre-existing malformed-data fallback. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- tests/test_artifact_command.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 484765d98d..c308632128 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -595,18 +595,6 @@ def test_info_rejects_corrupt_extension_registry(self, spec_kit_project: Path): with pytest.raises(ArtifactResolutionError): ArtifactCatalog(spec_kit_project).get_artifact_info("command:speckit.constitution") - def test_corrupt_preset_registry_uses_empty_registry_fallback(self, spec_kit_project: Path): - registry = spec_kit_project / ".specify" / "presets" / ".registry" - registry.write_text("{invalid", encoding="utf-8") - - catalog = ArtifactCatalog(spec_kit_project) - assert any(row.id == "command:speckit.constitution" for row in catalog.list_artifacts()) - assert ( - catalog.get_artifact_info("command:speckit.constitution")["id"] - == "command:speckit.constitution" - ) - - class TestKindHint: def test_kind_flag_disambiguates(self, spec_kit_project: Path): install_preset( From c903bb5b4170bba1cbb60a31492708102d15ab55 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Tue, 8 Sep 2026 21:45:34 -0500 Subject: [PATCH 101/113] chore(changelog): remove manual unreleased entry Leave release-note generation to the existing release workflow, which derives versioned changelog entries from commit subjects. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- CHANGELOG.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70309fe69b..b657824be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,6 @@ -## Unreleased - -### Added - -- feat(artifacts): include `sourcePath` on artifact stack layers for installed preset and extension contributions. - ## [1.0.5] - 2026-09-08 ### Changed From a5b779cae1048d9d815626e00ec89e5721ae7d47 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Wed, 9 Sep 2026 09:59:13 -0500 Subject: [PATCH 102/113] docs(artifacts): clarify layer resolution semantics Document that active reflects Spec Kit's existing layer precedence rather than successful content composition, and limit artifact resolution failures to errors encountered while collecting the stack. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- docs/reference/artifacts.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md index 046d44dd07..217b365dbd 100644 --- a/docs/reference/artifacts.md +++ b/docs/reference/artifacts.md @@ -145,7 +145,7 @@ The top-level `id`, `name`, `kind`, `description`, and `stack` fields match the | `lookupId` | Deterministic `{layer}:{sourceId}:{kind}:{name}` identifier, or `null` for built-in layers | | `sourcePath` | Project-relative POSIX path to the concrete file backing the layer, or `null` for built-in/synthetic layers | -`active` and `hidden` are independent labels, not opposites. Composing strategies (`wrap`, `prepend`, `append`) keep lower layers in the composed output, so an inactive layer is not necessarily hidden: only layers below the first `replace` layer are marked `hidden`. Built-in rows have no provenance: `layer`, `sourceId`, and `lookupId` are `null` — but `id` is always populated, even on built-in rows. `id` is the round-trip key: `specify artifact info` accepts it as input (for example, `specify artifact info command:speckit.specify --json`), and it resolves the same artifact whether the caller passes the bare name or the `id`. +`active` and `hidden` are independent labels, not opposites. `active` identifies the highest-precedence layer selected by the existing Spec Kit layer-resolution order; it does not validate that the layer content can be read or composed. This preserves the diagnostic behavior of `specify preset resolve`, which reports the discovered layer chain even when content composition later produces a warning. Composing strategies (`wrap`, `prepend`, `append`) keep lower layers in the composed output, so an inactive layer is not necessarily hidden: only layers below the first `replace` layer are marked `hidden`. Built-in rows have no provenance: `layer`, `sourceId`, and `lookupId` are `null` — but `id` is always populated, even on built-in rows. `id` is the round-trip key: `specify artifact info` accepts it as input (for example, `specify artifact info command:speckit.specify --json`), and it resolves the same artifact whether the caller passes the bare name or the `id`. Lookup IDs use the same grammar as [preset contribution identifiers](presets.md#contribution-identifiers) and [extension contribution identifiers](../../extensions/EXTENSION-API-REFERENCE.md#contribution-identifiers), so a `lookupId` from this command joins directly to `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` for manifest-declared layers. Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID that intentionally matches no manifest contribution. `lookupId` is manifest-backed layer provenance, not the round-trip key — use `id` for that. `sourcePath` is populated only when the layer maps to a concrete installed preset/extension file or a tracked agent materialization; core, project-override, and other synthetic rows report `null`. @@ -162,6 +162,6 @@ On failure, nothing is written to stdout. A single-key JSON envelope is written | `not a Spec Kit project: no .specify/ directory found` | Run outside an initialized project | | `unknown artifact ` | No artifact matches the requested name (and kind, when given) | | `ambiguous artifact : matches kinds [...]` | The bare name matches more than one kind — re-run with `--kind` | -| `artifact resolution failed` | The extension registry could not be read, or artifact content could not be composed | +| `artifact resolution failed` | The extension registry could not be read, or an error prevented the artifact layer stack from being collected | Exit code `2` is reserved for usage errors — a missing `--json` flag or an invalid `--kind` value — and emits a plain-text message on stderr rather than a JSON envelope. From 429579c389d79066ffaac44b0f1a15e88ecb6ea8 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Wed, 9 Sep 2026 10:59:24 -0500 Subject: [PATCH 103/113] docs(artifacts): explain resolver reuse Document why artifact inventory resolves each candidate through Spec Kit's existing single-artifact path and defers unmeasured shared caching. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- src/specify_cli/artifacts/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 9755917bfe..31fb2297a2 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -865,6 +865,9 @@ def _has_any_replace_layer(layers: list[dict[str, Any]]) -> bool: key = (kind, name) if not _is_valid_artifact_name_component(name, kind): continue + # Resolve each candidate through Spec Kit's existing single-artifact + # path for behavioral parity; optimize shared manifest reads only if + # typical small extension sets show a measurable inventory cost. layers = _layers_for(kind, name) if layers and _has_any_replace_layer(layers): names.add(key) From 165e9bd6de8d015cc475ac71b00c18410772b788 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Wed, 9 Sep 2026 11:29:12 -0500 Subject: [PATCH 104/113] fix(presets): preserve legacy layer resolution Keep filesystem-derived project, preset, and extension layers resolvable when legacy names cannot be represented by the contribution-ID grammar. Such layers omit lookupId while manifest-declared contributions remain strict. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- src/specify_cli/presets/__init__.py | 40 ++++++++++++++++++++++++----- tests/test_contribution_ids.py | 36 ++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 005cacab9e..240b041798 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -38,6 +38,7 @@ ) from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority from .._identifier import ( + IdentifierComponentError, PROJECT_OVERRIDE_LAYER, derive_named_id, ) @@ -5858,6 +5859,9 @@ def collect_all_layers( Returns: List of layer dicts ordered highest-to-lowest priority. + Filesystem-derived legacy layers whose names cannot be represented + by the contribution-ID grammar are preserved with ``lookupId=None``. + Manifest-declared layers remain subject to strict ID validation. """ if template_type == "template": subdirs = ["templates", ""] @@ -5874,6 +5878,12 @@ def collect_all_layers( layers: List[Dict[str, Any]] = [] + def _filesystem_lookup_id(layer: str, source_id: str) -> Optional[str]: + try: + return derive_named_id(layer, source_id, template_type, template_name) + except IdentifierComponentError: + return None + def _find_in_subdirs(base_dir: Path) -> Optional[Path]: for subdir in subdirs: if subdir: @@ -5896,9 +5906,7 @@ 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 - ), + "lookupId": _filesystem_lookup_id(PROJECT_OVERRIDE_LAYER, "_"), }) # Priority 2: Installed presets (sorted by priority — lower number = higher precedence) @@ -5972,8 +5980,17 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: "preset_id": pack_id, "pack_dir": pack_dir, "manifest_declared": entry is not None, - "lookupId": derive_named_id( - "preset", source_id_for_lookup, template_type, template_name + "lookupId": ( + derive_named_id( + "preset", + source_id_for_lookup, + template_type, + template_name, + ) + if entry is not None + else _filesystem_lookup_id( + "preset", source_id_for_lookup + ) ), }) @@ -6015,8 +6032,17 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: "extension_id": ext_id, "extension_dir": ext_dir, "manifest_declared": entry is not None, - "lookupId": derive_named_id( - "extension", source_id_for_lookup, template_type, template_name + "lookupId": ( + derive_named_id( + "extension", + source_id_for_lookup, + template_type, + template_name, + ) + if entry is not None + else _filesystem_lookup_id( + "extension", source_id_for_lookup + ) ), }) diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py index 02b65e0d8b..aae3496a8a 100644 --- a/tests/test_contribution_ids.py +++ b/tests/test_contribution_ids.py @@ -378,6 +378,42 @@ def test_project_override_layer_carries_sentinel_lookup_id(self, tmp_path): PROJECT_OVERRIDE_LAYER, "_", "template", "spec-template" ) + @pytest.mark.skipif(os.name == "nt", reason="':' filenames are unsupported on Windows") + @pytest.mark.parametrize("layer_kind", ["project", "preset", "extension"]) + def test_legacy_colon_name_preserves_resolution_without_lookup_id( + self, tmp_path, layer_kind + ): + project = _make_project(tmp_path) + name = "legacy:name" + + if layer_kind == "project": + candidate = ( + project / ".specify" / "templates" / "overrides" / f"{name}.md" + ) + candidate.parent.mkdir(parents=True) + else: + pack_id = f"legacy-{layer_kind}" + candidate = ( + project + / ".specify" + / f"{layer_kind}s" + / pack_id + / "templates" + / f"{name}.md" + ) + candidate.parent.mkdir(parents=True) + _write_registry(project, f"{layer_kind}s", pack_id) + candidate.write_text("legacy", encoding="utf-8") + + resolver = PresetResolver(project) + assert resolver.resolve(name, "template") == candidate + layer = next( + item + for item in resolver.collect_all_layers(name, "template") + if item["path"] == candidate + ) + assert layer["lookupId"] is None + def test_builtin_layer_preserves_resolver_provenance(self, tmp_path): project = _make_project(tmp_path) (project / "templates" / "spec-template.md").write_text("core", encoding="utf-8") From 2db45ef9d38e5b8491c9906b4cdb88504e3a23a4 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 11 Sep 2026 10:49:22 -0500 Subject: [PATCH 105/113] fix: preserve README convention resolution Keep root-level README templates aligned with the existing resolver and artifact inventory instead of introducing a filename-specific exclusion. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- src/specify_cli/artifacts/__init__.py | 4 +--- src/specify_cli/presets/__init__.py | 6 ------ tests/test_artifact_command.py | 15 ++++++++++----- tests/test_presets.py | 11 ++++++++--- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 31fb2297a2..24f751deb8 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -1199,8 +1199,7 @@ def _iter_convention_contributions( """Yield ``(kind, name, path)`` for files exposed by convention. Templates are also accepted at the pack root for legacy compatibility, - matching the resolver's ``templates/``-then-root lookup order. README files - are packaging metadata rather than artifacts and are excluded consistently. + matching the resolver's ``templates/``-then-root lookup order. """ for subdir, kind, suffix in _CONVENTION_SUBDIRS: candidate_dir = pack_dir / subdir @@ -1215,7 +1214,6 @@ def _iter_convention_contributions( if ( entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX - and entry.stem.lower() != "readme" and ":" not in entry.stem ): yield "template", entry.stem, entry diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 240b041798..d29fcacb84 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -5653,8 +5653,6 @@ def resolve( if subdir: candidate = pack_dir / subdir / f"{template_name}{ext}" else: - if template_name.lower() == "readme": - continue candidate = pack_dir / f"{template_name}{ext}" if candidate.exists(): return candidate @@ -5680,8 +5678,6 @@ def resolve( if subdir: candidate = ext_dir / subdir / f"{template_name}{ext}" else: - if template_name.lower() == "readme": - continue candidate = ext_dir / f"{template_name}{ext}" if candidate.exists(): return candidate @@ -5889,8 +5885,6 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: if subdir: candidate = base_dir / subdir / f"{template_name}{ext}" else: - if template_name.lower() == "readme": - continue candidate = base_dir / f"{template_name}{ext}" if candidate.exists(): return candidate diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index c308632128..1d8c2da700 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -266,7 +266,7 @@ def test_includes_project_local_core_assets(self, spec_kit_project: Path): assert layer["lookupId"] is None assert layer["sourcePath"] is None - def test_includes_root_level_pack_template_but_excludes_readme( + def test_includes_root_level_pack_templates( self, spec_kit_project: Path ): extension_dir = spec_kit_project / ".specify" / "extensions" / "legacy" @@ -281,7 +281,7 @@ def test_includes_root_level_pack_template_but_excludes_readme( names = {row.name for row in catalog.list_artifacts()} assert "legacy-root" in names - assert "README" not in names + assert "README" in names assert next( row for row in catalog.list_artifacts() if row.name == "legacy-root" ).description == "Legacy root template" @@ -1139,13 +1139,18 @@ def test_convention_command_and_script_are_listed(self, spec_kit_project: Path): 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): + def test_extension_readme_matches_resolver_inventory(self, spec_kit_project: Path): ext_dir = spec_kit_project / ".specify" / "extensions" / "legacy" ext_dir.mkdir(parents=True) (ext_dir / "README.md").write_text("docs", encoding="utf-8") - ids = {row.id for row in ArtifactCatalog(spec_kit_project).list_artifacts()} - assert "template:README" not in ids + catalog = ArtifactCatalog(spec_kit_project) + artifacts = {row.id: row for row in catalog.list_artifacts()} + + assert "template:README" in artifacts + assert catalog.get_artifact_info("README")["stack"][0]["sourcePath"] == ( + ".specify/extensions/legacy/README.md" + ) def test_disabled_extension_convention_file_is_excluded(self, spec_kit_project: Path): extensions_dir = spec_kit_project / ".specify" / "extensions" diff --git a/tests/test_presets.py b/tests/test_presets.py index 94a8db3931..25a95ba656 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -12631,15 +12631,20 @@ def test_extension_template_convention_lookup_unaffected_when_undeclared(self, p assert layers[0]["path"] == tmpl_dir / "legacy-template.md" @pytest.mark.parametrize("pack_kind", ["preset", "extension"]) - def test_root_readme_is_not_resolved_as_template(self, project_dir, pack_kind): + def test_root_readme_preserves_convention_resolution(self, project_dir, pack_kind): pack_dir = project_dir / ".specify" / f"{pack_kind}s" / "legacy" pack_dir.mkdir(parents=True) (pack_dir / "README.md").write_text("packaging notes\n") + if pack_kind == "preset": + PresetRegistry(pack_dir.parent).add( + "legacy", {"priority": 10, "version": "1.0.0"} + ) resolver = PresetResolver(project_dir) - assert resolver.resolve("README", "template") is None - assert resolver.collect_all_layers("README", "template") == [] + assert resolver.resolve("README", "template") == pack_dir / "README.md" + layers = resolver.collect_all_layers("README", "template") + assert layers[0]["path"] == pack_dir / "README.md" def test_extension_manifest_wins_over_stale_conventional_file(self, project_dir): """A declared entry is authoritative even when a stale file also sits at From 1b88a8f6261f1583adbe88603601cb6d57cd879d Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 11 Sep 2026 11:15:00 -0500 Subject: [PATCH 106/113] fix: preserve existing script resolution Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- docs/reference/artifacts.md | 8 +- src/specify_cli/_script_variants.py | 32 -------- src/specify_cli/artifacts/__init__.py | 113 ++++++++++++++++++++++---- src/specify_cli/presets/__init__.py | 53 ++++++------ tests/test_artifact_command.py | 51 +++++++++++- tests/test_presets.py | 110 ------------------------- 6 files changed, 173 insertions(+), 194 deletions(-) delete mode 100644 src/specify_cli/_script_variants.py diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md index 217b365dbd..16c66c51d3 100644 --- a/docs/reference/artifacts.md +++ b/docs/reference/artifacts.md @@ -42,13 +42,13 @@ Prints the full inventory of every visible artifact — one row per `(kind, name ] }, { - "id": "script:create-new-feature", - "name": "create-new-feature", + "id": "script:setup-plan", + "name": "setup-plan", "kind": "script", - "description": "Create a new feature branch and spec directory.", + "description": "Setup implementation plan for a feature.", "stack": [ { - "id": "script:create-new-feature", + "id": "script:setup-plan", "layer": null, "sourceId": null, "presetId": null, diff --git a/src/specify_cli/_script_variants.py b/src/specify_cli/_script_variants.py deleted file mode 100644 index 5a1b76c7c2..0000000000 --- a/src/specify_cli/_script_variants.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Canonical names and paths for the core script runtime variants.""" - -from __future__ import annotations - -from collections.abc import Iterator -from pathlib import Path - -_SCRIPT_VARIANTS = ( - ("bash", ".sh", False), - ("powershell", ".ps1", False), - ("python", ".py", True), -) - - -def canonical_script_name(path: Path) -> str | None: - """Return the logical name shared by a core script's runtime variants.""" - for runtime, suffix, uses_underscores in _SCRIPT_VARIANTS: - if path.parent.name == runtime and path.suffix == suffix: - return path.stem.replace("_", "-") if uses_underscores else path.stem - return None - - -def script_variant_paths(scripts_dir: Path, name: str) -> Iterator[Path]: - """Yield candidate paths for the logical script *name*. - - The legacy flat Bash path (``/.sh``) is yielded first so - existing projects keep working, followed by the runtime-specific paths. - """ - yield scripts_dir / f"{name}.sh" - for runtime, suffix, uses_underscores in _SCRIPT_VARIANTS: - stem = name.replace("-", "_") if uses_underscores else name - yield scripts_dir / runtime / f"{stem}{suffix}" diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 24f751deb8..7794469af6 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -12,6 +12,7 @@ from __future__ import annotations import re +import shlex from dataclasses import dataclass from pathlib import Path from typing import Any, Iterable, Literal @@ -28,7 +29,6 @@ source_id_from_lookup_id, validate_component, ) -from .._script_variants import canonical_script_name # --------------------------------------------------------------------------- # Public data classes @@ -846,14 +846,28 @@ def _collect_inventory( resolver = PresetResolver(self.project_root) layers_cache: dict[tuple[ArtifactKind, str], list[dict[str, Any]]] = {} + core_script_paths = self._selected_core_script_paths() def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: key = (kind, name) if key not in layers_cache: try: - layers_cache[key] = resolver.collect_all_layers(name, kind) + layers = resolver.collect_all_layers(name, kind) except (OSError, PresetError) as exc: raise ArtifactResolutionError() from exc + core_script = core_script_paths.get(name) if kind == "script" else None + if core_script is not None and not any( + layer.get("source") in {"core", "core (bundled)"} + for layer in layers + ): + layers.append( + { + "path": core_script, + "source": "core", + "strategy": "replace", + } + ) + layers_cache[key] = layers return layers_cache[key] def _has_any_replace_layer(layers: list[dict[str, Any]]) -> bool: @@ -861,7 +875,9 @@ def _has_any_replace_layer(layers: list[dict[str, Any]]) -> bool: names: set[tuple[ArtifactKind, str]] = set() try: - for kind, name in self._iter_candidate_artifacts(resolver): + for kind, name in self._iter_candidate_artifacts( + resolver, core_script_paths + ): key = (kind, name) if not _is_valid_artifact_name_component(name, kind): continue @@ -901,6 +917,7 @@ def _has_any_replace_layer(layers: list[dict[str, Any]]) -> bool: def _iter_candidate_artifacts( self, resolver: Any, + core_script_paths: dict[str, Path], ) -> Iterable[tuple[ArtifactKind, str]]: """Yield candidate ``(kind, name)`` pairs from every resolver tier. @@ -957,7 +974,7 @@ def _iter_candidate_artifacts( yield from self._iter_pack_candidates(manifest, ext_dir) yield from self._iter_project_override_candidates(resolver) - yield from self._iter_core_candidates() + yield from self._iter_core_candidates(core_script_paths) @staticmethod def _iter_pack_candidates( @@ -1025,7 +1042,9 @@ def _iter_project_override_candidates( continue yield "script", entry.stem - def _iter_core_candidates(self) -> Iterable[tuple[ArtifactKind, str]]: + def _iter_core_candidates( + self, core_script_paths: dict[str, Path] + ) -> Iterable[tuple[ArtifactKind, str]]: """Yield candidate names from resolver-compatible core asset paths.""" from ..extensions import CORE_COMMAND_NAMES # lazy: avoids circular import from ..presets import PresetResolver @@ -1066,7 +1085,6 @@ def _iter_core_candidates(self) -> Iterable[tuple[ArtifactKind, str]]: seen_templates.add(entry.stem) yield "template", entry.stem - seen_scripts: set[str] = set() for directory in ( _project_core_asset_root(self.project_root, "scripts"), _locate_shared_asset_dir("scripts"), @@ -1074,19 +1092,78 @@ def _iter_core_candidates(self) -> Iterable[tuple[ArtifactKind, str]]: if directory is None: continue for entry in sorted(directory.glob(f"*{_SCRIPT_SUFFIX}"), key=lambda p: p.name): - if entry.stem not in seen_scripts: - seen_scripts.add(entry.stem) - yield "script", entry.stem - for runtime_dir in sorted(directory.iterdir(), key=lambda p: p.name): - if not runtime_dir.is_dir(): + yield "script", entry.stem + yield from (("script", name) for name in sorted(core_script_paths)) + + def _selected_core_script_paths(self) -> dict[str, Path]: + """Return built-in scripts selected by the project's existing runtime policy.""" + from .._init_options import load_init_options + from ..agents import CommandRegistrar + from ..integrations.base import IntegrationBase + + command_dirs = tuple( + directory + for directory in ( + _project_core_asset_root(self.project_root, "commands"), + _locate_shared_asset_dir("commands"), + ) + if directory is not None + ) + script_dirs = tuple( + directory + for directory in ( + _project_core_asset_root(self.project_root, "scripts"), + _locate_shared_asset_dir("scripts"), + ) + if directory is not None + ) + requested = load_init_options(self.project_root).get("script") + selected: dict[str, Path] = {} + + for command_dir in command_dirs: + for template_path in sorted(command_dir.glob("*.md"), key=lambda p: p.name): + try: + content = template_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + frontmatter, _body = CommandRegistrar.parse_frontmatter(content) + scripts = frontmatter.get("scripts", {}) + if not isinstance(scripts, dict): + continue + script_commands = { + key: value + for key, value in scripts.items() + if isinstance(key, str) and isinstance(value, str) and value.strip() + } + if not script_commands: continue - for entry in sorted(runtime_dir.iterdir(), key=lambda p: p.name): - if not entry.is_file(): - continue - name = canonical_script_name(entry) - if name is not None and name not in seen_scripts: - seen_scripts.add(name) - yield "script", name + try: + variant = IntegrationBase.select_script_variant( + requested, script_commands + ) + tokens = shlex.split(script_commands[variant], posix=True) + except (KeyError, ValueError): + continue + if not tokens: + continue + + relative = Path(tokens[0]) + if relative.parts and relative.parts[0] == "scripts": + relative = Path(*relative.parts[1:]) + path = next( + ( + script_dir / relative + for script_dir in script_dirs + if (script_dir / relative).is_file() + ), + None, + ) + if path is None: + continue + name = path.stem.replace("_", "-") if variant == "py" else path.stem + selected.setdefault(name, path) + + return selected def _describe_layer( self, diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index d29fcacb84..915be12af1 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -42,7 +42,6 @@ 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, @@ -5698,11 +5697,8 @@ def resolve( if core.exists(): return core elif template_type == "script": - core = next( - (path for path in script_variant_paths(self.templates_dir / "scripts", template_name) if path.exists()), - None, - ) - if core is not None: + core = self.templates_dir / "scripts" / f"{template_name}{ext}" + if core.exists(): return core # Priority 5: Bundled core_pack (wheel install) or repo-root templates @@ -6058,11 +6054,8 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: if c.exists(): core = c elif template_type == "script": - c = next( - (path for path in script_variant_paths(self.templates_dir / "scripts", template_name) if path.exists()), - None, - ) - if c is not None: + c = self.templates_dir / "scripts" / f"{template_name}{ext}" + if c.exists(): core = c if core: layers.append({ @@ -6095,12 +6088,28 @@ def _find_bundled_core( ``collect_all_layers()`` can locate base layers even when ``.specify/templates/`` doesn't contain the core file. - Directory resolution is delegated to the shared - ``_locate_shared_asset_dir`` resolver — the same one the artifact - command's core-baseline enumeration and the extensions module's - core-command-name discovery use — so all three code paths agree on - what "core" means on this machine. + Command and template directory resolution is delegated to the shared + ``_locate_shared_asset_dir`` resolver. Script lookup preserves the + resolver's pre-existing flat ``.sh`` wheel-or-source behavior. """ + if template_type == "script": + try: + from specify_cli import _locate_core_pack, _repo_root + except ImportError: + return None + + core_pack = _locate_core_pack() + base = ( + core_pack / "scripts" + if core_pack is not None + else _repo_root() / "scripts" + ) + for name in self.name_candidates(template_name): + candidate = base / f"{name}{ext}" + if candidate.exists(): + return candidate + return None + try: from specify_cli._assets import _locate_shared_asset_dir except ImportError: @@ -6110,8 +6119,6 @@ def _find_bundled_core( base = _locate_shared_asset_dir("templates") elif template_type == "command": base = _locate_shared_asset_dir("commands") - elif template_type == "script": - base = _locate_shared_asset_dir("scripts") else: base = None @@ -6119,14 +6126,8 @@ def _find_bundled_core( return None for name in self.name_candidates(template_name): - if template_type == "script": - c = next( - (path for path in script_variant_paths(base, name) if path.exists()), - None, - ) - else: - c = base / f"{name}.md" - if c is not None and c.exists(): + c = base / f"{name}.md" + if c.exists(): return c return None diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 1d8c2da700..b8642dd337 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -99,20 +99,29 @@ 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 + @pytest.mark.parametrize( + ("requested", "runtime_dir"), + [("sh", "bash"), ("ps", "powershell"), ("py", "python")], + ) + def test_core_scripts_follow_existing_project_runtime_selection( + self, spec_kit_project: Path, requested: str, runtime_dir: str ): + (spec_kit_project / ".specify" / "init-options.json").write_text( + json.dumps({"script": requested}), + encoding="utf-8", + ) catalog = ArtifactCatalog(spec_kit_project) scripts = [row for row in catalog.list_artifacts() if row.kind == "script"] assert {row.name for row in scripts} == { "check-prerequisites", - "common", - "create-new-feature", "resolve-template", "setup-plan", "setup-tasks", } + selected_paths = catalog._selected_core_script_paths() + assert set(selected_paths) == {row.name for row in scripts} + assert all(path.parent.name == runtime_dir for path in selected_paths.values()) for script in scripts: info = catalog.get_artifact_info(script.id) assert info["stack"][-1]["layer"] is None @@ -120,6 +129,40 @@ def test_core_script_variants_have_one_resolvable_logical_name( assert info["stack"][-1]["lookupId"] is None assert info["stack"][-1]["sourcePath"] is None + def test_core_scripts_reuse_existing_runtime_fallback( + self, spec_kit_project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + commands_dir = tmp_path / "commands" + scripts_dir = tmp_path / "scripts" + commands_dir.mkdir() + (scripts_dir / "bash").mkdir(parents=True) + (commands_dir / "demo.md").write_text( + "---\n" + "scripts:\n" + " sh: scripts/bash/demo.sh\n" + "---\n", + encoding="utf-8", + ) + script = scripts_dir / "bash" / "demo.sh" + script.write_text("#!/bin/sh\n", encoding="utf-8") + (spec_kit_project / ".specify" / "init-options.json").write_text( + json.dumps({"script": "ps"}), + encoding="utf-8", + ) + + monkeypatch.setattr( + "specify_cli.artifacts._locate_shared_asset_dir", + lambda subdir: { + "commands": commands_dir, + "scripts": scripts_dir, + "templates": None, + }[subdir], + ) + + assert ArtifactCatalog(spec_kit_project)._selected_core_script_paths() == { + "demo": script + } + def test_excludes_disabled_and_unusable_manifest_contributions( self, spec_kit_project: Path ): diff --git a/tests/test_presets.py b/tests/test_presets.py index 25a95ba656..176d7f010f 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1788,33 +1788,6 @@ def test_resolve_nonexistent(self, project_dir): result = resolver.resolve("nonexistent-template") assert result is None - def test_core_fallback_uses_shared_asset_resolver(self, project_dir, monkeypatch): - """resolve() tier 5 and collect_all_layers() must agree on "core". - - Regression test: the tier-5 branch used to read ``core_pack//`` - directly, so a wheel bundle missing ``scripts/`` made ``resolve()`` - return nothing while ``collect_all_layers()`` fell back to the source - checkout via ``_locate_shared_asset_dir``. - """ - import specify_cli._assets as assets - - core_pack = project_dir.parent / "core_pack" - (core_pack / "commands").mkdir(parents=True) # bundle exists, no scripts/ - repo_root = project_dir.parent / "repo" - (repo_root / "scripts" / "bash").mkdir(parents=True) - script = repo_root / "scripts" / "bash" / "core-only.sh" - script.write_text("#!/bin/sh\n", encoding="utf-8") - - monkeypatch.setattr( - assets, "__file__", str(project_dir.parent / "_assets.py") - ) - monkeypatch.setattr(assets, "_repo_root", lambda: repo_root) - - resolver = PresetResolver(project_dir) - assert resolver.resolve("core-only", "script") == script - layers = resolver.collect_all_layers("core-only", "script") - assert [layer["path"] for layer in layers] == [script] - def test_resolver_ignores_traversing_registry_ids(self, project_dir): """Registry IDs cannot escape preset or extension install roots.""" for registry_dir, registry_key, outside_name in ( @@ -13613,89 +13586,6 @@ def test_layers_read_strategy_from_manifest(self, project_dir, temp_dir, valid_p assert layers[1]["strategy"] == "replace" -class TestCoreScriptRuntimeVariants: - """Core scripts resolve through whichever runtime variant is installed.""" - - @staticmethod - def _write_core_script(project_dir, runtime, filename, body): - script_dir = project_dir / ".specify" / "templates" / "scripts" / runtime - script_dir.mkdir(parents=True, exist_ok=True) - path = script_dir / filename - path.write_text(body) - return path - - def test_resolve_finds_powershell_only_core_script(self, project_dir): - """Only the .ps1 variant exists — resolve() must still find it.""" - path = self._write_core_script( - project_dir, "powershell", "ps-only-helper.ps1", "Write-Output 'ps'\n" - ) - - resolver = PresetResolver(project_dir) - assert resolver.resolve("ps-only-helper", "script") == path - - def test_collect_all_layers_finds_powershell_only_core_script(self, project_dir): - """Only the .ps1 variant exists — collect_all_layers() must find it.""" - path = self._write_core_script( - project_dir, "powershell", "ps-only-helper.ps1", "Write-Output 'ps'\n" - ) - - layers = PresetResolver(project_dir).collect_all_layers( - "ps-only-helper", "script" - ) - assert len(layers) == 1 - assert layers[0]["path"] == path - assert layers[0]["source"] == "core" - - def test_resolve_finds_python_only_core_script(self, project_dir): - """Only the underscored .py variant exists — the hyphenated logical - name must still resolve.""" - path = self._write_core_script( - project_dir, "python", "py_only_helper.py", "print('py')\n" - ) - - resolver = PresetResolver(project_dir) - assert resolver.resolve("py-only-helper", "script") == path - - def test_collect_all_layers_finds_python_only_core_script(self, project_dir): - """Only the underscored .py variant exists — collect_all_layers() must - map the hyphenated logical name onto it.""" - path = self._write_core_script( - project_dir, "python", "py_only_helper.py", "print('py')\n" - ) - - layers = PresetResolver(project_dir).collect_all_layers( - "py-only-helper", "script" - ) - assert len(layers) == 1 - assert layers[0]["path"] == path - assert layers[0]["source"] == "core" - - def test_resolve_finds_legacy_flat_core_script(self, project_dir): - """The legacy flat .specify/templates/scripts/.sh layout still - resolves.""" - scripts_dir = project_dir / ".specify" / "templates" / "scripts" - scripts_dir.mkdir(parents=True, exist_ok=True) - path = scripts_dir / "flat-helper.sh" - path.write_text("echo 'flat'\n") - - resolver = PresetResolver(project_dir) - assert resolver.resolve("flat-helper", "script") == path - - def test_collect_all_layers_finds_legacy_flat_core_script(self, project_dir): - """collect_all_layers() also honours the legacy flat layout.""" - scripts_dir = project_dir / ".specify" / "templates" / "scripts" - scripts_dir.mkdir(parents=True, exist_ok=True) - path = scripts_dir / "flat-helper.sh" - path.write_text("echo 'flat'\n") - - layers = PresetResolver(project_dir).collect_all_layers( - "flat-helper", "script" - ) - assert len(layers) == 1 - assert layers[0]["path"] == path - assert layers[0]["source"] == "core" - - class TestRemoveReconciliation: """Test that removing a preset re-registers the next layer's command.""" From bd506704480f1a2a98a0b162988cc8af0807682d Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 11 Sep 2026 12:22:59 -0500 Subject: [PATCH 107/113] refactor: isolate artifact provenance Keep lookup identifiers and provenance projection inside the artifact catalog while restoring existing resolver, extension, hook, asset, registrar, and integration behavior. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- docs/reference/artifacts.md | 2 +- docs/reference/presets.md | 21 - extensions/EXTENSION-API-REFERENCE.md | 63 -- src/specify_cli/_assets.py | 22 - src/specify_cli/_identifier.py | 241 -------- src/specify_cli/agents.py | 28 - src/specify_cli/artifacts/__init__.py | 501 +++++++++------- src/specify_cli/artifacts/_identifiers.py | 75 +++ src/specify_cli/events.py | 15 +- src/specify_cli/extensions/__init__.py | 187 ++---- src/specify_cli/integrations/base.py | 23 +- src/specify_cli/presets/__init__.py | 317 +++------- tests/test_artifact_command.py | 232 +++---- tests/test_artifact_command_parity.py | 14 +- tests/test_assets.py | 66 -- tests/test_contribution_ids.py | 699 ---------------------- tests/test_extensions.py | 81 +-- tests/test_presets.py | 31 +- 18 files changed, 664 insertions(+), 1954 deletions(-) delete mode 100644 src/specify_cli/_identifier.py create mode 100644 src/specify_cli/artifacts/_identifiers.py delete mode 100644 tests/test_assets.py delete mode 100644 tests/test_contribution_ids.py diff --git a/docs/reference/artifacts.md b/docs/reference/artifacts.md index 16c66c51d3..7fa8f428cf 100644 --- a/docs/reference/artifacts.md +++ b/docs/reference/artifacts.md @@ -147,7 +147,7 @@ The top-level `id`, `name`, `kind`, `description`, and `stack` fields match the `active` and `hidden` are independent labels, not opposites. `active` identifies the highest-precedence layer selected by the existing Spec Kit layer-resolution order; it does not validate that the layer content can be read or composed. This preserves the diagnostic behavior of `specify preset resolve`, which reports the discovered layer chain even when content composition later produces a warning. Composing strategies (`wrap`, `prepend`, `append`) keep lower layers in the composed output, so an inactive layer is not necessarily hidden: only layers below the first `replace` layer are marked `hidden`. Built-in rows have no provenance: `layer`, `sourceId`, and `lookupId` are `null` — but `id` is always populated, even on built-in rows. `id` is the round-trip key: `specify artifact info` accepts it as input (for example, `specify artifact info command:speckit.specify --json`), and it resolves the same artifact whether the caller passes the bare name or the `id`. -Lookup IDs use the same grammar as [preset contribution identifiers](presets.md#contribution-identifiers) and [extension contribution identifiers](../../extensions/EXTENSION-API-REFERENCE.md#contribution-identifiers), so a `lookupId` from this command joins directly to `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` for manifest-declared layers. Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID that intentionally matches no manifest contribution. `lookupId` is manifest-backed layer provenance, not the round-trip key — use `id` for that. `sourcePath` is populated only when the layer maps to a concrete installed preset/extension file or a tracked agent materialization; core, project-override, and other synthetic rows report `null`. +Lookup IDs are derived by the artifact command from the resolved layer and its existing preset or extension manifest. Manifest-declared layers use the manifest's `id`; convention-only layers use the installed preset or extension directory id. Project-local overrides carry a synthetic `project:_:{kind}:{name}` ID, while built-in layers have no `lookupId`. These values are artifact-stack provenance, not the round-trip key — use `id` for that. `sourcePath` is populated only when the layer maps to a concrete installed preset/extension file or a tracked agent materialization; core, project-override, and other synthetic rows report `null`. ## JSON Errors diff --git a/docs/reference/presets.md b/docs/reference/presets.md index 9889c92dbd..1098abfb42 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -205,27 +205,6 @@ specify preset add team-workflow --priority 10 For any file that both provide, `compliance` wins (priority 5 < 10). For files only one provides, that one is used. For files neither provides, the core default is used. -## Contribution Identifiers - -Every command, template, and script contributed by a preset or extension is addressable at read time by a deterministic opaque identifier of the form: - -```text -{layer}:{sourceId}:{kind}:{name} -``` - -- `layer` is one of `preset` or `extension`. -- `sourceId` is the preset pack id for `preset`, or the extension id for `extension`. -- `kind` is one of `command`, `template`, or `script`. -- `name` is the entry's declared `name` field. - -Identifiers are computed on demand from author-declared manifest content and are never persisted to `.specify/` or any cache. Copying a preset to another machine (or touching its files) does not change the identifiers it produces. - -`PresetResolver.collect_all_layers()` returns layer dicts that include a `lookupId` field for preset, extension, and project-override layers. For manifest-declared preset and extension layers, the `lookupId`'s `sourceId` component is the manifest's validated `id:` field, so it joins directly to the `id` used by `PresetManifest.iter_contributions()` / `ExtensionManifest.iter_contributions()` even when the installed directory was renamed. That join is guaranteed by the implementation, so consumers can key off `lookupId` directly rather than re-deriving the contribution id. Convention-only layers (undeclared in any manifest) have no manifest id to consult, so their `lookupId`'s `sourceId` falls back to the resolver's registry key or on-disk directory name instead; those layers have no manifest contribution to join to. Built-in fallback layers omit `lookupId`. Use `layer_kind_from_lookup_id` to classify lookup IDs rather than parsing the string yourself. Project-local overrides in `.specify/templates/overrides/` are a resolver-only concept — they carry a synthetic `project:_:{kind}:{name}` `lookupId` that intentionally does not match any manifest contribution. - -The `id` field (shape `kind:name`) is the stable round-trip key for every artifact and is accepted as input by `specify artifact info`. The `lookupId` field carries manifest-backed layer provenance and is present only for artifacts contributed by presets, extensions, or project overrides. Built-in-tier artifacts have no `lookupId`; use `id` to round-trip them. For example, given a stack row for a built-in artifact with only `id` populated, the round-trip is `specify artifact info command:speckit.plan --json`, which resolves the same artifact as `specify artifact info speckit.plan --json`. - -For the full grammar, including the hook name-component convention and last-write-wins deduplication used by extensions, see the [Extension API Reference — Contribution Identifiers](../../extensions/EXTENSION-API-REFERENCE.md#contribution-identifiers) section. - ## FAQ ### Can I use multiple presets at the same time? diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index 60ba428d5b..a7bece0b89 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -10,8 +10,6 @@ Technical reference for Spec Kit extension system APIs and manifest schema. 4. [Configuration Schema](#configuration-schema) 5. [Hook System](#hook-system) 6. [CLI Commands](#cli-commands) -7. [Contribution Identifiers](#contribution-identifiers) -8. [File System Layout](#file-system-layout) --- @@ -861,67 +859,6 @@ satisfied = version_satisfies("1.2.3", ">=1.0.0,<2.0.0") # bool --- -## Contribution Identifiers - -Every command, template, script, and hook contributed by an extension or preset is addressable at read time by a deterministic opaque identifier. Resolved artifact-stack layers carry a matching `lookupId` field when they have provenance. Manifest-declared preset and extension layers use the manifest's validated `id:` for `lookupId`'s `sourceId` component, so their `lookupId` joins directly to the matching `iter_contributions()` entry even after the installed directory is renamed; convention-only contributions have no manifest `id:` to consult and fall back to the on-disk directory / registry key instead (see [Determinism guarantees](#determinism-guarantees) below). Identifiers are **computed on demand from author-declared manifest content** and are **never persisted** to `.specify/` or to any cache file. - -### Grammar - -Named contributions (commands, templates, scripts) follow: - -```text -{layer}:{sourceId}:{kind}:{name} -``` - -- `layer` is one of `preset` or `extension`. -- `sourceId` is the preset pack id for `preset`, or the extension id for `extension`. -- `kind` is one of `command`, `template`, or `script`. -- `name` is the contribution's declared `name` field. - -Hook contributions use a compound name-component built from the event and command: - -```text -{layer}:{sourceId}:hook:{eventName}:{command} -``` - -Within a single event list, repeated `command` values collapse last-write-wins and -move to the end, so each surviving `(eventName, command)` pair has the same -identifier form above with no suffix. - -### Reserved character - -`:` is reserved as the identifier component separator. It cannot appear inside any of `layer`, `sourceId`, `kind`, `name`, `eventName`, or `command`. Extension ids, command names, template names, and script names are already constrained by their existing regex patterns (`^[a-z0-9-]+$` and friends), which forbid `:`. Hook event names (mapping keys) and hook `command` values are additionally validated to reject `:` at manifest load. - -### Project-local overrides - -Project-local overrides in `.specify/templates/overrides/` are a resolver-only concept — they have no backing manifest and cannot appear in `iter_contributions()`. Layers of that kind carry a synthetic `lookupId` of the form `project:_:{kind}:{name}` so consumers that reverse-lookup the id always see "not found", which is the intended behaviour: overrides are addressable at the stack level, not as first-class contributions. - -### Python API - -`ExtensionManifest.iter_contributions()` yields dicts of the form `{layer, sourceId, kind, name, id, ...author-declared fields}`; each entry's `id` is the computed identifier. `ExtensionManifest.contribution_id(kind, name)` returns the id for a single lookup, or `None` if no contribution matches. `PresetManifest` exposes the same two methods. - -`PresetResolver.collect_all_layers()` returns layer dicts that include a `lookupId` field for project overrides, preset contributions, and extension contributions. Manifest-declared preset and extension layers use the manifest's validated `id:` as the `lookupId` source id, so it matches the id `iter_contributions()` yields for that same contribution. Convention-only layers (no manifest entry declares the contribution) have no manifest id to consult, so their `lookupId` falls back to the resolver's registry key or on-disk directory name. Built-in fallback layers omit `lookupId`. - -### Round-trip via the public `id` - -The `id` field (shape `kind:name`) is the stable round-trip key for every artifact and is accepted as input by `specify artifact info`. The `lookupId` field carries manifest-backed layer provenance and is present only for artifacts contributed by presets, extensions, or project overrides. Built-in-tier artifacts have no `lookupId`; use `id` to round-trip them. - -For example, given a `specify artifact list --json` / `specify artifact info` stack row for a built-in artifact — which has only `id` populated (`layer`, `sourceId`, and `lookupId` are `null`) — the round-trip is: - -```bash -specify artifact info command:speckit.plan --json -``` - -This resolves the same artifact as `specify artifact info speckit.plan --json`, because `id` (not `lookupId`) is the source-agnostic identifier every artifact carries. - -### Determinism guarantees - -Manifest contribution identifier derivation reads only the in-memory declared manifest content. No filesystem paths, no `os.environ`, no timestamps, and no file-content hashes contribute to those manifest ids. Copying an extension or preset to a different machine (or touching its files) does not change the identifiers it produces. Manifest-declared resolver `lookupId` values share this stability — renaming the installed directory of a preset or extension that declares an `id:` does not change its `lookupId`. Only convention-only contributions (undeclared in any manifest) derive their `lookupId` from the on-disk directory name or registry key, so renaming that directory does change their `lookupId`. - -### Opacity guidance - -Identifiers are stable, but treat them as **opaque strings** in stored data (registries, cache files, external tooling). Do not parse them by string-splitting on `:` — hook ids contain a compound `{eventName}:{command}` component and future grammar extensions may otherwise catch you out. If you only need to classify a stack entry's layer, use `layer_kind_from_lookup_id`; `derive_named_id` and `derive_hook_id` construct new identifiers rather than parsing existing ones. - ## File System Layout ```text diff --git a/src/specify_cli/_assets.py b/src/specify_cli/_assets.py index f6f20469f1..31fb9708e6 100644 --- a/src/specify_cli/_assets.py +++ b/src/specify_cli/_assets.py @@ -32,28 +32,6 @@ def _repo_root() -> Path: return Path(__file__).parent.parent.parent -def _locate_shared_asset_dir(subdir: str) -> Path | None: - """Return an asset directory from the wheel bundle or source checkout. - - ``subdir`` is ``"commands"``, ``"templates"``, or ``"scripts"``. - Checks ``core_pack//`` first. In a source checkout, commands live - under ``templates/commands/`` and the other asset families use ``/``. - """ - package_dir = Path(__file__).resolve().parent - source_dir = ( - _repo_root() / "templates" / "commands" - if subdir == "commands" - else _repo_root() / subdir - ) - for candidate in [ - package_dir / "core_pack" / subdir, - source_dir, - ]: - if candidate.is_dir(): - return candidate - return None - - def _locate_bundled_extension(extension_id: str) -> Path | None: """Return the path to a bundled extension, or None. diff --git a/src/specify_cli/_identifier.py b/src/specify_cli/_identifier.py deleted file mode 100644 index 4783d6f382..0000000000 --- a/src/specify_cli/_identifier.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Deterministic identifiers for Spec Kit contributions and resolved stack layers. - -Every command, template, script, and hook contribution surfaced by a preset or -extension manifest carries a computed opaque ``id`` string, and provenance-backed -layers of a resolved artifact stack carry a matching ``lookupId``. The identifier -value is derived only from author-declared manifest data — it never depends on file -contents, timestamps, archive hashes, installation directory paths, install-time -random values, or list positions. That is what makes identifiers portable -across machines, project locations, and reinstalls, and what lets consumers use -them as stable join keys. - -Grammar for provenance-backed named contributions (commands, templates, scripts):: - - id = "{layer}:{sourceId}:{kind}:{name}" - - layer ∈ {"project", "preset", "extension"} - sourceId = "_" when layer == "project"; the preset or extension id otherwise - kind ∈ {"command", "template", "script"} - name = the contribution's declared ``name`` - -Hook identifiers use ``{eventName}:{command}`` as the name component:: - - id = "{layer}:{sourceId}:hook:{eventName}:{command}" - -Built-in artifacts have no public layer or lookup identifier. Their public -identifier is source-agnostic: ``"{kind}:{name}"``. - -The functions in this module are pure — inputs are strings or in-memory -mappings parsed from a manifest, outputs are strings. None of them read from -disk, look at ``os.environ``, call ``datetime``, or hash file contents. That -guarantee is what preserves portability, and it is enforced by inspection -rather than by runtime checks: any change here that adds an ambient input is a -change that breaks the identifier contract. -""" - -from __future__ import annotations - -from typing import Any - - -PROJECT_OVERRIDE_LAYER = "project" -"""Resolver-only layer label for project-local override layers. - -Project overrides are a resolver feature — they are not backed by any manifest -contribution. When a resolved artifact stack contains a project-override layer, -its ``lookupId`` uses this label so provenance-backed non-built-in layers have -a stable stack identity. 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. Built-in layers -carry no ``lookupId`` and round-trip through their source-agnostic public -``kind:name`` artifact ID instead. -""" - -_LAYER_KINDS = frozenset({PROJECT_OVERRIDE_LAYER, "preset", "extension"}) -_CONTRIBUTION_KINDS = frozenset({"command", "template", "script", "hook"}) -_NAMED_CONTRIBUTION_KINDS = _CONTRIBUTION_KINDS - {"hook"} -_HOOK_LAYERS = frozenset({"preset", "extension"}) - - -class IdentifierComponentError(ValueError): - """Raised when a manifest component would break identifier grammar.""" - - -def _source_id_matches_layer(layer: str, source_id: str) -> bool: - """Return whether ``source_id`` satisfies the layer sentinel contract.""" - if layer == PROJECT_OVERRIDE_LAYER: - return source_id == "_" - return layer in {"preset", "extension"} and source_id != "_" - - -def validate_component(value: Any, field_label: str) -> str: - """Return ``value`` unchanged if it is a non-empty ``:``-free string. - - Manifest components that appear in an identifier (``layer``, ``sourceId``, - ``kind``, ``name``, ``eventName``, ``command``) may not contain the ``:`` - delimiter — the grammar has no escape rule. This function is the guard used - by manifest validators to reject offending values at load time with a clear - message naming the field. - """ - if not isinstance(value, str): - raise IdentifierComponentError( - f"Invalid {field_label}: expected a string, got {type(value).__name__}" - ) - if not value: - raise IdentifierComponentError( - f"Invalid {field_label}: value must not be empty" - ) - if ":" in value: - raise IdentifierComponentError( - f"Invalid {field_label} '{value}': ':' is reserved as an identifier delimiter" - ) - return value - - -def derive_named_id(layer: str, source_id: str, kind: str, name: str) -> str: - """Build the identifier string for a named contribution kind. - - Each component is revalidated with :func:`validate_component` before the - join. Manifest-load-time validators generally validate ahead of the join, - but resolver callers can pass raw filesystem-derived names (POSIX permits - ``:`` in filenames the way manifest validators do not), and every layer - dict downstream relies on ``lookupId`` being a round-trippable string that - :func:`layer_kind_from_lookup_id` can parse — so this is the shared - derivation boundary that must enforce the grammar. Callers passing raw - strings should either pre-validate or handle - :class:`IdentifierComponentError`. - - The project-layer sentinel is also enforced here: the project layer uses - ``sourceId == "_"`` (see :data:`PROJECT_OVERRIDE_LAYER`) and no other - value; the preset and extension layers never use ``"_"``, which is - reserved for the project layer. Callers that mix these up would produce a - lookupId :func:`layer_kind_from_lookup_id` still parses but that no - manifest ever emits — a silent join-key mismatch. Rejecting the mix here - keeps the grammar's sentinel contract enforced at the single derivation - boundary rather than in each caller. - """ - validate_component(layer, "layer") - validate_component(source_id, "sourceId") - validate_component(kind, "kind") - if layer not in _LAYER_KINDS: - raise IdentifierComponentError(f"Invalid layer '{layer}'") - if kind not in _NAMED_CONTRIBUTION_KINDS: - raise IdentifierComponentError(f"Invalid named contribution kind '{kind}'") - validate_component(name, "name") - if layer == PROJECT_OVERRIDE_LAYER and not _source_id_matches_layer( - layer, source_id - ): - raise IdentifierComponentError( - f"Invalid sourceId '{source_id}': project layer requires '_'" - ) - if layer in {"preset", "extension"} and not _source_id_matches_layer( - layer, source_id - ): - raise IdentifierComponentError( - "Invalid sourceId '_': reserved for project layer" - ) - return f"{layer}:{source_id}:{kind}:{name}" - - -def derive_public_id(kind: str, name: str) -> str: - """Build the source-agnostic public identifier for an artifact.""" - validate_component(kind, "kind") - if kind not in _NAMED_CONTRIBUTION_KINDS: - raise IdentifierComponentError(f"Invalid public artifact kind '{kind}'") - validate_component(name, "name") - return f"{kind}:{name}" - - -def layer_kind_from_lookup_id(lookup_id: str) -> str | None: - """Return the layer segment of a resolved-stack ``lookupId``, or ``None``. - - ``lookupId`` values on resolved stack layers follow the same - ``"{layer}:..."`` grammar as manifest-contribution ``id`` values (see - module docstring), including :data:`PROJECT_OVERRIDE_LAYER` for project-local - override layers. - This is the single place that knows the set of valid layer prefixes, so - consumers can classify a lookupId without re-deriving the grammar via - string-prefix checks of their own. - - Validates the complete shape, not just the presence of a layer prefix: - named contributions require exactly the four ``{layer}:{sourceId}:{kind}: - {name}`` components, and hook contributions require exactly the five - ``{layer}:{sourceId}:hook:{eventName}:{command}`` components, with every - component non-empty. A value such as ``"preset:x"`` has a recognized layer - prefix but the wrong number of components, so it is malformed and returns - ``None`` rather than being treated as authoritative. Hook IDs are only - valid on preset/extension layers (see :data:`_HOOK_LAYERS`); a value such - as ``"project:_:hook:some-event:some-command"`` is rejected even though it - otherwise has the right shape, matching :func:`derive_hook_id`'s refusal - to build hook IDs for other layers. - """ - parts = lookup_id.split(":") - if len(parts) < 4 or any(not part for part in parts): - return None - layer = parts[0] - if layer not in _LAYER_KINDS: - return None - if not _source_id_matches_layer(layer, parts[1]): - return None - if parts[2] not in _CONTRIBUTION_KINDS: - return None - expected_len = 5 if parts[2] == "hook" else 4 - if len(parts) != expected_len: - return None - if parts[2] == "hook" and layer not in _HOOK_LAYERS: - return None - return layer - - -def is_dotted_command_name(value: str) -> bool: - """Return ``True`` when ``value`` is a dotted command-style name. - - Command-style names allow lowercase alphanumerics and ``-`` in each segment - and require at least one ``.`` separator. - """ - if "." not in value: - return False - segments = value.split(".") - return all( - segment - and all((("0" <= char <= "9") or ("a" <= char <= "z") or char == "-") for char in segment) - for segment in segments - ) - - -def source_id_from_lookup_id(lookup_id: str) -> str | None: - """Return the sourceId segment of a resolved-stack ``lookupId``, or ``None``. - - Returns ``None`` for any value that :func:`layer_kind_from_lookup_id` - would reject — same validation, same grammar, single source of truth. - Consumers must not ``.split(":")`` a ``lookupId`` themselves: the - grammar's segmentation lives in this module, and any caller doing its - own split leaks the layout across the codebase. - """ - if layer_kind_from_lookup_id(lookup_id) is None: - return None - return lookup_id.split(":", 2)[1] - - -def derive_hook_id( - layer: str, - source_id: str, - event_name: str, - command: str, -) -> str: - """Build the identifier string for a hook contribution. - - Each component is revalidated with :func:`validate_component` — same - contract as :func:`derive_named_id`. - """ - validate_component(layer, "layer") - validate_component(source_id, "sourceId") - if layer not in _HOOK_LAYERS: - raise IdentifierComponentError(f"Invalid layer '{layer}'") - if not _source_id_matches_layer(layer, source_id): - raise IdentifierComponentError( - "Invalid sourceId '_': reserved for project layer" - ) - validate_component(event_name, "eventName") - validate_component(command, "command") - return f"{layer}:{source_id}:hook:{event_name}:{command}" diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 6721001d1b..dede50e0b1 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -1049,34 +1049,6 @@ def _resolve_agent_dir( return legacy_dir return agent_dir - def resolve_agent_dir(self, agent_name: str, project_root: Path) -> Optional[Path]: - """Return the configured output directory for *agent_name*, if known.""" - self._ensure_configs() - agent_config = self.AGENT_CONFIGS.get(agent_name) - if agent_config is None: - return None - return self._resolve_agent_dir(agent_name, agent_config, project_root) - - def uses_skill_output(self, agent_name: str) -> bool: - """Return true when *agent_name* writes commands as ``SKILL.md`` files.""" - self._ensure_configs() - agent_config = self.AGENT_CONFIGS.get(agent_name) - return bool(agent_config and agent_config.get("extension") == "/SKILL.md") - - def resolve_command_output_path( - self, agent_name: str, cmd_name: str, project_root: Path - ) -> Optional[Path]: - """Return the command/skill output path this registrar uses for a command.""" - self._ensure_configs() - agent_config = self.AGENT_CONFIGS.get(agent_name) - if agent_config is None: - return None - output_name = self._compute_output_name(agent_name, cmd_name, agent_config) - return ( - self._resolve_agent_dir(agent_name, agent_config, project_root) - / f"{output_name}{agent_config['extension']}" - ) - def register_commands_for_all_agents( self, commands: List[Dict[str, Any]], diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index 7794469af6..e1d8a56f5f 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -19,14 +19,12 @@ import yaml -from .._assets import _locate_shared_asset_dir -from .._identifier import ( +from ._identifiers import ( PROJECT_OVERRIDE_LAYER, IdentifierComponentError, + derive_lookup_id, derive_public_id, is_dotted_command_name, - layer_kind_from_lookup_id, - source_id_from_lookup_id, validate_component, ) @@ -144,6 +142,26 @@ def __init__(self) -> None: _SCRIPT_SUFFIX = ".sh" +def _locate_shared_asset_dir(subdir: str) -> Path | None: + """Locate a core asset directory without changing shared asset behavior.""" + if subdir not in {"commands", "scripts", "templates"}: + return None + + from .._assets import _locate_core_pack, _repo_root + + core_pack = _locate_core_pack() + bundled = core_pack / subdir if core_pack is not None else None + source = ( + _repo_root() / "templates" / "commands" + if subdir == "commands" + else _repo_root() / subdir + ) + for candidate in (bundled, source): + if candidate is not None and candidate.is_dir(): + return candidate + return None + + def _project_core_asset_root(project_root: Path | None, subdir: str) -> Path | None: """Return the project-local built-in-tier directory for an asset family, if present.""" if project_root is None: @@ -246,78 +264,165 @@ def _describe_artifact_file(path: Path, kind: ArtifactKind) -> str: # --------------------------------------------------------------------------- -def _public_layer_shape( - resolver_layer: dict[str, Any], -) -> tuple[LayerName | None, str | None, str | None]: - """Translate resolver provenance into the public layer identity triple. +@dataclass(frozen=True) +class _LayerProvenance: + """Artifact-only metadata derived from an unchanged resolver layer.""" - Layers without a lookup identifier have no public provenance. Preset, - extension, and project override identities are retained unchanged. - """ - lookup_id = resolver_layer.get("lookupId") - if lookup_id is None: - return None, None, None - if not isinstance(lookup_id, str): - raise ArtifactResolutionError() - layer_kind = layer_kind_from_lookup_id(lookup_id) - if layer_kind not in ("project", "preset", "extension"): - raise ArtifactResolutionError() - source_id = source_id_from_lookup_id(lookup_id) - if source_id is None: - raise ArtifactResolutionError() - return layer_kind, source_id, lookup_id - - -def _derive_manifest_path(layer: dict[str, Any], project_root: Path) -> str | None: - """Return a repo-relative POSIX path to the manifest declaring this layer. - - ``layer`` is one dict entry from ``PresetResolver.collect_all_layers()``. - Only ``preset`` and ``extension`` layers have an on-disk manifest — core - and project-override layers return ``None``. - - The resolver may set the ``lookupId``'s ``sourceId`` component to the - manifest-declared ``id:`` (which can differ from the on-disk directory - name for renamed packs), so ``lookupId`` is never parsed for the on-disk - directory here. The on-disk directory identity is read exclusively from - the layer's explicit provenance keys — ``preset_id`` / ``pack_dir`` for - preset layers, ``extension_id`` / ``extension_dir`` for extension - layers — which ``collect_all_layers()`` always sets alongside - ``lookupId``. Missing provenance keys mean no manifest path is available. - - Convention-only contributions are surfaced by the resolver even when the - pack's manifest does not declare them — the manifest file exists on disk - but does not list the artifact in ``provides``. Reporting the manifest - path in that case would be a false positive: consumers joining on the - reported path would find no matching contribution. ``collect_all_layers`` - sets ``manifest_declared=True`` on layers that came from a manifest - ``provides`` entry, so those layers alone report a manifest path; a layer - without that flag falls through to ``None`` even when the manifest file - exists on disk. - - 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 not layer.get("manifest_declared"): - return None - lookup_id = layer.get("lookupId", "") - layer_kind = layer_kind_from_lookup_id(lookup_id) - if layer_kind == "preset": - pack_dir = layer.get("pack_dir") - pack_id = layer.get("preset_id") - tier_dir, manifest_name = "presets", "preset.yml" - elif layer_kind == "extension": - pack_dir = layer.get("extension_dir") - pack_id = layer.get("extension_id") - tier_dir, manifest_name = "extensions", "extension.yml" - else: + layer: LayerName | None + source_id: str | None + disk_id: str | None + pack_dir: Path | None + manifest: Any | None + manifest_entry: dict[str, Any] | None + + def lookup_id(self, kind: ArtifactKind, name: str) -> str | None: + if self.layer is None or self.source_id is None: + return None + try: + return derive_lookup_id(self.layer, self.source_id, kind, name) + except IdentifierComponentError: + return None + + +def _same_file(left: Path | None, right: Any) -> bool: + """Compare paths without requiring either path to exist at comparison time.""" + return isinstance(right, Path) and left is not None and left.resolve() == right.resolve() + + +def _manifest_entry_for_path( + manifest: Any, + layer: Literal["preset", "extension"], + pack_dir: Path, + kind: ArtifactKind, + name: str, + path: Path, +) -> dict[str, Any] | None: + """Return the existing manifest declaration that resolved to *path*.""" + if manifest is None: return None - if isinstance(pack_dir, Path): - manifest_path = pack_dir / manifest_name - elif pack_id: - manifest_path = project_root / ".specify" / tier_dir / pack_id / manifest_name + if layer == "preset": + entries = ( + entry + for entry in manifest.templates + if isinstance(entry, dict) and entry.get("type") == kind + ) else: + entries = { + "command": manifest.commands, + "template": manifest.templates, + "script": manifest.scripts, + }[kind] + for entry in entries: + if not isinstance(entry, dict) or entry.get("name") != name: + continue + relative_file = entry.get("file") + if isinstance(relative_file, str) and _same_file( + pack_dir / relative_file, path + ): + return entry + return None + + +def _layer_provenance( + resolver: Any, + resolver_layer: dict[str, Any], + kind: ArtifactKind, + name: str, + manifest_cache: dict[Path, Any | None], +) -> _LayerProvenance: + """Derive artifact provenance from the resolver's established layer shape.""" + source = resolver_layer.get("source") + path = resolver_layer.get("path") + + if source == "project override": + return _LayerProvenance("project", "_", None, None, None, None) + if source in {"core", "core (bundled)"}: + return _LayerProvenance(None, None, None, None, None, None) + if not isinstance(path, Path) or not isinstance(source, str): + raise ArtifactResolutionError() + + if source.startswith("extension:"): + extension_id = resolver_layer.get("extension_id") + extension_dir = resolver_layer.get("extension_dir") + if not isinstance(extension_id, str) or not isinstance(extension_dir, Path): + raise ArtifactResolutionError() + manifest_path = extension_dir / "extension.yml" + if manifest_path not in manifest_cache: + try: + from ..extensions import ExtensionManifest, ValidationError + + manifest_cache[manifest_path] = ( + ExtensionManifest(manifest_path) if manifest_path.is_file() else None + ) + except ( + ValidationError, + yaml.YAMLError, + OSError, + TypeError, + AttributeError, + ): + manifest_cache[manifest_path] = None + manifest = manifest_cache[manifest_path] + declared = _manifest_entry_for_path( + manifest, "extension", extension_dir, kind, name, path + ) + source_id = ( + manifest.id + if declared is not None + and manifest is not None + and isinstance(manifest.id, str) + and manifest.id + else extension_id + ) + return _LayerProvenance( + "extension", + source_id, + extension_id, + extension_dir, + manifest, + declared, + ) + + try: + relative = path.relative_to(resolver.presets_dir) + except ValueError as exc: + raise ArtifactResolutionError() from exc + if not relative.parts: + raise ArtifactResolutionError() + pack_id = relative.parts[0] + pack_dir = resolver.presets_dir / pack_id + manifest = resolver._get_manifest(pack_dir) + declared = _manifest_entry_for_path( + manifest, "preset", pack_dir, kind, name, path + ) + source_id = ( + manifest.id + if declared is not None + and manifest is not None + and isinstance(manifest.id, str) + and manifest.id + else pack_id + ) + return _LayerProvenance( + "preset", + source_id, + pack_id, + pack_dir, + manifest, + declared, + ) + + +def _derive_manifest_path( + provenance: _LayerProvenance, project_root: Path +) -> str | None: + """Return the declaring manifest path for an artifact layer.""" + if provenance.manifest_entry is None or provenance.pack_dir is None: return None + manifest_name = ( + "preset.yml" if provenance.layer == "preset" else "extension.yml" + ) + manifest_path = provenance.pack_dir / manifest_name if not manifest_path.is_file(): return None try: @@ -361,6 +466,7 @@ def _materialized_command_source_path( return None registrar = CommandRegistrar() + registrar._ensure_configs() registered_commands = metadata.get("registered_commands") if isinstance(registered_commands, dict): @@ -373,11 +479,13 @@ def _materialized_command_source_path( agent_config = registrar.AGENT_CONFIGS.get(agent_name) if agent_config is None: continue - command_path = registrar.resolve_command_output_path( - agent_name, name, project_root + output_name = registrar._compute_output_name( + agent_name, name, agent_config + ) + command_path = ( + registrar._resolve_agent_dir(agent_name, agent_config, project_root) + / f"{output_name}{agent_config['extension']}" ) - if command_path is None: - continue rel = _repo_relative_existing_file(project_root, command_path) if rel is not None: return rel @@ -421,12 +529,12 @@ def _materialized_command_source_path( agent_config = registrar.AGENT_CONFIGS.get(agent_name) if agent_config is None: continue - if registrar.uses_skill_output(agent_name): - skills_dir = registrar.resolve_agent_dir(agent_name, project_root) + if agent_config.get("extension") == "/SKILL.md": + skills_dir = registrar._resolve_agent_dir( + agent_name, agent_config, project_root + ) else: skills_dir = _project_skills_dir(project_root, agent_name) - if skills_dir is None: - continue for skill_name in sorted( n for n in skill_names if isinstance(n, str) and _is_safe_path_component(n) ): @@ -441,6 +549,7 @@ def _materialized_command_source_path( def _derive_source_path( + provenance: _LayerProvenance, layer: dict[str, Any], project_root: Path, kind: ArtifactKind, @@ -450,39 +559,33 @@ def _derive_source_path( ) -> str | None: """Return the repo-relative concrete file backing a preset/extension layer. - ``layer`` is one raw ``PresetResolver.collect_all_layers()`` row. Preset - and extension rows carry explicit on-disk provenance keys - (``preset_id``/``pack_dir`` or ``extension_id``/``extension_dir``) - alongside ``lookupId``; core and project rows intentionally do not produce - a source path here. - The tracked materialized agent output is shared by every stack row that contributed the same command name, so it only reflects the winning (``active``) row's content. Lower ``replace``/``merge`` rows must report their own installed pack file instead of that shared output. """ - lookup_id = layer.get("lookupId", "") - layer_kind = layer_kind_from_lookup_id(lookup_id) - if layer_kind == "preset": - pack_id = layer.get("preset_id") - if not isinstance(pack_id, str) or not pack_id: + if provenance.layer == "preset": + if provenance.disk_id is None: return None from ..presets import PresetRegistry - metadata = PresetRegistry(project_root / ".specify" / "presets").get(pack_id) + metadata = PresetRegistry(project_root / ".specify" / "presets").get( + provenance.disk_id + ) if kind == "command" and active: materialized = _materialized_command_source_path( project_root, metadata, name, source="preset" ) if materialized is not None: return materialized - elif layer_kind == "extension": - extension_id = layer.get("extension_id") - if not isinstance(extension_id, str) or not extension_id: + elif provenance.layer == "extension": + if provenance.disk_id is None: return None from ..extensions import ExtensionRegistry - metadata = ExtensionRegistry(project_root / ".specify" / "extensions").get(extension_id) + metadata = ExtensionRegistry(project_root / ".specify" / "extensions").get( + provenance.disk_id + ) if kind == "command" and active: materialized = _materialized_command_source_path( project_root, metadata, name, source="extension" @@ -490,8 +593,6 @@ def _derive_source_path( if materialized is not None: return materialized else: - # Core and project-override rows are built-in/synthetic from the public - # artifact contract's perspective, so their sourcePath stays null. return None # Non-active command layers, non-command preset/extension layers, and @@ -529,6 +630,8 @@ def _build_stack( kind: ArtifactKind, name: str, raw_layers: list[dict[str, Any]] | None = None, + resolver: Any | None = None, + manifest_cache: dict[Path, Any | None] | None = None, ) -> list[StackLayer]: """Build the ordered stack for a single artifact. @@ -543,8 +646,8 @@ def _build_stack( from ..presets import PresetError, PresetResolver # lazy: avoids circular import template_type = kind + resolver = resolver or PresetResolver(project_root) if raw_layers is None: - resolver = PresetResolver(project_root) try: raw = resolver.collect_all_layers(name, template_type) except (OSError, PresetError) as exc: @@ -553,6 +656,7 @@ def _build_stack( raw = raw_layers if not raw: return [] + manifest_cache = manifest_cache if manifest_cache is not None else {} first_replace_idx = next( (i for i, layer in enumerate(raw) if layer["strategy"] == "replace"), @@ -570,15 +674,20 @@ def _build_stack( else: hidden = idx > first_replace_idx - layer_kind, source_id, lookup_id = _public_layer_shape(layer) - source_path = _derive_source_path(layer, project_root, kind, name, active=active) + provenance = _layer_provenance( + resolver, layer, kind, name, manifest_cache + ) + lookup_id = provenance.lookup_id(kind, name) + source_path = _derive_source_path( + provenance, layer, project_root, kind, name, active=active + ) - if layer_kind == PROJECT_OVERRIDE_LAYER: + if provenance.layer == PROJECT_OVERRIDE_LAYER: rows.append( StackLayer( id=public_id, layer="project", - sourceId=source_id, + sourceId=provenance.source_id, presetId=None, presetName=None, strategy=strategy, @@ -591,13 +700,13 @@ def _build_stack( ) continue - if layer_kind == "extension": - manifest_path = _derive_manifest_path(layer, project_root) + if provenance.layer == "extension": + manifest_path = _derive_manifest_path(provenance, project_root) rows.append( StackLayer( id=public_id, layer="extension", - sourceId=source_id, + sourceId=provenance.source_id, presetId=None, presetName=None, strategy=strategy, @@ -610,7 +719,7 @@ def _build_stack( ) continue - if layer_kind is None: + if provenance.layer is None: rows.append( StackLayer( id=public_id, @@ -628,25 +737,17 @@ def _build_stack( ) continue - # Preset layers carry the on-disk directory identity separately from - # ``lookupId`` (which may use the manifest-declared ``id:``): use the - # explicit ``preset_id`` / ``pack_dir`` keys ``collect_all_layers()`` - # always sets, never ``lookupId`` parsing, so a renamed pack still - # resolves to the right on-disk directory for display-name and - # manifest-path lookup. - pack_id = layer.get("preset_id") or "" - pack_dir_layer = layer.get("pack_dir") - if isinstance(pack_dir_layer, Path): - pack_dir = pack_dir_layer - else: - pack_dir = project_root / ".specify" / "presets" / pack_id + pack_id = provenance.disk_id or "" + pack_dir = provenance.pack_dir or ( + project_root / ".specify" / "presets" / pack_id + ) display = _preset_display_name(pack_dir, pack_id) if pack_id else pack_id - manifest_path = _derive_manifest_path(layer, project_root) + manifest_path = _derive_manifest_path(provenance, project_root) rows.append( StackLayer( id=public_id, layer="preset", - sourceId=source_id, + sourceId=provenance.source_id, presetId=pack_id or None, presetName=display or None, strategy=strategy, @@ -749,12 +850,12 @@ def list_artifacts(self) -> list[Artifact]: is decided by :meth:`PresetResolver.collect_all_layers`'s own ordering (index 0 = winner), not by enumeration order here. """ - artifacts, _layers_cache = self._collect_inventory() + artifacts, _layers_cache, _resolver, _manifest_cache = self._collect_inventory() return artifacts def list_artifacts_with_stack(self) -> list[dict[str, Any]]: """Return list rows enriched with each artifact's full composition stack.""" - artifacts, layers_cache = self._collect_inventory() + artifacts, layers_cache, resolver, manifest_cache = self._collect_inventory() rows: list[dict[str, Any]] = [] for artifact in artifacts: stack = _build_stack( @@ -762,6 +863,8 @@ def list_artifacts_with_stack(self) -> list[dict[str, Any]]: artifact.kind, artifact.name, raw_layers=layers_cache.get((artifact.kind, artifact.name)), + resolver=resolver, + manifest_cache=manifest_cache, ) row = artifact.to_json_dict() row["stack"] = [layer.to_json_dict() for layer in stack] @@ -791,7 +894,7 @@ def get_artifact_info( # ``_collect_inventory`` below — the same chokepoint ``list_artifacts`` # uses — so both public methods fail closed identically instead of # each re-implementing the checks. - inventory, layers_cache = self._collect_inventory() + inventory, layers_cache, resolver, manifest_cache = self._collect_inventory() if resolved_kind is None: matches = [ (artifact.kind, artifact.name) @@ -820,6 +923,8 @@ def get_artifact_info( resolved_kind, validated_name, raw_layers=layers_cache.get((resolved_kind, validated_name)), + resolver=resolver, + manifest_cache=manifest_cache, ) if not stack: raise ArtifactNotFoundError(name) @@ -838,6 +943,8 @@ def _collect_inventory( ) -> tuple[ list[Artifact], dict[tuple[ArtifactKind, str], list[dict[str, Any]]], + Any, + dict[Path, Any | None], ]: _validate_project(self.project_root) _validate_extension_registry(self.project_root) @@ -892,12 +999,11 @@ def _has_any_replace_layer(layers: list[dict[str, Any]]) -> bool: artifacts: list[Artifact] = [] manifest_cache: dict[Path, Any | None] = {} - manifest_description_cache: dict[Path, dict[tuple[str, str, str], str]] = {} for kind, name in names: description = "" for layer in _layers_for(kind, name): candidate = self._describe_layer( - layer, kind, name, manifest_cache, manifest_description_cache + resolver, layer, kind, name, manifest_cache ) if candidate: description = candidate @@ -912,7 +1018,12 @@ def _has_any_replace_layer(layers: list[dict[str, Any]]) -> bool: ) kind_order = {"command": 0, "template": 1, "script": 2} - return sorted(artifacts, key=lambda a: (kind_order[a.kind], a.name)), layers_cache + return ( + sorted(artifacts, key=lambda a: (kind_order[a.kind], a.name)), + layers_cache, + resolver, + manifest_cache, + ) def _iter_candidate_artifacts( self, @@ -924,14 +1035,13 @@ def _iter_candidate_artifacts( Covers the ways a pack can contribute an artifact: * manifest-declared entries (``preset.yml`` / ``extension.yml``), read - via each manifest class's own ``iter_contributions()`` rather than - re-parsing ``provides`` by hand, and + through each manifest class's existing normalized properties, and * convention-placed extension files (``commands/``, ``templates/``, ``scripts/``) that the resolver picks up even without a manifest. - Presets and extensions are enumerated through the resolver's public - ``iter_*_by_priority()`` helpers, so the candidate set follows the same - install/enable/priority rules as resolution. Project overrides and + Presets and extensions are enumerated through the resolver's existing + priority helpers, so the candidate set follows the same install, + enable, and priority rules as resolution. Project overrides and resolver-compatible core asset paths are included only as candidate names; :meth:`PresetResolver.collect_all_layers` remains the source of truth for which candidates are actually present and which layer wins. @@ -950,16 +1060,14 @@ def _iter_candidate_artifacts( # -- Presets: the registry is authoritative, no unregistered fallback. preset_manager = PresetManager(self.project_root) - for pack_id, _metadata in resolver.iter_presets_by_priority(): + for pack_id, _metadata in resolver._get_all_presets_by_priority(): pack_dir = preset_manager.presets_dir / pack_id manifest = preset_manager.get_pack(pack_id) - yield from self._iter_pack_candidates(manifest, pack_dir) + yield from self._iter_pack_candidates(manifest, pack_dir, "preset") - # -- 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(). + # -- Extensions: use the resolver's own extension enumeration order. ext_manager = ExtensionManager(self.project_root) - for _priority, ext_id, metadata in resolver.iter_extensions_by_priority(): + for _priority, ext_id, metadata in resolver._get_all_extensions_by_priority(): ext_dir = resolver.extensions_dir / ext_id if metadata is not None: manifest = ext_manager.get_extension(ext_id) @@ -971,7 +1079,7 @@ def _iter_candidate_artifacts( manifest = ExtensionManifest(manifest_path) except (ValidationError, OSError, TypeError, AttributeError): manifest = None - yield from self._iter_pack_candidates(manifest, ext_dir) + yield from self._iter_pack_candidates(manifest, ext_dir, "extension") yield from self._iter_project_override_candidates(resolver) yield from self._iter_core_candidates(core_script_paths) @@ -980,11 +1088,28 @@ def _iter_candidate_artifacts( def _iter_pack_candidates( manifest: Any, pack_dir: Path, + layer: Literal["preset", "extension"], ) -> Iterable[tuple[ArtifactKind, str]]: """Yield manifest-declared and convention-based candidate names.""" if manifest is not None: - for contribution in manifest.iter_contributions(): - kind = contribution.get("kind") + if layer == "preset": + declarations = ( + (entry.get("type"), entry) + for entry in manifest.templates + if isinstance(entry, dict) + ) + else: + declarations = ( + (kind, entry) + for kind, entries in ( + ("command", manifest.commands), + ("template", manifest.templates), + ("script", manifest.scripts), + ) + for entry in entries + if isinstance(entry, dict) + ) + for kind, contribution in declarations: name = contribution.get("name") if ( kind in ("command", "template", "script") @@ -1024,8 +1149,7 @@ def _iter_project_override_candidates( for kind in ("command", "template"): layers = resolver.collect_all_layers(name, kind) if any( - layer_kind_from_lookup_id(str(layer.get("lookupId", ""))) - != PROJECT_OVERRIDE_LAYER + layer.get("source") != "project override" for layer in layers ): backed_kinds.append(kind) @@ -1065,7 +1189,15 @@ def _iter_core_candidates( if any( (directory / f"{candidate}.md").is_file() for directory in command_dirs - for candidate in PresetResolver.name_candidates(name) + for candidate in ( + name, + *( + (PresetResolver._core_stem(name),) + if PresetResolver._core_stem(name) + else () + ), + ) + if candidate is not None ): yield "command", name @@ -1167,15 +1299,15 @@ def _selected_core_script_paths(self) -> dict[str, Path]: def _describe_layer( self, + resolver: Any, layer: dict[str, Any], kind: ArtifactKind, name: str, manifest_cache: dict[Path, Any | None], - manifest_description_cache: dict[Path, dict[tuple[str, str, str], str]], ) -> str: """Return manifest metadata or on-disk metadata for one resolver layer.""" manifest_description = self._manifest_description_for_layer( - layer, kind, name, manifest_cache, manifest_description_cache + resolver, layer, kind, name, manifest_cache ) if manifest_description: return manifest_description @@ -1186,81 +1318,20 @@ def _describe_layer( def _manifest_description_for_layer( self, + resolver: Any, layer: dict[str, Any], kind: ArtifactKind, name: str, manifest_cache: dict[Path, Any | None], - manifest_description_cache: dict[Path, dict[tuple[str, str, str], str]], ) -> str: - lookup_id = layer.get("lookupId", "") - layer_kind = layer_kind_from_lookup_id(lookup_id) - manifest = None - if layer_kind == "preset": - pack_dir = layer.get("pack_dir") - if not isinstance(pack_dir, Path): - preset_id = layer.get("preset_id") - if not preset_id: - return "" - pack_dir = self.project_root / ".specify" / "presets" / preset_id - manifest_path = pack_dir / "preset.yml" - if manifest_path.is_file(): - if manifest_path not in manifest_cache: - try: - from ..presets import PresetManifest, PresetValidationError - - manifest_cache[manifest_path] = PresetManifest(manifest_path) - except ( - PresetValidationError, - yaml.YAMLError, - OSError, - TypeError, - AttributeError, - ): - manifest_cache[manifest_path] = None - manifest = manifest_cache[manifest_path] - elif layer_kind == "extension": - ext_dir = layer.get("extension_dir") - if not isinstance(ext_dir, Path): - extension_id = layer.get("extension_id") - if not extension_id: - return "" - ext_dir = self.project_root / ".specify" / "extensions" / extension_id - manifest_path = ext_dir / "extension.yml" - if manifest_path.is_file(): - if manifest_path not in manifest_cache: - try: - from ..extensions import ExtensionManifest, ValidationError - - manifest_cache[manifest_path] = ExtensionManifest(manifest_path) - except ( - ValidationError, - yaml.YAMLError, - OSError, - TypeError, - AttributeError, - ): - manifest_cache[manifest_path] = None - manifest = manifest_cache[manifest_path] - if manifest is None: + provenance = _layer_provenance( + resolver, layer, kind, name, manifest_cache + ) + entry = provenance.manifest_entry + if entry is None: return "" - if manifest_path not in manifest_description_cache: - descriptions: dict[tuple[str, str, str], str] = {} - for contribution in manifest.iter_contributions(): - contribution_id = contribution.get("id") - contribution_kind = contribution.get("kind") - contribution_name = contribution.get("name") - description = contribution.get("description", "") - if ( - isinstance(contribution_id, str) - and isinstance(contribution_kind, str) - and isinstance(contribution_name, str) - and isinstance(description, str) - ): - descriptions[ - (contribution_kind, contribution_name, contribution_id) - ] = description - manifest_description_cache[manifest_path] = descriptions - return manifest_description_cache[manifest_path].get((kind, name, lookup_id), "") + description = entry.get("description", "") + return description if isinstance(description, str) else "" _CONVENTION_SUBDIRS: tuple[tuple[str, ArtifactKind, str], ...] = ( diff --git a/src/specify_cli/artifacts/_identifiers.py b/src/specify_cli/artifacts/_identifiers.py new file mode 100644 index 0000000000..b417d78ea8 --- /dev/null +++ b/src/specify_cli/artifacts/_identifiers.py @@ -0,0 +1,75 @@ +"""Identifier helpers private to the artifact JSON surface.""" + +from __future__ import annotations + +from typing import Any + + +PROJECT_OVERRIDE_LAYER = "project" +_ARTIFACT_KINDS = frozenset({"command", "template", "script"}) +_LAYER_KINDS = frozenset({PROJECT_OVERRIDE_LAYER, "preset", "extension"}) + + +class IdentifierComponentError(ValueError): + """Raised when a value cannot be represented in an artifact identifier.""" + + +def validate_component(value: Any, field_label: str) -> str: + """Return a non-empty string that does not contain the ID delimiter.""" + if not isinstance(value, str): + raise IdentifierComponentError( + f"Invalid {field_label}: expected a string, got {type(value).__name__}" + ) + if not value: + raise IdentifierComponentError( + f"Invalid {field_label}: value must not be empty" + ) + if ":" in value: + raise IdentifierComponentError( + f"Invalid {field_label} '{value}': ':' is reserved as an identifier delimiter" + ) + return value + + +def derive_public_id(kind: str, name: str) -> str: + """Build the source-agnostic identifier exposed by ``specify artifact``.""" + validate_component(kind, "kind") + if kind not in _ARTIFACT_KINDS: + raise IdentifierComponentError(f"Invalid public artifact kind '{kind}'") + validate_component(name, "name") + return f"{kind}:{name}" + + +def derive_lookup_id(layer: str, source_id: str, kind: str, name: str) -> str: + """Build an artifact-stack lookup identifier.""" + validate_component(layer, "layer") + validate_component(source_id, "sourceId") + validate_component(kind, "kind") + validate_component(name, "name") + if layer not in _LAYER_KINDS: + raise IdentifierComponentError(f"Invalid layer '{layer}'") + if kind not in _ARTIFACT_KINDS: + raise IdentifierComponentError(f"Invalid artifact kind '{kind}'") + if layer == PROJECT_OVERRIDE_LAYER and source_id != "_": + raise IdentifierComponentError( + f"Invalid sourceId '{source_id}': project layer requires '_'" + ) + if layer != PROJECT_OVERRIDE_LAYER and source_id == "_": + raise IdentifierComponentError( + "Invalid sourceId '_': reserved for project layer" + ) + return f"{layer}:{source_id}:{kind}:{name}" + + +def is_dotted_command_name(value: str) -> bool: + """Return whether *value* follows the dotted command-name convention.""" + if "." not in value: + return False + return all( + segment + and all( + ("0" <= char <= "9") or ("a" <= char <= "z") or char == "-" + for char in segment + ) + for segment in value.split(".") + ) diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 7f04c9a4cb..ba0a4f6363 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -551,12 +551,17 @@ def _find_command_template(command_name: str, project_root: Path) -> tuple[Path # templates/commands). The previous bespoke inspect.getfile() math # pointed at core_pack/templates/commands, which never exists in a # wheel build (force-include maps templates/commands -> core_pack/commands). - from ._assets import _locate_shared_asset_dir - - commands_dir = _locate_shared_asset_dir("commands") + from ._assets import _locate_core_pack, _repo_root + core_pack = _locate_core_pack() + candidate_dirs = [ + core_pack / "commands" if core_pack is not None else None, + _repo_root() / "templates" / "commands", + ] stem = command_name.replace("speckit.", "").replace("spec.", "") - if commands_dir is not None: - candidate = commands_dir / f"{stem}.md" + for candidate_dir in candidate_dirs: + if candidate_dir is None or not candidate_dir.is_dir(): + continue + candidate = candidate_dir / f"{stem}.md" if candidate.exists(): return candidate, None diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 26aa65d83c..a440b6da9b 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -27,13 +27,7 @@ from packaging import version as pkg_version from packaging.specifiers import InvalidSpecifier, SpecifierSet -from .._assets import _locate_shared_asset_dir -from .._identifier import ( - IdentifierComponentError, - derive_hook_id, - derive_named_id, - validate_component, -) +from .._assets import _locate_core_pack, _repo_root from .._download_security import ( archive_format_from_name, archive_suffix, @@ -88,19 +82,29 @@ 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 :func:`_locate_shared_asset_dir` — the same - resolver ``PresetResolver._find_bundled_core`` and the artifact command's - core-baseline enumeration use — rather than bespoke ``Path(__file__)`` - arithmetic. Hand-counted ``.parent`` chains silently broke discovery once - already: the #3014 move of this module from ``specify_cli/extensions.py`` - to ``specify_cli/extensions/__init__.py`` pushed the file one directory - deeper without updating the counts, so both candidates resolved to - non-existent paths and every call fell through to the fallback (#3274). - The shared resolver is anchored to the package root, so discovery - survives future module moves. + 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. """ - commands_dir = _locate_shared_asset_dir("commands") - if commands_dir is not None: + 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 + command_names = { command_file.stem for command_file in commands_dir.iterdir() @@ -411,10 +415,6 @@ def _validate(self): raise ValidationError( f"Invalid hook '{hook_name}': list must contain at least one entry" ) - try: - validate_component(hook_name, f"hook event name '{hook_name}'") - except IdentifierComponentError as exc: - raise ValidationError(str(exc)) from exc for entry in coerce_hook_entries(hook_config): if not isinstance(entry, dict): raise ValidationError( @@ -425,13 +425,6 @@ 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): @@ -530,11 +523,14 @@ def _validate(self): command_ref = entry.get("command") if not isinstance(command_ref, str): continue - final_ref = self._canonicalize_command_ref( - command_ref, - ext["id"], - rename_map, - ) + # 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 if final_ref != command_ref: entry["command"] = final_ref self.warnings.append( @@ -556,11 +552,12 @@ def _validate(self): command_ref = event_config.get("command") if not isinstance(command_ref, str): continue - final_ref = self._canonicalize_command_ref( - command_ref, - ext["id"], - rename_map, - ) + 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 if final_ref != command_ref: event_config["command"] = final_ref self.warnings.append( @@ -569,18 +566,6 @@ def _validate(self): f"The extension author should update the manifest." ) - @staticmethod - def _canonicalize_command_ref( - command_ref: str, - ext_id: str, - rename_map: Dict[str, str], - ) -> str: - after_rename = rename_map.get(command_ref, command_ref) - parts = after_rename.split(".") - if len(parts) == 2 and parts[0] == ext_id: - return f"speckit.{ext_id}.{parts[1]}" - return after_rename - @staticmethod def _validate_provided_artifacts(entries: List[Any], section: str, singular: str) -> None: """Validate provides.templates / provides.scripts entries. @@ -740,102 +725,6 @@ def hooks(self) -> Dict[str, Any]: """Get hook definitions.""" return self.data.get("hooks", {}) - def iter_contributions(self) -> List[Dict[str, Any]]: - """Return an enriched, ordered list of every contribution this manifest declares. - - Each dict is a shallow copy of the underlying manifest entry with four - derived keys added: ``layer`` (always ``"extension"``), ``sourceId`` - (this manifest's ``id``), ``kind`` (``"command"`` / ``"template"`` / - ``"script"`` / ``"hook"``), and ``id`` (the deterministic identifier). - Hook entries also carry a synthesized ``name`` field of the form - ``"{eventName}:{command}"`` alongside the original ``eventName`` / - ``command`` values, so consumers can locate a hook by its identifier's - name component without re-splitting the string. - - The underlying ``self.data`` mapping is never mutated — the enriched - dicts are constructed fresh on every call so callers can safely rely on - the identifiers reflecting the current in-memory manifest state. - """ - source_id = self.id - contributions: List[Dict[str, Any]] = [] - - for cmd in self.commands: - enriched = dict(cmd) - name = cmd.get("name", "") - enriched.update( - layer="extension", - sourceId=source_id, - kind="command", - id=derive_named_id("extension", source_id, "command", name), - ) - contributions.append(enriched) - - for tmpl in self.templates: - enriched = dict(tmpl) - name = tmpl.get("name", "") - enriched.update( - layer="extension", - sourceId=source_id, - kind="template", - id=derive_named_id("extension", source_id, "template", name), - ) - contributions.append(enriched) - - for scr in self.scripts: - enriched = dict(scr) - name = scr.get("name", "") - enriched.update( - layer="extension", - sourceId=source_id, - kind="script", - id=derive_named_id("extension", source_id, "script", name), - ) - contributions.append(enriched) - - for event_name, hook_config in (self.hooks or {}).items(): - deduped: Dict[str, dict] = {} - for entry in coerce_hook_entries(hook_config): - if not isinstance(entry, dict): - continue - command_value = entry.get("command", "") - if command_value in deduped: - del deduped[command_value] - normalized = dict(entry) - # Overwrite (not setdefault) so an author-supplied - # ``eventName`` cannot contradict the containing hook key — - # otherwise an entry under ``before_plan`` carrying - # ``eventName: after_plan`` would be emitted with metadata - # that disagrees with its ``name`` and ``id`` (both of which - # derive from the hook key below). - normalized["eventName"] = event_name - deduped[command_value] = normalized - - for command_value, entry in deduped.items(): - enriched = dict(entry) - enriched.update( - layer="extension", - sourceId=source_id, - kind="hook", - name=f"{event_name}:{command_value}", - id=derive_hook_id( - "extension", source_id, event_name, command_value - ), - ) - contributions.append(enriched) - - return contributions - - def contribution_id(self, kind: str, name: str) -> Optional[str]: - """Return the computed identifier for a single contribution, if declared. - - ``name`` is the declared name for command/template/script kinds, or the - ``"{eventName}:{command}"`` compound for hook kinds. - """ - for entry in self.iter_contributions(): - if entry["kind"] == kind and entry.get("name") == name: - return entry["id"] - return None - def get_hash(self) -> str: """Calculate SHA256 hash of manifest file.""" h = hashlib.sha256() @@ -920,7 +809,7 @@ def is_corrupt(self) -> bool: return True if not isinstance(data, dict): return True - if "extensions" not in data or not isinstance(data["extensions"], dict): + if "extensions" in data and not isinstance(data["extensions"], dict): return True return False diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index ca389440cf..e58d231d36 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -27,7 +27,6 @@ import yaml -from .._assets import _locate_shared_asset_dir from .._invocation_style import get_invocation_prefix, is_dollar_skills_agent from .._toml_string import escape_toml_basic as _escape_toml_basic from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control @@ -436,7 +435,16 @@ def shared_commands_dir(self) -> Path | None: ``templates/commands/`` (source checkout). Returns ``None`` if neither exists. """ - return _locate_shared_asset_dir("commands") + import inspect + + pkg_dir = Path(inspect.getfile(IntegrationBase)).resolve().parent.parent + for candidate in [ + pkg_dir / "core_pack" / "commands", + pkg_dir.parent.parent / "templates" / "commands", + ]: + if candidate.is_dir(): + return candidate + return None def shared_templates_dir(self) -> Path | None: """Return path to the shared page templates directory. @@ -444,7 +452,16 @@ def shared_templates_dir(self) -> Path | None: Contains ``vscode-settings.json``, ``spec-template.md``, etc. Checks ``core_pack/templates/`` then ``templates/``. """ - return _locate_shared_asset_dir("templates") + import inspect + + pkg_dir = Path(inspect.getfile(IntegrationBase)).resolve().parent.parent + for candidate in [ + pkg_dir / "core_pack" / "templates", + pkg_dir.parent.parent / "templates", + ]: + if candidate.is_dir(): + return candidate + return None def list_command_templates(self) -> list[Path]: """Return ordered list of command template files from the shared directory.""" diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 915be12af1..abc63299c2 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -37,11 +37,6 @@ safe_extract_archive, ) from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority -from .._identifier import ( - IdentifierComponentError, - PROJECT_OVERRIDE_LAYER, - derive_named_id, -) from .._init_options import ( MISSING_INIT_OPTIONS_FILE, is_ai_skills_enabled, @@ -676,38 +671,6 @@ 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() @@ -2104,7 +2067,7 @@ def record_written(written: Dict[str, List[str]]) -> None: if not registered: # Top layer is a non-preset source (extension, core, or # project override). Register directly from the layer path. - source = layers[0].get("source") or "" + source = layers[0]["source"] extension_id = None written: Dict[str, List[str]] = {} if source.startswith("extension:"): @@ -2228,7 +2191,7 @@ def record_written(written: Dict[str, List[str]]) -> None: shared_composed.mkdir(parents=True, exist_ok=True) composed_file = shared_composed / f"{cmd_name}.md" composed_file.write_text(composed, encoding="utf-8") - source = layers[0].get("source") or "" + source = layers[0]["source"] if source.startswith("extension:"): source_id = source.split(":", 1)[1].split(" ", 1)[0] else: @@ -3739,11 +3702,13 @@ def _unregister_skills_in_dir( and restore_from_bundled_core and extension_restore is None ): - from .._assets import _locate_shared_asset_dir + from .. import _locate_core_pack, _repo_root - commands_dir = _locate_shared_asset_dir("commands") - if commands_dir is not None: - core_file = commands_dir / f"{short_name}.md" + _core_pack = _locate_core_pack() + if _core_pack is not None: + core_file = _core_pack / "commands" / f"{short_name}.md" + else: + core_file = _repo_root() / "templates" / "commands" / f"{short_name}.md" if not core_file.exists(): core_file = None @@ -5372,19 +5337,6 @@ def _get_all_presets_by_priority(self) -> List[tuple[str, dict]]: if self._is_safe_registry_id(pack_id) ] - def iter_presets_by_priority(self) -> List[tuple[str, dict]]: - """Return preset directories in resolver lookup order. - - Each entry is ``(pack_id, metadata)`` where ``pack_id`` is the registry - key / on-disk directory name. That key identifies *where* the pack - lives — it is used for lookup and provenance, and as the ``sourceId`` - of convention-only contribution IDs. Manifest-declared layers instead - take their ``lookupId`` ``sourceId`` from ``PresetManifest.id`` so the - ID joins directly to the manifest's own contributions even when the - installed directory was renamed. - """ - return self._get_all_presets_by_priority() - def _manifest_declared_template( self, pack_dir: Path, template_name: str, template_type: str ) -> tuple[dict | None, Path | None]: @@ -5420,20 +5372,16 @@ def _manifest_declared_template( def _extension_manifest_declared_template( self, ext_dir: Path, template_name: str, template_type: str - ) -> tuple[dict | None, Path | None, str | None]: + ) -> tuple[dict | None, Path | None]: """Resolve an extension's manifest-declared command/template/script entry and usable file. - Mirrors ``_manifest_declared_template`` (for presets): returns - ``(entry, candidate, manifest_id)`` where ``entry`` is the matching - ``provides.`` mapping, or ``None`` if the extension has no - (valid) manifest or doesn't declare this ``(name, type)``. - ``candidate`` is the declared ``file:`` resolved under ``ext_dir`` IFF - it is a regular file that stays within ``ext_dir`` (guards against path - traversal via a malformed manifest, mirroring - ``resolve_extension_command_via_manifest``); ``None`` otherwise. - ``manifest_id`` comes from the same successful parse that produced - ``entry``, so callers never need a second fallible read to derive the - contribution identity. + Mirrors ``_manifest_declared_template`` (for presets): returns ``(entry, candidate)`` + where ``entry`` is the matching ``provides.`` mapping, or ``None`` if the + extension has no (valid) manifest or doesn't declare this ``(name, type)``. + ``candidate`` is the declared ``file:`` resolved under ``ext_dir`` IFF it is a + regular file that stays within ``ext_dir`` (guards against path traversal via a + malformed manifest, mirroring ``resolve_extension_command_via_manifest``); + ``None`` otherwise. The manifest is authoritative: when ``entry`` is not ``None`` but ``candidate`` is ``None``, callers must NOT fall back to convention-based lookup — that would mask @@ -5442,16 +5390,16 @@ def _extension_manifest_declared_template( diverge (the divergence flagged in review on #4012). """ if template_type not in ("command", "template", "script"): - return None, None, None + return None, None ext_manifest_path = ext_dir / "extension.yml" if not ext_manifest_path.exists(): - return None, None, None + return None, None from ..extensions import ExtensionManifest, ValidationError as ExtValidationError try: ext_manifest = ExtensionManifest(ext_manifest_path) except (ExtValidationError, yaml.YAMLError, OSError, TypeError, AttributeError): - return None, None, None + return None, None if template_type == "command": entries = ext_manifest.commands elif template_type == "template": @@ -5463,10 +5411,10 @@ def _extension_manifest_declared_template( continue file_rel = entry.get("file") if not file_rel: - return entry, None, ext_manifest.id + return entry, None rel_path = Path(file_rel) if rel_path.is_absolute(): - return entry, None, ext_manifest.id + return entry, None candidate = ext_dir / rel_path try: # Resolve only for the containment check, not for the @@ -5476,13 +5424,9 @@ def _extension_manifest_declared_template( # lookup returns for the same directory. candidate.resolve().relative_to(ext_dir.resolve()) # raises ValueError if outside except (OSError, ValueError): - return entry, None, ext_manifest.id - return ( - entry, - candidate if candidate.is_file() else None, - ext_manifest.id, - ) - return None, None, None + return entry, None + return entry, (candidate if candidate.is_file() else None) + return None, None def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: """Build unified list of registered and unregistered extensions sorted by priority. @@ -5538,19 +5482,6 @@ def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: all_extensions.sort(key=lambda x: (x[0], x[1])) return all_extensions - def iter_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: - """Return extension directories in resolver lookup order. - - Each entry is ``(priority, ext_id, metadata_or_none)`` where ``ext_id`` - is always the on-disk directory name. That name identifies *where* the - extension lives — it is used for lookup and provenance, and as the - ``sourceId`` of convention-only contribution IDs. Manifest-declared - layers instead take their ``lookupId`` ``sourceId`` from - ``ExtensionManifest.id`` so the ID joins directly to the manifest's own - contributions even when the installed directory was renamed. - """ - return self._get_all_extensions_by_priority() - @staticmethod def _core_stem(template_name: str) -> Optional[str]: """Extract the stem for core command lookup. @@ -5564,23 +5495,6 @@ def _core_stem(template_name: str) -> Optional[str]: return template_name[len("speckit."):] return None - @classmethod - def name_candidates(cls, logical_name: str) -> list[str]: - """Return exact-first filename candidates for a ``speckit.`` logical name. - - Given a logical name like ``speckit.plan``, returns - ``["speckit.plan", "plan"]`` so callers can try the fully-qualified - filename first and then fall back to the bare stem. - - Names that do not follow the ``speckit.`` convention return a - single-element list containing the original name. - """ - names = [logical_name] - stem = cls._core_stem(logical_name) - if stem and stem != logical_name: - names.append(stem) - return names - def resolve( self, template_name: str, @@ -5664,10 +5578,8 @@ def resolve( # The extension manifest is authoritative, same as preset manifests # above: check it before convention-based lookup so a declared entry # at a non-conventional path wins over a stale conventional file. - entry, manifest_candidate, _manifest_id = ( - self._extension_manifest_declared_template( - ext_dir, template_name, template_type - ) + entry, manifest_candidate = self._extension_manifest_declared_template( + ext_dir, template_name, template_type ) if manifest_candidate is not None: return manifest_candidate @@ -5704,13 +5616,42 @@ def resolve( # Priority 5: Bundled core_pack (wheel install) or repo-root templates # (source-checkout / editable install). This is the canonical home for # speckit's built-in command/template files and must always be checked - # so that strategy:wrap presets can locate {CORE_TEMPLATE}. Delegated - # to the shared core asset resolver via ``_find_bundled_core`` so this - # tier and ``collect_all_layers()`` never disagree about what "core" - # means on this machine. - bundled = self._find_bundled_core(template_name, template_type, ext) - if bundled is not None: - return bundled + # so that strategy:wrap presets can locate {CORE_TEMPLATE}. + from specify_cli import _locate_core_pack, _repo_root # local import to avoid cycles + _core_pack = _locate_core_pack() + if _core_pack is not None: + # Wheel install path + if template_type == "template": + candidate = _core_pack / "templates" / f"{template_name}.md" + elif template_type == "command": + candidate = _core_pack / "commands" / f"{template_name}.md" + if not candidate.exists(): + stem = self._core_stem(template_name) + if stem: + candidate = _core_pack / "commands" / f"{stem}.md" + elif template_type == "script": + candidate = _core_pack / "scripts" / f"{template_name}{ext}" + else: + candidate = _core_pack / f"{template_name}.md" + if candidate.exists(): + return candidate + else: + # Source-checkout / editable install: templates live at repo root + repo_root = _repo_root() + if template_type == "template": + candidate = repo_root / "templates" / f"{template_name}.md" + elif template_type == "command": + candidate = repo_root / "templates" / "commands" / f"{template_name}.md" + if not candidate.exists(): + stem = self._core_stem(template_name) + if stem: + candidate = repo_root / "templates" / "commands" / f"{stem}.md" + elif template_type == "script": + candidate = repo_root / "scripts" / f"{template_name}{ext}" + else: + candidate = repo_root / f"{template_name}.md" + if candidate.exists(): + return candidate return None @@ -5851,9 +5792,6 @@ def collect_all_layers( Returns: List of layer dicts ordered highest-to-lowest priority. - Filesystem-derived legacy layers whose names cannot be represented - by the contribution-ID grammar are preserved with ``lookupId=None``. - Manifest-declared layers remain subject to strict ID validation. """ if template_type == "template": subdirs = ["templates", ""] @@ -5870,12 +5808,6 @@ def collect_all_layers( layers: List[Dict[str, Any]] = [] - def _filesystem_lookup_id(layer: str, source_id: str) -> Optional[str]: - try: - return derive_named_id(layer, source_id, template_type, template_name) - except IdentifierComponentError: - return None - def _find_in_subdirs(base_dir: Path) -> Optional[Path]: for subdir in subdirs: if subdir: @@ -5896,7 +5828,6 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: "path": override, "source": "project override", "strategy": "replace", - "lookupId": _filesystem_lookup_id(PROJECT_OVERRIDE_LAYER, "_"), }) # Priority 2: Installed presets (sorted by priority — lower number = higher precedence) @@ -5949,39 +5880,10 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: # strategy ("replace") when content is unreadable/invalid. pass version = metadata.get("version", "?") if metadata else "?" - # Manifest-declared entries derive their sourceId from the - # manifest's validated ``id:``, so ``lookupId`` joins - # directly to ``PresetManifest.iter_contributions()``'s - # ``id`` even when the installed directory (``pack_id``) - # was renamed. Convention-only contributions have no - # manifest to consult, so they fall back to the directory - # / registry key. The directory identity is still carried - # separately via ``preset_id`` / ``pack_dir`` / ``source`` - # for on-disk path lookup and provenance display. - source_id_for_lookup = pack_id - if entry is not None: - manifest = self._get_manifest(pack_dir) - if manifest is not None and isinstance(manifest.id, str) and manifest.id: - source_id_for_lookup = manifest.id layers.append({ "path": candidate, "source": f"{pack_id} v{version}", "strategy": strategy, - "preset_id": pack_id, - "pack_dir": pack_dir, - "manifest_declared": entry is not None, - "lookupId": ( - derive_named_id( - "preset", - source_id_for_lookup, - template_type, - template_name, - ) - if entry is not None - else _filesystem_lookup_id( - "preset", source_id_for_lookup - ) - ), }) # Priority 3: Extension-provided templates (always "replace") @@ -5993,7 +5895,7 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: # above: check it before convention-based lookup so a declared entry # at a non-conventional path wins over a stale conventional file, and # a declared-but-missing file isn't silently masked by convention. - entry, candidate, manifest_id = self._extension_manifest_declared_template( + entry, candidate = self._extension_manifest_declared_template( ext_dir, template_name, template_type ) if entry is None: @@ -6004,36 +5906,12 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: source = f"extension:{ext_id} v{version}" else: source = f"extension:{ext_id} (unregistered)" - # Manifest-declared entries use the manifest's validated ``id:`` - # for the lookupId's sourceId, so ``lookupId`` joins directly to - # ``ExtensionManifest.iter_contributions()``'s ``id`` even when - # the installed directory (``ext_id``) was renamed. Convention- - # only contributions have no manifest to consult and fall back - # to the directory identity. The directory identity is retained - # separately via ``extension_id`` / ``extension_dir`` for path - # / provenance lookup. - source_id_for_lookup = ext_id - if entry is not None and manifest_id is not None: - source_id_for_lookup = manifest_id layers.append({ "path": candidate, "source": source, "strategy": "replace", "extension_id": ext_id, "extension_dir": ext_dir, - "manifest_declared": entry is not None, - "lookupId": ( - derive_named_id( - "extension", - source_id_for_lookup, - template_type, - template_name, - ) - if entry is not None - else _filesystem_lookup_id( - "extension", source_id_for_lookup - ) - ), }) # Priority 4: Core templates (always "replace") @@ -6070,7 +5948,7 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: if bundled: layers.append({ "path": bundled, - "source": "core", + "source": "core (bundled)", "strategy": "replace", }) @@ -6087,48 +5965,43 @@ 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. - - Command and template directory resolution is delegated to the shared - ``_locate_shared_asset_dir`` resolver. Script lookup preserves the - resolver's pre-existing flat ``.sh`` wheel-or-source behavior. """ - if template_type == "script": - try: - from specify_cli import _locate_core_pack, _repo_root - except ImportError: - return None - - core_pack = _locate_core_pack() - base = ( - core_pack / "scripts" - if core_pack is not None - else _repo_root() / "scripts" - ) - for name in self.name_candidates(template_name): - candidate = base / f"{name}{ext}" - if candidate.exists(): - return candidate - return None - try: - from specify_cli._assets import _locate_shared_asset_dir + from specify_cli import _locate_core_pack, _repo_root except ImportError: return None - if template_type == "template": - base = _locate_shared_asset_dir("templates") - elif template_type == "command": - base = _locate_shared_asset_dir("commands") - else: - base = None - - if base is None: - return None + stem = self._core_stem(template_name) + names = [template_name] + if stem and stem != template_name: + names.append(stem) - for name in self.name_candidates(template_name): - c = base / f"{name}.md" - if c.exists(): - return c + core_pack = _locate_core_pack() + if core_pack is not None: + for name in names: + if template_type == "template": + c = core_pack / "templates" / f"{name}.md" + elif template_type == "command": + c = core_pack / "commands" / f"{name}.md" + elif template_type == "script": + c = core_pack / "scripts" / f"{name}{ext}" + else: + c = core_pack / f"{name}.md" + if c.exists(): + return c + else: + repo_root = _repo_root() + for name in names: + if template_type == "template": + c = repo_root / "templates" / f"{name}.md" + elif template_type == "command": + c = repo_root / "templates" / "commands" / f"{name}.md" + elif template_type == "script": + c = repo_root / "scripts" / f"{name}{ext}" + else: + c = repo_root / f"{name}.md" + if c.exists(): + return c return None def resolve_content( diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index b8642dd337..b7c7874977 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -26,11 +26,9 @@ ArtifactNotFoundError, ArtifactResolutionError, NotASpecKitProjectError, - _derive_manifest_path, _preset_display_name, - _public_layer_shape, ) -from specify_cli.extensions import ExtensionRegistry +from specify_cli.extensions import CORE_COMMAND_NAMES, ExtensionRegistry from specify_cli.presets import PresetRegistry, PresetResolver from tests.conftest import install_preset @@ -99,6 +97,22 @@ 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_every_core_command_is_listed_and_resolvable( + self, spec_kit_project: Path + ): + catalog = ArtifactCatalog(spec_kit_project) + listed = { + row.name for row in catalog.list_artifacts() if row.kind == "command" + } + expected = {f"speckit.{name}" for name in CORE_COMMAND_NAMES} + + assert expected <= listed + for name in expected: + info = catalog.get_artifact_info(f"command:{name}") + assert info["id"] == f"command:{name}" + assert info["kind"] == "command" + assert info["stack"] + @pytest.mark.parametrize( ("requested", "runtime_dir"), [("sh", "bash"), ("ps", "powershell"), ("py", "python")], @@ -230,6 +244,10 @@ def test_unregistered_extension_manifest_id_wins_for_lookup(self, spec_kit_proje "---\ndescription: Manifest identity wins\n---\nbody\n", encoding="utf-8", ) + (ext_dir / "commands" / "speckit.renamed.convention.md").write_text( + "---\ndescription: Convention identity uses directory\n---\nbody\n", + encoding="utf-8", + ) (ext_dir / "extension.yml").write_text( yaml.safe_dump( { @@ -263,15 +281,13 @@ def test_unregistered_extension_manifest_id_wins_for_lookup(self, spec_kit_proje row.id for row in catalog.list_artifacts() } info = catalog.get_artifact_info("speckit.original.hello") - # Manifest-declared entries use ``extension.id`` for the ``lookupId`` - # so the join to ``ExtensionManifest.iter_contributions()`` stays - # direct even when the installed directory (``renamed``) was renamed. + # Artifact projection uses the manifest id without changing the + # resolver's established layer shape. assert info["stack"][0]["lookupId"] == "extension:original:command:speckit.original.hello" - assert ( - PresetResolver(spec_kit_project) - .collect_all_layers("speckit.original.hello", "command")[0]["lookupId"] - == "extension:original:command:speckit.original.hello" - ) + resolver_layer = PresetResolver(spec_kit_project).collect_all_layers( + "speckit.original.hello", "command" + )[0] + assert "lookupId" not in resolver_layer # The stack row's manifestPath must still reflect the actual on-disk # extension directory (``renamed``), not the manifest id embedded in # ``lookupId``. @@ -279,6 +295,14 @@ def test_unregistered_extension_manifest_id_wins_for_lookup(self, spec_kit_proje info["stack"][0]["manifestPath"] == ".specify/extensions/renamed/extension.yml" ) + convention = catalog.get_artifact_info("speckit.renamed.convention")[ + "stack" + ][0] + assert convention["sourceId"] == "renamed" + assert convention["lookupId"] == ( + "extension:renamed:command:speckit.renamed.convention" + ) + assert convention["manifestPath"] is None def test_includes_project_local_core_assets(self, spec_kit_project: Path): templates_dir = spec_kit_project / ".specify" / "templates" @@ -329,14 +353,13 @@ def test_includes_root_level_pack_templates( row for row in catalog.list_artifacts() if row.name == "legacy-root" ).description == "Legacy root template" - def test_extension_registry_missing_collection_key_is_corrupt( + def test_extension_registry_missing_collection_key_uses_existing_normalization( self, spec_kit_project: Path ): registry_path = spec_kit_project / ".specify" / "extensions" / ".registry" registry_path.write_text('{"schema_version": "1.0"}', encoding="utf-8") - with pytest.raises(ArtifactResolutionError): - ArtifactCatalog(spec_kit_project).list_artifacts() + assert ArtifactCatalog(spec_kit_project).list_artifacts() @pytest.mark.skipif(os.name == "nt", reason="':' filenames are unsupported on Windows") def test_skips_invalid_colon_names_in_project_local_inventory(self, spec_kit_project: Path): @@ -515,7 +538,7 @@ def test_builtin_row_shape(self, spec_kit_project: Path): resolver_layer = PresetResolver(spec_kit_project).collect_all_layers( "speckit.constitution", "command" )[-1] - assert resolver_layer["source"] == "core" + assert resolver_layer["source"] == "core (bundled)" assert "lookupId" not in resolver_layer info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") @@ -529,14 +552,6 @@ def test_builtin_row_shape(self, spec_kit_project: Path): assert builtin["lookupId"] is None assert builtin["sourcePath"] is None - def test_public_layer_shape_preserves_non_core_identity(self): - assert _public_layer_shape( - { - "source": "preset:foo v1", - "lookupId": "preset:foo:template:spec-template", - } - ) == ("preset", "foo", "preset:foo:template:spec-template") - def test_project_override_row_shape(self, spec_kit_project: Path): overrides = spec_kit_project / ".specify" / "templates" / "overrides" overrides.mkdir() @@ -1170,6 +1185,51 @@ def test_unregistered_extension_template_without_manifest(self, spec_kit_project assert any(row.id == "template:legacy-template" for row in catalog.list_artifacts()) info = catalog.get_artifact_info("legacy-template") assert info["stack"][0]["lookupId"] == "extension:legacy:template:legacy-template" + assert info["stack"][0]["manifestPath"] is None + + def test_convention_only_extension_does_not_claim_manifest( + self, spec_kit_project: Path + ): + ext_dir = spec_kit_project / ".specify" / "extensions" / "legacy" + (ext_dir / "templates").mkdir(parents=True) + (ext_dir / "templates" / "legacy-template.md").write_text( + "body", encoding="utf-8" + ) + (ext_dir / "commands").mkdir() + (ext_dir / "commands" / "other.md").write_text("body", encoding="utf-8") + (ext_dir / "extension.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "extension": { + "id": "legacy", + "name": "Legacy", + "version": "1.0.0", + "description": "test", + "author": "test", + "repository": "https://example.com", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.2.0"}, + "provides": { + "commands": [ + { + "name": "speckit.legacy.other", + "file": "commands/other.md", + } + ] + }, + } + ), + encoding="utf-8", + ) + + layer = ArtifactCatalog(spec_kit_project).get_artifact_info( + "legacy-template" + )["stack"][0] + + assert layer["lookupId"] == "extension:legacy:template:legacy-template" + assert layer["manifestPath"] is None def test_convention_command_and_script_are_listed(self, spec_kit_project: Path): ext_dir = spec_kit_project / ".specify" / "extensions" / "legacy" @@ -1359,132 +1419,6 @@ 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_preset_manifest_path_is_repo_relative(self, tmp_path: Path): - project_root = tmp_path / "proj" - pack_dir = project_root / ".specify" / "presets" / "my-pack" - pack_dir.mkdir(parents=True) - (pack_dir / "preset.yml").write_text("id: my-pack\n", encoding="utf-8") - - layer = { - "lookupId": "preset:my-pack:template:spec-template", - "path": pack_dir / "spec-template.md", - "preset_id": "my-pack", - "pack_dir": pack_dir, - "manifest_declared": True, - } - assert ( - _derive_manifest_path(layer, project_root) - == ".specify/presets/my-pack/preset.yml" - ) - - def test_extension_manifest_path_is_repo_relative(self, tmp_path: Path): - project_root = tmp_path / "proj" - ext_dir = project_root / ".specify" / "extensions" / "my-ext" - ext_dir.mkdir(parents=True) - (ext_dir / "extension.yml").write_text("id: my-ext\n", encoding="utf-8") - - layer = { - "lookupId": "extension:my-ext:command:speckit.my-ext.go", - "path": ext_dir / "commands" / "speckit.my-ext.go.md", - "extension_id": "my-ext", - "extension_dir": ext_dir, - "manifest_declared": True, - } - assert ( - _derive_manifest_path(layer, project_root) - == ".specify/extensions/my-ext/extension.yml" - ) - - def test_renamed_pack_directory_wins_over_lookup_id_source(self, tmp_path: Path): - """The manifest path must track the on-disk directory, never a stale - directory guessed from ``lookupId``'s manifest-declared ``sourceId``.""" - project_root = tmp_path / "proj" - pack_dir = project_root / ".specify" / "presets" / "renamed-on-disk" - pack_dir.mkdir(parents=True) - (pack_dir / "preset.yml").write_text("id: original-manifest-id\n", encoding="utf-8") - # A stale directory matching the manifest id must not exist, so a - # lookupId-based guess would resolve to a nonexistent manifest. - stale_dir = project_root / ".specify" / "presets" / "original-manifest-id" - assert not stale_dir.exists() - - layer = { - "lookupId": "preset:original-manifest-id:template:spec-template", - "path": pack_dir / "spec-template.md", - "preset_id": "renamed-on-disk", - "pack_dir": pack_dir, - "manifest_declared": True, - } - assert ( - _derive_manifest_path(layer, project_root) - == ".specify/presets/renamed-on-disk/preset.yml" - ) - - def test_missing_manifest_file_is_none(self, tmp_path: Path): - project_root = tmp_path / "proj" - pack_dir = project_root / ".specify" / "presets" / "my-pack" - pack_dir.mkdir(parents=True) - - layer = { - "lookupId": "preset:my-pack:template:spec-template", - "path": pack_dir / "spec-template.md", - "preset_id": "my-pack", - "pack_dir": pack_dir, - "manifest_declared": True, - } - assert _derive_manifest_path(layer, project_root) is None - - def test_missing_provenance_keys_is_none(self, tmp_path: Path): - """Without explicit ``preset_id``/``pack_dir``, no path is guessed from - ``lookupId`` — the caller gets ``None`` instead of a wrong path.""" - project_root = tmp_path / "proj" - pack_dir = project_root / ".specify" / "presets" / "my-pack" - pack_dir.mkdir(parents=True) - (pack_dir / "preset.yml").write_text("id: my-pack\n", encoding="utf-8") - - layer = { - "lookupId": "preset:my-pack:template:spec-template", - "path": pack_dir / "spec-template.md", - "manifest_declared": True, - } - assert _derive_manifest_path(layer, project_root) is None - - def test_builtin_and_project_layers_have_no_manifest(self, tmp_path: Path): - project_root = tmp_path / "proj" - project_root.mkdir() - - builtin_layer = {} - project_layer = {"lookupId": "project:_:template:spec-template"} - assert _derive_manifest_path(builtin_layer, project_root) is None - assert _derive_manifest_path(project_layer, project_root) is None - - def test_convention_only_extension_layer_reports_no_manifest_path( - self, tmp_path: Path - ): - """A contribution the manifest does not declare in ``provides`` — a - "convention-only" contribution — must NOT report the manifest as its - source, even when the manifest file exists on disk. Joining on the - reported path would find no matching contribution. One extension test - covers both the preset and extension branches: ``_derive_manifest_path`` - gates on the layer's ``manifest_declared`` flag before dispatching by - layer kind.""" - project_root = tmp_path / "proj" - ext_dir = project_root / ".specify" / "extensions" / "foo" - ext_dir.mkdir(parents=True) - (ext_dir / "extension.yml").write_text("id: foo\n", encoding="utf-8") - - layer = { - "lookupId": "extension:foo:command:speckit.baz", - "path": ext_dir / "commands" / "baz.md", - "extension_id": "foo", - "extension_dir": ext_dir, - "manifest_declared": False, - } - assert _derive_manifest_path(layer, project_root) is None - - class TestPresetDisplayName: """`_preset_display_name` delegates to the validated `PresetManifest.name`.""" diff --git a/tests/test_artifact_command_parity.py b/tests/test_artifact_command_parity.py index be2ba80d91..00eeb85619 100644 --- a/tests/test_artifact_command_parity.py +++ b/tests/test_artifact_command_parity.py @@ -96,17 +96,15 @@ def test_preset_manifest_id_mismatch_uses_manifest_id(self, spec_kit_project: Pa ) assert winner == "body-from-renamed-preset" - # Manifest-declared entries use the manifest's validated id, so the - # ``lookupId`` joins directly to ``PresetManifest.iter_contributions()`` - # regardless of the installed directory name. + # Artifact projection uses the manifest's validated id regardless of + # the installed directory name. assert active["lookupId"] == ( "preset:original-preset:command:speckit.preset-renamed.hello" ) - assert ( - PresetResolver(spec_kit_project) - .collect_all_layers("speckit.preset-renamed.hello", "command")[0]["lookupId"] - == "preset:original-preset:command:speckit.preset-renamed.hello" - ) + resolver_layer = PresetResolver(spec_kit_project).collect_all_layers( + "speckit.preset-renamed.hello", "command" + )[0] + assert "lookupId" not in resolver_layer # The stack row's presetId / manifestPath must still reflect the # actual on-disk directory (``renamed-preset``), not the manifest id # embedded in ``lookupId`` — otherwise the display and manifest path diff --git a/tests/test_assets.py b/tests/test_assets.py deleted file mode 100644 index da79b81a4c..0000000000 --- a/tests/test_assets.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Tests for the shared bundle-path resolvers in `specify_cli._assets`.""" - -from __future__ import annotations - -import specify_cli._assets as assets - - -class TestLocateSharedAssetDir: - """Tests for the shared wheel-then-source asset directory lookup.""" - - def test_prefers_wheel_core_pack_over_repo_checkout(self, tmp_path, monkeypatch): - package_dir = tmp_path / "site-packages" / "specify_cli" - core_pack = package_dir / "core_pack" - (core_pack / "commands").mkdir(parents=True) - repo_root = tmp_path / "repo" - (repo_root / "templates" / "commands").mkdir(parents=True) - - monkeypatch.setattr(assets, "__file__", str(package_dir / "_assets.py")) - monkeypatch.setattr(assets, "_repo_root", lambda: repo_root) - - assert assets._locate_shared_asset_dir("commands") == core_pack / "commands" - - def test_falls_back_to_repo_checkout_when_no_wheel_bundle(self, tmp_path, monkeypatch): - package_dir = tmp_path / "site-packages" / "specify_cli" - repo_root = tmp_path / "repo" - (repo_root / "templates" / "commands").mkdir(parents=True) - (repo_root / "templates").mkdir(exist_ok=True) - (repo_root / "scripts").mkdir(parents=True, exist_ok=True) - - monkeypatch.setattr(assets, "__file__", str(package_dir / "_assets.py")) - monkeypatch.setattr(assets, "_repo_root", lambda: repo_root) - - assert ( - assets._locate_shared_asset_dir("commands") - == repo_root / "templates" / "commands" - ) - assert assets._locate_shared_asset_dir("templates") == repo_root / "templates" - assert assets._locate_shared_asset_dir("scripts") == repo_root / "scripts" - - def test_returns_none_when_directory_missing(self, tmp_path, monkeypatch): - package_dir = tmp_path / "site-packages" / "specify_cli" - monkeypatch.setattr(assets, "__file__", str(package_dir / "_assets.py")) - monkeypatch.setattr(assets, "_repo_root", lambda: tmp_path / "nonexistent") - - assert assets._locate_shared_asset_dir("commands") is None - - def test_falls_back_to_repo_checkout_when_wheel_bundle_missing_subdir( - self, tmp_path, monkeypatch - ): - """A wheel bundle without the requested family subdir must not short-circuit - the source-checkout fallback, matching the "wheel, then source" pattern - used by ``_locate_bundled_extension``/``_locate_bundled_workflow``/ - ``_locate_bundled_preset``.""" - package_dir = tmp_path / "site-packages" / "specify_cli" - core_pack = package_dir / "core_pack" - core_pack.mkdir(parents=True) # bundle exists but has no "commands/" subdir - repo_root = tmp_path / "repo" - (repo_root / "templates" / "commands").mkdir(parents=True) - - monkeypatch.setattr(assets, "__file__", str(package_dir / "_assets.py")) - monkeypatch.setattr(assets, "_repo_root", lambda: repo_root) - - assert ( - assets._locate_shared_asset_dir("commands") - == repo_root / "templates" / "commands" - ) diff --git a/tests/test_contribution_ids.py b/tests/test_contribution_ids.py deleted file mode 100644 index aae3496a8a..0000000000 --- a/tests/test_contribution_ids.py +++ /dev/null @@ -1,699 +0,0 @@ -"""Tests for the deterministic contribution-id and stack lookup-id feature. - -Every command / template / script / hook contribution surfaced by a preset or -extension manifest exposes a computed ``id`` derived from author-declared data -only, and every layer of a resolved artifact stack exposes a matching -``lookupId``. The scenarios below cover: the identifier grammar across every -``layer x kind`` combination, hook deduplication, cross-process byte-stability, -path/mtime independence, and the additive-only shape guarantee for the -enriched contribution dicts. -""" - -from __future__ import annotations - -import copy -import json -import os -import shutil -import subprocess -import sys -import textwrap -import time -from pathlib import Path - -import pytest -import yaml - -from specify_cli._identifier import ( - IdentifierComponentError, - PROJECT_OVERRIDE_LAYER, - derive_hook_id, - derive_named_id, - derive_public_id, - layer_kind_from_lookup_id, - source_id_from_lookup_id, - validate_component, -) -from specify_cli.extensions import ExtensionManifest, ValidationError -from specify_cli.presets import PresetManifest, PresetResolver - - -# --------------------------------------------------------------------------- -# Fixture builders (programmatic — no on-disk fixture tree) -# --------------------------------------------------------------------------- - - -def _preset_data(pack_id: str = "speckit-core") -> dict: - return { - "schema_version": "1.0", - "preset": { - "id": pack_id, - "name": pack_id, - "version": "1.0.0", - "description": "Fixture preset", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - {"type": "command", "name": "speckit.plan", "file": "commands/plan.md"}, - {"type": "template", "name": "spec-template", "file": "templates/spec.md"}, - {"type": "script", "name": "setup-plan", "file": "scripts/setup-plan.sh"}, - ] - }, - } - - -def _extension_data( - ext_id: str = "speckit-git", - hooks: dict | None = None, - with_commands: bool = True, - with_templates: bool = True, - with_scripts: bool = True, -) -> dict: - data = { - "schema_version": "1.0", - "extension": { - "id": ext_id, - "name": ext_id, - "version": "1.0.0", - "description": "Fixture extension", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": {}, - } - if with_commands: - data["provides"]["commands"] = [ - { - "name": f"speckit.{ext_id.replace('-', '')}.branch", - "file": "commands/branch.md", - "description": "Fixture command", - } - ] - if with_templates: - data["provides"]["templates"] = [ - {"name": "pr-body", "file": "templates/pr-body.md"} - ] - if with_scripts: - data["provides"]["scripts"] = [ - {"name": "post-commit", "file": "scripts/post-commit.sh"} - ] - if hooks is not None: - data["hooks"] = hooks - return data - - -def _write_manifest(tmp_path: Path, data: dict, filename: str) -> Path: - manifest_path = tmp_path / filename - with open(manifest_path, "w", encoding="utf-8") as fh: - yaml.safe_dump(data, fh, sort_keys=False) - return manifest_path - - -# --------------------------------------------------------------------------- -# Identifier grammar — layer x kind derivation matrix -# --------------------------------------------------------------------------- - - -class TestIdentifierDerivation: - """Every layer x kind combination produces the expected grammar.""" - - @pytest.mark.parametrize( - "layer, source_id, kind, name, expected", - [ - ("project", "_", "command", "speckit.constitution", "project:_:command:speckit.constitution"), - ("project", "_", "template", "spec-template", "project:_:template:spec-template"), - ("project", "_", "script", "setup-plan", "project:_:script:setup-plan"), - ("preset", "speckit-core", "command", "speckit.plan", "preset:speckit-core:command:speckit.plan"), - ("preset", "speckit-core", "template", "spec-template", "preset:speckit-core:template:spec-template"), - ("preset", "speckit-core", "script", "setup-plan", "preset:speckit-core:script:setup-plan"), - ("extension", "speckit-git", "command", "speckit.git.branch", "extension:speckit-git:command:speckit.git.branch"), - ("extension", "speckit-git", "template", "pr-body", "extension:speckit-git:template:pr-body"), - ("extension", "speckit-git", "script", "post-commit", "extension:speckit-git:script:post-commit"), - ], - ) - def test_named_id_grammar(self, layer, source_id, kind, name, expected): - assert derive_named_id(layer, source_id, kind, name) == expected - - @pytest.mark.parametrize( - "layer, source_id, event, command, expected", - [ - ("preset", "speckit-core", "before_plan", "speckit.plan", "preset:speckit-core:hook:before_plan:speckit.plan"), - ("extension", "speckit-git", "before_specify", "speckit.git.branch", "extension:speckit-git:hook:before_specify:speckit.git.branch"), - ], - ) - def test_hook_id_no_discriminator(self, layer, source_id, event, command, expected): - assert derive_hook_id(layer, source_id, event, command) == expected - - def test_named_id_stable_across_two_derivations(self): - a = derive_named_id("preset", "speckit-core", "command", "speckit.plan") - b = derive_named_id("preset", "speckit-core", "command", "speckit.plan") - assert a == b - - def test_public_id_is_source_agnostic(self): - assert derive_public_id("command", "speckit.plan") == "command:speckit.plan" - - @pytest.mark.parametrize( - "args", - [ - ("preset", "speckit-core", "hook", "before_plan:speckit.plan"), - ("unknown", "source", "command", "speckit.plan"), - ("core", "_", "command", "speckit.plan"), - ], - ) - def test_named_id_rejects_invalid_layer_or_kind(self, args): - with pytest.raises(IdentifierComponentError): - derive_named_id(*args) - - def test_public_id_rejects_non_artifact_kind(self): - with pytest.raises(IdentifierComponentError): - derive_public_id("hook", "before_plan:speckit.plan") - - @pytest.mark.parametrize("layer", [PROJECT_OVERRIDE_LAYER, "core"]) - def test_hook_id_rejects_non_manifest_layer(self, layer): - with pytest.raises(IdentifierComponentError): - derive_hook_id(layer, "_", "before_plan", "speckit.plan") - - -class TestLayerKindFromLookupId: - """``layer_kind_from_lookup_id`` extracts the layer segment of a lookupId.""" - - @pytest.mark.parametrize( - "lookup_id, expected", - [ - ("preset:speckit-core:template:spec-template", "preset"), - ("extension:speckit-git:script:post-commit", "extension"), - (f"{PROJECT_OVERRIDE_LAYER}:_:template:spec-template", PROJECT_OVERRIDE_LAYER), - ( - "extension:speckit-git:hook:before_specify:speckit.git.branch", - "extension", - ), - ], - ) - def test_recognized_layer_prefixes(self, lookup_id, expected): - assert layer_kind_from_lookup_id(lookup_id) == expected - - @pytest.mark.parametrize( - "lookup_id", - [ - "", - "bogus:_:command:speckit.plan", - "core:_:command:speckit.plan", - "core", - ":_:command:speckit.plan", - "core:not-an-id", - "preset:x", - "core:_:command", - "extension:speckit-git:hook:before_specify", - "core::command:speckit.plan", - "core:_:bogus:speckit.plan", - "project:_:hook:some-event:some-command", - ], - ) - def test_unrecognized_or_malformed_returns_none(self, lookup_id): - assert layer_kind_from_lookup_id(lookup_id) is None - - -class TestHookContributions: - def test_duplicate_commands_are_last_wins_and_move_to_end(self, tmp_path): - data = _extension_data( - hooks={ - "before_plan": [ - {"command": "speckit.speckitgit.branch", "priority": 10}, - {"command": "speckit.speckitgit.status", "priority": 20}, - {"command": "speckit.speckitgit.branch", "priority": 30}, - ] - } - ) - manifest = ExtensionManifest(_write_manifest(tmp_path, data, "extension.yml")) - hooks = [c for c in manifest.iter_contributions() if c["kind"] == "hook"] - assert len(hooks) == 2 - assert [(hook["command"], hook["priority"]) for hook in hooks] == [ - ("speckit.speckitgit.status", 20), - ("speckit.speckitgit.branch", 30), - ] - assert hooks[-1]["id"] == ( - "extension:speckit-git:hook:before_plan:speckit.speckitgit.branch" - ) - assert manifest.contribution_id( - "hook", "before_plan:speckit.speckitgit.branch" - ) == hooks[-1]["id"] - - -# --------------------------------------------------------------------------- -# Manifest component `:` guard -# --------------------------------------------------------------------------- - - -class TestComponentGuard: - def test_validate_component_rejects_colon(self): - with pytest.raises(IdentifierComponentError) as exc_info: - validate_component("has:colon", "test field") - assert "':' is reserved" in str(exc_info.value) - - def test_validate_component_rejects_empty(self): - with pytest.raises(IdentifierComponentError): - validate_component("", "test field") - - def test_validate_component_rejects_non_string(self): - with pytest.raises(IdentifierComponentError): - validate_component(42, "test field") - - def test_extension_hook_event_name_with_colon_rejected(self, tmp_path): - data = _extension_data( - hooks={"before:plan": {"command": "speckit.speckitgit.branch"}} - ) - with pytest.raises(ValidationError) as exc_info: - ExtensionManifest(_write_manifest(tmp_path, data, "extension.yml")) - assert "':' is reserved" in str(exc_info.value) - - def test_extension_hook_command_with_colon_rejected(self, tmp_path): - data = _extension_data( - hooks={"before_plan": {"command": "speckit:bad:command"}} - ) - with pytest.raises(ValidationError) as exc_info: - ExtensionManifest(_write_manifest(tmp_path, data, "extension.yml")) - assert "':' is reserved" in str(exc_info.value) - - -# --------------------------------------------------------------------------- -# `iter_contributions` output surface -# --------------------------------------------------------------------------- - - -class TestContributionSurface: - def test_preset_iter_contributions_matrix(self, tmp_path): - manifest = PresetManifest(_write_manifest(tmp_path, _preset_data(), "preset.yml")) - entries = manifest.iter_contributions() - by_kind = {e["kind"]: e for e in entries} - assert by_kind["command"]["id"] == "preset:speckit-core:command:speckit.plan" - assert by_kind["template"]["id"] == "preset:speckit-core:template:spec-template" - assert by_kind["script"]["id"] == "preset:speckit-core:script:setup-plan" - for entry in entries: - assert entry["layer"] == "preset" - assert entry["sourceId"] == "speckit-core" - - def test_extension_iter_contributions_matrix(self, tmp_path): - data = _extension_data( - hooks={"before_specify": {"command": "speckit.speckitgit.branch"}} - ) - manifest = ExtensionManifest(_write_manifest(tmp_path, data, "extension.yml")) - entries = manifest.iter_contributions() - kinds = {e["kind"]: e for e in entries} - assert kinds["command"]["id"] == "extension:speckit-git:command:speckit.speckitgit.branch" - assert kinds["template"]["id"] == "extension:speckit-git:template:pr-body" - assert kinds["script"]["id"] == "extension:speckit-git:script:post-commit" - assert kinds["hook"]["id"] == "extension:speckit-git:hook:before_specify:speckit.speckitgit.branch" - assert kinds["hook"]["name"] == "before_specify:speckit.speckitgit.branch" - - def test_contribution_id_lookup(self, tmp_path): - manifest = PresetManifest(_write_manifest(tmp_path, _preset_data(), "preset.yml")) - assert ( - manifest.contribution_id("command", "speckit.plan") - == "preset:speckit-core:command:speckit.plan" - ) - assert manifest.contribution_id("command", "does-not-exist") is None - - def test_representation_shape_is_additive_for_preset(self, tmp_path): - original = _preset_data() - manifest = PresetManifest(_write_manifest(tmp_path, original, "preset.yml")) - derived_keys = {"layer", "sourceId", "kind", "id"} - for src_entry, out_entry in zip(original["provides"]["templates"], manifest.iter_contributions()): - assert set(src_entry.keys()).issubset(out_entry.keys()) - assert derived_keys.issubset(out_entry.keys()) - - def test_representation_shape_is_additive_for_extension(self, tmp_path): - original = _extension_data( - hooks={"before_specify": {"command": "speckit.speckitgit.branch"}} - ) - manifest = ExtensionManifest(_write_manifest(tmp_path, original, "extension.yml")) - entries = manifest.iter_contributions() - derived_named = {"layer", "sourceId", "kind", "id"} - - cmd_entry = original["provides"]["commands"][0] - cmd_out = next(e for e in entries if e["kind"] == "command") - assert set(cmd_entry.keys()).issubset(cmd_out.keys()) - assert derived_named.issubset(cmd_out.keys()) - - hook_entry = original["hooks"]["before_specify"] - hook_out = next(e for e in entries if e["kind"] == "hook") - assert set(hook_entry.keys()).issubset(hook_out.keys()) - assert derived_named.issubset(hook_out.keys()) - assert hook_out["name"] == "before_specify:speckit.speckitgit.branch" - - def test_underlying_data_not_mutated(self, tmp_path): - original = _preset_data() - original_snapshot = copy.deepcopy(original) - manifest = PresetManifest(_write_manifest(tmp_path, original, "preset.yml")) - _ = manifest.iter_contributions() - assert manifest.data == original_snapshot - - -# --------------------------------------------------------------------------- -# `lookupId` round-trip through the resolver -# --------------------------------------------------------------------------- - - -def _make_project(root: Path) -> Path: - """Create a minimal project layout the resolver understands.""" - (root / ".specify" / "presets").mkdir(parents=True) - (root / ".specify" / "extensions").mkdir(parents=True) - (root / ".specify" / "memory").mkdir(parents=True) - (root / "templates" / "commands").mkdir(parents=True) - (root / "templates" / "scripts").mkdir(parents=True) - return root - - -class TestLookupIdRoundTrip: - def test_project_override_layer_carries_sentinel_lookup_id(self, tmp_path): - project = _make_project(tmp_path) - overrides_dir = project / ".specify" / "templates" / "overrides" - overrides_dir.mkdir(parents=True) - (overrides_dir / "spec-template.md").write_text("override", encoding="utf-8") - resolver = PresetResolver(project) - layers = resolver.collect_all_layers("spec-template", "template") - override_layer = next( - layer for layer in layers if layer["source"] == "project override" - ) - assert override_layer["lookupId"] == derive_named_id( - PROJECT_OVERRIDE_LAYER, "_", "template", "spec-template" - ) - - @pytest.mark.skipif(os.name == "nt", reason="':' filenames are unsupported on Windows") - @pytest.mark.parametrize("layer_kind", ["project", "preset", "extension"]) - def test_legacy_colon_name_preserves_resolution_without_lookup_id( - self, tmp_path, layer_kind - ): - project = _make_project(tmp_path) - name = "legacy:name" - - if layer_kind == "project": - candidate = ( - project / ".specify" / "templates" / "overrides" / f"{name}.md" - ) - candidate.parent.mkdir(parents=True) - else: - pack_id = f"legacy-{layer_kind}" - candidate = ( - project - / ".specify" - / f"{layer_kind}s" - / pack_id - / "templates" - / f"{name}.md" - ) - candidate.parent.mkdir(parents=True) - _write_registry(project, f"{layer_kind}s", pack_id) - candidate.write_text("legacy", encoding="utf-8") - - resolver = PresetResolver(project) - assert resolver.resolve(name, "template") == candidate - layer = next( - item - for item in resolver.collect_all_layers(name, "template") - if item["path"] == candidate - ) - assert layer["lookupId"] is None - - def test_builtin_layer_preserves_resolver_provenance(self, tmp_path): - project = _make_project(tmp_path) - (project / "templates" / "spec-template.md").write_text("core", encoding="utf-8") - # PresetResolver reads templates from a bundled/repo path — point the - # resolver at the fixture project by monkey-patching the templates_dir. - resolver = PresetResolver(project) - resolver.templates_dir = project / "templates" - layers = resolver.collect_all_layers("spec-template", "template") - builtin_layer = next(layer for layer in layers if layer["source"] == "core") - assert "lookupId" not in builtin_layer - - def test_preset_layer_lookup_id_matches_manifest_contribution_id(self, tmp_path): - project = _make_project(tmp_path) - pack_id = "speckit-fixture" - pack_dir = project / ".specify" / "presets" / pack_id - (pack_dir / "templates").mkdir(parents=True) - (pack_dir / "templates" / "spec-template.md").write_text("preset", encoding="utf-8") - _write_manifest( - pack_dir, - { - "schema_version": "1.0", - "preset": { - "id": pack_id, - "name": pack_id, - "version": "1.0.0", - "description": "Fixture", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - } - ] - }, - }, - "preset.yml", - ) - registry = { - "schema_version": "1.0", - "presets": { - pack_id: {"version": "1.0.0", "priority": 10, "enabled": True} - }, - } - (project / ".specify" / "presets" / ".registry").write_text( - json.dumps(registry), encoding="utf-8" - ) - resolver = PresetResolver(project) - layers = resolver.collect_all_layers("spec-template", "template") - preset_layer = next( - layer for layer in layers if layer["source"].startswith(pack_id) - ) - manifest = PresetManifest(pack_dir / "preset.yml") - assert preset_layer["lookupId"] == manifest.contribution_id("template", "spec-template") - assert preset_layer["lookupId"] == f"preset:{pack_id}:template:spec-template" - - -# --------------------------------------------------------------------------- -# Determinism across environments -# --------------------------------------------------------------------------- - - -_SUBPROCESS_SCRIPT = textwrap.dedent( - """ - import sys, json - from specify_cli.extensions import ExtensionManifest - manifest = ExtensionManifest(sys.argv[1]) - ids = [c["id"] for c in manifest.iter_contributions()] - sys.stdout.write(json.dumps(ids)) - """ -) - - -class TestDeterminism: - def _fixture_manifest(self, tmp_path: Path) -> Path: - data = _extension_data( - hooks={ - "before_specify": {"command": "speckit.speckitgit.branch"}, - "before_plan": [ - {"command": "speckit.speckitgit.branch", "priority": 10}, - {"command": "speckit.speckitgit.branch", "priority": 20}, - ], - } - ) - return _write_manifest(tmp_path, data, "extension.yml") - - def test_identifiers_match_across_subprocesses(self, tmp_path): - manifest_path = self._fixture_manifest(tmp_path) - env = os.environ.copy() - env["PYTHONPATH"] = os.pathsep.join( - [str(Path(__file__).resolve().parent.parent / "src"), env.get("PYTHONPATH", "")] - ) - - def _run() -> str: - proc = subprocess.run( - [sys.executable, "-c", _SUBPROCESS_SCRIPT, str(manifest_path)], - capture_output=True, - text=True, - env=env, - check=True, - ) - return proc.stdout - - assert _run() == _run() - - def test_ids_independent_of_paths_and_mtimes(self, tmp_path): - original_dir = tmp_path / "orig" - copied_dir = tmp_path / "copy" - original_dir.mkdir() - manifest_path = self._fixture_manifest(original_dir) - original_ids = [c["id"] for c in ExtensionManifest(manifest_path).iter_contributions()] - - shutil.copytree(original_dir, copied_dir) - distant_past = time.time() - 3600 - os.utime(copied_dir / manifest_path.name, (distant_past, distant_past)) - copied_ids = [ - c["id"] for c in ExtensionManifest(copied_dir / manifest_path.name).iter_contributions() - ] - assert original_ids == copied_ids - - -# --------------------------------------------------------------------------- -# Identifiers never persisted -# --------------------------------------------------------------------------- - - -class TestNoPersistence: - def test_no_id_written_to_manifest_files(self, tmp_path): - data = _extension_data( - hooks={"before_specify": {"command": "speckit.speckitgit.branch"}} - ) - manifest_path = _write_manifest(tmp_path, data, "extension.yml") - # Read identifiers to force the derivation code path. - manifest = ExtensionManifest(manifest_path) - ids = [c["id"] for c in manifest.iter_contributions()] - assert ids # sanity check — feature actually ran - on_disk = manifest_path.read_text(encoding="utf-8") - assert ":command:" not in on_disk - assert ":template:" not in on_disk - assert ":script:" not in on_disk - assert ":hook:" not in on_disk - - def test_no_id_written_to_preset_manifest_files(self, tmp_path): - preset_path = _write_manifest(tmp_path, _preset_data(), "preset.yml") - manifest = PresetManifest(preset_path) - _ = [c["id"] for c in manifest.iter_contributions()] - on_disk = preset_path.read_text(encoding="utf-8") - assert ":command:" not in on_disk - assert ":template:" not in on_disk - assert ":script:" not in on_disk - - -# --------------------------------------------------------------------------- -# `_identifier.py` review-round nits — sourceId accessor + derive_named_id -# sentinel enforcement. Consumers must not ``.split(":")`` a lookupId -# themselves, and the ``project``/``_`` pairing is enforced at the single -# derivation boundary rather than at each caller. -# --------------------------------------------------------------------------- - - -class TestSourceIdFromLookupId: - @pytest.mark.parametrize( - "lookup_id, expected", - [ - ("preset:speckit-core:command:speckit.plan", "speckit-core"), - ( - "extension:speckit-git:hook:before_specify:speckit.git.branch", - "speckit-git", - ), - ("", None), - ("preset:foo", None), - ("unknown:foo:command:bar", None), - ("project:_:hook:evt:cmd", None), - ("project:foo:command:bar", None), - ("preset:_:command:bar", None), - ("extension:_:hook:before_plan:speckit.plan", None), - ], - ) - def test_extracts_source_id_or_none(self, lookup_id, expected): - assert source_id_from_lookup_id(lookup_id) == expected - - -class TestDeriveNamedIdSentinel: - @pytest.mark.parametrize( - "layer, source_id", - [ - (PROJECT_OVERRIDE_LAYER, "other"), - ("preset", "_"), - ("extension", "_"), - ], - ) - def test_rejects_invalid_layer_source_pairs(self, layer, source_id): - with pytest.raises(IdentifierComponentError): - derive_named_id(layer, source_id, "command", "n") - - def test_project_layer_accepts_underscore_source(self): - assert ( - derive_named_id(PROJECT_OVERRIDE_LAYER, "_", "command", "n") - == f"{PROJECT_OVERRIDE_LAYER}:_:command:n" - ) - - def test_hook_rejects_project_source_sentinel(self): - with pytest.raises(IdentifierComponentError): - derive_hook_id("extension", "_", "before_plan", "speckit.plan") - - -# --------------------------------------------------------------------------- -# Manifest-declared id wins over installed-directory name — one preset test -# and one extension test because the two branches of ``collect_all_layers`` -# could diverge independently. Each proves ``lookupId`` on the resolved layer -# equals the manifest contribution ``id``. -# --------------------------------------------------------------------------- - - -def _write_registry(project: Path, tier: str, pack_id: str) -> None: - registry = { - "schema_version": "1.0", - tier: {pack_id: {"version": "1.0.0", "priority": 10, "enabled": True}}, - } - (project / ".specify" / tier / ".registry").write_text( - json.dumps(registry), encoding="utf-8" - ) - - -class TestManifestIdWinsOverDirectoryName: - def test_preset_lookup_id_uses_manifest_id_when_directory_renamed(self, tmp_path): - project = _make_project(tmp_path) - dir_name, manifest_id = "renamed-preset", "original-preset" - pack_dir = project / ".specify" / "presets" / dir_name - (pack_dir / "templates").mkdir(parents=True) - (pack_dir / "templates" / "spec-template.md").write_text("p", encoding="utf-8") - data = _preset_data(manifest_id) - data["provides"] = { - "templates": [ - {"type": "template", "name": "spec-template", "file": "templates/spec-template.md"} - ] - } - _write_manifest(pack_dir, data, "preset.yml") - _write_registry(project, "presets", dir_name) - - layers = PresetResolver(project).collect_all_layers("spec-template", "template") - layer = next(L for L in layers if L["source"].startswith(dir_name)) - manifest = PresetManifest(pack_dir / "preset.yml") - assert layer["lookupId"] == manifest.contribution_id("template", "spec-template") - assert layer["lookupId"] == f"preset:{manifest_id}:template:spec-template" - - def test_extension_lookup_id_uses_manifest_id_when_directory_renamed( - self, tmp_path, monkeypatch - ): - project = _make_project(tmp_path) - dir_name, manifest_id = "renamed-ext", "original-ext" - # Extension commands are auto-namespaced under speckit. - namespaced = f"speckit.{manifest_id}.branch" - ext_dir = project / ".specify" / "extensions" / dir_name - (ext_dir / "commands").mkdir(parents=True) - (ext_dir / "commands" / "branch.md").write_text("e", encoding="utf-8") - data = _extension_data(manifest_id, with_templates=False, with_scripts=False) - data["provides"]["commands"] = [ - {"name": "speckit.branch", "file": "commands/branch.md", "description": "F"} - ] - manifest_path = _write_manifest(ext_dir, data, "extension.yml") - _write_registry(project, "extensions", dir_name) - - manifest = ExtensionManifest(manifest_path) - read_count = 0 - - def read_manifest_once(path): - nonlocal read_count - read_count += 1 - if read_count > 1: - raise OSError("simulated transient second-read failure") - return ExtensionManifest(path) - - monkeypatch.setattr( - "specify_cli.extensions.ExtensionManifest", read_manifest_once - ) - layers = PresetResolver(project).collect_all_layers(namespaced, "command") - layer = next(L for L in layers if L.get("extension_id") == dir_name) - assert read_count == 1 - assert layer["lookupId"] == manifest.contribution_id("command", namespaced) - assert layer["lookupId"] == f"extension:{manifest_id}:command:{namespaced}" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 32a99f7637..d41c4cbbe0 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -276,10 +276,9 @@ 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 the shared ``_locate_shared_asset_dir`` resolver - at a temp tree with *different* command names: the old off-by-one path - math read nothing and returned the baked-in fallback; the fixed loader - returns the temp stems. + from 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 specify_cli.extensions import ( _load_core_command_names, @@ -295,17 +294,34 @@ 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_shared_asset_dir", - lambda subdir: commands if subdir == "commands" else None, - ) + monkeypatch.setattr(ext, "_locate_core_pack", lambda: None) + monkeypatch.setattr(ext, "_repo_root", lambda: Path(tmp)) result = _load_core_command_names() assert result == {"widget", "gadget"} assert result != _FALLBACK_CORE_COMMAND_NAMES + def test_load_core_command_names_prefers_wheel_core_pack(self, monkeypatch): + """When a wheel ``core_pack`` bundle exists, discovery reads + ``core_pack/commands`` (the force-include target) ahead of the source + tree (#3274).""" + from specify_cli.extensions import _load_core_command_names + import specify_cli.extensions as ext + + with tempfile.TemporaryDirectory() as tmp: + core_pack = Path(tmp) / "core_pack" + (core_pack / "commands").mkdir(parents=True) + (core_pack / "commands" / "sprocket.md").write_text("# sprocket", encoding="utf-8") + + monkeypatch.setattr(ext, "_locate_core_pack", lambda: core_pack) + # Source fallback should be ignored while the bundle resolves. + monkeypatch.setattr(ext, "_repo_root", lambda: Path(tmp) / "nonexistent") + + result = _load_core_command_names() + + assert result == {"sprocket"} + def test_load_core_command_names_falls_back_when_nothing_found(self, monkeypatch): """With neither a bundle nor a source tree, discovery returns the baked-in fallback so validation still works (#3274).""" @@ -315,9 +331,11 @@ def test_load_core_command_names_falls_back_when_nothing_found(self, monkeypatch ) import specify_cli.extensions as ext - monkeypatch.setattr(ext, "_locate_shared_asset_dir", lambda subdir: None) + with tempfile.TemporaryDirectory() as tmp: + monkeypatch.setattr(ext, "_locate_core_pack", lambda: None) + monkeypatch.setattr(ext, "_repo_root", lambda: Path(tmp) / "nonexistent") - 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.""" @@ -944,30 +962,6 @@ def test_hook_list_command_refs_normalized(self, temp_dir, valid_manifest_data): lifted = [w for w in manifest.warnings if "updated to canonical form" in w] assert len(lifted) == 2 - def test_duplicate_hook_entries_allowed_after_command_normalization( - self, - temp_dir, - valid_manifest_data, - ): - """Equivalent hook entries are accepted after command refs canonicalize.""" - import yaml - - valid_manifest_data["provides"]["commands"][0]["name"] = "speckit.hello" - valid_manifest_data["hooks"]["after_tasks"] = [ - {"command": "speckit.hello", "optional": True}, - {"command": "speckit.test-ext.hello", "optional": True}, - ] - - manifest_path = temp_dir / "extension.yml" - with open(manifest_path, 'w', encoding="utf-8") as f: - yaml.dump(valid_manifest_data, f) - - manifest = ExtensionManifest(manifest_path) - assert [entry["command"] for entry in manifest.hooks["after_tasks"]] == [ - "speckit.test-ext.hello", - "speckit.test-ext.hello", - ] - def test_hook_empty_list_rejected(self, temp_dir, valid_manifest_data): """An empty list for a hook event is rejected rather than silently registering nothing.""" @@ -982,6 +976,23 @@ def test_hook_empty_list_rejected(self, temp_dir, valid_manifest_data): with pytest.raises(ValidationError, match="must contain at least one entry"): ExtensionManifest(manifest_path) + def test_hook_colons_remain_accepted(self, temp_dir, valid_manifest_data): + """Artifact identifiers must not narrow the existing hook manifest contract.""" + import yaml + + valid_manifest_data["hooks"] = { + "custom:after": {"command": "/skill:speckit-test-ext-hello"} + } + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + assert manifest.hooks["custom:after"]["command"] == ( + "/skill:speckit-test-ext-hello" + ) + def test_hook_priority_field_validation(self, temp_dir, valid_manifest_data): """Hook entry ``priority`` must be a positive integer when provided.""" import yaml diff --git a/tests/test_presets.py b/tests/test_presets.py index 176d7f010f..57a70b4192 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -2294,8 +2294,7 @@ def test_collect_all_layers_finds_bundled_core_without_specify_commands( resolver = PresetResolver(project_dir) layers = resolver.collect_all_layers("speckit.implement", "command") assert layers, "expected a bundled core base layer to be found" - assert layers[-1]["source"] == "core" - assert "lookupId" not in layers[-1] + assert layers[-1]["source"] == "core (bundled)" assert layers[-1]["path"].parts[-2:] == ("commands", "implement.md") def test_resolve_command_falls_back_to_bundled_core(self, project_dir): @@ -12603,22 +12602,6 @@ def test_extension_template_convention_lookup_unaffected_when_undeclared(self, p assert layers, "expected convention-based lookup to still find the template" assert layers[0]["path"] == tmpl_dir / "legacy-template.md" - @pytest.mark.parametrize("pack_kind", ["preset", "extension"]) - def test_root_readme_preserves_convention_resolution(self, project_dir, pack_kind): - pack_dir = project_dir / ".specify" / f"{pack_kind}s" / "legacy" - pack_dir.mkdir(parents=True) - (pack_dir / "README.md").write_text("packaging notes\n") - if pack_kind == "preset": - PresetRegistry(pack_dir.parent).add( - "legacy", {"priority": 10, "version": "1.0.0"} - ) - - resolver = PresetResolver(project_dir) - - assert resolver.resolve("README", "template") == pack_dir / "README.md" - layers = resolver.collect_all_layers("README", "template") - assert layers[0]["path"] == pack_dir / "README.md" - def test_extension_manifest_wins_over_stale_conventional_file(self, project_dir): """A declared entry is authoritative even when a stale file also sits at the conventional path (templates/.md) — the manifest must win, @@ -13518,7 +13501,6 @@ def test_single_core_layer(self, project_dir): layers = resolver.collect_all_layers("spec-template") assert len(layers) == 1 assert layers[0]["source"] == "core" - assert "lookupId" not in layers[0] assert layers[0]["strategy"] == "replace" def test_layers_include_presets(self, project_dir, temp_dir, valid_pack_data): @@ -13970,10 +13952,7 @@ def test_seeds_from_core_when_no_preset(self, project_dir): memory = project_dir / ".specify" / "memory" / "constitution.md" assert memory.exists() assert "[PROJECT_NAME]" in memory.read_text() - provenance = json.loads( - (memory.parent / ".constitution-template.json").read_text() - ) - assert provenance["source"] == "core" + assert (memory.parent / ".constitution-template.json").exists() def test_seeds_from_preset_when_installed(self, project_dir): from specify_cli.commands.init import ensure_constitution_from_template @@ -14420,9 +14399,7 @@ def test_resolve_accepts_dotted_command_name(self, project_dir): ) assert result.exit_code == 0, (result.output, result.exception) - output = " ".join(strip_ansi(result.output).split()) - assert "constitution.md" in output - assert "top layer from: core" in output + assert "constitution.md" in "".join(strip_ansi(result.output).split()) def test_resolve_rejects_empty_command_segments(self, project_dir): """Dotted command identifiers cannot contain empty path-like segments.""" @@ -14655,7 +14632,7 @@ def test_wrap_composes_over_core_constitution(self, project_dir): assert len(layers) >= 2, "expected preset wrap layer plus a core base" assert layers[0]["strategy"] == "wrap" assert any("constitution-sync" in str(layer["path"]) for layer in layers) - assert layers[-1]["source"] == "core" + assert layers[-1]["source"] == "core (bundled)" def test_resolved_content_embeds_core_and_sync_pass(self, project_dir): """resolve_content substitutes {CORE_TEMPLATE} so the effective command From 7d23dfb3912a738ce08ff13b3edfb2f035347fd7 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 11 Sep 2026 12:38:24 -0500 Subject: [PATCH 108/113] refactor: split artifact catalog modules Separate artifact models, catalog inventory, and resolver stack projection while preserving the existing package API and command behavior. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- src/specify_cli/artifacts/__init__.py | 1381 +---------------------- src/specify_cli/artifacts/catalog.py | 762 +++++++++++++ src/specify_cli/artifacts/models.py | 110 ++ src/specify_cli/artifacts/resolution.py | 515 +++++++++ tests/test_artifact_command.py | 4 +- 5 files changed, 1403 insertions(+), 1369 deletions(-) create mode 100644 src/specify_cli/artifacts/catalog.py create mode 100644 src/specify_cli/artifacts/models.py create mode 100644 src/specify_cli/artifacts/resolution.py diff --git a/src/specify_cli/artifacts/__init__.py b/src/specify_cli/artifacts/__init__.py index e1d8a56f5f..778b17a1e8 100644 --- a/src/specify_cli/artifacts/__init__.py +++ b/src/specify_cli/artifacts/__init__.py @@ -1,1372 +1,19 @@ -"""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 -import shlex -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Iterable, Literal - -import yaml - -from ._identifiers import ( - PROJECT_OVERRIDE_LAYER, - IdentifierComponentError, - derive_lookup_id, - derive_public_id, - is_dotted_command_name, - validate_component, +"""Public API for artifact inventory and resolution.""" + +from .catalog import ArtifactCatalog +from .models import ( + AmbiguousArtifactError, + Artifact, + ArtifactError, + ArtifactKind, + ArtifactNotFoundError, + ArtifactResolutionError, + LayerName, + NotASpecKitProjectError, + StackLayer, + Strategy, ) -# --------------------------------------------------------------------------- -# Public data classes -# --------------------------------------------------------------------------- - -ArtifactKind = Literal["command", "template", "script"] -LayerName = Literal["project", "preset", "extension"] -Strategy = Literal["replace", "wrap", "prepend", "append"] - - -@dataclass(frozen=True) -class Artifact: - """One row in the flat inventory returned by ``list_artifacts()``.""" - - id: str - name: str - kind: ArtifactKind - description: str - - def to_json_dict(self) -> dict[str, Any]: - return { - "id": self.id, - "name": self.name, - "kind": self.kind, - "description": self.description, - } - - -@dataclass(frozen=True) -class StackLayer: - """One row inside the ``stack`` array returned by ``get_artifact_info()``. - - ``id`` is the source-agnostic round-trip key (``f"{kind}:{name}"``) for the - artifact this stack row belongs to — every row in a given stack carries - the same ``id``, matching the top-level ``id`` on the ``info`` payload and - the corresponding row's ``id`` on ``artifact list``. It is populated for - every row, including built-in-tier rows that have no ``lookupId``. - ``lookupId`` is separate, manifest-backed layer provenance: it is only - present when the row has a specific preset/extension/project-override - layer to point at, and is ``None`` for the built-in tier. - """ - - id: str - layer: LayerName | None - sourceId: str | None - presetId: str | None - presetName: str | None - strategy: Strategy - active: bool - hidden: bool - manifestPath: str | None - lookupId: str | None - sourcePath: str | None - - def to_json_dict(self) -> dict[str, Any]: - return { - "id": self.id, - "layer": self.layer, - "sourceId": self.sourceId, - "presetId": self.presetId, - "presetName": self.presetName, - "strategy": self.strategy, - "active": self.active, - "hidden": self.hidden, - "manifestPath": self.manifestPath, - "lookupId": self.lookupId, - "sourcePath": self.sourcePath, - } - - -# --------------------------------------------------------------------------- -# Exceptions — pinned error strings (see artifact-error contract regex) -# --------------------------------------------------------------------------- - - -class ArtifactError(Exception): - """Base class for the three logical error conditions this module raises. - - Each subclass carries a ``.message`` attribute whose value is the exact - string emitted to stderr under the ``error`` key of the JSON envelope. - The contract regex is ``^(unknown artifact |ambiguous artifact |artifact resolution failed|not a Spec Kit project)``. - """ - - message: str - - -class ArtifactNotFoundError(ArtifactError): - def __init__(self, name: str) -> None: - self.message = f"unknown artifact {name}" - super().__init__(self.message) - - -class AmbiguousArtifactError(ArtifactError): - def __init__(self, name: str, kinds: Iterable[str]) -> None: - kinds_list = sorted(kinds) - self.message = f"ambiguous artifact {name}: matches kinds {kinds_list}" - super().__init__(self.message) - - -class NotASpecKitProjectError(ArtifactError): - def __init__(self) -> None: - self.message = "not a Spec Kit project: no .specify/ directory found" - super().__init__(self.message) - - -class ArtifactResolutionError(ArtifactError): - def __init__(self) -> None: - self.message = "artifact resolution failed" - super().__init__(self.message) - - -_TEMPLATE_SUFFIX = ".md" -_SCRIPT_SUFFIX = ".sh" - - -def _locate_shared_asset_dir(subdir: str) -> Path | None: - """Locate a core asset directory without changing shared asset behavior.""" - if subdir not in {"commands", "scripts", "templates"}: - return None - - from .._assets import _locate_core_pack, _repo_root - - core_pack = _locate_core_pack() - bundled = core_pack / subdir if core_pack is not None else None - source = ( - _repo_root() / "templates" / "commands" - if subdir == "commands" - else _repo_root() / subdir - ) - for candidate in (bundled, source): - if candidate is not None and candidate.is_dir(): - return candidate - return None - - -def _project_core_asset_root(project_root: Path | None, subdir: str) -> Path | None: - """Return the project-local built-in-tier directory for an asset family, if present.""" - if project_root is None: - return None - if subdir not in {"commands", "scripts", "templates"}: - return None # pragma: no cover — internal misuse - from ..presets import PresetResolver # lazy: avoids circular import - - candidate = PresetResolver(project_root).templates_dir - if subdir != "templates": - candidate = candidate / subdir - return candidate if candidate.is_dir() else None - - -def _core_command_logical_name(stem: str) -> str: - return stem if stem.startswith("speckit.") else f"speckit.{stem}" - - -def _extract_frontmatter_description(text: str) -> str: - """Return the ``description`` value from YAML frontmatter, else ``""``. - - Matches the frontmatter shape used by every core command/template on disk: - a ``---`` fence pair at the top of the file with a YAML mapping between - them. Anything malformed silently yields the empty string — the contract - forbids omission but permits ``""``. - """ - lines = text.splitlines(keepends=True) - if not lines or lines[0].rstrip("\r\n") != "---": - return "" - fence_end = -1 - for i, line in enumerate(lines[1:], start=1): - if line.rstrip("\r\n") == "---": - fence_end = i - break - if fence_end == -1: - return "" - try: - data = yaml.safe_load("".join(lines[1:fence_end])) - except yaml.YAMLError: - return "" - if not isinstance(data, dict): - return "" - value = data.get("description", "") - return value if isinstance(value, str) else "" - - -def _extract_script_description(text: str) -> str: - """Return the first docstring/comment line of a script, else ``""``. - - Supports the three script runtimes SpecKit ships: - - * Python (``.py``): the first line of the module docstring. - * Bash (``.sh``): the first ``#``-prefixed comment line following the - shebang. - * PowerShell (``.ps1``): either the first line of a ``<# ... #>`` block - comment or the first ``#``-prefixed line. - - Anything unrecognized yields the empty string. - """ - py_match = re.match(r'^(?:#![^\n]*\n)?\s*(?:"""|\'\'\')(.*?)(?:"""|\'\'\')', text, re.DOTALL) - if py_match: - first = py_match.group(1).strip().splitlines() - if first: - return first[0].strip() - - ps_block = re.match(r'^(?:<#\s*(.*?)#>)', text, re.DOTALL) - if ps_block: - first = ps_block.group(1).strip().splitlines() - if first: - return first[0].strip().lstrip(".").strip() - - for raw in text.splitlines(): - stripped = raw.strip() - if not stripped or stripped.startswith("#!"): - continue - if stripped.startswith("#"): - return stripped.lstrip("#").strip() - break - return "" - - -def _describe_artifact_file(path: Path, kind: ArtifactKind) -> str: - """Return the on-disk description for an artifact file, else ``""``. - - Routes to the same extractors the inventory uses so a project - override reports its own metadata instead of inheriting the description - of the core/preset layer it hides. - """ - try: - text = path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - return "" - if kind == "script": - return _extract_script_description(text) - return _extract_frontmatter_description(text) - - -# --------------------------------------------------------------------------- -# Resolver-adaptation helpers -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class _LayerProvenance: - """Artifact-only metadata derived from an unchanged resolver layer.""" - - layer: LayerName | None - source_id: str | None - disk_id: str | None - pack_dir: Path | None - manifest: Any | None - manifest_entry: dict[str, Any] | None - - def lookup_id(self, kind: ArtifactKind, name: str) -> str | None: - if self.layer is None or self.source_id is None: - return None - try: - return derive_lookup_id(self.layer, self.source_id, kind, name) - except IdentifierComponentError: - return None - - -def _same_file(left: Path | None, right: Any) -> bool: - """Compare paths without requiring either path to exist at comparison time.""" - return isinstance(right, Path) and left is not None and left.resolve() == right.resolve() - - -def _manifest_entry_for_path( - manifest: Any, - layer: Literal["preset", "extension"], - pack_dir: Path, - kind: ArtifactKind, - name: str, - path: Path, -) -> dict[str, Any] | None: - """Return the existing manifest declaration that resolved to *path*.""" - if manifest is None: - return None - if layer == "preset": - entries = ( - entry - for entry in manifest.templates - if isinstance(entry, dict) and entry.get("type") == kind - ) - else: - entries = { - "command": manifest.commands, - "template": manifest.templates, - "script": manifest.scripts, - }[kind] - for entry in entries: - if not isinstance(entry, dict) or entry.get("name") != name: - continue - relative_file = entry.get("file") - if isinstance(relative_file, str) and _same_file( - pack_dir / relative_file, path - ): - return entry - return None - - -def _layer_provenance( - resolver: Any, - resolver_layer: dict[str, Any], - kind: ArtifactKind, - name: str, - manifest_cache: dict[Path, Any | None], -) -> _LayerProvenance: - """Derive artifact provenance from the resolver's established layer shape.""" - source = resolver_layer.get("source") - path = resolver_layer.get("path") - - if source == "project override": - return _LayerProvenance("project", "_", None, None, None, None) - if source in {"core", "core (bundled)"}: - return _LayerProvenance(None, None, None, None, None, None) - if not isinstance(path, Path) or not isinstance(source, str): - raise ArtifactResolutionError() - - if source.startswith("extension:"): - extension_id = resolver_layer.get("extension_id") - extension_dir = resolver_layer.get("extension_dir") - if not isinstance(extension_id, str) or not isinstance(extension_dir, Path): - raise ArtifactResolutionError() - manifest_path = extension_dir / "extension.yml" - if manifest_path not in manifest_cache: - try: - from ..extensions import ExtensionManifest, ValidationError - - manifest_cache[manifest_path] = ( - ExtensionManifest(manifest_path) if manifest_path.is_file() else None - ) - except ( - ValidationError, - yaml.YAMLError, - OSError, - TypeError, - AttributeError, - ): - manifest_cache[manifest_path] = None - manifest = manifest_cache[manifest_path] - declared = _manifest_entry_for_path( - manifest, "extension", extension_dir, kind, name, path - ) - source_id = ( - manifest.id - if declared is not None - and manifest is not None - and isinstance(manifest.id, str) - and manifest.id - else extension_id - ) - return _LayerProvenance( - "extension", - source_id, - extension_id, - extension_dir, - manifest, - declared, - ) - - try: - relative = path.relative_to(resolver.presets_dir) - except ValueError as exc: - raise ArtifactResolutionError() from exc - if not relative.parts: - raise ArtifactResolutionError() - pack_id = relative.parts[0] - pack_dir = resolver.presets_dir / pack_id - manifest = resolver._get_manifest(pack_dir) - declared = _manifest_entry_for_path( - manifest, "preset", pack_dir, kind, name, path - ) - source_id = ( - manifest.id - if declared is not None - and manifest is not None - and isinstance(manifest.id, str) - and manifest.id - else pack_id - ) - return _LayerProvenance( - "preset", - source_id, - pack_id, - pack_dir, - manifest, - declared, - ) - - -def _derive_manifest_path( - provenance: _LayerProvenance, project_root: Path -) -> str | None: - """Return the declaring manifest path for an artifact layer.""" - if provenance.manifest_entry is None or provenance.pack_dir is None: - return None - manifest_name = ( - "preset.yml" if provenance.layer == "preset" else "extension.yml" - ) - manifest_path = provenance.pack_dir / manifest_name - if not manifest_path.is_file(): - return None - try: - return manifest_path.relative_to(project_root).as_posix() - except ValueError: - return None - - -def _repo_relative_existing_file(project_root: Path, path: Path) -> str | None: - """Return *path* relative to the project root when it is an existing file.""" - if not path.is_file(): - return None - try: - return path.relative_to(project_root).as_posix() - except ValueError: - return None - - -def _is_safe_path_component(value: str) -> bool: - """Return true when *value* is a single non-traversing path component.""" - if not value or value in (".", ".."): - return False - path = Path(value) - return not path.is_absolute() and len(path.parts) == 1 and path.name == value - - -def _materialized_command_source_path( - project_root: Path, - metadata: dict[str, Any] | None, - name: str, - *, - source: Literal["preset", "extension"], -) -> str | None: - """Return the tracked agent output path for an installed command layer.""" - if not isinstance(metadata, dict): - return None - - try: - from ..agents import CommandRegistrar - except ImportError: - return None - - registrar = CommandRegistrar() - registrar._ensure_configs() - - registered_commands = metadata.get("registered_commands") - if isinstance(registered_commands, dict): - for agent_name in sorted(registered_commands): - cmd_names = registered_commands.get(agent_name) - if not isinstance(cmd_names, list): - continue - if name not in cmd_names: - continue - agent_config = registrar.AGENT_CONFIGS.get(agent_name) - if agent_config is None: - continue - output_name = registrar._compute_output_name( - agent_name, name, agent_config - ) - command_path = ( - registrar._resolve_agent_dir(agent_name, agent_config, project_root) - / f"{output_name}{agent_config['extension']}" - ) - rel = _repo_relative_existing_file(project_root, command_path) - if rel is not None: - return rel - - registered_skills = metadata.get("registered_skills") - if source == "preset": - skill_names_by_agent = registered_skills if isinstance(registered_skills, dict) else {} - elif isinstance(registered_skills, list): - # Extension registries store skills as a flat list, unlike presets' - # per-agent map. Probe every known agent's project-local skills - # directory and return the first extant tracked file. - skill_names_by_agent = { - agent_name: registered_skills for agent_name in sorted(registrar.AGENT_CONFIGS) - } - else: - skill_names_by_agent = {} - - expected_skill_names: set[str] | None = None - if source == "extension": - try: - from ..extensions import ExtensionManager - - expected_skill_names = {ExtensionManager._skill_name_for_command(name)} - except ImportError: - expected_skill_names = None - else: - try: - from ..presets import PresetManager - - expected_skill_names = set(PresetManager._skill_names_for_command(name)) - except ImportError: - expected_skill_names = None - - if isinstance(skill_names_by_agent, dict): - from .. import _get_skills_dir as _project_skills_dir - - for agent_name in sorted(skill_names_by_agent): - skill_names = skill_names_by_agent.get(agent_name) - if not isinstance(agent_name, str) or not isinstance(skill_names, list): - continue - agent_config = registrar.AGENT_CONFIGS.get(agent_name) - if agent_config is None: - continue - if agent_config.get("extension") == "/SKILL.md": - skills_dir = registrar._resolve_agent_dir( - agent_name, agent_config, project_root - ) - else: - skills_dir = _project_skills_dir(project_root, agent_name) - for skill_name in sorted( - n for n in skill_names if isinstance(n, str) and _is_safe_path_component(n) - ): - if expected_skill_names is not None and skill_name not in expected_skill_names: - continue - skill_path = skills_dir / skill_name / "SKILL.md" - rel = _repo_relative_existing_file(project_root, skill_path) - if rel is not None: - return rel - - return None - - -def _derive_source_path( - provenance: _LayerProvenance, - layer: dict[str, Any], - project_root: Path, - kind: ArtifactKind, - name: str, - *, - active: bool, -) -> str | None: - """Return the repo-relative concrete file backing a preset/extension layer. - - The tracked materialized agent output is shared by every stack row that - contributed the same command name, so it only reflects the winning - (``active``) row's content. Lower ``replace``/``merge`` rows must report - their own installed pack file instead of that shared output. - """ - if provenance.layer == "preset": - if provenance.disk_id is None: - return None - from ..presets import PresetRegistry - - metadata = PresetRegistry(project_root / ".specify" / "presets").get( - provenance.disk_id - ) - if kind == "command" and active: - materialized = _materialized_command_source_path( - project_root, metadata, name, source="preset" - ) - if materialized is not None: - return materialized - elif provenance.layer == "extension": - if provenance.disk_id is None: - return None - from ..extensions import ExtensionRegistry - - metadata = ExtensionRegistry(project_root / ".specify" / "extensions").get( - provenance.disk_id - ) - if kind == "command" and active: - materialized = _materialized_command_source_path( - project_root, metadata, name, source="extension" - ) - if materialized is not None: - return materialized - else: - return None - - # Non-active command layers, non-command preset/extension layers, and - # active command layers without a tracked materialized agent output all - # report the installed pack file from the raw - # PresetResolver.collect_all_layers() row's concrete ``path`` key. - path = layer.get("path") - if isinstance(path, Path): - return _repo_relative_existing_file(project_root, path) - return None - - -def _preset_display_name(pack_dir: Path, pack_id: str) -> str: - """Return the preset's human-friendly name from ``preset.yml``, or ``pack_id``. - - Delegates parsing and validation to :class:`PresetManifest` — the same - class ``PresetManager.list_installed()`` and ``specify preset list`` use — - instead of re-parsing the YAML by hand. Falls back to ``pack_id`` when the - manifest file is missing or fails manifest validation (for example, an - older flat-layout manifest with no ``preset:`` section at all). - """ - from ..presets import PresetManifest, PresetValidationError # lazy: avoids circular import - - manifest_path = pack_dir / "preset.yml" - if not manifest_path.is_file(): - return pack_id - try: - return PresetManifest(manifest_path).name - except PresetValidationError: - return pack_id - - -def _build_stack( - project_root: Path, - kind: ArtifactKind, - name: str, - raw_layers: list[dict[str, Any]] | None = None, - resolver: Any | None = None, - manifest_cache: dict[Path, Any | None] | None = None, -) -> list[StackLayer]: - """Build the ordered stack for a single artifact. - - Delegates the actual composition math to - :meth:`PresetResolver.collect_all_layers`; this function only reshapes - each raw layer dict into a :class:`StackLayer` and computes the - ``active`` / ``hidden`` labels documented on the data model. - - Returns an empty list when the artifact is not visible from any tier - (no preset, no extension, no built-in asset). - """ - from ..presets import PresetError, PresetResolver # lazy: avoids circular import - - template_type = kind - resolver = resolver or PresetResolver(project_root) - if raw_layers is None: - try: - raw = resolver.collect_all_layers(name, template_type) - except (OSError, PresetError) as exc: - raise ArtifactResolutionError() from exc - else: - raw = raw_layers - if not raw: - return [] - manifest_cache = manifest_cache if manifest_cache is not None else {} - - first_replace_idx = next( - (i for i, layer in enumerate(raw) if layer["strategy"] == "replace"), - None, - ) - - public_id = derive_public_id(kind, name) - rows: list[StackLayer] = [] - for idx, layer in enumerate(raw): - strategy = layer["strategy"] - active = idx == 0 - - if first_replace_idx is None: - hidden = False - else: - hidden = idx > first_replace_idx - - provenance = _layer_provenance( - resolver, layer, kind, name, manifest_cache - ) - lookup_id = provenance.lookup_id(kind, name) - source_path = _derive_source_path( - provenance, layer, project_root, kind, name, active=active - ) - - if provenance.layer == PROJECT_OVERRIDE_LAYER: - rows.append( - StackLayer( - id=public_id, - layer="project", - sourceId=provenance.source_id, - presetId=None, - presetName=None, - strategy=strategy, - active=active, - hidden=hidden, - manifestPath=None, - lookupId=lookup_id, - sourcePath=source_path, - ) - ) - continue - - if provenance.layer == "extension": - manifest_path = _derive_manifest_path(provenance, project_root) - rows.append( - StackLayer( - id=public_id, - layer="extension", - sourceId=provenance.source_id, - presetId=None, - presetName=None, - strategy=strategy, - active=active, - hidden=hidden, - manifestPath=manifest_path, - lookupId=lookup_id, - sourcePath=source_path, - ) - ) - continue - - if provenance.layer is None: - rows.append( - StackLayer( - id=public_id, - layer=None, - sourceId=None, - presetId=None, - presetName=None, - strategy=strategy, - active=active, - hidden=hidden, - manifestPath=None, - lookupId=None, - sourcePath=source_path, - ) - ) - continue - - pack_id = provenance.disk_id or "" - pack_dir = provenance.pack_dir or ( - project_root / ".specify" / "presets" / pack_id - ) - display = _preset_display_name(pack_dir, pack_id) if pack_id else pack_id - manifest_path = _derive_manifest_path(provenance, project_root) - rows.append( - StackLayer( - id=public_id, - layer="preset", - sourceId=provenance.source_id, - presetId=pack_id or None, - presetName=display or None, - strategy=strategy, - active=active, - hidden=hidden, - manifestPath=manifest_path, - lookupId=lookup_id, - sourcePath=source_path, - ) - ) - return rows - - -# --------------------------------------------------------------------------- -# ArtifactCatalog — public façade -# --------------------------------------------------------------------------- - - -def _validate_project(project_root: Path) -> None: - """Raise NotASpecKitProjectError when ``project_root`` isn't a Spec Kit project. - - The two invariants the rest of the module relies on are that - ``project_root`` exists and that a ``.specify/`` subdirectory sits under - it. Anything else — missing presets/, missing extensions/, missing - templates/ — is a valid empty-inventory scenario and is not treated as - an error. - """ - if not (project_root / ".specify").is_dir(): - raise NotASpecKitProjectError() - - -def _validate_extension_registry(project_root: Path) -> None: - extensions_dir = project_root / ".specify" / "extensions" - if not extensions_dir.exists(): - return - - from ..extensions import ExtensionRegistry - - if ExtensionRegistry(extensions_dir).is_corrupt(): - raise ArtifactResolutionError() - - -def _resolve_kind_hint(name: str, kind: ArtifactKind | None) -> tuple[str, ArtifactKind | None]: - """Parse ``kind:name`` shorthand and reconcile it with an explicit ``--kind`` flag. - - Returns ``(bare_name, resolved_kind)``. When ``name`` uses the ``kind:name`` - grammar and ``kind`` is also set explicitly, the two must agree — a - mismatch is treated as an unknown artifact. - """ - if ":" in name: - prefix, _, bare = name.partition(":") - if prefix in ("command", "template", "script"): - resolved: ArtifactKind = prefix # type: ignore[assignment] - if kind is not None and kind != resolved: - raise ArtifactNotFoundError(name) - return bare, resolved - return name, kind - - -def _validate_artifact_name(name: str, kind: ArtifactKind) -> str: - """Validate the structural identifier component constraints for ``name``.""" - try: - return validate_component(name, f"{kind} name") - except IdentifierComponentError as exc: - raise ArtifactNotFoundError(name) from exc - - -def _is_valid_artifact_name_component(name: Any, kind: ArtifactKind) -> bool: - """Return ``True`` when ``name`` can appear in an artifact identifier.""" - try: - validate_component(name, f"{kind} name") - except IdentifierComponentError: - return False - return True - - -class ArtifactCatalog: - """Read-only view over one Spec Kit project's artifact inventory.""" - - def __init__(self, project_root: Path) -> None: - self.project_root = project_root - - # ------------------------------------------------------------------ list - def list_artifacts(self) -> list[Artifact]: - """Return every artifact SpecKit exposes for this project, deduped. - - Sort order is deterministic — first by ``kind`` in the fixed - ``["command", "template", "script"]`` order, then by ``name``. - Returns an empty list when no artifacts are found rather than raising; - a fresh install with no presets, no extensions, and no built-in assets is - still a valid Spec Kit project. - - Skills (``.github/skills/**/SKILL.md``) are intentionally excluded — - they are integration-specific output, not a shipped asset family. - - Descriptions are picked from the highest-priority layer that has one, - not the first layer discovered — a built-in command that an active - preset overrides must report the preset's description, and two - competing packs must report the higher-precedence one's. Precedence - is decided by :meth:`PresetResolver.collect_all_layers`'s own - ordering (index 0 = winner), not by enumeration order here. - """ - artifacts, _layers_cache, _resolver, _manifest_cache = self._collect_inventory() - return artifacts - - def list_artifacts_with_stack(self) -> list[dict[str, Any]]: - """Return list rows enriched with each artifact's full composition stack.""" - artifacts, layers_cache, resolver, manifest_cache = self._collect_inventory() - rows: list[dict[str, Any]] = [] - for artifact in artifacts: - stack = _build_stack( - self.project_root, - artifact.kind, - artifact.name, - raw_layers=layers_cache.get((artifact.kind, artifact.name)), - resolver=resolver, - manifest_cache=manifest_cache, - ) - row = artifact.to_json_dict() - row["stack"] = [layer.to_json_dict() for layer in stack] - rows.append(row) - return rows - - # ------------------------------------------------------------------ info - def get_artifact_info( - self, - name: str, - kind: ArtifactKind | None = None, - ) -> dict[str, Any]: - """Return the full JSON-ready dict for ``specify artifact info``. - - Argument resolution: - - * ``name`` accepts the ``kind:name`` grammar as shorthand; when both - the shorthand and ``kind`` are supplied they must agree. - * When neither the shorthand nor ``kind`` narrows the search and - more than one kind matches ``name``, raises - :class:`AmbiguousArtifactError`. - * When no artifact matches, raises :class:`ArtifactNotFoundError`. - """ - bare, resolved_kind = _resolve_kind_hint(name, kind) - - # Project and registry validation happens once, inside - # ``_collect_inventory`` below — the same chokepoint ``list_artifacts`` - # uses — so both public methods fail closed identically instead of - # each re-implementing the checks. - inventory, layers_cache, resolver, manifest_cache = self._collect_inventory() - if resolved_kind is None: - matches = [ - (artifact.kind, artifact.name) - for artifact in inventory - if artifact.name == bare - ] - if not matches: - raise ArtifactNotFoundError(name) - if len(matches) > 1: - raise AmbiguousArtifactError(bare, [k for k, _ in matches]) - resolved_kind = matches[0][0] - - validated_name = _validate_artifact_name(bare, resolved_kind) - artifact = next( - ( - item - for item in inventory - if item.kind == resolved_kind and item.name == validated_name - ), - None, - ) - if artifact is None: - raise ArtifactNotFoundError(name) - stack = _build_stack( - self.project_root, - resolved_kind, - validated_name, - raw_layers=layers_cache.get((resolved_kind, validated_name)), - resolver=resolver, - manifest_cache=manifest_cache, - ) - if not stack: - raise ArtifactNotFoundError(name) - - return { - "id": derive_public_id(resolved_kind, validated_name), - "name": validated_name, - "kind": resolved_kind, - "description": artifact.description, - "stack": [layer.to_json_dict() for layer in stack], - } - - # -------------------------------------------------------------- internals - def _collect_inventory( - self, - ) -> tuple[ - list[Artifact], - dict[tuple[ArtifactKind, str], list[dict[str, Any]]], - Any, - dict[Path, Any | None], - ]: - _validate_project(self.project_root) - _validate_extension_registry(self.project_root) - - from ..presets import PresetError, PresetResolver # lazy: avoids circular import - - resolver = PresetResolver(self.project_root) - layers_cache: dict[tuple[ArtifactKind, str], list[dict[str, Any]]] = {} - core_script_paths = self._selected_core_script_paths() - - def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: - key = (kind, name) - if key not in layers_cache: - try: - layers = resolver.collect_all_layers(name, kind) - except (OSError, PresetError) as exc: - raise ArtifactResolutionError() from exc - core_script = core_script_paths.get(name) if kind == "script" else None - if core_script is not None and not any( - layer.get("source") in {"core", "core (bundled)"} - for layer in layers - ): - layers.append( - { - "path": core_script, - "source": "core", - "strategy": "replace", - } - ) - layers_cache[key] = layers - return layers_cache[key] - - def _has_any_replace_layer(layers: list[dict[str, Any]]) -> bool: - return any(layer.get("strategy") == "replace" for layer in layers) - - names: set[tuple[ArtifactKind, str]] = set() - try: - for kind, name in self._iter_candidate_artifacts( - resolver, core_script_paths - ): - key = (kind, name) - if not _is_valid_artifact_name_component(name, kind): - continue - # Resolve each candidate through Spec Kit's existing single-artifact - # path for behavioral parity; optimize shared manifest reads only if - # typical small extension sets show a measurable inventory cost. - layers = _layers_for(kind, name) - if layers and _has_any_replace_layer(layers): - names.add(key) - except (OSError, PresetError) as exc: - raise ArtifactResolutionError() from exc - - artifacts: list[Artifact] = [] - manifest_cache: dict[Path, Any | None] = {} - for kind, name in names: - description = "" - for layer in _layers_for(kind, name): - candidate = self._describe_layer( - resolver, layer, kind, name, manifest_cache - ) - if candidate: - description = candidate - break - artifacts.append( - Artifact( - id=derive_public_id(kind, name), - name=name, - kind=kind, - description=description, - ) - ) - - kind_order = {"command": 0, "template": 1, "script": 2} - return ( - sorted(artifacts, key=lambda a: (kind_order[a.kind], a.name)), - layers_cache, - resolver, - manifest_cache, - ) - - def _iter_candidate_artifacts( - self, - resolver: Any, - core_script_paths: dict[str, Path], - ) -> Iterable[tuple[ArtifactKind, str]]: - """Yield candidate ``(kind, name)`` pairs from every resolver tier. - - Covers the ways a pack can contribute an artifact: - - * manifest-declared entries (``preset.yml`` / ``extension.yml``), read - through each manifest class's existing normalized properties, and - * convention-placed extension files (``commands/``, ``templates/``, - ``scripts/``) that the resolver picks up even without a manifest. - - Presets and extensions are enumerated through the resolver's existing - priority helpers, so the candidate set follows the same install, - enable, and priority rules as resolution. Project overrides and - resolver-compatible core asset paths are included only as candidate - names; :meth:`PresetResolver.collect_all_layers` remains the source of - truth for which candidates are actually present and which layer wins. - - Project-local overrides under ``.specify/templates/overrides`` are - included too, so an artifact that exists only as an override is still - listed. - - Silent on any manifest that fails to parse — that would already be - surfaced by ``specify preset list`` or ``specify extension list``, and - this command's job is to describe the composed inventory, not to be - the second validation surface. - """ - from ..extensions import ExtensionManager, ExtensionManifest, ValidationError - from ..presets import PresetManager # lazy: avoids circular import - - # -- Presets: the registry is authoritative, no unregistered fallback. - preset_manager = PresetManager(self.project_root) - for pack_id, _metadata in resolver._get_all_presets_by_priority(): - pack_dir = preset_manager.presets_dir / pack_id - manifest = preset_manager.get_pack(pack_id) - yield from self._iter_pack_candidates(manifest, pack_dir, "preset") - - # -- Extensions: use the resolver's own extension enumeration order. - ext_manager = ExtensionManager(self.project_root) - for _priority, ext_id, metadata in resolver._get_all_extensions_by_priority(): - ext_dir = resolver.extensions_dir / ext_id - if metadata is not None: - manifest = ext_manager.get_extension(ext_id) - else: - manifest_path = ext_dir / "extension.yml" - manifest = None - if manifest_path.is_file(): - try: - manifest = ExtensionManifest(manifest_path) - except (ValidationError, OSError, TypeError, AttributeError): - manifest = None - yield from self._iter_pack_candidates(manifest, ext_dir, "extension") - - yield from self._iter_project_override_candidates(resolver) - yield from self._iter_core_candidates(core_script_paths) - - @staticmethod - def _iter_pack_candidates( - manifest: Any, - pack_dir: Path, - layer: Literal["preset", "extension"], - ) -> Iterable[tuple[ArtifactKind, str]]: - """Yield manifest-declared and convention-based candidate names.""" - if manifest is not None: - if layer == "preset": - declarations = ( - (entry.get("type"), entry) - for entry in manifest.templates - if isinstance(entry, dict) - ) - else: - declarations = ( - (kind, entry) - for kind, entries in ( - ("command", manifest.commands), - ("template", manifest.templates), - ("script", manifest.scripts), - ) - for entry in entries - if isinstance(entry, dict) - ) - for kind, contribution in declarations: - name = contribution.get("name") - if ( - kind in ("command", "template", "script") - and isinstance(name, str) - and name - and ":" not in name - ): - yield kind, name - - yield from ((kind, name) for kind, name, _path in _iter_convention_contributions(pack_dir)) - - def _iter_project_override_candidates( - self, - resolver: Any, - ) -> Iterable[tuple[ArtifactKind, str]]: - """Yield candidate ``(kind, name)`` pairs for project overrides. - - A root ``overrides/.md`` file is the override for both the - ``template`` and the ``command`` lookup of ````. It is reported - for every kind backed by another layer; the fallback heuristic is used - only when the override is the sole layer. - - A dotted name (``speckit.local``) is treated as a command even when - the override is the only layer — matching the exact ID - ``preset resolve``/``artifact info`` accepts for it. - """ - overrides_dir = resolver.overrides_dir - if not overrides_dir.is_dir(): - return - for entry in sorted(overrides_dir.iterdir(), key=lambda p: p.name): - if not entry.is_file() or entry.suffix != _TEMPLATE_SUFFIX: - continue - name = entry.stem - if not _is_valid_artifact_name_component(name, "command"): - continue - backed_kinds: list[ArtifactKind] = [] - for kind in ("command", "template"): - layers = resolver.collect_all_layers(name, kind) - if any( - layer.get("source") != "project override" - for layer in layers - ): - backed_kinds.append(kind) - if not backed_kinds: - backed_kinds.append("command" if is_dotted_command_name(name) else "template") - for kind in backed_kinds: - yield kind, name - scripts_dir = overrides_dir / "scripts" - if not scripts_dir.is_dir(): - return - for entry in sorted(scripts_dir.iterdir(), key=lambda p: p.name): - if entry.is_file() and entry.suffix == _SCRIPT_SUFFIX: - if not _is_valid_artifact_name_component(entry.stem, "script"): - continue - yield "script", entry.stem - - def _iter_core_candidates( - self, core_script_paths: dict[str, Path] - ) -> Iterable[tuple[ArtifactKind, str]]: - """Yield candidate names from resolver-compatible core asset paths.""" - from ..extensions import CORE_COMMAND_NAMES # lazy: avoids circular import - from ..presets import PresetResolver - - project_commands_dir = _project_core_asset_root(self.project_root, "commands") - bundled_commands_dir = _locate_shared_asset_dir("commands") - command_dirs = tuple( - directory - for directory in (project_commands_dir, bundled_commands_dir) - if directory is not None - ) - command_names = {_core_command_logical_name(name) for name in CORE_COMMAND_NAMES} - for directory in command_dirs: - for entry in sorted(directory.iterdir(), key=lambda p: p.name): - if entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX: - command_names.add(_core_command_logical_name(entry.stem)) - for name in sorted(command_names): - if any( - (directory / f"{candidate}.md").is_file() - for directory in command_dirs - for candidate in ( - name, - *( - (PresetResolver._core_stem(name),) - if PresetResolver._core_stem(name) - else () - ), - ) - if candidate is not None - ): - yield "command", name - - seen_templates: set[str] = set() - for directory in ( - _project_core_asset_root(self.project_root, "templates"), - _locate_shared_asset_dir("templates"), - ): - if directory is None: - continue - for entry in sorted(directory.iterdir(), key=lambda p: p.name): - if ( - entry.is_file() - and entry.suffix == _TEMPLATE_SUFFIX - and entry.stem not in seen_templates - ): - seen_templates.add(entry.stem) - yield "template", entry.stem - - for directory in ( - _project_core_asset_root(self.project_root, "scripts"), - _locate_shared_asset_dir("scripts"), - ): - if directory is None: - continue - for entry in sorted(directory.glob(f"*{_SCRIPT_SUFFIX}"), key=lambda p: p.name): - yield "script", entry.stem - yield from (("script", name) for name in sorted(core_script_paths)) - - def _selected_core_script_paths(self) -> dict[str, Path]: - """Return built-in scripts selected by the project's existing runtime policy.""" - from .._init_options import load_init_options - from ..agents import CommandRegistrar - from ..integrations.base import IntegrationBase - - command_dirs = tuple( - directory - for directory in ( - _project_core_asset_root(self.project_root, "commands"), - _locate_shared_asset_dir("commands"), - ) - if directory is not None - ) - script_dirs = tuple( - directory - for directory in ( - _project_core_asset_root(self.project_root, "scripts"), - _locate_shared_asset_dir("scripts"), - ) - if directory is not None - ) - requested = load_init_options(self.project_root).get("script") - selected: dict[str, Path] = {} - - for command_dir in command_dirs: - for template_path in sorted(command_dir.glob("*.md"), key=lambda p: p.name): - try: - content = template_path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - continue - frontmatter, _body = CommandRegistrar.parse_frontmatter(content) - scripts = frontmatter.get("scripts", {}) - if not isinstance(scripts, dict): - continue - script_commands = { - key: value - for key, value in scripts.items() - if isinstance(key, str) and isinstance(value, str) and value.strip() - } - if not script_commands: - continue - try: - variant = IntegrationBase.select_script_variant( - requested, script_commands - ) - tokens = shlex.split(script_commands[variant], posix=True) - except (KeyError, ValueError): - continue - if not tokens: - continue - - relative = Path(tokens[0]) - if relative.parts and relative.parts[0] == "scripts": - relative = Path(*relative.parts[1:]) - path = next( - ( - script_dir / relative - for script_dir in script_dirs - if (script_dir / relative).is_file() - ), - None, - ) - if path is None: - continue - name = path.stem.replace("_", "-") if variant == "py" else path.stem - selected.setdefault(name, path) - - return selected - - def _describe_layer( - self, - resolver: Any, - layer: dict[str, Any], - kind: ArtifactKind, - name: str, - manifest_cache: dict[Path, Any | None], - ) -> str: - """Return manifest metadata or on-disk metadata for one resolver layer.""" - manifest_description = self._manifest_description_for_layer( - resolver, layer, kind, name, manifest_cache - ) - if manifest_description: - return manifest_description - path = layer.get("path") - if isinstance(path, Path): - return _describe_artifact_file(path, kind) - return "" - - def _manifest_description_for_layer( - self, - resolver: Any, - layer: dict[str, Any], - kind: ArtifactKind, - name: str, - manifest_cache: dict[Path, Any | None], - ) -> str: - provenance = _layer_provenance( - resolver, layer, kind, name, manifest_cache - ) - entry = provenance.manifest_entry - if entry is None: - return "" - description = entry.get("description", "") - return description if isinstance(description, str) else "" - - -_CONVENTION_SUBDIRS: tuple[tuple[str, ArtifactKind, str], ...] = ( - ("commands", "command", _TEMPLATE_SUFFIX), - ("templates", "template", _TEMPLATE_SUFFIX), - ("scripts", "script", _SCRIPT_SUFFIX), -) - - -def _iter_convention_contributions( - pack_dir: Path, -) -> Iterable[tuple[ArtifactKind, str, Path]]: - """Yield ``(kind, name, path)`` for files exposed by convention. - - Templates are also accepted at the pack root for legacy compatibility, - matching the resolver's ``templates/``-then-root lookup order. - """ - for subdir, kind, suffix in _CONVENTION_SUBDIRS: - candidate_dir = pack_dir / subdir - if not candidate_dir.is_dir(): - continue - for entry in sorted(candidate_dir.iterdir(), key=lambda p: p.name): - if entry.is_file() and entry.suffix == suffix and ":" not in entry.stem: - yield kind, entry.stem, entry - if not pack_dir.is_dir(): - return - for entry in sorted(pack_dir.iterdir(), key=lambda p: p.name): - if ( - entry.is_file() - and entry.suffix == _TEMPLATE_SUFFIX - and ":" not in entry.stem - ): - yield "template", entry.stem, entry - - __all__ = [ "AmbiguousArtifactError", "Artifact", diff --git a/src/specify_cli/artifacts/catalog.py b/src/specify_cli/artifacts/catalog.py new file mode 100644 index 0000000000..43c2469a69 --- /dev/null +++ b/src/specify_cli/artifacts/catalog.py @@ -0,0 +1,762 @@ +"""Artifact inventory and catalog logic. No Typer decorators. + +Two public entry points: + +* :meth:`ArtifactCatalog.list_artifacts` — flat inventory (id, name, kind, description). +* :meth:`ArtifactCatalog.get_artifact_info` — one row plus its full ordered stack. + +Everything else in this module is internal machinery. Callers outside +:mod:`specify_cli.artifacts._commands` should not import the private helpers. +""" + +from __future__ import annotations + +import re +import shlex +from pathlib import Path +from typing import Any, Iterable, Literal + +import yaml + +from ._identifiers import ( + IdentifierComponentError, + derive_public_id, + is_dotted_command_name, + validate_component, +) +from .models import ( + AmbiguousArtifactError, + Artifact, + ArtifactKind, + ArtifactNotFoundError, + ArtifactResolutionError, + NotASpecKitProjectError, +) +from .resolution import _build_stack, _layer_provenance + + +_TEMPLATE_SUFFIX = ".md" +_SCRIPT_SUFFIX = ".sh" + + +def _locate_shared_asset_dir(subdir: str) -> Path | None: + """Locate a core asset directory without changing shared asset behavior.""" + if subdir not in {"commands", "scripts", "templates"}: + return None + + from .._assets import _locate_core_pack, _repo_root + + core_pack = _locate_core_pack() + bundled = core_pack / subdir if core_pack is not None else None + source = ( + _repo_root() / "templates" / "commands" + if subdir == "commands" + else _repo_root() / subdir + ) + for candidate in (bundled, source): + if candidate is not None and candidate.is_dir(): + return candidate + return None + + +def _project_core_asset_root(project_root: Path | None, subdir: str) -> Path | None: + """Return the project-local built-in-tier directory for an asset family, if present.""" + if project_root is None: + return None + if subdir not in {"commands", "scripts", "templates"}: + return None # pragma: no cover — internal misuse + from ..presets import PresetResolver # lazy: avoids circular import + + candidate = PresetResolver(project_root).templates_dir + if subdir != "templates": + candidate = candidate / subdir + return candidate if candidate.is_dir() else None + + +def _core_command_logical_name(stem: str) -> str: + return stem if stem.startswith("speckit.") else f"speckit.{stem}" + + +def _extract_frontmatter_description(text: str) -> str: + """Return the ``description`` value from YAML frontmatter, else ``""``. + + Matches the frontmatter shape used by every core command/template on disk: + a ``---`` fence pair at the top of the file with a YAML mapping between + them. Anything malformed silently yields the empty string — the contract + forbids omission but permits ``""``. + """ + lines = text.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return "" + fence_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + fence_end = i + break + if fence_end == -1: + return "" + try: + data = yaml.safe_load("".join(lines[1:fence_end])) + except yaml.YAMLError: + return "" + if not isinstance(data, dict): + return "" + value = data.get("description", "") + return value if isinstance(value, str) else "" + + +def _extract_script_description(text: str) -> str: + """Return the first docstring/comment line of a script, else ``""``. + + Supports the three script runtimes SpecKit ships: + + * Python (``.py``): the first line of the module docstring. + * Bash (``.sh``): the first ``#``-prefixed comment line following the + shebang. + * PowerShell (``.ps1``): either the first line of a ``<# ... #>`` block + comment or the first ``#``-prefixed line. + + Anything unrecognized yields the empty string. + """ + py_match = re.match(r'^(?:#![^\n]*\n)?\s*(?:"""|\'\'\')(.*?)(?:"""|\'\'\')', text, re.DOTALL) + if py_match: + first = py_match.group(1).strip().splitlines() + if first: + return first[0].strip() + + ps_block = re.match(r'^(?:<#\s*(.*?)#>)', text, re.DOTALL) + if ps_block: + first = ps_block.group(1).strip().splitlines() + if first: + return first[0].strip().lstrip(".").strip() + + for raw in text.splitlines(): + stripped = raw.strip() + if not stripped or stripped.startswith("#!"): + continue + if stripped.startswith("#"): + return stripped.lstrip("#").strip() + break + return "" + + +def _describe_artifact_file(path: Path, kind: ArtifactKind) -> str: + """Return the on-disk description for an artifact file, else ``""``. + + Routes to the same extractors the inventory uses so a project + override reports its own metadata instead of inheriting the description + of the core/preset layer it hides. + """ + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return "" + if kind == "script": + return _extract_script_description(text) + return _extract_frontmatter_description(text) + + +# --------------------------------------------------------------------------- +# ArtifactCatalog — public façade +# --------------------------------------------------------------------------- + + +def _validate_project(project_root: Path) -> None: + """Raise NotASpecKitProjectError when ``project_root`` isn't a Spec Kit project. + + The two invariants the rest of the module relies on are that + ``project_root`` exists and that a ``.specify/`` subdirectory sits under + it. Anything else — missing presets/, missing extensions/, missing + templates/ — is a valid empty-inventory scenario and is not treated as + an error. + """ + if not (project_root / ".specify").is_dir(): + raise NotASpecKitProjectError() + + +def _validate_extension_registry(project_root: Path) -> None: + extensions_dir = project_root / ".specify" / "extensions" + if not extensions_dir.exists(): + return + + from ..extensions import ExtensionRegistry + + if ExtensionRegistry(extensions_dir).is_corrupt(): + raise ArtifactResolutionError() + + +def _resolve_kind_hint(name: str, kind: ArtifactKind | None) -> tuple[str, ArtifactKind | None]: + """Parse ``kind:name`` shorthand and reconcile it with an explicit ``--kind`` flag. + + Returns ``(bare_name, resolved_kind)``. When ``name`` uses the ``kind:name`` + grammar and ``kind`` is also set explicitly, the two must agree — a + mismatch is treated as an unknown artifact. + """ + if ":" in name: + prefix, _, bare = name.partition(":") + if prefix in ("command", "template", "script"): + resolved: ArtifactKind = prefix # type: ignore[assignment] + if kind is not None and kind != resolved: + raise ArtifactNotFoundError(name) + return bare, resolved + return name, kind + + +def _validate_artifact_name(name: str, kind: ArtifactKind) -> str: + """Validate the structural identifier component constraints for ``name``.""" + try: + return validate_component(name, f"{kind} name") + except IdentifierComponentError as exc: + raise ArtifactNotFoundError(name) from exc + + +def _is_valid_artifact_name_component(name: Any, kind: ArtifactKind) -> bool: + """Return ``True`` when ``name`` can appear in an artifact identifier.""" + try: + validate_component(name, f"{kind} name") + except IdentifierComponentError: + return False + return True + + +class ArtifactCatalog: + """Read-only view over one Spec Kit project's artifact inventory.""" + + def __init__(self, project_root: Path) -> None: + self.project_root = project_root + + # ------------------------------------------------------------------ list + def list_artifacts(self) -> list[Artifact]: + """Return every artifact SpecKit exposes for this project, deduped. + + Sort order is deterministic — first by ``kind`` in the fixed + ``["command", "template", "script"]`` order, then by ``name``. + Returns an empty list when no artifacts are found rather than raising; + a fresh install with no presets, no extensions, and no built-in assets is + still a valid Spec Kit project. + + Skills (``.github/skills/**/SKILL.md``) are intentionally excluded — + they are integration-specific output, not a shipped asset family. + + Descriptions are picked from the highest-priority layer that has one, + not the first layer discovered — a built-in command that an active + preset overrides must report the preset's description, and two + competing packs must report the higher-precedence one's. Precedence + is decided by :meth:`PresetResolver.collect_all_layers`'s own + ordering (index 0 = winner), not by enumeration order here. + """ + artifacts, _layers_cache, _resolver, _manifest_cache = self._collect_inventory() + return artifacts + + def list_artifacts_with_stack(self) -> list[dict[str, Any]]: + """Return list rows enriched with each artifact's full composition stack.""" + artifacts, layers_cache, resolver, manifest_cache = self._collect_inventory() + rows: list[dict[str, Any]] = [] + for artifact in artifacts: + stack = _build_stack( + self.project_root, + artifact.kind, + artifact.name, + raw_layers=layers_cache.get((artifact.kind, artifact.name)), + resolver=resolver, + manifest_cache=manifest_cache, + ) + row = artifact.to_json_dict() + row["stack"] = [layer.to_json_dict() for layer in stack] + rows.append(row) + return rows + + # ------------------------------------------------------------------ info + def get_artifact_info( + self, + name: str, + kind: ArtifactKind | None = None, + ) -> dict[str, Any]: + """Return the full JSON-ready dict for ``specify artifact info``. + + Argument resolution: + + * ``name`` accepts the ``kind:name`` grammar as shorthand; when both + the shorthand and ``kind`` are supplied they must agree. + * When neither the shorthand nor ``kind`` narrows the search and + more than one kind matches ``name``, raises + :class:`AmbiguousArtifactError`. + * When no artifact matches, raises :class:`ArtifactNotFoundError`. + """ + bare, resolved_kind = _resolve_kind_hint(name, kind) + + # Project and registry validation happens once, inside + # ``_collect_inventory`` below — the same chokepoint ``list_artifacts`` + # uses — so both public methods fail closed identically instead of + # each re-implementing the checks. + inventory, layers_cache, resolver, manifest_cache = self._collect_inventory() + if resolved_kind is None: + matches = [ + (artifact.kind, artifact.name) + for artifact in inventory + if artifact.name == bare + ] + if not matches: + raise ArtifactNotFoundError(name) + if len(matches) > 1: + raise AmbiguousArtifactError(bare, [k for k, _ in matches]) + resolved_kind = matches[0][0] + + validated_name = _validate_artifact_name(bare, resolved_kind) + artifact = next( + ( + item + for item in inventory + if item.kind == resolved_kind and item.name == validated_name + ), + None, + ) + if artifact is None: + raise ArtifactNotFoundError(name) + stack = _build_stack( + self.project_root, + resolved_kind, + validated_name, + raw_layers=layers_cache.get((resolved_kind, validated_name)), + resolver=resolver, + manifest_cache=manifest_cache, + ) + if not stack: + raise ArtifactNotFoundError(name) + + return { + "id": derive_public_id(resolved_kind, validated_name), + "name": validated_name, + "kind": resolved_kind, + "description": artifact.description, + "stack": [layer.to_json_dict() for layer in stack], + } + + # -------------------------------------------------------------- internals + def _collect_inventory( + self, + ) -> tuple[ + list[Artifact], + dict[tuple[ArtifactKind, str], list[dict[str, Any]]], + Any, + dict[Path, Any | None], + ]: + _validate_project(self.project_root) + _validate_extension_registry(self.project_root) + + from ..presets import PresetError, PresetResolver # lazy: avoids circular import + + resolver = PresetResolver(self.project_root) + layers_cache: dict[tuple[ArtifactKind, str], list[dict[str, Any]]] = {} + core_script_paths = self._selected_core_script_paths() + + def _layers_for(kind: ArtifactKind, name: str) -> list[dict[str, Any]]: + key = (kind, name) + if key not in layers_cache: + try: + layers = resolver.collect_all_layers(name, kind) + except (OSError, PresetError) as exc: + raise ArtifactResolutionError() from exc + core_script = core_script_paths.get(name) if kind == "script" else None + if core_script is not None and not any( + layer.get("source") in {"core", "core (bundled)"} + for layer in layers + ): + layers.append( + { + "path": core_script, + "source": "core", + "strategy": "replace", + } + ) + layers_cache[key] = layers + return layers_cache[key] + + def _has_any_replace_layer(layers: list[dict[str, Any]]) -> bool: + return any(layer.get("strategy") == "replace" for layer in layers) + + names: set[tuple[ArtifactKind, str]] = set() + try: + for kind, name in self._iter_candidate_artifacts( + resolver, core_script_paths + ): + key = (kind, name) + if not _is_valid_artifact_name_component(name, kind): + continue + # Resolve each candidate through Spec Kit's existing single-artifact + # path for behavioral parity; optimize shared manifest reads only if + # typical small extension sets show a measurable inventory cost. + layers = _layers_for(kind, name) + if layers and _has_any_replace_layer(layers): + names.add(key) + except (OSError, PresetError) as exc: + raise ArtifactResolutionError() from exc + + artifacts: list[Artifact] = [] + manifest_cache: dict[Path, Any | None] = {} + for kind, name in names: + description = "" + for layer in _layers_for(kind, name): + candidate = self._describe_layer( + resolver, layer, kind, name, manifest_cache + ) + if candidate: + description = candidate + break + artifacts.append( + Artifact( + id=derive_public_id(kind, name), + name=name, + kind=kind, + description=description, + ) + ) + + kind_order = {"command": 0, "template": 1, "script": 2} + return ( + sorted(artifacts, key=lambda a: (kind_order[a.kind], a.name)), + layers_cache, + resolver, + manifest_cache, + ) + + def _iter_candidate_artifacts( + self, + resolver: Any, + core_script_paths: dict[str, Path], + ) -> Iterable[tuple[ArtifactKind, str]]: + """Yield candidate ``(kind, name)`` pairs from every resolver tier. + + Covers the ways a pack can contribute an artifact: + + * manifest-declared entries (``preset.yml`` / ``extension.yml``), read + through each manifest class's existing normalized properties, and + * convention-placed extension files (``commands/``, ``templates/``, + ``scripts/``) that the resolver picks up even without a manifest. + + Presets and extensions are enumerated through the resolver's existing + priority helpers, so the candidate set follows the same install, + enable, and priority rules as resolution. Project overrides and + resolver-compatible core asset paths are included only as candidate + names; :meth:`PresetResolver.collect_all_layers` remains the source of + truth for which candidates are actually present and which layer wins. + + Project-local overrides under ``.specify/templates/overrides`` are + included too, so an artifact that exists only as an override is still + listed. + + Silent on any manifest that fails to parse — that would already be + surfaced by ``specify preset list`` or ``specify extension list``, and + this command's job is to describe the composed inventory, not to be + the second validation surface. + """ + from ..extensions import ExtensionManager, ExtensionManifest, ValidationError + from ..presets import PresetManager # lazy: avoids circular import + + # -- Presets: the registry is authoritative, no unregistered fallback. + preset_manager = PresetManager(self.project_root) + for pack_id, _metadata in resolver._get_all_presets_by_priority(): + pack_dir = preset_manager.presets_dir / pack_id + manifest = preset_manager.get_pack(pack_id) + yield from self._iter_pack_candidates(manifest, pack_dir, "preset") + + # -- Extensions: use the resolver's own extension enumeration order. + ext_manager = ExtensionManager(self.project_root) + for _priority, ext_id, metadata in resolver._get_all_extensions_by_priority(): + ext_dir = resolver.extensions_dir / ext_id + if metadata is not None: + manifest = ext_manager.get_extension(ext_id) + else: + manifest_path = ext_dir / "extension.yml" + manifest = None + if manifest_path.is_file(): + try: + manifest = ExtensionManifest(manifest_path) + except (ValidationError, OSError, TypeError, AttributeError): + manifest = None + yield from self._iter_pack_candidates(manifest, ext_dir, "extension") + + yield from self._iter_project_override_candidates(resolver) + yield from self._iter_core_candidates(core_script_paths) + + @staticmethod + def _iter_pack_candidates( + manifest: Any, + pack_dir: Path, + layer: Literal["preset", "extension"], + ) -> Iterable[tuple[ArtifactKind, str]]: + """Yield manifest-declared and convention-based candidate names.""" + if manifest is not None: + if layer == "preset": + declarations = ( + (entry.get("type"), entry) + for entry in manifest.templates + if isinstance(entry, dict) + ) + else: + declarations = ( + (kind, entry) + for kind, entries in ( + ("command", manifest.commands), + ("template", manifest.templates), + ("script", manifest.scripts), + ) + for entry in entries + if isinstance(entry, dict) + ) + for kind, contribution in declarations: + name = contribution.get("name") + if ( + kind in ("command", "template", "script") + and isinstance(name, str) + and name + and ":" not in name + ): + yield kind, name + + yield from ((kind, name) for kind, name, _path in _iter_convention_contributions(pack_dir)) + + def _iter_project_override_candidates( + self, + resolver: Any, + ) -> Iterable[tuple[ArtifactKind, str]]: + """Yield candidate ``(kind, name)`` pairs for project overrides. + + A root ``overrides/.md`` file is the override for both the + ``template`` and the ``command`` lookup of ````. It is reported + for every kind backed by another layer; the fallback heuristic is used + only when the override is the sole layer. + + A dotted name (``speckit.local``) is treated as a command even when + the override is the only layer — matching the exact ID + ``preset resolve``/``artifact info`` accepts for it. + """ + overrides_dir = resolver.overrides_dir + if not overrides_dir.is_dir(): + return + for entry in sorted(overrides_dir.iterdir(), key=lambda p: p.name): + if not entry.is_file() or entry.suffix != _TEMPLATE_SUFFIX: + continue + name = entry.stem + if not _is_valid_artifact_name_component(name, "command"): + continue + backed_kinds: list[ArtifactKind] = [] + for kind in ("command", "template"): + layers = resolver.collect_all_layers(name, kind) + if any( + layer.get("source") != "project override" + for layer in layers + ): + backed_kinds.append(kind) + if not backed_kinds: + backed_kinds.append("command" if is_dotted_command_name(name) else "template") + for kind in backed_kinds: + yield kind, name + scripts_dir = overrides_dir / "scripts" + if not scripts_dir.is_dir(): + return + for entry in sorted(scripts_dir.iterdir(), key=lambda p: p.name): + if entry.is_file() and entry.suffix == _SCRIPT_SUFFIX: + if not _is_valid_artifact_name_component(entry.stem, "script"): + continue + yield "script", entry.stem + + def _iter_core_candidates( + self, core_script_paths: dict[str, Path] + ) -> Iterable[tuple[ArtifactKind, str]]: + """Yield candidate names from resolver-compatible core asset paths.""" + from ..extensions import CORE_COMMAND_NAMES # lazy: avoids circular import + from ..presets import PresetResolver + + project_commands_dir = _project_core_asset_root(self.project_root, "commands") + bundled_commands_dir = _locate_shared_asset_dir("commands") + command_dirs = tuple( + directory + for directory in (project_commands_dir, bundled_commands_dir) + if directory is not None + ) + command_names = {_core_command_logical_name(name) for name in CORE_COMMAND_NAMES} + for directory in command_dirs: + for entry in sorted(directory.iterdir(), key=lambda p: p.name): + if entry.is_file() and entry.suffix == _TEMPLATE_SUFFIX: + command_names.add(_core_command_logical_name(entry.stem)) + for name in sorted(command_names): + if any( + (directory / f"{candidate}.md").is_file() + for directory in command_dirs + for candidate in ( + name, + *( + (PresetResolver._core_stem(name),) + if PresetResolver._core_stem(name) + else () + ), + ) + if candidate is not None + ): + yield "command", name + + seen_templates: set[str] = set() + for directory in ( + _project_core_asset_root(self.project_root, "templates"), + _locate_shared_asset_dir("templates"), + ): + if directory is None: + continue + for entry in sorted(directory.iterdir(), key=lambda p: p.name): + if ( + entry.is_file() + and entry.suffix == _TEMPLATE_SUFFIX + and entry.stem not in seen_templates + ): + seen_templates.add(entry.stem) + yield "template", entry.stem + + for directory in ( + _project_core_asset_root(self.project_root, "scripts"), + _locate_shared_asset_dir("scripts"), + ): + if directory is None: + continue + for entry in sorted(directory.glob(f"*{_SCRIPT_SUFFIX}"), key=lambda p: p.name): + yield "script", entry.stem + yield from (("script", name) for name in sorted(core_script_paths)) + + def _selected_core_script_paths(self) -> dict[str, Path]: + """Return built-in scripts selected by the project's existing runtime policy.""" + from .._init_options import load_init_options + from ..agents import CommandRegistrar + from ..integrations.base import IntegrationBase + + command_dirs = tuple( + directory + for directory in ( + _project_core_asset_root(self.project_root, "commands"), + _locate_shared_asset_dir("commands"), + ) + if directory is not None + ) + script_dirs = tuple( + directory + for directory in ( + _project_core_asset_root(self.project_root, "scripts"), + _locate_shared_asset_dir("scripts"), + ) + if directory is not None + ) + requested = load_init_options(self.project_root).get("script") + selected: dict[str, Path] = {} + + for command_dir in command_dirs: + for template_path in sorted(command_dir.glob("*.md"), key=lambda p: p.name): + try: + content = template_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + frontmatter, _body = CommandRegistrar.parse_frontmatter(content) + scripts = frontmatter.get("scripts", {}) + if not isinstance(scripts, dict): + continue + script_commands = { + key: value + for key, value in scripts.items() + if isinstance(key, str) and isinstance(value, str) and value.strip() + } + if not script_commands: + continue + try: + variant = IntegrationBase.select_script_variant( + requested, script_commands + ) + tokens = shlex.split(script_commands[variant], posix=True) + except (KeyError, ValueError): + continue + if not tokens: + continue + + relative = Path(tokens[0]) + if relative.parts and relative.parts[0] == "scripts": + relative = Path(*relative.parts[1:]) + path = next( + ( + script_dir / relative + for script_dir in script_dirs + if (script_dir / relative).is_file() + ), + None, + ) + if path is None: + continue + name = path.stem.replace("_", "-") if variant == "py" else path.stem + selected.setdefault(name, path) + + return selected + + def _describe_layer( + self, + resolver: Any, + layer: dict[str, Any], + kind: ArtifactKind, + name: str, + manifest_cache: dict[Path, Any | None], + ) -> str: + """Return manifest metadata or on-disk metadata for one resolver layer.""" + manifest_description = self._manifest_description_for_layer( + resolver, layer, kind, name, manifest_cache + ) + if manifest_description: + return manifest_description + path = layer.get("path") + if isinstance(path, Path): + return _describe_artifact_file(path, kind) + return "" + + def _manifest_description_for_layer( + self, + resolver: Any, + layer: dict[str, Any], + kind: ArtifactKind, + name: str, + manifest_cache: dict[Path, Any | None], + ) -> str: + provenance = _layer_provenance( + resolver, layer, kind, name, manifest_cache + ) + entry = provenance.manifest_entry + if entry is None: + return "" + description = entry.get("description", "") + return description if isinstance(description, str) else "" + + +_CONVENTION_SUBDIRS: tuple[tuple[str, ArtifactKind, str], ...] = ( + ("commands", "command", _TEMPLATE_SUFFIX), + ("templates", "template", _TEMPLATE_SUFFIX), + ("scripts", "script", _SCRIPT_SUFFIX), +) + + +def _iter_convention_contributions( + pack_dir: Path, +) -> Iterable[tuple[ArtifactKind, str, Path]]: + """Yield ``(kind, name, path)`` for files exposed by convention. + + Templates are also accepted at the pack root for legacy compatibility, + matching the resolver's ``templates/``-then-root lookup order. + """ + for subdir, kind, suffix in _CONVENTION_SUBDIRS: + candidate_dir = pack_dir / subdir + if not candidate_dir.is_dir(): + continue + for entry in sorted(candidate_dir.iterdir(), key=lambda p: p.name): + if entry.is_file() and entry.suffix == suffix and ":" not in entry.stem: + yield kind, entry.stem, entry + if not pack_dir.is_dir(): + return + for entry in sorted(pack_dir.iterdir(), key=lambda p: p.name): + if ( + entry.is_file() + and entry.suffix == _TEMPLATE_SUFFIX + and ":" not in entry.stem + ): + yield "template", entry.stem, entry diff --git a/src/specify_cli/artifacts/models.py b/src/specify_cli/artifacts/models.py new file mode 100644 index 0000000000..8def90db66 --- /dev/null +++ b/src/specify_cli/artifacts/models.py @@ -0,0 +1,110 @@ +"""Public data contracts and errors for artifact inspection.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable, Literal + +ArtifactKind = Literal["command", "template", "script"] +LayerName = Literal["project", "preset", "extension"] +Strategy = Literal["replace", "wrap", "prepend", "append"] + + +@dataclass(frozen=True) +class Artifact: + """One row in the flat artifact inventory.""" + + id: str + name: str + kind: ArtifactKind + description: str + + def to_json_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "kind": self.kind, + "description": self.description, + } + + +@dataclass(frozen=True) +class StackLayer: + """One row in an artifact's ordered composition stack. + + ``id`` is the source-agnostic round-trip key for the artifact this stack + row belongs to. ``lookupId`` identifies a specific non-core contribution + when one exists. + """ + + id: str + layer: LayerName | None + sourceId: str | None + presetId: str | None + presetName: str | None + strategy: Strategy + active: bool + hidden: bool + manifestPath: str | None + lookupId: str | None + sourcePath: str | None + + def to_json_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "layer": self.layer, + "sourceId": self.sourceId, + "presetId": self.presetId, + "presetName": self.presetName, + "strategy": self.strategy, + "active": self.active, + "hidden": self.hidden, + "manifestPath": self.manifestPath, + "lookupId": self.lookupId, + "sourcePath": self.sourcePath, + } + + +class ArtifactError(Exception): + """Base class for artifact command errors with stable messages.""" + + message: str + + +class ArtifactNotFoundError(ArtifactError): + def __init__(self, name: str) -> None: + self.message = f"unknown artifact {name}" + super().__init__(self.message) + + +class AmbiguousArtifactError(ArtifactError): + def __init__(self, name: str, kinds: Iterable[str]) -> None: + kinds_list = sorted(kinds) + self.message = f"ambiguous artifact {name}: matches kinds {kinds_list}" + super().__init__(self.message) + + +class NotASpecKitProjectError(ArtifactError): + def __init__(self) -> None: + self.message = "not a Spec Kit project: no .specify/ directory found" + super().__init__(self.message) + + +class ArtifactResolutionError(ArtifactError): + def __init__(self) -> None: + self.message = "artifact resolution failed" + super().__init__(self.message) + + +__all__ = [ + "AmbiguousArtifactError", + "Artifact", + "ArtifactError", + "ArtifactKind", + "ArtifactNotFoundError", + "ArtifactResolutionError", + "LayerName", + "NotASpecKitProjectError", + "StackLayer", + "Strategy", +] diff --git a/src/specify_cli/artifacts/resolution.py b/src/specify_cli/artifacts/resolution.py new file mode 100644 index 0000000000..c393424824 --- /dev/null +++ b/src/specify_cli/artifacts/resolution.py @@ -0,0 +1,515 @@ +"""Artifact stack projection over the existing preset resolver.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +import yaml + +from ._identifiers import ( + PROJECT_OVERRIDE_LAYER, + IdentifierComponentError, + derive_lookup_id, + derive_public_id, +) +from .models import ArtifactKind, ArtifactResolutionError, LayerName, StackLayer + + +@dataclass(frozen=True) +class _LayerProvenance: + """Artifact-only metadata derived from an unchanged resolver layer.""" + + layer: LayerName | None + source_id: str | None + disk_id: str | None + pack_dir: Path | None + manifest: Any | None + manifest_entry: dict[str, Any] | None + + def lookup_id(self, kind: ArtifactKind, name: str) -> str | None: + if self.layer is None or self.source_id is None: + return None + try: + return derive_lookup_id(self.layer, self.source_id, kind, name) + except IdentifierComponentError: + return None + + +def _same_file(left: Path | None, right: Any) -> bool: + """Compare paths without requiring either path to exist at comparison time.""" + return isinstance(right, Path) and left is not None and left.resolve() == right.resolve() + + +def _manifest_entry_for_path( + manifest: Any, + layer: Literal["preset", "extension"], + pack_dir: Path, + kind: ArtifactKind, + name: str, + path: Path, +) -> dict[str, Any] | None: + """Return the existing manifest declaration that resolved to *path*.""" + if manifest is None: + return None + if layer == "preset": + entries = ( + entry + for entry in manifest.templates + if isinstance(entry, dict) and entry.get("type") == kind + ) + else: + entries = { + "command": manifest.commands, + "template": manifest.templates, + "script": manifest.scripts, + }[kind] + for entry in entries: + if not isinstance(entry, dict) or entry.get("name") != name: + continue + relative_file = entry.get("file") + if isinstance(relative_file, str) and _same_file( + pack_dir / relative_file, path + ): + return entry + return None + + +def _layer_provenance( + resolver: Any, + resolver_layer: dict[str, Any], + kind: ArtifactKind, + name: str, + manifest_cache: dict[Path, Any | None], +) -> _LayerProvenance: + """Derive artifact provenance from the resolver's established layer shape.""" + source = resolver_layer.get("source") + path = resolver_layer.get("path") + + if source == "project override": + return _LayerProvenance("project", "_", None, None, None, None) + if source in {"core", "core (bundled)"}: + return _LayerProvenance(None, None, None, None, None, None) + if not isinstance(path, Path) or not isinstance(source, str): + raise ArtifactResolutionError() + + if source.startswith("extension:"): + extension_id = resolver_layer.get("extension_id") + extension_dir = resolver_layer.get("extension_dir") + if not isinstance(extension_id, str) or not isinstance(extension_dir, Path): + raise ArtifactResolutionError() + manifest_path = extension_dir / "extension.yml" + if manifest_path not in manifest_cache: + try: + from ..extensions import ExtensionManifest, ValidationError + + manifest_cache[manifest_path] = ( + ExtensionManifest(manifest_path) if manifest_path.is_file() else None + ) + except ( + ValidationError, + yaml.YAMLError, + OSError, + TypeError, + AttributeError, + ): + manifest_cache[manifest_path] = None + manifest = manifest_cache[manifest_path] + declared = _manifest_entry_for_path( + manifest, "extension", extension_dir, kind, name, path + ) + source_id = ( + manifest.id + if declared is not None + and manifest is not None + and isinstance(manifest.id, str) + and manifest.id + else extension_id + ) + return _LayerProvenance( + "extension", + source_id, + extension_id, + extension_dir, + manifest, + declared, + ) + + try: + relative = path.relative_to(resolver.presets_dir) + except ValueError as exc: + raise ArtifactResolutionError() from exc + if not relative.parts: + raise ArtifactResolutionError() + pack_id = relative.parts[0] + pack_dir = resolver.presets_dir / pack_id + manifest = resolver._get_manifest(pack_dir) + declared = _manifest_entry_for_path( + manifest, "preset", pack_dir, kind, name, path + ) + source_id = ( + manifest.id + if declared is not None + and manifest is not None + and isinstance(manifest.id, str) + and manifest.id + else pack_id + ) + return _LayerProvenance( + "preset", + source_id, + pack_id, + pack_dir, + manifest, + declared, + ) + + +def _derive_manifest_path( + provenance: _LayerProvenance, project_root: Path +) -> str | None: + """Return the declaring manifest path for an artifact layer.""" + if provenance.manifest_entry is None or provenance.pack_dir is None: + return None + manifest_name = ( + "preset.yml" if provenance.layer == "preset" else "extension.yml" + ) + manifest_path = provenance.pack_dir / manifest_name + if not manifest_path.is_file(): + return None + try: + return manifest_path.relative_to(project_root).as_posix() + except ValueError: + return None + + +def _repo_relative_existing_file(project_root: Path, path: Path) -> str | None: + """Return *path* relative to the project root when it is an existing file.""" + if not path.is_file(): + return None + try: + return path.relative_to(project_root).as_posix() + except ValueError: + return None + + +def _is_safe_path_component(value: str) -> bool: + """Return true when *value* is a single non-traversing path component.""" + if not value or value in (".", ".."): + return False + path = Path(value) + return not path.is_absolute() and len(path.parts) == 1 and path.name == value + + +def _materialized_command_source_path( + project_root: Path, + metadata: dict[str, Any] | None, + name: str, + *, + source: Literal["preset", "extension"], +) -> str | None: + """Return the tracked agent output path for an installed command layer.""" + if not isinstance(metadata, dict): + return None + + try: + from ..agents import CommandRegistrar + except ImportError: + return None + + registrar = CommandRegistrar() + registrar._ensure_configs() + + registered_commands = metadata.get("registered_commands") + if isinstance(registered_commands, dict): + for agent_name in sorted(registered_commands): + cmd_names = registered_commands.get(agent_name) + if not isinstance(cmd_names, list): + continue + if name not in cmd_names: + continue + agent_config = registrar.AGENT_CONFIGS.get(agent_name) + if agent_config is None: + continue + output_name = registrar._compute_output_name( + agent_name, name, agent_config + ) + command_path = ( + registrar._resolve_agent_dir(agent_name, agent_config, project_root) + / f"{output_name}{agent_config['extension']}" + ) + rel = _repo_relative_existing_file(project_root, command_path) + if rel is not None: + return rel + + registered_skills = metadata.get("registered_skills") + if source == "preset": + skill_names_by_agent = registered_skills if isinstance(registered_skills, dict) else {} + elif isinstance(registered_skills, list): + # Extension registries store skills as a flat list, unlike presets' + # per-agent map. Probe every known agent's project-local skills + # directory and return the first extant tracked file. + skill_names_by_agent = { + agent_name: registered_skills for agent_name in sorted(registrar.AGENT_CONFIGS) + } + else: + skill_names_by_agent = {} + + expected_skill_names: set[str] | None = None + if source == "extension": + try: + from ..extensions import ExtensionManager + + expected_skill_names = {ExtensionManager._skill_name_for_command(name)} + except ImportError: + expected_skill_names = None + else: + try: + from ..presets import PresetManager + + expected_skill_names = set(PresetManager._skill_names_for_command(name)) + except ImportError: + expected_skill_names = None + + if isinstance(skill_names_by_agent, dict): + from .. import _get_skills_dir as _project_skills_dir + + for agent_name in sorted(skill_names_by_agent): + skill_names = skill_names_by_agent.get(agent_name) + if not isinstance(agent_name, str) or not isinstance(skill_names, list): + continue + agent_config = registrar.AGENT_CONFIGS.get(agent_name) + if agent_config is None: + continue + if agent_config.get("extension") == "/SKILL.md": + skills_dir = registrar._resolve_agent_dir( + agent_name, agent_config, project_root + ) + else: + skills_dir = _project_skills_dir(project_root, agent_name) + for skill_name in sorted( + n for n in skill_names if isinstance(n, str) and _is_safe_path_component(n) + ): + if expected_skill_names is not None and skill_name not in expected_skill_names: + continue + skill_path = skills_dir / skill_name / "SKILL.md" + rel = _repo_relative_existing_file(project_root, skill_path) + if rel is not None: + return rel + + return None + + +def _derive_source_path( + provenance: _LayerProvenance, + layer: dict[str, Any], + project_root: Path, + kind: ArtifactKind, + name: str, + *, + active: bool, +) -> str | None: + """Return the repo-relative concrete file backing a preset/extension layer. + + The tracked materialized agent output is shared by every stack row that + contributed the same command name, so it only reflects the winning + (``active``) row's content. Lower ``replace``/``merge`` rows must report + their own installed pack file instead of that shared output. + """ + if provenance.layer == "preset": + if provenance.disk_id is None: + return None + from ..presets import PresetRegistry + + metadata = PresetRegistry(project_root / ".specify" / "presets").get( + provenance.disk_id + ) + if kind == "command" and active: + materialized = _materialized_command_source_path( + project_root, metadata, name, source="preset" + ) + if materialized is not None: + return materialized + elif provenance.layer == "extension": + if provenance.disk_id is None: + return None + from ..extensions import ExtensionRegistry + + metadata = ExtensionRegistry(project_root / ".specify" / "extensions").get( + provenance.disk_id + ) + if kind == "command" and active: + materialized = _materialized_command_source_path( + project_root, metadata, name, source="extension" + ) + if materialized is not None: + return materialized + else: + return None + + # Non-active command layers, non-command preset/extension layers, and + # active command layers without a tracked materialized agent output all + # report the installed pack file from the raw + # PresetResolver.collect_all_layers() row's concrete ``path`` key. + path = layer.get("path") + if isinstance(path, Path): + return _repo_relative_existing_file(project_root, path) + return None + + +def _preset_display_name(pack_dir: Path, pack_id: str) -> str: + """Return the preset's human-friendly name from ``preset.yml``, or ``pack_id``. + + Delegates parsing and validation to :class:`PresetManifest` — the same + class ``PresetManager.list_installed()`` and ``specify preset list`` use — + instead of re-parsing the YAML by hand. Falls back to ``pack_id`` when the + manifest file is missing or fails manifest validation (for example, an + older flat-layout manifest with no ``preset:`` section at all). + """ + from ..presets import PresetManifest, PresetValidationError # lazy: avoids circular import + + manifest_path = pack_dir / "preset.yml" + if not manifest_path.is_file(): + return pack_id + try: + return PresetManifest(manifest_path).name + except PresetValidationError: + return pack_id + + +def _build_stack( + project_root: Path, + kind: ArtifactKind, + name: str, + raw_layers: list[dict[str, Any]] | None = None, + resolver: Any | None = None, + manifest_cache: dict[Path, Any | None] | None = None, +) -> list[StackLayer]: + """Build the ordered stack for a single artifact. + + Delegates the actual composition math to + :meth:`PresetResolver.collect_all_layers`; this function only reshapes + each raw layer dict into a :class:`StackLayer` and computes the + ``active`` / ``hidden`` labels documented on the data model. + + Returns an empty list when the artifact is not visible from any tier + (no preset, no extension, no built-in asset). + """ + from ..presets import PresetError, PresetResolver # lazy: avoids circular import + + template_type = kind + resolver = resolver or PresetResolver(project_root) + if raw_layers is None: + try: + raw = resolver.collect_all_layers(name, template_type) + except (OSError, PresetError) as exc: + raise ArtifactResolutionError() from exc + else: + raw = raw_layers + if not raw: + return [] + manifest_cache = manifest_cache if manifest_cache is not None else {} + + first_replace_idx = next( + (i for i, layer in enumerate(raw) if layer["strategy"] == "replace"), + None, + ) + + public_id = derive_public_id(kind, name) + rows: list[StackLayer] = [] + for idx, layer in enumerate(raw): + strategy = layer["strategy"] + active = idx == 0 + + if first_replace_idx is None: + hidden = False + else: + hidden = idx > first_replace_idx + + provenance = _layer_provenance( + resolver, layer, kind, name, manifest_cache + ) + lookup_id = provenance.lookup_id(kind, name) + source_path = _derive_source_path( + provenance, layer, project_root, kind, name, active=active + ) + + if provenance.layer == PROJECT_OVERRIDE_LAYER: + rows.append( + StackLayer( + id=public_id, + layer="project", + sourceId=provenance.source_id, + presetId=None, + presetName=None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=None, + lookupId=lookup_id, + sourcePath=source_path, + ) + ) + continue + + if provenance.layer == "extension": + manifest_path = _derive_manifest_path(provenance, project_root) + rows.append( + StackLayer( + id=public_id, + layer="extension", + sourceId=provenance.source_id, + presetId=None, + presetName=None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=manifest_path, + lookupId=lookup_id, + sourcePath=source_path, + ) + ) + continue + + if provenance.layer is None: + rows.append( + StackLayer( + id=public_id, + layer=None, + sourceId=None, + presetId=None, + presetName=None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=None, + lookupId=None, + sourcePath=source_path, + ) + ) + continue + + pack_id = provenance.disk_id or "" + pack_dir = provenance.pack_dir or ( + project_root / ".specify" / "presets" / pack_id + ) + display = _preset_display_name(pack_dir, pack_id) if pack_id else pack_id + manifest_path = _derive_manifest_path(provenance, project_root) + rows.append( + StackLayer( + id=public_id, + layer="preset", + sourceId=provenance.source_id, + presetId=pack_id or None, + presetName=display or None, + strategy=strategy, + active=active, + hidden=hidden, + manifestPath=manifest_path, + lookupId=lookup_id, + sourcePath=source_path, + ) + ) + return rows + diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index b7c7874977..4fd0c49666 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -26,8 +26,8 @@ ArtifactNotFoundError, ArtifactResolutionError, NotASpecKitProjectError, - _preset_display_name, ) +from specify_cli.artifacts.resolution import _preset_display_name from specify_cli.extensions import CORE_COMMAND_NAMES, ExtensionRegistry from specify_cli.presets import PresetRegistry, PresetResolver from tests.conftest import install_preset @@ -165,7 +165,7 @@ def test_core_scripts_reuse_existing_runtime_fallback( ) monkeypatch.setattr( - "specify_cli.artifacts._locate_shared_asset_dir", + "specify_cli.artifacts.catalog._locate_shared_asset_dir", lambda subdir: { "commands": commands_dir, "scripts": scripts_dir, From 68b8278819ff36505e9e82db68fe262f8af41682 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 11 Sep 2026 12:38:44 -0500 Subject: [PATCH 109/113] style: normalize artifact resolution EOF Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- src/specify_cli/artifacts/resolution.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/specify_cli/artifacts/resolution.py b/src/specify_cli/artifacts/resolution.py index c393424824..73625a630f 100644 --- a/src/specify_cli/artifacts/resolution.py +++ b/src/specify_cli/artifacts/resolution.py @@ -512,4 +512,3 @@ def _build_stack( ) ) return rows - From d14b5b374fd4a217b03e6e1cb013b748afde592c Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 11 Sep 2026 13:12:47 -0500 Subject: [PATCH 110/113] fix: expose both override artifact kinds Remove filename-based kind guessing for root project overrides and let the existing resolver validate both command and template candidates. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- src/specify_cli/artifacts/_identifiers.py | 14 ----- src/specify_cli/artifacts/catalog.py | 21 ++------ tests/test_artifact_command.py | 64 ++++++++++++++--------- 3 files changed, 41 insertions(+), 58 deletions(-) diff --git a/src/specify_cli/artifacts/_identifiers.py b/src/specify_cli/artifacts/_identifiers.py index b417d78ea8..3035a407a8 100644 --- a/src/specify_cli/artifacts/_identifiers.py +++ b/src/specify_cli/artifacts/_identifiers.py @@ -59,17 +59,3 @@ def derive_lookup_id(layer: str, source_id: str, kind: str, name: str) -> str: "Invalid sourceId '_': reserved for project layer" ) return f"{layer}:{source_id}:{kind}:{name}" - - -def is_dotted_command_name(value: str) -> bool: - """Return whether *value* follows the dotted command-name convention.""" - if "." not in value: - return False - return all( - segment - and all( - ("0" <= char <= "9") or ("a" <= char <= "z") or char == "-" - for char in segment - ) - for segment in value.split(".") - ) diff --git a/src/specify_cli/artifacts/catalog.py b/src/specify_cli/artifacts/catalog.py index 43c2469a69..0df0cb397c 100644 --- a/src/specify_cli/artifacts/catalog.py +++ b/src/specify_cli/artifacts/catalog.py @@ -21,7 +21,6 @@ from ._identifiers import ( IdentifierComponentError, derive_public_id, - is_dotted_command_name, validate_component, ) from .models import ( @@ -523,13 +522,9 @@ def _iter_project_override_candidates( """Yield candidate ``(kind, name)`` pairs for project overrides. A root ``overrides/.md`` file is the override for both the - ``template`` and the ``command`` lookup of ````. It is reported - for every kind backed by another layer; the fallback heuristic is used - only when the override is the sole layer. - - A dotted name (``speckit.local``) is treated as a command even when - the override is the only layer — matching the exact ID - ``preset resolve``/``artifact info`` accepts for it. + ``template`` and the ``command`` lookup of ````. Both candidates + are emitted and the normal inventory resolution path decides whether + each is present. """ overrides_dir = resolver.overrides_dir if not overrides_dir.is_dir(): @@ -540,17 +535,7 @@ def _iter_project_override_candidates( name = entry.stem if not _is_valid_artifact_name_component(name, "command"): continue - backed_kinds: list[ArtifactKind] = [] for kind in ("command", "template"): - layers = resolver.collect_all_layers(name, kind) - if any( - layer.get("source") != "project override" - for layer in layers - ): - backed_kinds.append(kind) - if not backed_kinds: - backed_kinds.append("command" if is_dotted_command_name(name) else "template") - for kind in backed_kinds: yield kind, name scripts_dir = overrides_dir / "scripts" if not scripts_dir.is_dir(): diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 4fd0c49666..382d92ad3b 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -557,7 +557,9 @@ def test_project_override_row_shape(self, spec_kit_project: Path): overrides.mkdir() (overrides / "speckit.constitution.md").write_text("override", encoding="utf-8") - info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + info = ArtifactCatalog(spec_kit_project).get_artifact_info( + "command:speckit.constitution" + ) project = next(layer for layer in info["stack"] if layer["layer"] == "project") assert project["presetId"] is None @@ -595,7 +597,9 @@ def test_every_stack_row_carries_id(self, spec_kit_project: Path): overrides.mkdir() (overrides / "speckit.constitution.md").write_text("override", encoding="utf-8") - info = ArtifactCatalog(spec_kit_project).get_artifact_info("speckit.constitution") + info = ArtifactCatalog(spec_kit_project).get_artifact_info( + "command:speckit.constitution" + ) assert len(info["stack"]) >= 2 for layer in info["stack"]: assert layer["id"] == "command:speckit.constitution" @@ -1281,10 +1285,14 @@ def test_project_override_only_artifact_is_listed(self, spec_kit_project: Path): catalog = ArtifactCatalog(spec_kit_project) ids = {row.id for row in catalog.list_artifacts()} + assert "command:local-template" in ids assert "template:local-template" in ids assert "script:local-script" in ids - info = catalog.get_artifact_info("local-template") - assert info["stack"][0]["layer"] == "project" + for kind in ("command", "template"): + info = catalog.get_artifact_info(f"{kind}:local-template") + assert info["stack"][0]["layer"] == "project" + with pytest.raises(AmbiguousArtifactError): + catalog.get_artifact_info("local-template") def test_project_override_reports_its_own_description(self, spec_kit_project: Path): """An override's frontmatter/comment metadata wins over the hidden layer.""" @@ -1339,32 +1347,31 @@ def test_project_override_describes_both_backed_kinds(self, spec_kit_project: Pa assert rows["command:shared"] == "Shared override" assert rows["template:shared"] == "Shared override" - def test_dotted_override_only_artifact_is_a_command(self, spec_kit_project: Path): + @pytest.mark.parametrize("name", ["local", "speckit.local"]) + def test_override_only_artifact_exposes_both_resolvable_kinds( + self, spec_kit_project: Path, name: str + ): overrides = spec_kit_project / ".specify" / "templates" / "overrides" overrides.mkdir(parents=True) - (overrides / "speckit.local.md").write_text("body", encoding="utf-8") + override = overrides / f"{name}.md" + override.write_text("body", encoding="utf-8") catalog = ArtifactCatalog(spec_kit_project) ids = {row.id for row in catalog.list_artifacts()} - assert "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" + assert f"command:{name}" in ids + assert f"template:{name}" in ids - 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") + resolver = PresetResolver(spec_kit_project) + for kind in ("command", "template"): + layers = resolver.collect_all_layers(name, kind) + assert layers[0]["path"] == override - 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 + info = catalog.get_artifact_info(f"{kind}:{name}") + assert info["kind"] == kind + assert info["stack"][0]["layer"] == "project" + + with pytest.raises(AmbiguousArtifactError): + catalog.get_artifact_info(name) def test_unregistered_preset_template_without_manifest(self, spec_kit_project: Path): pack_dir = spec_kit_project / ".specify" / "presets" / "legacy-preset" @@ -1404,7 +1411,9 @@ def test_stale_registry_entry_with_missing_pack_dir_is_skipped( ids = {row.id for row in catalog.list_artifacts()} assert not any("removed-preset" in artifact_id for artifact_id in ids) - def test_command_override_is_not_duplicated_as_template(self, spec_kit_project: Path): + def test_command_backed_override_also_exposes_resolvable_template( + self, spec_kit_project: Path + ): ext_dir = spec_kit_project / ".specify" / "extensions" / "legacy" / "commands" ext_dir.mkdir(parents=True) (ext_dir / "speckit.legacy.md").write_text("body", encoding="utf-8") @@ -1415,8 +1424,11 @@ def test_command_override_is_not_duplicated_as_template(self, spec_kit_project: 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" + assert "template:speckit.legacy" in ids + assert catalog.get_artifact_info("command:speckit.legacy")["kind"] == "command" + assert catalog.get_artifact_info("template:speckit.legacy")["kind"] == "template" + with pytest.raises(AmbiguousArtifactError): + catalog.get_artifact_info("speckit.legacy") class TestPresetDisplayName: From ff695dd82fb8ba06655bb01cd2cbe556070e4a62 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 11 Sep 2026 13:22:46 -0500 Subject: [PATCH 111/113] docs: use Spec Kit product spelling Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- src/specify_cli/artifacts/_commands.py | 4 ++-- src/specify_cli/artifacts/catalog.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/artifacts/_commands.py b/src/specify_cli/artifacts/_commands.py index 2c19b6166b..11e78bbe70 100644 --- a/src/specify_cli/artifacts/_commands.py +++ b/src/specify_cli/artifacts/_commands.py @@ -35,7 +35,7 @@ artifact_app = typer.Typer( name="artifact", - help="Introspect commands, templates, and scripts SpecKit exposes.", + help="Introspect commands, templates, and scripts Spec Kit exposes.", no_args_is_help=True, ) @@ -100,7 +100,7 @@ def artifact_list( help="Emit the inventory as a JSON array on stdout.", ), ) -> None: - """List every command, template, and script SpecKit exposes.""" + """List every command, template, and script Spec Kit exposes.""" _require_json_flag(json_flag) try: root = _resolve_project_root() diff --git a/src/specify_cli/artifacts/catalog.py b/src/specify_cli/artifacts/catalog.py index 0df0cb397c..37db9528ec 100644 --- a/src/specify_cli/artifacts/catalog.py +++ b/src/specify_cli/artifacts/catalog.py @@ -107,7 +107,7 @@ def _extract_frontmatter_description(text: str) -> str: def _extract_script_description(text: str) -> str: """Return the first docstring/comment line of a script, else ``""``. - Supports the three script runtimes SpecKit ships: + Supports the three script runtimes Spec Kit ships: * Python (``.py``): the first line of the module docstring. * Bash (``.sh``): the first ``#``-prefixed comment line following the @@ -226,7 +226,7 @@ def __init__(self, project_root: Path) -> None: # ------------------------------------------------------------------ list def list_artifacts(self) -> list[Artifact]: - """Return every artifact SpecKit exposes for this project, deduped. + """Return every artifact Spec Kit exposes for this project, deduped. Sort order is deterministic — first by ``kind`` in the fixed ``["command", "template", "script"]`` order, then by ``name``. From 599106fbbdbb913083d6b4cd3df17868969a9aa7 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 11 Sep 2026 13:31:04 -0500 Subject: [PATCH 112/113] fix: confine artifact script references Reject anchored, traversing, and symlink-escaping script references before artifact discovery reads files outside the selected script root. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- src/specify_cli/artifacts/catalog.py | 42 ++++++++++---- tests/test_artifact_command.py | 82 ++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 12 deletions(-) diff --git a/src/specify_cli/artifacts/catalog.py b/src/specify_cli/artifacts/catalog.py index 37db9528ec..c27f083eb6 100644 --- a/src/specify_cli/artifacts/catalog.py +++ b/src/specify_cli/artifacts/catalog.py @@ -13,7 +13,7 @@ import re import shlex -from pathlib import Path +from pathlib import Path, PurePosixPath, PureWindowsPath from typing import Any, Iterable, Literal import yaml @@ -38,6 +38,30 @@ _SCRIPT_SUFFIX = ".sh" +def _resolve_script_reference(script_root: Path, token: str) -> Path | None: + """Resolve a command script reference that remains inside *script_root*.""" + posix_path = PurePosixPath(token) + windows_path = PureWindowsPath(token) + if posix_path.anchor or windows_path.anchor: + return None + if ".." in posix_path.parts or ".." in windows_path.parts: + return None + + relative = Path(token) + if relative.parts and relative.parts[0] == "scripts": + relative = Path(*relative.parts[1:]) + if not relative.parts: + return None + + try: + resolved_root = script_root.resolve() + candidate = (resolved_root / relative).resolve() + candidate.relative_to(resolved_root) + except (OSError, ValueError): + return None + return candidate if candidate.is_file() else None + + def _locate_shared_asset_dir(subdir: str) -> Path | None: """Locate a core asset directory without changing shared asset behavior.""" if subdir not in {"commands", "scripts", "templates"}: @@ -659,17 +683,11 @@ def _selected_core_script_paths(self) -> dict[str, Path]: if not tokens: continue - relative = Path(tokens[0]) - if relative.parts and relative.parts[0] == "scripts": - relative = Path(*relative.parts[1:]) - path = next( - ( - script_dir / relative - for script_dir in script_dirs - if (script_dir / relative).is_file() - ), - None, - ) + path = None + for script_dir in script_dirs: + path = _resolve_script_reference(script_dir, tokens[0]) + if path is not None: + break if path is None: continue name = path.stem.replace("_", "-") if variant == "py" else path.stem diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 382d92ad3b..4c5cfb3181 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -177,6 +177,88 @@ def test_core_scripts_reuse_existing_runtime_fallback( "demo": script } + @pytest.mark.parametrize( + "reference_kind", + [ + "absolute", + "windows-drive", + "unc", + "traversal", + ], + ) + def test_core_scripts_reject_unsafe_references( + self, + spec_kit_project: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + reference_kind: str, + ): + commands_dir = tmp_path / "commands" + scripts_dir = tmp_path / "scripts" + commands_dir.mkdir() + (scripts_dir / "bash").mkdir(parents=True) + outside = tmp_path / "outside.sh" + outside.write_text( + "#!/bin/sh\n# Must not be read\n", encoding="utf-8" + ) + script_reference = { + "absolute": outside.resolve().as_posix(), + "windows-drive": "C:/outside/demo.sh", + "unc": "//server/share/demo.sh", + "traversal": "scripts/bash/../../outside.sh", + }[reference_kind] + (commands_dir / "demo.md").write_text( + "---\n" + "scripts:\n" + f" sh: {script_reference}\n" + "---\n", + encoding="utf-8", + ) + monkeypatch.setattr( + "specify_cli.artifacts.catalog._locate_shared_asset_dir", + lambda subdir: { + "commands": commands_dir, + "scripts": scripts_dir, + "templates": None, + }[subdir], + ) + + catalog = ArtifactCatalog(spec_kit_project) + assert catalog._selected_core_script_paths() == {} + assert all(row.id != "script:outside" for row in catalog.list_artifacts()) + + def test_core_scripts_reject_symlinks_escaping_script_root( + self, spec_kit_project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + commands_dir = tmp_path / "commands" + scripts_dir = tmp_path / "scripts" + commands_dir.mkdir() + (scripts_dir / "bash").mkdir(parents=True) + outside = tmp_path / "outside.sh" + outside.write_text("#!/bin/sh\n# Must not be read\n", encoding="utf-8") + link = scripts_dir / "bash" / "demo.sh" + try: + link.symlink_to(outside) + except OSError: + pytest.skip("symlink creation is not available") + (commands_dir / "demo.md").write_text( + "---\n" + "scripts:\n" + " sh: scripts/bash/demo.sh\n" + "---\n", + encoding="utf-8", + ) + monkeypatch.setattr( + "specify_cli.artifacts.catalog._locate_shared_asset_dir", + lambda subdir: { + "commands": commands_dir, + "scripts": scripts_dir, + "templates": None, + }[subdir], + ) + + assert ArtifactCatalog(spec_kit_project)._selected_core_script_paths() == {} + def test_excludes_disabled_and_unusable_manifest_contributions( self, spec_kit_project: Path ): From 6c77ca9a9848d2e3b4e2e102d571957a85efb144 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 11 Sep 2026 13:44:26 -0500 Subject: [PATCH 113/113] test: cover composing stack visibility Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f --- tests/test_artifact_command.py | 75 ++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/test_artifact_command.py b/tests/test_artifact_command.py index 4c5cfb3181..7571874d95 100644 --- a/tests/test_artifact_command.py +++ b/tests/test_artifact_command.py @@ -1161,6 +1161,81 @@ def test_output_is_utf8_without_bom(self, spec_kit_project: Path, monkeypatch: p class TestStackComposition: + @pytest.mark.parametrize( + ("strategy", "top_content"), + [ + ("prepend", "top contribution"), + ("append", "top contribution"), + ("wrap", "before top\n{CORE_TEMPLATE}\nafter top"), + ], + ) + def test_composing_stack_keeps_all_contributing_layers_visible( + self, + spec_kit_project: Path, + strategy: str, + top_content: str, + ): + base_content = PresetResolver(spec_kit_project).resolve_content("spec-template") + assert base_content is not None + + lower_pack = install_preset( + spec_kit_project, + "lower-composer", + { + "templates": [ + { + "name": "spec-template", + "strategy": "prepend", + } + ] + }, + priority=10, + ) + (lower_pack / "templates").mkdir() + (lower_pack / "templates" / "spec-template.md").write_text( + "lower contribution", encoding="utf-8" + ) + + top_pack = install_preset( + spec_kit_project, + "top-composer", + { + "templates": [ + { + "name": "spec-template", + "strategy": strategy, + } + ] + }, + priority=5, + ) + (top_pack / "templates").mkdir() + (top_pack / "templates" / "spec-template.md").write_text( + top_content, encoding="utf-8" + ) + + lower_composed = f"lower contribution\n\n{base_content}" + if strategy == "prepend": + expected = f"{top_content}\n\n{lower_composed}" + elif strategy == "append": + expected = f"{lower_composed}\n\n{top_content}" + else: + expected = top_content.replace("{CORE_TEMPLATE}", lower_composed) + + resolved = PresetResolver(spec_kit_project).resolve_content("spec-template") + stack = ArtifactCatalog(spec_kit_project).get_artifact_info( + "template:spec-template" + )["stack"] + + assert resolved == expected + assert [row["strategy"] for row in stack] == [ + strategy, + "prepend", + "replace", + ] + assert [row["active"] for row in stack] == [True, False, False] + assert [row["hidden"] for row in stack] == [False, False, False] + def test_preset_command_uses_entry_type(self, spec_kit_project: Path): pack = install_preset( spec_kit_project,