mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
fix(manifests): reject non-string requires.speckit_version (#3980)
`requires.speckit_version` was presence-checked but never type-checked in both the extension and preset manifest validators, so an unquoted YAML `speckit_version: 1.0` (a float) passed validation and reached `SpecifierSet(required)` in `check_compatibility()`. That call is guarded by `except InvalidSpecifier` alone, which a non-string escapes two different ways: - a float/int/bool/None raises `TypeError: 'float' object is not iterable` from the `SpecifierSet` constructor; - a list or dict is an *iterable*, so `SpecifierSet` accepts it and the failure surfaces much later as `AttributeError: 'str' object has no attribute 'filter'` from inside `.contains()`. Neither is a `CompatibilityError`/`PresetCompatibilityError`, so both bypass the CLI's "Compatibility Error" handler in `_commands.py` and exit 1 with a raw traceback that names no field, leaving the author with no hint which manifest key is wrong. Type-check the field in both validators, requiring a non-empty string, and additionally guard `check_compatibility()` in both managers since each is public and reachable with a hand-built or mutated manifest. This mirrors the sibling `IntegrationDescriptor`, which already requires a non-empty string for the same key, and completes the type-checking pass started in #3943 for the neighbouring `extension`/`preset` fields. Adds 33 regression tests across both modules covering every escape path; 26 of them fail without this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Assisted-by: Claude Code (model: Claude Opus 5, supervised)
This commit is contained in:
co-authored by
Claude Opus 5
parent
0fa86e8e9c
commit
cd996f74eb
@@ -341,6 +341,25 @@ class ExtensionManifest:
|
||||
)
|
||||
if "speckit_version" not in requires:
|
||||
raise ValidationError("Missing requires.speckit_version")
|
||||
# Presence alone is not enough: check_compatibility() feeds this value to
|
||||
# ``SpecifierSet(required)``, guarded only by ``except InvalidSpecifier``,
|
||||
# which a non-string escapes two different ways. A float/int/bool/None
|
||||
# raises TypeError from the constructor, while a list or dict is an
|
||||
# *iterable*, so SpecifierSet accepts it and the failure surfaces much
|
||||
# later as ``AttributeError: 'str' object has no attribute 'filter'`` from
|
||||
# inside .contains(). Neither is a CompatibilityError, so both bypass the
|
||||
# CLI's "Compatibility Error" handler and exit 1 with a raw traceback
|
||||
# naming no field. An unquoted ``speckit_version: 1.0`` is an easy YAML
|
||||
# slip. Mirrors the sibling IntegrationDescriptor, which already requires
|
||||
# a non-empty string here.
|
||||
if (
|
||||
not isinstance(requires["speckit_version"], str)
|
||||
or not requires["speckit_version"].strip()
|
||||
):
|
||||
raise ValidationError(
|
||||
"Invalid requires.speckit_version: expected a non-empty string, "
|
||||
f"got {type(requires['speckit_version']).__name__}"
|
||||
)
|
||||
|
||||
# Validate provides section
|
||||
provides = self.data["provides"]
|
||||
@@ -1851,6 +1870,17 @@ class ExtensionManager:
|
||||
required = manifest.requires_speckit_version
|
||||
|
||||
# Parse version specifier (e.g., ">=0.1.0,<2.0.0")
|
||||
# Defense in depth: the manifest validator now rejects a non-string
|
||||
# requires.speckit_version, but this method is public and also reachable
|
||||
# with a hand-built manifest object. ``InvalidSpecifier`` alone does not
|
||||
# cover a non-string -- scalars raise TypeError from the constructor, and
|
||||
# a list/dict is iterable so it constructs here and only breaks inside
|
||||
# .contains(). Reject up front so this always reports a CompatibilityError.
|
||||
if not isinstance(required, str):
|
||||
raise CompatibilityError(
|
||||
"Invalid version specifier: expected a string, got "
|
||||
f"{type(required).__name__} ({required!r})"
|
||||
)
|
||||
try:
|
||||
SpecifierSet(required) # Just to validate
|
||||
except InvalidSpecifier:
|
||||
|
||||
@@ -344,6 +344,25 @@ class PresetManifest:
|
||||
requires = self.data["requires"]
|
||||
if "speckit_version" not in requires:
|
||||
raise PresetValidationError("Missing requires.speckit_version")
|
||||
# Presence alone is not enough: check_compatibility() feeds this value to
|
||||
# ``SpecifierSet(required)``, guarded only by ``except InvalidSpecifier``,
|
||||
# which a non-string escapes two different ways. A float/int/bool/None
|
||||
# raises TypeError from the constructor, while a list or dict is an
|
||||
# *iterable*, so SpecifierSet accepts it and the failure surfaces much
|
||||
# later as ``AttributeError: 'str' object has no attribute 'filter'`` from
|
||||
# inside .contains(). Neither is a PresetCompatibilityError, so both
|
||||
# bypass the CLI's "Compatibility Error" handler and exit 1 with a raw
|
||||
# traceback naming no field. An unquoted ``speckit_version: 1.0`` is an
|
||||
# easy YAML slip. Mirrors the sibling IntegrationDescriptor, which already
|
||||
# requires a non-empty string here.
|
||||
if (
|
||||
not isinstance(requires["speckit_version"], str)
|
||||
or not requires["speckit_version"].strip()
|
||||
):
|
||||
raise PresetValidationError(
|
||||
"Invalid requires.speckit_version: expected a non-empty string, "
|
||||
f"got {type(requires['speckit_version']).__name__}"
|
||||
)
|
||||
|
||||
# Validate provides section
|
||||
provides = self.data["provides"]
|
||||
@@ -756,6 +775,18 @@ class PresetManager:
|
||||
PresetCompatibilityError: If pack is incompatible
|
||||
"""
|
||||
required = manifest.requires_speckit_version
|
||||
# Defense in depth: the manifest validator now rejects a non-string
|
||||
# requires.speckit_version, but this method is public and also reachable
|
||||
# with a hand-built manifest object. ``InvalidSpecifier`` alone does not
|
||||
# cover a non-string -- scalars raise TypeError from the constructor, and
|
||||
# a list/dict is iterable so it constructs here and only breaks inside
|
||||
# .contains(). Reject up front so this always reports a
|
||||
# PresetCompatibilityError.
|
||||
if not isinstance(required, str):
|
||||
raise PresetCompatibilityError(
|
||||
"Invalid version specifier: expected a string, got "
|
||||
f"{type(required).__name__} ({required!r})"
|
||||
)
|
||||
try:
|
||||
SpecifierSet(required) # Just to validate
|
||||
except InvalidSpecifier:
|
||||
|
||||
Reference in New Issue
Block a user