From 9ca684854abd57d07e594627570601d1f0c4715b Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Wed, 22 Jul 2026 21:30:22 +0500 Subject: [PATCH] fix(bundler): reject a top-level non-mapping bundle-catalogs.yml in _merge_config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _merge_config silently ignored a top-level non-mapping document (a YAML list or scalar) — `data.get("catalogs") if isinstance(data, dict) else None` made it fall through to the built-in default stack — while the sibling reader of the SAME file (commands_impl/catalog_config._read) raises "expected a mapping at the top level". #3623 already made the inner non-list `catalogs` value agree between the two readers; this closes the remaining top-level-shape gap so both readers reject the same malformed documents. An empty file (load_yaml coerces to {}), absent `catalogs`, and `catalogs: []` all remain no-ops. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/specify_cli/bundler/models/catalog.py | 12 +++++++++++- tests/contract/test_catalog_schema.py | 11 +++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/bundler/models/catalog.py b/src/specify_cli/bundler/models/catalog.py index 293d31c69b..bb3b818847 100644 --- a/src/specify_cli/bundler/models/catalog.py +++ b/src/specify_cli/bundler/models/catalog.py @@ -250,7 +250,17 @@ def _merge_config(by_id: dict[str, CatalogSource], config_path: Path, scope: Sco if not config_path.exists(): return data = load_yaml(config_path) - catalogs = data.get("catalogs") if isinstance(data, dict) else None + if not isinstance(data, dict): + # A top-level non-mapping (a YAML list or scalar) is malformed. The + # sibling reader of the SAME file (commands_impl/catalog_config._read) + # raises here; #3623 already made the inner non-list `catalogs` value + # agree between the two readers, and this closes the remaining + # top-level-shape gap so both readers reject the same documents. + raise BundlerError( + f"Malformed catalog config at {config_path}: expected a mapping at " + f"the top level, got {type(data).__name__}." + ) + catalogs = data.get("catalogs") if catalogs is None: return if not isinstance(catalogs, list): diff --git a/tests/contract/test_catalog_schema.py b/tests/contract/test_catalog_schema.py index bfa289d497..e2efd40029 100644 --- a/tests/contract/test_catalog_schema.py +++ b/tests/contract/test_catalog_schema.py @@ -68,6 +68,17 @@ def test_falsy_non_list_catalogs_still_raises(tmp_path: Path, value: str): load_source_stack(tmp_path) +@pytest.mark.parametrize("body", ["- a\n- b\n", "42\n"]) +def test_toplevel_non_mapping_raises(tmp_path: Path, body: str): + """A top-level non-mapping bundle-catalogs.yml (a YAML list or scalar) must + raise, matching the sibling reader (catalog_config._read) — not silently + fall back to the built-in default stack.""" + make_project(tmp_path) + (tmp_path / ".specify" / "bundle-catalogs.yml").write_text(body, encoding="utf-8") + with pytest.raises(BundlerError, match="expected a mapping at the top level"): + load_source_stack(tmp_path) + + @pytest.mark.parametrize("body", ["catalogs:\n", "catalogs: []\n"]) def test_absent_or_empty_catalogs_is_noop(tmp_path: Path, body: str): """An absent (``None``) or empty-list ``catalogs:`` is valid: it contributes