Skip to content
Open
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
12 changes: 8 additions & 4 deletions src/specify_cli/bundler/models/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,10 @@ def from_dict(cls, data: Any) -> "BundleManifest":
license=str(bundle_raw.get("license", "")).strip(),
)

requires_raw = data.get("requires") or {}
if not isinstance(requires_raw, dict):
requires_raw = data.get("requires")
if requires_raw is None:
requires_raw = {}
elif not isinstance(requires_raw, dict):
raise BundlerError("'requires' must be a mapping when present.")
requires = Requires(
speckit_version=str(requires_raw.get("speckit_version", "")).strip(),
Expand All @@ -130,8 +132,10 @@ def from_dict(cls, data: Any) -> "BundleManifest":
if isinstance(integration_raw, dict) and integration_raw.get("id"):
integration = IntegrationRef(id=str(integration_raw["id"]).strip())

provides = data.get("provides") or {}
if not isinstance(provides, dict):
provides = data.get("provides")
if provides is None:
provides = {}
elif not isinstance(provides, dict):
raise BundlerError("'provides' must be a mapping when present.")

tags_raw = data.get("tags")
Expand Down
31 changes: 31 additions & 0 deletions tests/contract/test_manifest_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,34 @@ def test_string_integration_rejected_not_silently_dropped():
data["integration"] = "copilot"
with pytest.raises(BundlerError, match="'integration' must be a mapping when present"):
BundleManifest.from_dict(data)


@pytest.mark.parametrize("bad", [[], "", 0, False, "extensions"])
def test_non_mapping_provides_rejected_including_falsy(bad):
# `data.get("provides") or {}` coerced a FALSY non-mapping ([], '', 0, False)
# to {} before the type check, so a malformed manifest passed validation as
# a bundle that provides nothing. Only an absent/None value means "empty".
data = valid_manifest_dict()
data["provides"] = bad
with pytest.raises(BundlerError, match="'provides' must be a mapping when present"):
BundleManifest.from_dict(data)


@pytest.mark.parametrize("bad", [[], "", 0, False, "speckit>=0.1"])
def test_non_mapping_requires_rejected_including_falsy(bad):
# Same falsy-coercion hole for `requires`.
data = valid_manifest_dict()
data["requires"] = bad
with pytest.raises(BundlerError, match="'requires' must be a mapping when present"):
BundleManifest.from_dict(data)


def test_absent_provides_and_requires_do_not_raise_mapping_error():
# Absent (None) optional mappings default to empty and must NOT trigger the
# "must be a mapping when present" guard — that is reserved for present
# non-mappings. (Structural completeness, e.g. requires.speckit_version, is
# a separate concern checked by structural_errors().)
data = valid_manifest_dict()
data.pop("provides", None)
data.pop("requires", None)
BundleManifest.from_dict(data) # does not raise BundlerError