Files
github-spec-kit/tests/contract/test_catalog_schema.py
Ali jawwad cf0abe28f7 fix(bundler): reject a top-level non-mapping bundle-catalogs.yml in _merge_config (#3659)
* fix(bundler): reject a top-level non-mapping bundle-catalogs.yml in _merge_config

_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) <noreply@anthropic.com>

* fix(bundler): reject FALSY non-mapping catalog configs (parse raw, not load_yaml)

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) <noreply@anthropic.com>

* fix(bundler): correct load_yaml so only empty documents become {} (not falsy non-mappings)

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) <noreply@anthropic.com>

* fix(bundler): distinguish an empty YAML document from an explicit null in load_yaml

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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:15:14 -05:00

258 lines
9.7 KiB
Python

"""Contract tests for the catalog schema and source stack.
Mirrors contracts/bundle-catalog.schema.md: source precedence project > user >
built-in, install policy gating, payload parsing.
"""
from __future__ import annotations
import json
import tomllib
from pathlib import Path
import yaml
from specify_cli.bundler.models.catalog import (
BUILTIN_DEFAULT_STACK,
CatalogSource,
InstallPolicy,
Scope,
load_catalog_payload,
load_source_stack,
)
from specify_cli.bundler import BundlerError
import pytest
from tests.bundler_helpers import catalog_entry_dict, catalog_payload, make_project
def test_non_integer_source_priority_raises_actionable_error():
with pytest.raises(BundlerError, match="non-integer priority"):
CatalogSource.from_dict(
{"id": "corp", "url": "https://corp/catalog.json", "priority": "high"},
Scope.PROJECT,
)
def test_builtin_default_stack_when_no_config(tmp_path: Path):
make_project(tmp_path)
sources = load_source_stack(tmp_path)
ids = [s.id for s in sources]
assert ids == ["default", "community"]
assert sources[0].install_policy is InstallPolicy.INSTALL_ALLOWED
assert sources[1].install_policy is InstallPolicy.DISCOVERY_ONLY
assert sources[1].priority == 20
assert all(s.scope is Scope.BUILTIN for s in sources)
def test_non_list_catalogs_raises_actionable_error(tmp_path: Path):
"""A scalar ``catalogs:`` value raises a clean BundlerError, not a raw
'int object is not iterable' TypeError — matching what the sibling reader
(bundle catalog list) already reports for the same file."""
make_project(tmp_path)
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(
"catalogs: 5\n", encoding="utf-8"
)
with pytest.raises(BundlerError, match="must be a list"):
load_source_stack(tmp_path)
@pytest.mark.parametrize("value", ["false", "0", "''", "{}"])
def test_falsy_non_list_catalogs_still_raises(tmp_path: Path, value: str):
"""A *falsy* non-list ``catalogs:`` value (false/0/''/{}) must also raise —
only an absent/``None`` value means "nothing to merge". A plain falsy check
would silently swallow these, diverging from the sibling reader."""
make_project(tmp_path)
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(
f"catalogs: {value}\n", encoding="utf-8"
)
with pytest.raises(BundlerError, match="must be a list"):
load_source_stack(tmp_path)
@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
"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/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, '') 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", # 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 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.
sources = load_source_stack(tmp_path)
assert len(sources) > 0
def test_project_config_overrides_same_id(tmp_path: Path):
make_project(tmp_path)
config = {
"schema_version": "1.0",
"catalogs": [
{"id": "default", "url": "file://local", "priority": 1,
"install_policy": "install-allowed"},
{"id": "corp", "url": "https://corp/catalog.json", "priority": 0,
"install_policy": "install-allowed"},
],
}
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(
yaml.safe_dump(config), encoding="utf-8"
)
sources = load_source_stack(tmp_path)
by_id = {s.id: s for s in sources}
assert by_id["default"].scope is Scope.PROJECT
assert by_id["default"].url == "file://local"
# Highest precedence (lowest priority number) sorts first.
assert sources[0].id == "corp"
def test_user_scope_between_builtin_and_project(tmp_path: Path):
make_project(tmp_path)
user_dir = tmp_path / "userconf"
user_dir.mkdir()
(user_dir / "bundle-catalogs.yml").write_text(
yaml.safe_dump(
{"catalogs": [
{"id": "community", "url": "https://u", "priority": 2,
"install_policy": "install-allowed"}
]}
),
encoding="utf-8",
)
sources = load_source_stack(tmp_path, user_config_dir=user_dir)
by_id = {s.id: s for s in sources}
# User overrode the built-in community policy to install-allowed.
assert by_id["community"].scope is Scope.USER
assert by_id["community"].install_allowed is True
def test_load_payload_parses_entries():
payload = catalog_payload({"demo-bundle": catalog_entry_dict()})
entries = load_catalog_payload(payload)
assert "demo-bundle" in entries
assert entries["demo-bundle"].version == "1.2.0"
assert entries["demo-bundle"].provides["presets"] == 1
def test_builtin_default_stack_constant_shape():
ids = {raw["id"] for raw in BUILTIN_DEFAULT_STACK}
assert ids == {"default", "community"}
def test_repository_community_bundle_catalog_matches_contract():
catalog_path = Path(__file__).parents[2] / "bundles" / "catalog.community.json"
payload = json.loads(catalog_path.read_text(encoding="utf-8"))
assert payload["schema_version"] == "1.0"
assert payload["catalog_url"].endswith("/bundles/catalog.community.json")
entries = load_catalog_payload(payload)
assert all(entry.verified is False for entry in entries.values())
def test_wheel_packages_community_bundle_catalog():
repo_root = Path(__file__).parents[2]
with (repo_root / "pyproject.toml").open("rb") as pyproject_file:
pyproject = tomllib.load(pyproject_file)
force_include = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][
"force-include"
]
assert force_include["bundles/catalog.community.json"] == (
"specify_cli/core_pack/bundles/catalog.community.json"
)
def test_catalog_entry_rejects_string_tags():
from specify_cli.bundler.models.catalog import CatalogEntry
data = catalog_entry_dict("demo")
data["tags"] = "not-a-list"
with pytest.raises(BundlerError, match="'tags' must be a list"):
CatalogEntry.from_dict(data)
def test_catalog_entry_rejects_non_boolean_verified():
from specify_cli.bundler.models.catalog import CatalogEntry
data = catalog_entry_dict("demo")
data["verified"] = "false" # truthy string must not mark the entry verified
with pytest.raises(BundlerError, match="'verified' must be a boolean"):
CatalogEntry.from_dict(data)
def test_load_payload_rejects_id_key_mismatch():
# The enclosing key is authoritative; an entry whose own id disagrees with
# the key must be rejected so a catalog can't list a spoofed/unresolvable id.
payload = catalog_payload({"demo-bundle": catalog_entry_dict("other-id")})
with pytest.raises(BundlerError, match="id mismatch"):
load_catalog_payload(payload)
def test_load_payload_rejects_missing_entry_id():
entry = catalog_entry_dict("demo-bundle")
entry["id"] = ""
payload = catalog_payload({"demo-bundle": entry})
with pytest.raises(BundlerError, match="missing its 'id'"):
load_catalog_payload(payload)
def test_catalog_entry_rejects_non_mapping_requires():
from specify_cli.bundler.models.catalog import CatalogEntry
data = catalog_entry_dict("demo")
data["requires"] = "speckit>=0.1"
with pytest.raises(BundlerError, match="'requires' must be a mapping"):
CatalogEntry.from_dict(data)
def test_catalog_entry_rejects_non_mapping_provides():
from specify_cli.bundler.models.catalog import CatalogEntry
data = catalog_entry_dict("demo")
data["provides"] = "extensions"
with pytest.raises(BundlerError, match="'provides' must be a mapping"):
CatalogEntry.from_dict(data)
@pytest.mark.parametrize("field", ["requires", "provides"])
@pytest.mark.parametrize("bad", [[], "", 0, False])
def test_catalog_entry_rejects_falsy_non_mapping(field, bad):
# `or {}` coerced a FALSY non-mapping ([], '', 0, False) to {} before the
# isinstance guard, silently accepting a corrupt entry; only absent/None
# means "not present". Mirrors the manifest requires/provides guard.
from specify_cli.bundler.models.catalog import CatalogEntry
data = catalog_entry_dict("demo")
data[field] = bad
with pytest.raises(BundlerError, match=f"'{field}' must be a mapping"):
CatalogEntry.from_dict(data)