From 0c031d60b0fdbb28925970622434721f3fd631db Mon Sep 17 00:00:00 2001 From: Bartok Date: Mon, 29 Jun 2026 05:56:49 -0400 Subject: [PATCH] fix(genbi): normalise null apps/schema_version in app index (#2406) --- core/wren/src/wren/genbi/index.py | 20 +++++++++- core/wren/tests/unit/test_genbi_index.py | 47 ++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/core/wren/src/wren/genbi/index.py b/core/wren/src/wren/genbi/index.py index 3032b00ee..01b6a6fba 100644 --- a/core/wren/src/wren/genbi/index.py +++ b/core/wren/src/wren/genbi/index.py @@ -50,8 +50,24 @@ def load_index(project_path: Path) -> dict: f"{path} is malformed — expected a mapping at the top level, " f"got {type(data).__name__}." ) - data.setdefault("schema_version", INDEX_SCHEMA_VERSION) - data.setdefault("apps", {}) + # Normalise explicit null values (``apps:`` / ``schema_version:`` with no + # value, e.g. from a hand-edit or a truncated write). ``setdefault`` is a + # no-op when the key already exists, which left ``apps: None`` in place and + # made every accessor crash with an opaque ``AttributeError`` deep in a + # command. We check ``is None`` specifically rather than truthiness so a + # valid falsy value is preserved and surfaced for validation instead of + # being silently coerced to a default. + if data.get("schema_version") is None: + data["schema_version"] = INDEX_SCHEMA_VERSION + apps = data.get("apps") + if apps is None: + apps = {} + if not isinstance(apps, dict): + raise MalformedIndexError( + f"{path} is malformed — 'apps' must be a mapping, " + f"got {type(apps).__name__}." + ) + data["apps"] = apps return data diff --git a/core/wren/tests/unit/test_genbi_index.py b/core/wren/tests/unit/test_genbi_index.py index 2d1d8dad2..2be768ef2 100644 --- a/core/wren/tests/unit/test_genbi_index.py +++ b/core/wren/tests/unit/test_genbi_index.py @@ -145,3 +145,50 @@ def test_register_rejects_path_traversal_name(tmp_path: Path) -> None: result = runner.invoke(app, ["genbi", "register", "../evil", "-p", str(project)]) assert result.exit_code != 0 assert "invalid app name" in result.output + + +def test_load_index_normalises_null_apps(tmp_path: Path) -> None: + # Regression: an explicit ``apps:`` with no value (hand-edit / truncated + # write) is valid YAML and parses to None. setdefault() was a no-op since + # the key existed, leaving ``apps: None`` so every accessor crashed with an + # opaque AttributeError. load_index must normalise it to an empty dict. + path = index_path(tmp_path) + path.parent.mkdir(parents=True) + path.write_text("schema_version: 1\napps:\n") + + data = load_index(tmp_path) + assert data["apps"] == {} + assert data["schema_version"] == 1 + # And the normal accessor must not crash on it. + from wren.genbi.index import get_app + + assert get_app(tmp_path, "missing") is None + + +def test_load_index_normalises_null_schema_version(tmp_path: Path) -> None: + path = index_path(tmp_path) + path.parent.mkdir(parents=True) + path.write_text("schema_version:\napps: {}\n") + + data = load_index(tmp_path) + assert data["schema_version"] == 1 + + +def test_load_index_raises_on_non_mapping_apps(tmp_path: Path) -> None: + path = index_path(tmp_path) + path.parent.mkdir(parents=True) + path.write_text("apps:\n - one\n - two\n") # apps is a list, not a mapping + + with pytest.raises(MalformedIndexError): + load_index(tmp_path) + + +def test_load_index_preserves_falsy_schema_version(tmp_path: Path) -> None: + # A literal 0 is a real value, not a missing one — it must be preserved + # rather than coerced to the default, so only explicit null normalises. + path = index_path(tmp_path) + path.parent.mkdir(parents=True) + path.write_text("schema_version: 0\napps: {}\n") + + data = load_index(tmp_path) + assert data["schema_version"] == 0