mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
fix(bundler): reject falsy non-list bundles/contributed_components in records (#3666)
load_records and InstalledBundleRecord.from_dict defaulted their list fields
with `data.get(...) or []` BEFORE the isinstance(list) guard, so a FALSY
non-list value (0, '', False, {}) was coerced to [] and the guard became dead
code — a corrupt .specify/bundle-records.json was silently read as "no
bundles"/"no components" instead of raising. Only an absent/None value should
mean empty.
Handle None explicitly and reject every other non-list, mirroring the merged
requires/provides/integration guards (#3629, #3661) and the catalog_config
sibling reader.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,8 +55,13 @@ class InstalledBundleRecord:
|
||||
def from_dict(cls, data: Any) -> "InstalledBundleRecord":
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError("Each installed-bundle record must be a mapping.")
|
||||
components_raw = data.get("contributed_components") or []
|
||||
if not isinstance(components_raw, list):
|
||||
components_raw = data.get("contributed_components")
|
||||
if components_raw is None:
|
||||
components_raw = []
|
||||
elif not isinstance(components_raw, list):
|
||||
# `or []` would coerce a FALSY non-list (0, '', False, {}) to []
|
||||
# before this guard, silently accepting a corrupt record; only an
|
||||
# absent/None value means "no components".
|
||||
raise BundlerError(
|
||||
"Corrupt record: 'contributed_components' must be a list."
|
||||
)
|
||||
@@ -121,8 +126,13 @@ def load_records(project_root: Path) -> list[InstalledBundleRecord]:
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError(f"Corrupt records file: {path}")
|
||||
_check_schema_version(data.get("schema_version"), path=path, required=True)
|
||||
bundles = data.get("bundles") or []
|
||||
if not isinstance(bundles, list):
|
||||
bundles = data.get("bundles")
|
||||
if bundles is None:
|
||||
bundles = []
|
||||
elif not isinstance(bundles, list):
|
||||
# `or []` would coerce a FALSY non-list (0, '', False, {}) to [] before
|
||||
# this guard, silently treating a corrupt file as "no bundles"; only an
|
||||
# absent/None value means empty.
|
||||
raise BundlerError(
|
||||
f"Corrupt records file: {path} — 'bundles' must be a list."
|
||||
)
|
||||
|
||||
@@ -45,6 +45,27 @@ def test_load_missing_file_returns_empty(tmp_path: Path):
|
||||
assert load_records(tmp_path) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, False, "", {}])
|
||||
def test_load_records_rejects_falsy_non_list_bundles(tmp_path: Path, bad):
|
||||
# `data.get("bundles") or []` coerced a FALSY non-list (0, '', False, {})
|
||||
# to [] before the isinstance guard, silently treating a corrupt records
|
||||
# file as "no bundles". Only an absent/None value means empty.
|
||||
(tmp_path / ".specify").mkdir()
|
||||
records_path(tmp_path).write_text(
|
||||
json.dumps({"schema_version": "1.0", "bundles": bad}), encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(BundlerError, match="'bundles' must be a list"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, False, "", {}])
|
||||
def test_from_dict_rejects_falsy_non_list_contributed_components(bad):
|
||||
# Same falsy-coercion hole for a record's 'contributed_components'.
|
||||
data = {"bundle_id": "a", "version": "1.0.0", "contributed_components": bad}
|
||||
with pytest.raises(BundlerError, match="'contributed_components' must be a list"):
|
||||
InstalledBundleRecord.from_dict(data)
|
||||
|
||||
|
||||
def test_corrupt_priority_raises_actionable_error(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
rec = _record("a", [("presets", "p1")])
|
||||
|
||||
Reference in New Issue
Block a user