fix(bundler): treat an explicit-null manifest field as missing, not the text "None" (#3798)

BundleManifest.from_dict read every required scalar as
`str(raw.get(key, "")).strip()`. The `""` default only covers a MISSING key. A
key present but null -- exactly how YAML spells an empty field (`author:` with
nothing after it) -- yields None, and `str(None)` is the literal string "None".
That value is non-empty, so it sailed past the `if not value` required-field
checks in structural_errors().

Reproduced on main:

    bundle.yml with description:/author:/license: left empty
    -> description='None'  author='None'  license='None'
    -> structural_errors() == []
    -> specify bundle validate: exit 0, "demo is well-formed and valid."

So an empty required field was silently accepted and the bundle shipped the
literal text "None" as its author/license/description -- which is what
`bundle info` and a catalog entry then display. A null `provides.<kind>[].id`
likewise became a component literally named "None".

Add a `_text()` helper beside the existing `_parse_str_list` (the file's
established "one coercion helper applied at every site" shape) mapping an
explicit null to "", and route the required scalars through it. Same
silent-acceptance class as the already-merged guards in this function: #3629
(non-mapping `integration:`) and #3661 (falsy non-mapping requires/provides).

Non-null values are still `str()`-coerced and stripped, and an absent key
already produced "" -- so valid manifests are byte-for-byte unaffected.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali jawwad
2026-07-29 18:40:26 +05:00
committed by GitHub
parent f8e474d6fd
commit 884950f88a
2 changed files with 65 additions and 10 deletions

View File

@@ -96,19 +96,19 @@ class BundleManifest:
if not isinstance(data, dict):
raise BundlerError("Manifest must be a YAML mapping at the top level.")
schema_version = str(data.get("schema_version", "")).strip()
schema_version = _text(data.get("schema_version"))
bundle_raw = data.get("bundle")
if not isinstance(bundle_raw, dict):
raise BundlerError("Manifest is missing the required 'bundle' mapping.")
meta = BundleMeta(
id=str(bundle_raw.get("id", "")).strip(),
name=str(bundle_raw.get("name", "")).strip(),
version=str(bundle_raw.get("version", "")).strip(),
role=str(bundle_raw.get("role", "")).strip(),
description=str(bundle_raw.get("description", "")).strip(),
author=str(bundle_raw.get("author", "")).strip(),
license=str(bundle_raw.get("license", "")).strip(),
id=_text(bundle_raw.get("id")),
name=_text(bundle_raw.get("name")),
version=_text(bundle_raw.get("version")),
role=_text(bundle_raw.get("role")),
description=_text(bundle_raw.get("description")),
author=_text(bundle_raw.get("author")),
license=_text(bundle_raw.get("license")),
)
requires_raw = data.get("requires")
@@ -117,7 +117,7 @@ class BundleManifest:
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(),
speckit_version=_text(requires_raw.get("speckit_version")),
tools=_parse_str_list(requires_raw.get("tools"), "requires.tools"),
mcp=_parse_str_list(requires_raw.get("mcp"), "requires.mcp"),
)
@@ -220,6 +220,22 @@ class BundleManifest:
return self.integration is None
def _text(raw: Any) -> str:
"""Coerce a manifest scalar into stripped text, mapping an explicit null to ``""``.
A ``.get(key, "")`` default only covers a *missing* key. A key that is
present but null -- how YAML spells an empty field (``author:`` with nothing
after it) -- yields ``None``, and ``str(None)`` is the literal ``"None"``.
That text is non-empty, so it sailed past the ``if not value`` required-field
checks in :meth:`BundleManifest.structural_errors`: an empty required field
was silently accepted and the bundle shipped ``"None"`` as its
author/license/description.
"""
if raw is None:
return ""
return str(raw).strip()
def _parse_str_list(raw: Any, field_name: str) -> tuple[str, ...]:
"""Coerce a manifest list-of-strings field into a tuple of strings.
@@ -247,7 +263,7 @@ def _parse_refs(kind: str, raw: Any) -> list[ComponentRef]:
refs.append(
ComponentRef(
kind=kind,
id=str(item.get("id", "")).strip(),
id=_text(item.get("id")),
version=(str(item["version"]).strip() if item.get("version") else None),
source=(str(item["source"]).strip() if item.get("source") else None),
priority=priority,

View File

@@ -26,6 +26,45 @@ def test_missing_required_field_is_reported_by_name():
assert any("bundle.license" in e for e in errors)
@pytest.mark.parametrize(
"field", ["name", "role", "description", "author", "license"]
)
def test_explicit_null_bundle_field_is_reported_as_missing(field):
"""A field present but null is how YAML spells an empty value (`author:`).
`str(None)` is the literal text "None", which is non-empty, so it passed the
required-field checks: the bundle validated clean and shipped "None" as its
author/license/description.
"""
data = valid_manifest_dict()
data["bundle"][field] = None
manifest = BundleManifest.from_dict(data)
assert getattr(manifest.bundle, field) == ""
assert any(f"bundle.{field}" in e for e in manifest.structural_errors())
def test_explicit_null_speckit_version_is_reported_as_missing():
data = valid_manifest_dict()
data["requires"]["speckit_version"] = None
manifest = BundleManifest.from_dict(data)
assert manifest.requires.speckit_version == ""
assert any("speckit_version" in e for e in manifest.structural_errors())
def test_explicit_null_component_id_is_not_named_none():
"""A null component id must not become a component literally named "None"."""
data = valid_manifest_dict()
for kind, items in (data.get("provides") or {}).items():
if isinstance(items, list) and items and isinstance(items[0], dict):
items[0]["id"] = None
break
else: # pragma: no cover - fixture is expected to provide components
pytest.skip("fixture has no component list to null out")
manifest = BundleManifest.from_dict(data)
assert manifest.components, "fixture is expected to declare components"
assert all(ref.id != "None" for ref in manifest.components)
def test_unsupported_schema_version_is_rejected():
data = valid_manifest_dict(schema_version="9.9")
errors = BundleManifest.from_dict(data).structural_errors()