diff --git a/src/apm_cli/commands/audit.py b/src/apm_cli/commands/audit.py index c701f1826..053a21e61 100644 --- a/src/apm_cli/commands/audit.py +++ b/src/apm_cli/commands/audit.py @@ -436,7 +436,7 @@ def _audit_ci_gate( fail_fast = not no_fail_fast # Always run baseline checks - ci_result = run_baseline_checks(cfg.project_root, fail_fast=fail_fast) + ci_result = run_baseline_checks(cfg.project_root, fail_fast=fail_fast, ci_mode=True) # Resolve policy source: explicit --policy wins; otherwise mirror # install's auto-discovery (closes #827) so CI catches sideloaded diff --git a/src/apm_cli/policy/ci_checks.py b/src/apm_cli/policy/ci_checks.py index 7b2ab1222..d71f69a53 100644 --- a/src/apm_cli/policy/ci_checks.py +++ b/src/apm_cli/policy/ci_checks.py @@ -16,7 +16,7 @@ from pathlib import Path from typing import TYPE_CHECKING, List, Optional, Sequence # noqa: F401, UP035 -from ..deps.lockfile import _SELF_KEY +from ..deps.lockfile import _SELF_KEY, LEGACY_LOCKFILE_NAME, LOCKFILE_NAME from .models import CheckResult, CIAuditResult if TYPE_CHECKING: @@ -503,11 +503,14 @@ def run_baseline_checks( project_root: Path, *, fail_fast: bool = True, + ci_mode: bool = False, ) -> CIAuditResult: """Run all baseline CI checks against a project directory. When *fail_fast* is ``True`` (default), stops after the first failing check to skip expensive I/O (e.g. content integrity scan). + When *ci_mode* is ``True``, the ``manifest-missing`` check is a hard + failure (``passed=False``); otherwise it is an advisory warning only. Returns :class:`CIAuditResult` with individual check results. """ from ..deps.lockfile import LockFile, get_lockfile_path @@ -545,8 +548,25 @@ def run_baseline_checks( lockfile_path = get_lockfile_path(project_root) # If there's no apm.yml or no lockfile, the first check already passed - # (no deps needed). Skip remaining checks. + # (no deps needed). Skip remaining checks -- but warn if APM artifacts + # exist without a manifest (evidence of a deleted apm.yml). if not apm_yml_path.exists() or not lockfile_path.exists(): + if not apm_yml_path.exists(): + apm_dir = project_root / ".apm" + lock_file = project_root / LOCKFILE_NAME + legacy_lock_file = project_root / LEGACY_LOCKFILE_NAME + if apm_dir.is_dir() or lock_file.exists() or legacy_lock_file.exists(): + result.checks.append( + CheckResult( + name="manifest-missing", + passed=not ci_mode, + message=( + "apm.yml is missing but APM artifacts" + " (.apm/ or apm.lock.yaml or apm.lock) were found" + " -- this may indicate a deleted manifest" + ), + ) + ) return result lock = LockFile.read(lockfile_path) diff --git a/src/apm_cli/policy/models.py b/src/apm_cli/policy/models.py index 5ee31aae4..a0d6cc880 100644 --- a/src/apm_cli/policy/models.py +++ b/src/apm_cli/policy/models.py @@ -34,6 +34,7 @@ "scripts-policy": "apm.yml", "unmanaged-files": "apm.yml", "manifest-parse": "apm.yml", + "manifest-missing": "apm.yml", } diff --git a/tests/unit/policy/test_ci_checks.py b/tests/unit/policy/test_ci_checks.py index 0dacfaede..4e885f950 100644 --- a/tests/unit/policy/test_ci_checks.py +++ b/tests/unit/policy/test_ci_checks.py @@ -1119,3 +1119,80 @@ def test_remediation_hint_present_in_error_message(self, tmp_path: Path) -> None assert parse_check.name == "manifest-parse" assert "Cannot parse apm.yml" in parse_check.message assert "fix the YAML syntax error in apm.yml and re-run" in parse_check.message + + +class TestManifestMissingWarning: + """Tests for the manifest-missing warning when apm.yml is absent.""" + + def test_no_artifacts_no_warning(self, tmp_path: Path) -> None: + """Clean non-APM project: no apm.yml, no .apm/, no lockfile -> no warning.""" + result = run_baseline_checks(tmp_path) + names = [c.name for c in result.checks] + assert "manifest-missing" not in names + assert result.passed + + def test_apm_dir_triggers_warning(self, tmp_path: Path) -> None: + """apm.yml absent but .apm/ dir exists -> manifest-missing warning.""" + (tmp_path / ".apm").mkdir() + result = run_baseline_checks(tmp_path) + names = [c.name for c in result.checks] + assert "manifest-missing" in names + check = next(c for c in result.checks if c.name == "manifest-missing") + assert check.passed is True + assert ".apm/" in check.message or "apm.lock.yaml" in check.message + + def test_lockfile_triggers_warning(self, tmp_path: Path) -> None: + """apm.yml absent but apm.lock.yaml exists -> manifest-missing warning.""" + (tmp_path / "apm.lock.yaml").write_text("packages: []\n", encoding="utf-8") + result = run_baseline_checks(tmp_path) + names = [c.name for c in result.checks] + assert "manifest-missing" in names + check = next(c for c in result.checks if c.name == "manifest-missing") + assert check.passed is True + + def test_manifest_present_no_warning(self, tmp_path: Path) -> None: + """apm.yml present -> no manifest-missing check at all.""" + _write_apm_yml(tmp_path) + result = run_baseline_checks(tmp_path) + names = [c.name for c in result.checks] + assert "manifest-missing" not in names + + def test_apm_dir_ci_mode_fails(self, tmp_path: Path) -> None: + """In CI mode, .apm/ without apm.yml fails the check.""" + (tmp_path / ".apm").mkdir() + result = run_baseline_checks(tmp_path, ci_mode=True) + check = next(c for c in result.checks if c.name == "manifest-missing") + assert check.passed is False + assert not result.passed + + def test_lockfile_ci_mode_fails(self, tmp_path: Path) -> None: + """In CI mode, apm.lock.yaml without apm.yml fails the check.""" + (tmp_path / "apm.lock.yaml").write_text("packages: []\n", encoding="utf-8") + result = run_baseline_checks(tmp_path, ci_mode=True) + check = next(c for c in result.checks if c.name == "manifest-missing") + assert check.passed is False + assert not result.passed + + def test_legacy_lockfile_triggers_warning(self, tmp_path: Path) -> None: + """apm.yml absent but legacy apm.lock exists -> manifest-missing warning.""" + (tmp_path / "apm.lock").write_text("packages: []\n", encoding="utf-8") + result = run_baseline_checks(tmp_path) + names = [c.name for c in result.checks] + assert "manifest-missing" in names + check = next(c for c in result.checks if c.name == "manifest-missing") + assert check.passed is True + + def test_legacy_lockfile_ci_mode_fails(self, tmp_path: Path) -> None: + """In CI mode, legacy apm.lock without apm.yml fails the check.""" + (tmp_path / "apm.lock").write_text("packages: []\n", encoding="utf-8") + result = run_baseline_checks(tmp_path, ci_mode=True) + check = next(c for c in result.checks if c.name == "manifest-missing") + assert check.passed is False + assert not result.passed + + def test_no_artifacts_ci_mode_still_passes(self, tmp_path: Path) -> None: + """Clean project with no APM artifacts passes even in CI mode.""" + result = run_baseline_checks(tmp_path, ci_mode=True) + names = [c.name for c in result.checks] + assert "manifest-missing" not in names + assert result.passed