diff --git a/core/wren/src/wren/context.py b/core/wren/src/wren/context.py index 6b0bac8a1..a4e6cd502 100644 --- a/core/wren/src/wren/context.py +++ b/core/wren/src/wren/context.py @@ -1117,6 +1117,29 @@ def validate_project(project_path: Path) -> list[ValidationError]: ) ) + props = model.get("properties") + if props is not None and not isinstance(props, dict): + errors.append( + ValidationError( + "error", + f"{src_path} > {name}", + "properties must be a mapping", + ) + ) + for j, col in enumerate(columns): + if not isinstance(col, dict): + continue + col_props = col.get("properties") + if col_props is not None and not isinstance(col_props, dict): + col_name = col.get("name") or f"columns[{j}]" + errors.append( + ValidationError( + "error", + f"{src_path} > {name} > {col_name}", + "properties must be a mapping", + ) + ) + # v1 legacy views.yml may contain non-mapping entries (e.g. `- null`). # load_views() silently drops those (matching the other loaders), but # validate_project's job is to tell the user about hand-edited mistakes @@ -1232,6 +1255,16 @@ def validate_project(project_path: Path) -> list[ValidationError]: ) ) + view_props = view.get("properties") + if view_props is not None and not isinstance(view_props, dict): + errors.append( + ValidationError( + "error", + f"views/{src_dir}", + "properties must be a mapping", + ) + ) + # Check relationships — walk the raw list when available so indices match # the file (filtered loader positions would renumber past dropped junk). all_entity_names = model_names | view_names @@ -1644,7 +1677,10 @@ _VALID_LEVELS = frozenset({"error", "warning", "strict"}) def _prop_description(item: dict) -> str | None: - return (item.get("properties") or {}).get("description") + props = item.get("properties") or {} + if not isinstance(props, dict): + return None + return props.get("description") def _check_descriptions(manifest: dict, *, strict: bool = False) -> list[str]: diff --git a/core/wren/tests/unit/test_check_descriptions_guards.py b/core/wren/tests/unit/test_check_descriptions_guards.py new file mode 100644 index 000000000..7fe1a2823 --- /dev/null +++ b/core/wren/tests/unit/test_check_descriptions_guards.py @@ -0,0 +1,60 @@ +"""Guards for non-mapping properties and safe description lookup.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from wren.context import _prop_description, validate_project + + +def test_prop_description_non_mapping_returns_none(): + assert _prop_description({"properties": "oops"}) is None + assert _prop_description({"properties": {"description": "ok"}}) == "ok" + assert _prop_description({}) is None + + +def test_validate_project_reports_non_mapping_properties(tmp_path: Path): + (tmp_path / "wren_project.yml").write_text( + "name: demo\ndata_source: postgres\nschema_version: 2\n", + encoding="utf-8", + ) + model_dir = tmp_path / "models" / "orders" + model_dir.mkdir(parents=True) + (model_dir / "metadata.yml").write_text( + textwrap.dedent( + """\ + name: orders + table_reference: + table: orders + properties: oops + columns: + - name: id + type: INTEGER + properties: not-a-map + """ + ), + encoding="utf-8", + ) + view_dir = tmp_path / "views" / "v1" + view_dir.mkdir(parents=True) + (view_dir / "metadata.yml").write_text( + textwrap.dedent( + """\ + name: v1 + properties: [] + """ + ), + encoding="utf-8", + ) + (view_dir / "sql.yml").write_text( + "statement: SELECT 1\n", + encoding="utf-8", + ) + + errors = validate_project(tmp_path) + messages = [f"{e.path}: {e.message}" for e in errors] + joined = "\n".join(messages) + assert "properties must be a mapping" in joined + # model + column + view + assert sum("properties must be a mapping" in m for m in messages) == 3