diff --git a/CHANGELOG.md b/CHANGELOG.md index 674411a76..92bab3a2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Manifest and policy parsers now reject wrong-typed native schema values and + report unknown policy keys. Migration: quote numeric `apm.yml` versions, use + non-empty string identities, and use mappings/lists for policy blocks. (closes #2137) (#2143) - Fresh checkouts with declared consumer targets no longer remain `apm audit --ci`-red for files those targets cannot restore: `apm install` now removes stale `deployed_files` entries outside the legitimate target diff --git a/docs/src/content/docs/concepts/package-anatomy.md b/docs/src/content/docs/concepts/package-anatomy.md index e116a3ea7..7bcc9ae8d 100644 --- a/docs/src/content/docs/concepts/package-anatomy.md +++ b/docs/src/content/docs/concepts/package-anatomy.md @@ -29,7 +29,8 @@ name: my-pkg version: 1.0.0 ``` -`name` and `version` are the only required fields. +`name` and `version` are the only required fields. Both must be non-empty +strings; quote a numeric version so YAML does not parse it as a number. `apm install` will validate the manifest, generate `apm.lock.yaml`, and deploy `hello` to whatever harnesses you target. diff --git a/docs/src/content/docs/reference/policy-schema.md b/docs/src/content/docs/reference/policy-schema.md index 2b2780c29..ed10578c8 100644 --- a/docs/src/content/docs/reference/policy-schema.md +++ b/docs/src/content/docs/reference/policy-schema.md @@ -67,7 +67,10 @@ The `` accepts: | `executables` | object | see section | no | Org ceiling for executable-primitive trust (hooks, bin, self-defined MCP, canvas). See [executables](#executables). | | `bin_deploy` | object | see section | no | DEPRECATED alias folded into `executables.deny` (bin-scoped). See [bin_deploy](#bin_deploy). | -Unknown top-level keys produce a warning, never an error -- so newer policy files load on older clients. +Unknown top-level keys produce a warning, never an error -- so newer policy +files load on older clients. `apm policy status --json` returns these in the +`warnings` array. Known fields with the wrong native YAML type are rejected +instead of being silently replaced by defaults. ## Enforcement modes diff --git a/packages/apm-guide/.apm/skills/apm-usage/governance.md b/packages/apm-guide/.apm/skills/apm-usage/governance.md index d3d895770..25df904b8 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/governance.md +++ b/packages/apm-guide/.apm/skills/apm-usage/governance.md @@ -22,6 +22,10 @@ environment-only so redirecting binary downloads remains invocation-scoped. ## Policy schema overview +Unknown top-level keys are reported as warnings. Known fields with the wrong +native YAML type are rejected; for example, `cache` must be a mapping and +`dependencies.allow` must be a list. + ```yaml name: "Contoso Engineering Policy" version: "1.0.0" diff --git a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md index 4272dc7b2..e77fefb70 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md +++ b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md @@ -31,6 +31,8 @@ backfills one from the other: is no apm.yml-side equivalent. - `name`, `version`, `license`, `dependencies`, `scripts` live exclusively in `apm.yml`. +- `name` and `version` must be non-empty strings. Quote numeric versions so + YAML does not parse them as numbers. Populate both descriptions when you ship a HYBRID package. `apm pack` warns when `apm.yml.description` is missing so listings do not diff --git a/src/apm_cli/commands/deps/_utils.py b/src/apm_cli/commands/deps/_utils.py index 450a70db4..fcbe5da18 100644 --- a/src/apm_cli/commands/deps/_utils.py +++ b/src/apm_cli/commands/deps/_utils.py @@ -5,6 +5,7 @@ from ...constants import APM_DIR, APM_YML_FILENAME, SKILL_MD_FILENAME from ...models.apm_package import APMPackage +from ...utils.yaml_io import load_yaml def _scan_installed_packages(apm_modules_dir: Path) -> list: @@ -154,6 +155,35 @@ def _get_detailed_context_counts(package_path: Path) -> dict[str, int]: return counts +def _tolerant_identity(package_path: Path, apm_yml_path: Path) -> dict[str, str] | None: + """Best-effort name/version read for the tolerant ``deps`` display paths. + + ``APMPackage.from_apm_yml`` is the strict identity authority: it rejects + empty/non-string ``name`` and ``version`` so ``audit`` and ``policy`` + surfaces fail schema-invalid manifests. The ``deps list`` / ``deps info`` + display commands are a different surface -- they must stay tolerant so a + manifest that merely omits or empties ``version`` still renders as + ``@unknown`` rather than an alarming ``@error`` that masks the package. + + Returns a ``{"name", "version"}`` dict on a best-effort read, or ``None`` + only when the apm.yml cannot be parsed at all (genuinely malformed YAML), + which the callers surface as ``error``. + """ + try: + data = load_yaml(apm_yml_path) + except Exception: + return None + if not isinstance(data, dict): + return None + raw_name = data.get("name") + name = raw_name.strip() if isinstance(raw_name, str) and raw_name.strip() else package_path.name + raw_version = data.get("version") + version = ( + raw_version.strip() if isinstance(raw_version, str) and raw_version.strip() else "unknown" + ) + return {"name": name, "version": version} + + def _get_package_display_info(package_path: Path) -> dict[str, str]: """Get package display information.""" try: @@ -173,10 +203,17 @@ def _get_package_display_info(package_path: Path) -> dict[str, str]: "version": "unknown", } except Exception: + identity = _tolerant_identity(package_path, package_path / APM_YML_FILENAME) + if identity is None: + return { + "display_name": f"{package_path.name}@error", + "name": package_path.name, + "version": "error", + } return { - "display_name": f"{package_path.name}@error", - "name": package_path.name, - "version": "error", + "display_name": f"{identity['name']}@{identity['version']}", + "name": identity["name"], + "version": identity["version"], } @@ -228,6 +265,22 @@ def _get_detailed_package_info(package_path: Path) -> dict[str, Any]: "hooks": primitives.get("hooks", 0), } except Exception as e: + identity = _tolerant_identity(package_path, package_path / APM_YML_FILENAME) + if identity is not None: + # Tolerant display: a readable manifest with incomplete identity + # (e.g. empty/missing version) renders gracefully -- symmetric with + # `deps list` -- instead of masking the package behind an error. + return { + "name": identity["name"], + "version": identity["version"], + "description": "apm.yml identity incomplete", + "author": "Unknown", + "source": "local", + "install_path": str(package_path.resolve()), + "context_files": _get_detailed_context_counts(package_path), + "workflows": 0, + "hooks": 0, + } return { "name": package_path.name, "version": "error", diff --git a/src/apm_cli/commands/policy.py b/src/apm_cli/commands/policy.py index a241555b6..06f1cb6b9 100644 --- a/src/apm_cli/commands/policy.py +++ b/src/apm_cli/commands/policy.py @@ -199,6 +199,7 @@ def _build_report( "cached": bool(result.cached), "fetch_error": result.fetch_error, "error": result.error, + "warnings": result.warnings, "extends_chain": chain, "rule_counts": counts, "rule_summary": _summarize_rules(counts), @@ -228,6 +229,10 @@ def _render_table(report: dict[str, Any]) -> None: "Effective rules", "; ".join(report["rule_summary"]) if report["rule_summary"] else "none", ), + ( + "Warnings", + "; ".join(report["warnings"]) if report["warnings"] else "none", + ), ] console = _get_console() diff --git a/src/apm_cli/models/apm_package.py b/src/apm_cli/models/apm_package.py index 7a4e6adcf..c43e64558 100644 --- a/src/apm_cli/models/apm_package.py +++ b/src/apm_cli/models/apm_package.py @@ -388,6 +388,10 @@ def from_apm_yml( raise ValueError("Missing required field 'name' in apm.yml") if "version" not in data: raise ValueError("Missing required field 'version' in apm.yml") + if not isinstance(data["name"], str) or not data["name"].strip(): + raise ValueError("Invalid apm.yml identity: 'name' must be a non-empty string") + if not isinstance(data["version"], str) or not data["version"].strip(): + raise ValueError("Invalid apm.yml identity: 'version' must be a non-empty string") # Top-level ``registries:`` block per design §3.1. registries, default_registry = _parse_registries_block(data, apm_yml_path) diff --git a/src/apm_cli/policy/discovery.py b/src/apm_cli/policy/discovery.py index c88d42e7d..b51a8792f 100644 --- a/src/apm_cli/policy/discovery.py +++ b/src/apm_cli/policy/discovery.py @@ -196,7 +196,7 @@ def _verify_hash_pin( POLICY_CACHE_DIR = ".policy-cache" DEFAULT_CACHE_TTL = 3600 # 1 hour MAX_STALE_TTL = 7 * 24 * 3600 # 7 days -- stale cache usable on refresh failure -CACHE_SCHEMA_VERSION = "3" # Bump when cache format changes to auto-invalidate +CACHE_SCHEMA_VERSION = "4" # Bump when cache format changes to auto-invalidate @dataclass @@ -229,6 +229,7 @@ class PolicyFetchResult: cache_stale: bool = False # True if cache was served past TTL fetch_error: str | None = None # Network/parse error on refresh attempt outcome: str = "" # See docstring for valid values + warnings: list[str] = field(default_factory=list) # -- Hash-pin fields (#827 supply-chain hardening) -- # raw_bytes_hash is the digest of the leaf policy bytes off the wire, @@ -502,6 +503,7 @@ def _resolve_and_persist_chain( policy_override=next_ref, no_cache=False, ) + fetch_result.warnings.extend(parent_result.warnings) if parent_result.policy is None: # Parent fetch failed -- merge what we have so far and warn. @@ -541,7 +543,13 @@ def _resolve_and_persist_chain( cache_key = _strip_source_prefix(leaf_source) if leaf_source else "" if cache_key: - _write_cache(cache_key, merged, project_root, chain_refs=chain_refs) + _write_cache( + cache_key, + merged, + project_root, + chain_refs=chain_refs, + warnings=fetch_result.warnings, + ) fetch_result.policy = merged @@ -628,7 +636,7 @@ def _load_from_file(path: Path, *, expected_hash: str | None = None) -> PolicyFe return mismatch try: - policy, _warnings = load_policy(content) + policy, warnings = load_policy(content) outcome = "empty" if _is_policy_empty(policy) else "found" actual_hash = ( _compute_hash_normalized(content, expected_hash) if expected_hash is not None else None @@ -639,9 +647,14 @@ def _load_from_file(path: Path, *, expected_hash: str | None = None) -> PolicyFe outcome=outcome, raw_bytes_hash=actual_hash, expected_hash=expected_hash, + warnings=warnings, ) except PolicyValidationError as e: - return PolicyFetchResult(error=f"Invalid policy file {path}: {e}", outcome="malformed") + return PolicyFetchResult( + error=f"Invalid policy file {path}: {e}", + outcome="malformed", + warnings=e.warnings, + ) def _auto_discover( @@ -811,6 +824,7 @@ def _fetch_from_url( outcome=outcome, raw_bytes_hash=cache_entry.raw_bytes_hash or None, expected_hash=expected_hash, + warnings=cache_entry.warnings, ) fetch_error: str | None = None @@ -860,12 +874,13 @@ def _fetch_from_url( return mismatch try: - policy, _warnings = load_policy(content) + policy, warnings = load_policy(content) except PolicyValidationError as e: return PolicyFetchResult( error=f"Invalid policy from {url}: {e}", source=source_label, outcome="malformed", + warnings=e.warnings, ) chain_refs = [url] @@ -876,6 +891,7 @@ def _fetch_from_url( project_root, chain_refs=chain_refs, raw_bytes_hash=actual_hash, + warnings=warnings, ) outcome = "empty" if _is_policy_empty(policy) else "found" return PolicyFetchResult( @@ -884,6 +900,7 @@ def _fetch_from_url( outcome=outcome, raw_bytes_hash=actual_hash, expected_hash=expected_hash, + warnings=warnings, ) @@ -913,6 +930,7 @@ def _fetch_from_repo( outcome=outcome, raw_bytes_hash=cache_entry.raw_bytes_hash or None, expected_hash=expected_hash, + warnings=cache_entry.warnings, ) content, error = _fetch_github_contents(repo_ref, "apm-policy.yml") @@ -938,12 +956,13 @@ def _fetch_from_repo( return mismatch try: - policy, _warnings = load_policy(content) + policy, warnings = load_policy(content) except PolicyValidationError as e: return PolicyFetchResult( error=f"Invalid policy in {repo_ref}: {e}", source=source_label, outcome="malformed", + warnings=e.warnings, ) chain_refs = [repo_ref] @@ -954,6 +973,7 @@ def _fetch_from_repo( project_root, chain_refs=chain_refs, raw_bytes_hash=actual_hash, + warnings=warnings, ) outcome = "empty" if _is_policy_empty(policy) else "found" return PolicyFetchResult( @@ -962,6 +982,7 @@ def _fetch_from_repo( outcome=outcome, raw_bytes_hash=actual_hash, expected_hash=expected_hash, + warnings=warnings, ) @@ -1063,6 +1084,7 @@ def _fetch_from_ado_repo( outcome=outcome, raw_bytes_hash=cache_entry.raw_bytes_hash or None, expected_hash=expected_hash, + warnings=cache_entry.warnings, ) content, error = _fetch_ado_contents(org, project, repo, "apm-policy.yml", host=host) @@ -1084,12 +1106,13 @@ def _fetch_from_ado_repo( return mismatch try: - policy, _warnings = load_policy(content) + policy, warnings = load_policy(content) except PolicyValidationError as e: return PolicyFetchResult( error=f"Invalid policy in {repo_ref}: {e}", source=source_label, outcome="malformed", + warnings=e.warnings, ) chain_refs = [repo_ref] @@ -1100,6 +1123,7 @@ def _fetch_from_ado_repo( project_root, chain_refs=chain_refs, raw_bytes_hash=actual_hash, + warnings=warnings, ) outcome = "empty" if _is_policy_empty(policy) else "found" return PolicyFetchResult( @@ -1108,6 +1132,7 @@ def _fetch_from_ado_repo( outcome=outcome, raw_bytes_hash=actual_hash, expected_hash=expected_hash, + warnings=warnings, ) @@ -1211,6 +1236,7 @@ class _CacheEntry: age_seconds: int stale: bool # True if past TTL (but within MAX_STALE_TTL) chain_refs: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) fingerprint: str = "" raw_bytes_hash: str = "" # ":" of leaf bytes off wire (#827) @@ -1350,6 +1376,7 @@ def _stale_fallback_or_error( cache_age_seconds=cache_entry.age_seconds, fetch_error=fetch_error_msg, outcome="cached_stale", + warnings=cache_entry.warnings, ) return PolicyFetchResult( error=fetch_error_msg, @@ -1386,6 +1413,7 @@ def _detect_garbage( cache_age_seconds=cache_entry.age_seconds, fetch_error=msg, outcome="cached_stale", + warnings=cache_entry.warnings, ) return PolicyFetchResult( error=msg + " (possible captive portal or redirect)", @@ -1405,6 +1433,7 @@ def _detect_garbage( cache_age_seconds=cache_entry.age_seconds, fetch_error=msg, outcome="cached_stale", + warnings=cache_entry.warnings, ) return PolicyFetchResult( error=msg, @@ -1473,6 +1502,11 @@ def _read_cache_entry( return None policy, _warnings = load_policy(policy_file) + cached_warnings = meta.get("warnings", []) + if not isinstance(cached_warnings, list): + cached_warnings = [] + else: + cached_warnings = [str(warning) for warning in cached_warnings] # Determine source label if repo_ref.startswith("http://") or repo_ref.startswith("https://"): @@ -1486,6 +1520,7 @@ def _read_cache_entry( age_seconds=age, stale=age > ttl, chain_refs=meta.get("chain_refs", [repo_ref]), + warnings=cached_warnings, fingerprint=meta.get("fingerprint", ""), raw_bytes_hash=raw_bytes_hash, ) @@ -1513,6 +1548,7 @@ def _read_cache( cached=True, cache_age_seconds=entry.age_seconds, outcome=outcome, + warnings=entry.warnings, ) @@ -1523,6 +1559,7 @@ def _write_cache( *, chain_refs: list[str] | None = None, raw_bytes_hash: str | None = None, + warnings: list[str] | None = None, ) -> None: """Write merged effective policy and metadata to cache atomically. @@ -1566,6 +1603,7 @@ def _write_cache( "repo_ref": repo_ref, "cached_at": time.time(), "chain_refs": chain_refs if chain_refs is not None else [repo_ref], + "warnings": warnings or [], "schema_version": CACHE_SCHEMA_VERSION, "fingerprint": fingerprint, "raw_bytes_hash": raw_bytes_hash or "", diff --git a/src/apm_cli/policy/parser.py b/src/apm_cli/policy/parser.py index e4f38b80b..7e30ade83 100644 --- a/src/apm_cli/policy/parser.py +++ b/src/apm_cli/policy/parser.py @@ -54,6 +54,7 @@ "manifest", "unmanaged_files", "security", + "registry_source", "bin_deploy", "executables", } @@ -62,8 +63,9 @@ class PolicyValidationError(Exception): """Raised when policy YAML is malformed or violates schema constraints.""" - def __init__(self, errors: list[str]): + def __init__(self, errors: list[str], warnings: list[str] | None = None): self.errors = errors + self.warnings = warnings or [] super().__init__(f"Policy validation failed: {'; '.join(errors)}") @@ -84,6 +86,11 @@ def validate_policy(data: dict) -> tuple[list[str], list[str]]: for key in sorted(unknown): warnings.append(f"Unknown top-level policy key: '{key}'") + for key in ("mcp", "manifest", "compilation", "registry_source", "bin_deploy"): + value = data.get(key) + if value is not None and not isinstance(value, dict): + errors.append(f"{key} must be a YAML mapping") + # enforcement (coerce YAML booleans: off → "off") enforcement = data.get("enforcement") if isinstance(enforcement, bool): @@ -107,7 +114,9 @@ def validate_policy(data: dict) -> tuple[list[str], list[str]]: # cache.ttl cache = data.get("cache") - if isinstance(cache, dict): + if cache is not None and not isinstance(cache, dict): + errors.append("cache must be a YAML mapping") + elif isinstance(cache, dict): ttl = cache.get("ttl") if ttl is not None: if not isinstance(ttl, int) or isinstance(ttl, bool): @@ -117,7 +126,15 @@ def validate_policy(data: dict) -> tuple[list[str], list[str]]: # dependencies deps = data.get("dependencies") - if isinstance(deps, dict): + if deps is not None and not isinstance(deps, dict): + errors.append("dependencies must be a YAML mapping") + elif isinstance(deps, dict): + for key in ("allow", "deny", "require"): + value = deps.get(key) + if value is not None and ( + not isinstance(value, list) or not all(isinstance(item, str) for item in value) + ): + errors.append(f"dependencies.{key} must be a YAML list of package patterns") rr = deps.get("require_resolution") if rr is not None and rr not in _VALID_REQUIRE_RESOLUTION: errors.append( @@ -452,7 +469,7 @@ def load_policy(source: str | Path) -> tuple[ApmPolicy, list[str]]: errors, warnings = validate_policy(data) if errors: - raise PolicyValidationError(errors) + raise PolicyValidationError(errors, warnings) return _build_policy(data), warnings diff --git a/tests/test_apm_package_models.py b/tests/test_apm_package_models.py index 3a8b40c61..32ad47e60 100644 --- a/tests/test_apm_package_models.py +++ b/tests/test_apm_package_models.py @@ -617,6 +617,24 @@ def test_from_apm_yml_missing_required_fields(self): Path(f.name).unlink() + @pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("name", "", "'name' must be a non-empty string"), + ("name", ["a", "b"], "'name' must be a non-empty string"), + ("version", 123, "'version' must be a non-empty string"), + ], + ) + def test_from_apm_yml_rejects_invalid_identity(self, tmp_path, field, value, message): + """Manifest identity fields reject empty and wrong-typed native values.""" + apm_yml = tmp_path / "apm.yml" + manifest = {"name": "valid-pkg", "version": "1.0.0"} + manifest[field] = value + apm_yml.write_text(yaml.safe_dump(manifest), encoding="utf-8") + + with pytest.raises(ValueError, match=message): + APMPackage.from_apm_yml(apm_yml) + def test_from_apm_yml_invalid_yaml(self): """Test loading invalid YAML.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: diff --git a/tests/unit/commands/test_policy_status.py b/tests/unit/commands/test_policy_status.py index 1a7d2200f..7866439f9 100644 --- a/tests/unit/commands/test_policy_status.py +++ b/tests/unit/commands/test_policy_status.py @@ -316,6 +316,41 @@ def test_policy_source_override(self, runner, tmp_path): assert data["source"].startswith("file:") assert str(policy_file) in data["source"] + def test_json_surfaces_unknown_top_level_key_warning(self, runner, tmp_path): + policy_file = tmp_path / "apm-policy.yml" + policy_file.write_text( + ("version: '1.0'\nenforcment: true\ndependencies:\n deny: [blocked/package]\n"), + encoding="utf-8", + ) + + result = runner.invoke( + policy_group, + ["status", "--check", "--policy-source", str(policy_file), "--json"], + ) + + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["warnings"] == ["Unknown top-level policy key: 'enforcment'"] + + def test_json_preserves_warning_when_policy_has_schema_errors(self, runner, tmp_path): + policy_file = tmp_path / "apm-policy.yml" + policy_file.write_text( + ("version: '1.0'\nenforcment: true\ncache: []\ndependencies:\n allow: ''\n"), + encoding="utf-8", + ) + + result = runner.invoke( + policy_group, + ["status", "--check", "--policy-source", str(policy_file), "--json"], + ) + + assert result.exit_code == 1, result.output + data = json.loads(result.output) + assert data["error"] is not None + assert "cache must be a YAML mapping" in data["error"] + assert "dependencies.allow must be a YAML list" in data["error"] + assert data["warnings"] == ["Unknown top-level policy key: 'enforcment'"] + def test_policy_source_routes_through_discover_policy(self, runner): result_obj = PolicyFetchResult( policy=_rich_policy(), diff --git a/tests/unit/policy/test_discovery.py b/tests/unit/policy/test_discovery.py index c3d4af22c..bd26ee153 100644 --- a/tests/unit/policy/test_discovery.py +++ b/tests/unit/policy/test_discovery.py @@ -212,6 +212,43 @@ def test_write_then_read(self): self.assertTrue(result.cached) self.assertEqual(result.source, f"org:{repo_ref}") + def test_policy_warnings_survive_cache_round_trip(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + repo_ref = "contoso/.github" + warnings = ["Unknown top-level policy key: 'enforcment'"] + + _write_cache(repo_ref, _make_test_policy(), root, warnings=warnings) + + result = _read_cache(repo_ref, root) + self.assertIsNotNone(result) + self.assertEqual(result.warnings, warnings) + + def test_corrupt_cached_warnings_render_gracefully(self): + cases = ( + ("not-a-list", [], "none"), + (["unknown key", 7, None], ["unknown key", "7", "None"], "unknown key; 7; None"), + ) + + for corrupt_warnings, expected_warnings, expected_rendering in cases: + with self.subTest(warnings=corrupt_warnings): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + repo_ref = "contoso/.github" + _write_cache(repo_ref, _make_test_policy(), root) + + meta_file = _get_cache_dir(root) / f"{_cache_key(repo_ref)}.meta.json" + meta = json.loads(meta_file.read_text(encoding="utf-8")) + meta["warnings"] = corrupt_warnings + meta_file.write_text(json.dumps(meta), encoding="utf-8") + + result = _read_cache(repo_ref, root) + + self.assertIsNotNone(result) + self.assertEqual(result.warnings, expected_warnings) + rendered = "; ".join(result.warnings) if result.warnings else "none" + self.assertEqual(rendered, expected_rendering) + def test_expired_cache(self): with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) diff --git a/tests/unit/policy/test_parser.py b/tests/unit/policy/test_parser.py index 75535cb3a..0b55f16b0 100644 --- a/tests/unit/policy/test_parser.py +++ b/tests/unit/policy/test_parser.py @@ -118,6 +118,43 @@ def test_unknown_top_level_keys_no_error(self): self.assertEqual(len(warnings), 1) self.assertIn("custom_field", warnings[0]) + def test_typo_and_wrong_typed_native_values_are_diagnosed(self): + errors, warnings = validate_policy( + { + "version": "1.0", + "enforcment": True, + "cache": [], + "dependencies": {"allow": ""}, + } + ) + + self.assertEqual(warnings, ["Unknown top-level policy key: 'enforcment'"]) + self.assertEqual( + errors, + [ + "cache must be a YAML mapping", + "dependencies.allow must be a YAML list of package patterns", + ], + ) + + def test_known_policy_blocks_and_pattern_items_use_native_schema_types(self): + errors, _ = validate_policy( + { + "mcp": [], + "manifest": [], + "dependencies": {"allow": [123]}, + } + ) + + self.assertEqual( + errors, + [ + "mcp must be a YAML mapping", + "manifest must be a YAML mapping", + "dependencies.allow must be a YAML list of package patterns", + ], + ) + def test_non_dict_input(self): errors, warnings = validate_policy("not a dict") # type: ignore[arg-type] # noqa: RUF059 self.assertEqual(len(errors), 1) diff --git a/tests/unit/test_deps_utils.py b/tests/unit/test_deps_utils.py index ddcc2993c..0b24a739e 100644 --- a/tests/unit/test_deps_utils.py +++ b/tests/unit/test_deps_utils.py @@ -398,6 +398,25 @@ def test_no_apm_yml(self, tmp_path): assert info["version"] == "unknown" assert info["description"] == "No apm.yml found" + def test_empty_version_renders_unknown_symmetric_with_list(self, tmp_path): + """A readable manifest with empty version renders '@unknown', not 'error'. + + The strict identity authority (APMPackage.from_apm_yml) rejects the + empty version, but the tolerant display path must stay symmetric with + `deps list` and surface 'unknown' rather than masking the package. + """ + _make_apm_yml(tmp_path, "nover", version="") + info = _get_detailed_package_info(tmp_path) + assert info["name"] == "nover" + assert info["version"] == "unknown" + assert info["version"] != "error" + + def test_malformed_apm_yml_reports_error(self, tmp_path): + """Genuinely unparsable apm.yml still reports 'error' for the version.""" + (tmp_path / APM_YML_FILENAME).write_text(_MALFORMED_YML) + info = _get_detailed_package_info(tmp_path) + assert info["version"] == "error" + def test_hooks_counted(self, tmp_path): """Hook .json files are reflected in the result.""" _make_apm_yml(tmp_path, "hookpkg", version="1.0.0")