mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user