From 3807522189ae466e096c0c43bd247dc66d25a9c3 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 21 Aug 2026 13:58:32 -0500 Subject: [PATCH 01/85] 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 02/85] 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 03/85] 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 04/85] 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 05/85] 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 06/85] 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 07/85] 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 08/85] 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 09/85] 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 10/85] 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 11/85] 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 12/85] 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 13/85] 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 14/85] 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 15/85] 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 16/85] 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 17/85] 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 18/85] 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 19/85] 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 20/85] 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 21/85] 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 22/85] 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 23/85] 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 24/85] 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 25/85] 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 26/85] 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 27/85] 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 28/85] 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 29/85] 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 30/85] 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 31/85] 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 32/85] 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 33/85] 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 34/85] 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 35/85] 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 36/85] 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 37/85] 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 38/85] 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 39/85] 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 40/85] 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 41/85] 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 42/85] 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 43/85] 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 44/85] 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 45/85] 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 46/85] 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 47/85] 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 48/85] 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 49/85] 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 50/85] 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 51/85] 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 52/85] 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 53/85] 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 54/85] 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 55/85] 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 56/85] 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 57/85] 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 58/85] 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 59/85] 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 60/85] 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 61/85] 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 62/85] 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 63/85] 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 64/85] 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 65/85] 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 66/85] 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 67/85] 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 68/85] 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 69/85] 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 70/85] 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 71/85] 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 72/85] 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 73/85] 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 74/85] 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 75/85] 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 76/85] 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 77/85] 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 78/85] 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 79/85] 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 80/85] 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 81/85] 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 82/85] 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 83/85] 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 84/85] 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 85/85] 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