From dbb720fa26f80c842e297740fcb706c0b9aec148 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Wed, 22 Jul 2026 21:30:22 +0500 Subject: [PATCH 1/4] 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 848ec789da..ab1aa69381 100644 --- a/src/specify_cli/bundler/models/catalog.py +++ b/src/specify_cli/bundler/models/catalog.py @@ -257,7 +257,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 64cd47db6e..8c2ee04f9f 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 From 5a2e08f78c97ec53fccae72f76f4fbe58c252cdd Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Thu, 23 Jul 2026 18:52:15 +0500 Subject: [PATCH 2/4] fix(bundler): reject FALSY non-mapping catalog configs (parse raw, not load_yaml) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review (Copilot on #3659): the top-level guard used the shared load_yaml, whose `yaml.safe_load(...) or {}` coerces a FALSY top-level document ([], false, 0, '') to {} BEFORE the isinstance check — so those malformed configs silently fell back to the built-in defaults instead of raising. Only truthy non-mappings ([a,b], 42) were caught. Parse the raw document in both readers of bundle-catalogs.yml (models/catalog._merge_config AND commands_impl/catalog_config._read): an empty document (None) stays a no-op, but every non-mapping top level — falsy or truthy — now raises "expected a mapping at the top level". This keeps the two readers genuinely consistent. Tests cover the falsy cases for both. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bundler/commands_impl/catalog_config.py | 16 ++++++++++-- src/specify_cli/bundler/models/catalog.py | 25 +++++++++++++------ tests/contract/test_catalog_schema.py | 20 ++++++++++++--- tests/unit/test_bundler_catalog_config.py | 13 ++++++++++ 4 files changed, 61 insertions(+), 13 deletions(-) diff --git a/src/specify_cli/bundler/commands_impl/catalog_config.py b/src/specify_cli/bundler/commands_impl/catalog_config.py index dadc7e9672..59037f4d91 100644 --- a/src/specify_cli/bundler/commands_impl/catalog_config.py +++ b/src/specify_cli/bundler/commands_impl/catalog_config.py @@ -10,8 +10,10 @@ from urllib.parse import urlparse import re +import yaml + from .. import BundlerError -from ..lib.yamlio import dump_yaml, ensure_within, load_yaml +from ..lib.yamlio import dump_yaml, ensure_within from ..models.catalog import ( CONFIG_FILENAME, BUILTIN_DEFAULT_STACK, @@ -40,7 +42,17 @@ def _read(project_root: Path) -> list[dict]: path = ensure_within(project_root, _config_path(project_root)) if not path.exists(): return [] - data = load_yaml(path) + # Parse the RAW document, not the shared ``load_yaml`` (whose ``… or {}`` + # coerces a falsy top-level value to ``{}``): an empty document is a no-op, + # but a falsy non-mapping (``[]``/``false``/``0``/``''``) is malformed and + # must raise like a truthy one, staying consistent with the other reader of + # this file (models/catalog._merge_config). + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise BundlerError(f"Invalid YAML in {path}: {exc}") from exc + except OSError as exc: + raise BundlerError(f"Could not read {path}: {exc}") from exc if data is None: return [] if not isinstance(data, dict): diff --git a/src/specify_cli/bundler/models/catalog.py b/src/specify_cli/bundler/models/catalog.py index ab1aa69381..ea275d428e 100644 --- a/src/specify_cli/bundler/models/catalog.py +++ b/src/specify_cli/bundler/models/catalog.py @@ -11,8 +11,10 @@ from pathlib import Path from typing import Any +import yaml + from .. import BundlerError -from ..lib.yamlio import ensure_within, load_yaml +from ..lib.yamlio import ensure_within CONFIG_FILENAME = "bundle-catalogs.yml" @@ -256,13 +258,22 @@ def load_source_stack(project_root: Path, user_config_dir: Path | None = None) - def _merge_config(by_id: dict[str, CatalogSource], config_path: Path, scope: Scope) -> None: if not config_path.exists(): return - data = load_yaml(config_path) + # Parse the RAW document rather than the shared ``load_yaml`` helper, whose + # ``… or {}`` coerces a falsy top-level value to ``{}``: an empty document + # (``None``) is a no-op, but every non-mapping top-level — including the + # falsy ones ``[]``/``false``/``0``/``''`` that ``load_yaml`` would hide — + # is malformed and must raise, matching the sibling reader + # commands_impl/catalog_config._read (which does the same). #3623 already + # aligned the inner non-list ``catalogs`` value between the two readers. + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise BundlerError(f"Invalid YAML in {config_path}: {exc}") from exc + except OSError as exc: + raise BundlerError(f"Could not read {config_path}: {exc}") from exc + if data is None: + return 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__}." diff --git a/tests/contract/test_catalog_schema.py b/tests/contract/test_catalog_schema.py index 8c2ee04f9f..08f09ee8b2 100644 --- a/tests/contract/test_catalog_schema.py +++ b/tests/contract/test_catalog_schema.py @@ -68,11 +68,23 @@ 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"]) +@pytest.mark.parametrize( + "body", + [ + "- a\n- b\n", # truthy list + "42\n", # truthy scalar + "[]\n", # falsy list + "false\n", # falsy bool + "0\n", # falsy int + "''\n", # falsy empty string + ], +) 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.""" + """A top-level non-mapping bundle-catalogs.yml (list/scalar) must raise, + matching the sibling reader (catalog_config._read) — not silently fall back + to the built-in default stack. This includes FALSY non-mappings ([], false, + 0, ''); the shared load_yaml would coerce those to {} and hide them, so both + readers parse the raw document instead.""" 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"): diff --git a/tests/unit/test_bundler_catalog_config.py b/tests/unit/test_bundler_catalog_config.py index 5ad6ecbb93..337d5b9242 100644 --- a/tests/unit/test_bundler_catalog_config.py +++ b/tests/unit/test_bundler_catalog_config.py @@ -154,6 +154,19 @@ def test_read_rejects_non_mapping_top_level(tmp_path: Path): cc._read(project) +@pytest.mark.parametrize("body", ["[]\n", "false\n", "0\n", "''\n"]) +def test_read_rejects_falsy_non_mapping_top_level(tmp_path: Path, body: str): + # A FALSY non-mapping top level ([], false, 0, '') must raise like a truthy + # one. The shared load_yaml would coerce these to {} and hide them, so _read + # parses the raw document — staying consistent with models/catalog._merge_config. + project = tmp_path / "proj" + (project / ".specify").mkdir(parents=True) + cc._config_path(project).write_text(body, encoding="utf-8") + + with pytest.raises(BundlerError, match="expected a mapping at the top level"): + cc._read(project) + + def test_read_rejects_unknown_schema_version(tmp_path: Path): project = tmp_path / "proj" (project / ".specify").mkdir(parents=True) From 6b10434f6820e87e0522c3092f766b46a4224e91 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Thu, 23 Jul 2026 19:24:36 +0500 Subject: [PATCH 3/4] fix(bundler): correct load_yaml so only empty documents become {} (not falsy non-mappings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review (Copilot re-review of #3659): the previous fix duplicated YAML parsing + exception wrapping inline in two readers, bypassing the centralized yamlio helper. Instead, correct the root cause in load_yaml. load_yaml did `yaml.safe_load(...) or {}`, which coerced ANY falsy result (None empty-doc, but also [], false, 0, '') to {} — contradicting its own docstring ("{} for an empty document") and hiding malformed non-mapping configs from callers' shape guards. Change to `{} if data is None else data` so only an empty document becomes {}; a non-mapping top level is returned as-parsed. Revert the inline raw-parse in models/catalog._merge_config and commands_impl/catalog_config._read back to the centralized load_yaml; their existing `isinstance(data, dict)` guards now correctly reject falsy non-mappings too. All three load_yaml callers (these two + manifest.from_dict) already guard the top-level shape, so none regresses. Falsy-case tests for both readers retained. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bundler/commands_impl/catalog_config.py | 20 +++++----------- src/specify_cli/bundler/lib/yamlio.py | 12 ++++++++-- src/specify_cli/bundler/models/catalog.py | 23 +++++-------------- 3 files changed, 22 insertions(+), 33 deletions(-) diff --git a/src/specify_cli/bundler/commands_impl/catalog_config.py b/src/specify_cli/bundler/commands_impl/catalog_config.py index 59037f4d91..caa6329622 100644 --- a/src/specify_cli/bundler/commands_impl/catalog_config.py +++ b/src/specify_cli/bundler/commands_impl/catalog_config.py @@ -10,10 +10,8 @@ from urllib.parse import urlparse import re -import yaml - from .. import BundlerError -from ..lib.yamlio import dump_yaml, ensure_within +from ..lib.yamlio import dump_yaml, ensure_within, load_yaml from ..models.catalog import ( CONFIG_FILENAME, BUILTIN_DEFAULT_STACK, @@ -42,17 +40,11 @@ def _read(project_root: Path) -> list[dict]: path = ensure_within(project_root, _config_path(project_root)) if not path.exists(): return [] - # Parse the RAW document, not the shared ``load_yaml`` (whose ``… or {}`` - # coerces a falsy top-level value to ``{}``): an empty document is a no-op, - # but a falsy non-mapping (``[]``/``false``/``0``/``''``) is malformed and - # must raise like a truthy one, staying consistent with the other reader of - # this file (models/catalog._merge_config). - try: - data = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - raise BundlerError(f"Invalid YAML in {path}: {exc}") from exc - except OSError as exc: - raise BundlerError(f"Could not read {path}: {exc}") from exc + # ``load_yaml`` returns ``{}`` only for an empty document and the raw parse + # otherwise, so a falsy non-mapping (``[]``/``false``/``0``/``''``) is caught + # by the isinstance guard below and raised like a truthy one, staying + # consistent with the other reader of this file (models/catalog._merge_config). + data = load_yaml(path) if data is None: return [] if not isinstance(data, dict): diff --git a/src/specify_cli/bundler/lib/yamlio.py b/src/specify_cli/bundler/lib/yamlio.py index b5fb0e5691..2b9fc7106f 100644 --- a/src/specify_cli/bundler/lib/yamlio.py +++ b/src/specify_cli/bundler/lib/yamlio.py @@ -39,17 +39,25 @@ def ensure_within(root: Path, candidate: Path) -> Path: def load_yaml(path: Path) -> Any: - """Parse a YAML file, returning ``{}`` for an empty document.""" + """Parse a YAML file, returning ``{}`` for an empty document. + + Only an *empty* document (``yaml.safe_load`` -> ``None``) becomes ``{}``. + A non-empty document is returned exactly as parsed — including a falsy + non-mapping such as ``[]``, ``false``, ``0``, or ``''`` — so callers can + validate the top-level shape (e.g. reject a non-mapping config) instead of + having it silently coerced to an empty mapping. + """ path = Path(path) if not path.exists(): raise BundlerError(f"File not found: {path}") try: with path.open("r", encoding="utf-8") as handle: - return yaml.safe_load(handle) or {} + data = yaml.safe_load(handle) except yaml.YAMLError as exc: raise BundlerError(f"Invalid YAML in {path}: {exc}") from exc except OSError as exc: raise BundlerError(f"Could not read {path}: {exc}") from exc + return {} if data is None else data def dump_yaml(path: Path, data: Any, *, within: Path | None = None) -> Path: diff --git a/src/specify_cli/bundler/models/catalog.py b/src/specify_cli/bundler/models/catalog.py index ea275d428e..9621b5a334 100644 --- a/src/specify_cli/bundler/models/catalog.py +++ b/src/specify_cli/bundler/models/catalog.py @@ -11,10 +11,8 @@ from pathlib import Path from typing import Any -import yaml - from .. import BundlerError -from ..lib.yamlio import ensure_within +from ..lib.yamlio import ensure_within, load_yaml CONFIG_FILENAME = "bundle-catalogs.yml" @@ -258,21 +256,12 @@ def load_source_stack(project_root: Path, user_config_dir: Path | None = None) - def _merge_config(by_id: dict[str, CatalogSource], config_path: Path, scope: Scope) -> None: if not config_path.exists(): return - # Parse the RAW document rather than the shared ``load_yaml`` helper, whose - # ``… or {}`` coerces a falsy top-level value to ``{}``: an empty document - # (``None``) is a no-op, but every non-mapping top-level — including the - # falsy ones ``[]``/``false``/``0``/``''`` that ``load_yaml`` would hide — - # is malformed and must raise, matching the sibling reader - # commands_impl/catalog_config._read (which does the same). #3623 already + # ``load_yaml`` returns ``{}`` only for an empty document and the raw parse + # otherwise, so a non-mapping top level (a YAML list or scalar, including + # the falsy ``[]``/``false``/``0``/``''``) is caught here and raised — + # matching the sibling reader commands_impl/catalog_config._read. #3623 # aligned the inner non-list ``catalogs`` value between the two readers. - try: - data = yaml.safe_load(config_path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - raise BundlerError(f"Invalid YAML in {config_path}: {exc}") from exc - except OSError as exc: - raise BundlerError(f"Could not read {config_path}: {exc}") from exc - if data is None: - return + data = load_yaml(config_path) if not isinstance(data, dict): raise BundlerError( f"Malformed catalog config at {config_path}: expected a mapping at " From 4a777765f8483503faed474643690d4ee40a85ae Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Thu, 23 Jul 2026 20:53:50 +0500 Subject: [PATCH 4/4] fix(bundler): distinguish an empty YAML document from an explicit null in load_yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review (Copilot on #3659): yaml.safe_load returns None for BOTH an empty document AND an explicit null scalar (`null`/`~`), so mapping None to {} still let a top-level null bundle-catalogs.yml fall back to defaults instead of being rejected by the mapping guard. Use yaml.compose (which yields a node only for a non-empty document) to tell the two apart: a truly empty document becomes {}, while an explicit null is returned as None so the callers' isinstance(dict) guard rejects it like any other non-mapping. Drop the now-incorrect `if data is None: return []` short-circuit in catalog_config._read so an explicit null reaches that guard. Tests cover null/~ for both readers plus empty/comment-only no-op. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bundler/commands_impl/catalog_config.py | 9 +++-- src/specify_cli/bundler/lib/yamlio.py | 34 ++++++++++++------- tests/contract/test_catalog_schema.py | 23 +++++++++---- tests/unit/test_bundler_catalog_config.py | 9 ++--- 4 files changed, 48 insertions(+), 27 deletions(-) diff --git a/src/specify_cli/bundler/commands_impl/catalog_config.py b/src/specify_cli/bundler/commands_impl/catalog_config.py index caa6329622..37e45bf990 100644 --- a/src/specify_cli/bundler/commands_impl/catalog_config.py +++ b/src/specify_cli/bundler/commands_impl/catalog_config.py @@ -41,12 +41,11 @@ def _read(project_root: Path) -> list[dict]: if not path.exists(): return [] # ``load_yaml`` returns ``{}`` only for an empty document and the raw parse - # otherwise, so a falsy non-mapping (``[]``/``false``/``0``/``''``) is caught - # by the isinstance guard below and raised like a truthy one, staying - # consistent with the other reader of this file (models/catalog._merge_config). + # otherwise, so a non-mapping top level — a falsy ``[]``/``false``/``0``/``''`` + # or an explicit null (``load_yaml`` -> ``None``) — is caught by the isinstance + # guard below and raised like a truthy one, staying consistent with the other + # reader of this file (models/catalog._merge_config). data = load_yaml(path) - if data is None: - return [] if not isinstance(data, dict): raise BundlerError( f"Malformed catalog config at {path}: expected a mapping at the top " diff --git a/src/specify_cli/bundler/lib/yamlio.py b/src/specify_cli/bundler/lib/yamlio.py index 2b9fc7106f..5143a1f689 100644 --- a/src/specify_cli/bundler/lib/yamlio.py +++ b/src/specify_cli/bundler/lib/yamlio.py @@ -39,25 +39,35 @@ def ensure_within(root: Path, candidate: Path) -> Path: def load_yaml(path: Path) -> Any: - """Parse a YAML file, returning ``{}`` for an empty document. - - Only an *empty* document (``yaml.safe_load`` -> ``None``) becomes ``{}``. - A non-empty document is returned exactly as parsed — including a falsy - non-mapping such as ``[]``, ``false``, ``0``, or ``''`` — so callers can - validate the top-level shape (e.g. reject a non-mapping config) instead of - having it silently coerced to an empty mapping. + """Parse a YAML file, returning ``{}`` only for an *empty* document. + + A non-empty document is returned exactly as parsed — including a + non-mapping such as ``[]``, ``false``, ``0``, ``''``, or an explicit null + (``null``/``~``) — so callers can validate the top-level shape (e.g. reject + a non-mapping config) instead of having it silently coerced to an empty + mapping. + + ``yaml.safe_load`` returns ``None`` for *both* an empty document and an + explicit null scalar, so ``yaml.compose`` (which yields no node only for a + truly empty document) is used to tell them apart: an empty document becomes + ``{}`` while an explicit ``null``/``~`` is returned as ``None`` for the + caller to reject. """ path = Path(path) if not path.exists(): raise BundlerError(f"File not found: {path}") try: - with path.open("r", encoding="utf-8") as handle: - data = yaml.safe_load(handle) - except yaml.YAMLError as exc: - raise BundlerError(f"Invalid YAML in {path}: {exc}") from exc + text = path.read_text(encoding="utf-8") except OSError as exc: raise BundlerError(f"Could not read {path}: {exc}") from exc - return {} if data is None else data + try: + has_node = yaml.compose(text) is not None + data = yaml.safe_load(text) + except yaml.YAMLError as exc: + raise BundlerError(f"Invalid YAML in {path}: {exc}") from exc + if data is None and not has_node: + return {} + return data def dump_yaml(path: Path, data: Any, *, within: Path | None = None) -> Path: diff --git a/tests/contract/test_catalog_schema.py b/tests/contract/test_catalog_schema.py index 08f09ee8b2..97600d21ef 100644 --- a/tests/contract/test_catalog_schema.py +++ b/tests/contract/test_catalog_schema.py @@ -77,24 +77,35 @@ def test_falsy_non_list_catalogs_still_raises(tmp_path: Path, value: str): "false\n", # falsy bool "0\n", # falsy int "''\n", # falsy empty string + "null\n", # explicit null scalar (safe_load -> None, but a real node) + "~\n", # explicit null scalar (alt spelling) ], ) def test_toplevel_non_mapping_raises(tmp_path: Path, body: str): - """A top-level non-mapping bundle-catalogs.yml (list/scalar) must raise, + """A top-level non-mapping bundle-catalogs.yml (list/scalar/null) must raise, matching the sibling reader (catalog_config._read) — not silently fall back to the built-in default stack. This includes FALSY non-mappings ([], false, - 0, ''); the shared load_yaml would coerce those to {} and hide them, so both - readers parse the raw document instead.""" + 0, '') and an explicit null (null/~); the shared load_yaml would coerce those + to {} and hide them, so it distinguishes them from a truly empty document.""" 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"]) +@pytest.mark.parametrize( + "body", + [ + "catalogs:\n", # present key, null value + "catalogs: []\n", # present key, empty list + "", # truly empty document + "# only a comment\n", # comment-only == empty document + ], +) def test_absent_or_empty_catalogs_is_noop(tmp_path: Path, body: str): - """An absent (``None``) or empty-list ``catalogs:`` is valid: it contributes - no project sources and falls back to the built-in default stack.""" + """An empty document, comment-only file, or absent/empty-list ``catalogs:`` + is valid: it contributes no project sources and falls back to the built-in + default stack (must not be confused with an explicit top-level null).""" make_project(tmp_path) (tmp_path / ".specify" / "bundle-catalogs.yml").write_text(body, encoding="utf-8") # Does not raise; still yields the built-in defaults. diff --git a/tests/unit/test_bundler_catalog_config.py b/tests/unit/test_bundler_catalog_config.py index 337d5b9242..46c333700a 100644 --- a/tests/unit/test_bundler_catalog_config.py +++ b/tests/unit/test_bundler_catalog_config.py @@ -154,11 +154,12 @@ def test_read_rejects_non_mapping_top_level(tmp_path: Path): cc._read(project) -@pytest.mark.parametrize("body", ["[]\n", "false\n", "0\n", "''\n"]) +@pytest.mark.parametrize("body", ["[]\n", "false\n", "0\n", "''\n", "null\n", "~\n"]) def test_read_rejects_falsy_non_mapping_top_level(tmp_path: Path, body: str): - # A FALSY non-mapping top level ([], false, 0, '') must raise like a truthy - # one. The shared load_yaml would coerce these to {} and hide them, so _read - # parses the raw document — staying consistent with models/catalog._merge_config. + # A FALSY non-mapping top level ([], false, 0, '') OR an explicit null + # (null/~) must raise like a truthy one. safe_load coerces these to + # None/{}, so load_yaml distinguishes them from a truly empty document — + # staying consistent with models/catalog._merge_config. project = tmp_path / "proj" (project / ".specify").mkdir(parents=True) cc._config_path(project).write_text(body, encoding="utf-8")