Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions presets/PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ preset:

requires:
speckit_version: ">=0.1.0" # Required spec-kit version
extensions: # Optional: extensions this preset needs
- "companion-extension"

provides:
templates:
Expand All @@ -93,6 +95,41 @@ tags: # 2-5 relevant tags
- ✅ Command names use dot notation (e.g. `speckit.specify`)
- ✅ Tags are lowercase and descriptive

#### Declaring extension dependencies

If your preset overrides commands that call into an extension, declare it in
`requires.extensions`. Without the extension the preset still installs and the
overrides fall through to the core workflow, so nothing errors — the feature
just silently does less than the user expects. Declaring the dependency makes
`specify preset add` say so, and spell out how to resolve it.

Use a bare id, or a mapping when you need a version constraint or an optional
dependency:

```yaml
requires:
speckit_version: ">=0.9.0"
extensions:
- "companion-extension" # required, any version
- id: "other-extension"
version: ">=1.2.0,<2" # optional PEP 440 specifier
required: false # optional, defaults to true
```

`version` accepts any PEP 440 specifier, not just a lower bound — upper bounds
(`<2`), exact pins (`==1.2.0`), and exclusions (`!=1.3.0`) all work.

Notes:

- The field is optional. A preset that declares nothing behaves exactly as before.
- A dependency that is missing, stale, disabled, or version-unsatisfied produces a **warning, not a failure** — the install still succeeds.
- A disabled extension counts as unmet, and so does one whose registry entry survives after its files were removed. Resolution skips both, so the preset is just as inert as if the extension were absent.
- The warning names an exact command for the missing, stale, and disabled cases. For a version mismatch it states the constraint to satisfy rather than naming a command, because `specify extension update` only moves forward to the catalog release and cannot satisfy an upper bound, a pin, or a downgrade.
- A recorded version that cannot be parsed is treated as uncomparable rather than as a mismatch, so an extension whose registry version reads `unknown` is not reported as failing a constraint it was never evaluated against.
- An extension present on disk but absent from the registry counts as satisfied. Resolution admits unregistered directories, so the preset works and warning about it would be a false alarm — though with no recorded version, a `version` constraint cannot be checked against it.
- `required: false` documents an enhancing-but-optional extension and is never warned about.
- Declare it in `preset.yml`, not only in your catalog entry. The catalog is not consulted for `--dev` and `--from <url>` installs, so the manifest is the only copy present on every install path.

### 3. Test Locally

