diff --git a/src/flightdeck/cli.py b/src/flightdeck/cli.py index e808b15..fcd7143 100644 --- a/src/flightdeck/cli.py +++ b/src/flightdeck/cli.py @@ -70,7 +70,7 @@ def _org(root: Path) -> Org: try: return load_org(root) except ConfigError as exc: - err.print(f"[red]config error:[/red] {exc}") + err.print(f"[red]config error:[/red] {escape(str(exc))}") raise typer.Exit(2) from None diff --git a/src/flightdeck/config.py b/src/flightdeck/config.py index 8189343..9ec108b 100644 --- a/src/flightdeck/config.py +++ b/src/flightdeck/config.py @@ -96,6 +96,20 @@ def _read_yaml(path: Path) -> dict: return raw +def _read_collection(path: Path, key: str) -> list[object]: + """Load a strict single-key document whose value is a list or null.""" + raw = _read_yaml(path) + if unknown := sorted(repr(name) for name in raw if name != key): + names = ", ".join(unknown) + raise ConfigError(f"{path}: unknown top-level key(s): {names}; expected only {key!r}") + items = raw.get(key) + if items is None: + return [] + if not isinstance(items, list): + raise ConfigError(f"{path}: {key!r} must be a list, got {type(items).__name__}") + return items + + def _validation_error(path: Path, exc: ValidationError) -> ConfigError: lines = [] for err in exc.errors(): @@ -152,7 +166,7 @@ def load_org(root: Path | str) -> Org: models_path = root / MODELS_FILE models = _load_indexed_items( - _read_yaml(models_path).get("models") or [], + _read_collection(models_path, "models"), path=models_path, item_type=ModelSpec, kind="model", @@ -164,7 +178,7 @@ def load_org(root: Path | str) -> Org: usecases_path = root / USECASES_FILE if usecases_path.exists(): usecases = _load_indexed_items( - _read_yaml(usecases_path).get("usecases") or [], + _read_collection(usecases_path, "usecases"), path=usecases_path, item_type=UseCase, kind="use case", diff --git a/tests/test_cli_and_html.py b/tests/test_cli_and_html.py index 3de33b6..b4045e1 100644 --- a/tests/test_cli_and_html.py +++ b/tests/test_cli_and_html.py @@ -38,6 +38,23 @@ def test_init_scaffolds_a_loadable_org(tmp_path): assert "mock-fast" in org.models +def test_config_error_survives_rich_markup_in_unknown_wrapper_key(tmp_path): + from rich.errors import MarkupError + + root = _init(tmp_path) + path = root / "models.yaml" + document = yaml.safe_load(path.read_text(encoding="utf-8")) + document["[/]"] = [] + path.write_text(yaml.safe_dump(document), encoding="utf-8") + + result = invoke("report", "--dir", str(root)) + + assert not isinstance(result.exception, MarkupError) + assert result.exit_code == 2 + assert "unknown top-level key" in result.output + assert "[/]" in result.output + + def test_init_refuses_to_overwrite(tmp_path): root = _init(tmp_path) result = invoke("init", "--dir", str(root)) diff --git a/tests/test_store_config.py b/tests/test_store_config.py index b939945..9d4ac78 100644 --- a/tests/test_store_config.py +++ b/tests/test_store_config.py @@ -132,6 +132,52 @@ def test_missing_or_null_use_case_collection_loads_empty(tmp_path, collection): assert load_org(root).usecases == {} +@pytest.mark.parametrize( + ("filename", "key"), + [("models.yaml", "models"), ("usecases.yaml", "usecases")], +) +def test_scalar_config_collections_fail_as_config_errors(tmp_path, filename, key): + root = write_org(tmp_path / "org", workflows=[]) + path = root / filename + path.write_text(yaml.safe_dump({key: 7}), encoding="utf-8") + + with pytest.raises(ConfigError) as excinfo: + load_org(root) + + assert str(path) in str(excinfo.value) + assert f"{key!r} must be a list, got int" in str(excinfo.value) + + +@pytest.mark.parametrize( + ("filename", "key", "items"), + [ + ("models.yaml", "models", [MODELS[0]]), + ("usecases.yaml", "usecases", []), + ], +) +def test_unknown_config_collection_keys_fail_loudly(tmp_path, filename, key, items): + root = write_org(tmp_path / "org", workflows=[]) + path = root / filename + typo = f"{key}_typo" + path.write_text(yaml.safe_dump({key: items, typo: []}), encoding="utf-8") + + with pytest.raises(ConfigError) as excinfo: + load_org(root) + + message = str(excinfo.value) + assert repr(typo) in message + assert f"expected only {key!r}" in message + + +def test_non_string_config_collection_key_is_a_config_error(tmp_path): + root = write_org(tmp_path / "org", workflows=[]) + path = root / "models.yaml" + path.write_text(yaml.safe_dump({"models": [MODELS[0]], 7: []}), encoding="utf-8") + + with pytest.raises(ConfigError, match=r"models\.yaml.*7"): + load_org(root) + + def test_absent_workflow_directory_loads_empty(tmp_path): root = write_org(tmp_path / "org", workflows=[])