```bash
Expand Down
279 changes: 279 additions & 0 deletions src/specify_cli/presets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,20 @@ def _content_sha256(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()


def _is_comparable_version(value: str) -> bool:
"""Return whether a recorded version can be evaluated against a specifier.

``version_satisfies()`` answers "does not satisfy" for an unparseable
version, which is indistinguishable from a genuine mismatch. Callers that
need to tell those apart check here first.
"""
try:
pkg_version.Version(value)
except pkg_version.InvalidVersion:
return False
return True


def _constitution_is_generated(
project_root: Path,
memory_constitution: Path,
Expand Down Expand Up @@ -381,6 +395,14 @@ def _validate(self):
f"got {type(requires['speckit_version']).__name__}"
)

# Validate the optional extension dependency list. A preset that
# overrides commands calling into an extension is inert without it, and
# until now the only place that could be said was the README -- see
# issue #4231. Absent means "no dependencies", so every existing preset
# stays valid.
if "extensions" in requires:
self._validate_requires_extensions(requires["extensions"])

# Validate provides section
provides = self.data["provides"]
if "templates" not in provides:
Expand Down Expand Up @@ -524,11 +546,121 @@ def author(self) -> str:
"""Get preset author."""
return self.data["preset"].get("author", "")

@staticmethod
def _validate_requires_extensions(declared: Any) -> None:
"""Validate the optional ``requires.extensions`` list.

Accepts either a bare extension id or a mapping carrying an optional
version specifier and an optional ``required`` flag:

.. code-block:: yaml

requires:
extensions:
- speckit-inventory
- id: other-ext
version: ">=1.2.0"
required: false

Raises:
PresetValidationError: If the list or any entry is malformed.
"""
if not isinstance(declared, list):
raise PresetValidationError(
"Invalid requires.extensions: expected a list, "
f"got {type(declared).__name__}"
)

for index, entry in enumerate(declared):
label = f"requires.extensions[{index}]"

if isinstance(entry, str):
entry = {"id": entry}
elif not isinstance(entry, dict):
raise PresetValidationError(
f"Invalid {label}: expected a string or a mapping, "
f"got {type(entry).__name__}"
)

if "id" not in entry:
raise PresetValidationError(f"Missing {label}.id")
extension_id = entry["id"]
if not isinstance(extension_id, str):
raise PresetValidationError(
f"Invalid {label}.id: expected a string, "
f"got {type(extension_id).__name__}"
)
# Same id shape the extension loader enforces, so a dependency can
# never name something that could not be installed in the first
# place. fullmatch rather than match with an anchored pattern: `$`
# also matches before a trailing newline, so "demo-ext\n" would
# otherwise validate here while PresetResolver._is_safe_registry_id
# (which uses fullmatch) rejects it, and the newline would land in
# a suggested command.
if not re.fullmatch(r'[a-z0-9-]+', extension_id):
raise PresetValidationError(
f"Invalid {label}.id {extension_id!r}: "
"must be lowercase alphanumeric with hyphens only"
)

if "version" in entry:
constraint = entry["version"]
# Mirrors the requires.speckit_version reasoning: a non-string
# escapes InvalidSpecifier two ways -- scalars raise TypeError
# from the constructor, and a list/dict is iterable so it
# constructs and only fails later inside .contains().
if not isinstance(constraint, str) or not constraint.strip():
raise PresetValidationError(
f"Invalid {label}.version: expected a non-empty string, "
f"got {type(constraint).__name__}"
)
try:
SpecifierSet(constraint)
except InvalidSpecifier:
raise PresetValidationError(
f"Invalid {label}.version '{constraint}': "
"not a valid version specifier"
)

if "required" in entry and not isinstance(entry["required"], bool):
raise PresetValidationError(
f"Invalid {label}.required: expected a boolean, "
f"got {type(entry['required']).__name__}"
)

@property
def requires_speckit_version(self) -> str:
"""Get required spec-kit version range."""
return self.data["requires"]["speckit_version"]

@property
def requires_extensions(self) -> List[Dict[str, Any]]:
"""Get declared extension dependencies, normalized to mappings.

Returns:
One entry per dependency with ``id``, ``version`` (``None`` when
unconstrained), and ``required`` (defaulting to ``True``). Empty
when the manifest declares no dependencies.
"""
declared = self.data["requires"].get("extensions")
if not isinstance(declared, list):
return []

normalized: List[Dict[str, Any]] = []
for entry in declared:
if isinstance(entry, str):
entry = {"id": entry}
if not isinstance(entry, dict) or not isinstance(entry.get("id"), str):
continue
normalized.append(
{
"id": entry["id"],
"version": entry.get("version"),
"required": entry.get("required", True),
}
)
return normalized

@property
def templates(self) -> List[Dict[str, Any]]:
"""Get list of provided templates."""
Expand Down Expand Up @@ -841,6 +973,153 @@ def check_compatibility(

return True

def find_unmet_extension_dependencies(
self,
manifest: PresetManifest
) -> List[Dict[str, Any]]:
"""Find declared extension dependencies that are not satisfied.

Reports rather than raises. A preset whose overrides call into an
extension is written to degrade safely -- without the extension the
core workflow still runs -- so a missing dependency is a warning, not
an install failure. See issue #4231.

Args:
manifest: Preset manifest to inspect

Returns:
One entry per unsatisfied dependency, each with ``id``, the
requested ``version`` specifier (``None`` when unconstrained), the
``installed`` version (``None`` when absent or unusable), and a
``reason`` of ``"missing"``, ``"corrupt"``, ``"stale"``,
``"disabled"``, or ``"version"``. Optional dependencies
(``required: false``) are never reported.

An unreadable registry yields no results rather than raising, since
this runs after the install has already succeeded.

A registry version that cannot be parsed is treated as
uncomparable, not as a mismatch: the extension is installed and
usable, and only its recorded version is unreadable. An extension
present on disk but absent from the registry is likewise treated as
satisfied, because resolution admits unregistered directories.
"""
# Defense in depth, mirroring check_compatibility(): this method is
# public and also reachable with a hand-built manifest object that
# predates this field. A manifest without it declares nothing.
candidates = getattr(manifest, "requires_extensions", None)
if not isinstance(candidates, list):
return []

# Collapse exact repeats so a manifest naming the same dependency twice
# warns once. Two entries for one id with *different* constraints are
# kept, since both genuinely have to hold.
declared: List[Dict[str, Any]] = []
seen: Set[tuple] = set()
for dep in candidates:
if not isinstance(dep, dict) or not dep.get("required", True):
continue
key = (dep.get("id"), dep.get("version"))
if key in seen:
continue
seen.add(key)
declared.append(dep)
if not declared:
return []

extensions_dir = self.project_root / ".specify" / "extensions"
try:
registry = ExtensionRegistry(extensions_dir)
registered_ids = registry.keys()
registry_corrupt = registry.is_corrupt()
except OSError:
# Both reads can raise: _load() recovers from malformed content but
# deliberately lets OSError through, and is_corrupt() re-reads the
# file. This check runs *after* the install has completed, and
# preset_add only handles preset-domain errors, so letting that
# escape would turn a finished install into a traceback over a
# warning. An unreadable registry simply cannot be inspected.
return []

unmet: List[Dict[str, Any]] = []

for dep in declared:
metadata = registry.get(dep["id"])
if metadata is None:
# An absent registry entry does not mean the extension is
# unusable. _get_all_extensions_by_priority() admits a safe
# on-disk directory as an unregistered extension at implicit
# priority 10, so it resolves and the preset works -- but only
# when the registry is readable, since a corrupt one makes that
# path fail closed and contribute nothing.
#
# get() returns None for a corrupted (non-dict) entry as well as
# an absent one, but keys() retains corrupted ids -- both so
# resolution does not re-admit their directories as
# unregistered, and because is_installed() still counts them, so
# a plain `extension add` would be refused as already installed.
# That is a different state from absent, and needs a different
# remedy.
if dep["id"] in registered_ids:
unmet.append({**dep, "installed": None, "reason": "corrupt"})
continue
if (
(extensions_dir / dep["id"]).is_dir()
and PresetResolver._is_safe_registry_id(dep["id"])
and not registry_corrupt
):
# Unregistered means no recorded version, so a constraint
# cannot be evaluated -- uncomparable, not unsatisfied.
continue
unmet.append({**dep, "installed": None, "reason": "missing"})
Comment thread
Copilot marked this conversation as resolved.
continue
Comment thread
Copilot marked this conversation as resolved.

installed_version = metadata.get("version")
installed_version = (
installed_version if isinstance(installed_version, str) else None
)

# A registry entry is not proof the extension can contribute. If
# its directory is gone, PresetResolver skips it outright (both
# template lookup and layer collection guard on ``is_dir()``), so
# the preset is as inert as if it were never installed -- but the
# surviving entry would otherwise read as satisfied.
if not (extensions_dir / dep["id"]).is_dir():
unmet.append(
{**dep, "installed": installed_version, "reason": "stale"}
)
continue

# A disabled extension is registered but contributes nothing:
# resolution skips it (see _collect_extension_layers), so the
# preset is just as inert as if it were absent. Report it before
# any version check -- enabling it is the prerequisite, and the
# version may well be fine once it is.
if not metadata.get("enabled", True):
unmet.append(
{**dep, "installed": installed_version, "reason": "disabled"}
)
continue

constraint = dep["version"]
if not constraint:
continue

# A version that cannot be compared is not a mismatch. Absent or
# non-string is one way to be unusable; an unparseable string such
# as "unknown" is another, and version_satisfies() cannot tell them
# apart -- it catches InvalidVersion and returns False, which would
# report a mismatch against a version nobody can evaluate. Check
# parseability up front so only real comparisons reach the warning.
if installed_version is None or not _is_comparable_version(installed_version):
continue
if not version_satisfies(installed_version, constraint):
unmet.append(
{**dep, "installed": installed_version, "reason": "version"}
)

return unmet

def _register_commands(
self,
manifest: PresetManifest,
Expand Down
Loading