diff --git a/src/apm_cli/policy/ci_checks.py b/src/apm_cli/policy/ci_checks.py index 918b27919..5d5c74e06 100644 --- a/src/apm_cli/policy/ci_checks.py +++ b/src/apm_cli/policy/ci_checks.py @@ -231,23 +231,52 @@ def _check_config_consistency( lock: LockFile, ) -> CheckResult: """Verify MCP server configs match lockfile baseline.""" + from ..deps.path_anchoring import LocalResolutionError, resolve_local_dep_dir from ..drift import detect_config_drift from ..integration.mcp_integrator import MCPIntegrator + from ..models.apm_package import APMPackage + details: list[str] = [] mcp_deps = manifest.get_all_mcp_dependencies() + for locked_dep in lock.get_package_dependencies(): + if locked_dep.source != "local": + continue + try: + package_dir = resolve_local_dep_dir(locked_dep, lock, manifest.package_path) + except LocalResolutionError as exc: + details.append( + f"{locked_dep.repo_url}: cannot resolve local package ({exc}) -- " + "fix the resolved_by chain or re-run 'apm install'" + ) + continue + package_manifest = package_dir / "apm.yml" + if not package_manifest.exists(): + details.append( + f"{locked_dep.repo_url}: local package manifest not found at " + f"{package_manifest} -- restore the source or re-run 'apm install'" + ) + continue + try: + package = APMPackage.from_apm_yml(package_manifest) + except (OSError, ValueError) as exc: + details.append( + f"{locked_dep.repo_url}: cannot parse local package manifest ({exc}) -- " + "fix the manifest or re-run 'apm install'" + ) + continue + mcp_deps.extend(package.get_mcp_dependencies()) + mcp_deps = MCPIntegrator.deduplicate(mcp_deps) current_configs = MCPIntegrator.get_server_configs(mcp_deps) stored_configs = lock.mcp_configs or {} # No MCP deps at all -- nothing to check - if not current_configs and not stored_configs: + if not current_configs and not stored_configs and not details: return CheckResult( name="config-consistency", passed=True, message="No MCP configs to check", ) - details: list[str] = [] - # Detect drift on servers that exist in both sets drifted = detect_config_drift(current_configs, stored_configs) for name in sorted(drifted): diff --git a/tests/unit/policy/test_ci_checks.py b/tests/unit/policy/test_ci_checks.py index cd073a417..bf2b6f845 100644 --- a/tests/unit/policy/test_ci_checks.py +++ b/tests/unit/policy/test_ci_checks.py @@ -417,6 +417,145 @@ def test_fail_mcp_config_drift(self, tmp_path): assert not result.passed assert any("my-server" in d and "differs" in d for d in result.details) + def test_fail_local_path_subpackage_mcp_config_drift(self, tmp_path): + """A local sub-package MCP source change must differ from its lock baseline.""" + package = tmp_path / "packages" / "agent-config" + package.mkdir(parents=True) + _write_apm_yml(tmp_path, deps=["./packages/agent-config"]) + (package / "apm.yml").write_text( + textwrap.dedent("""\ + name: agent-config + version: 1.0.0 + dependencies: + mcp: + - name: shadcn + registry: false + transport: stdio + command: echo + args: [changed] + """), + encoding="utf-8", + ) + _write_lockfile( + tmp_path, + textwrap.dedent("""\ + lockfile_version: '1' + generated_at: '2025-01-01T00:00:00Z' + dependencies: + - repo_url: _local/agent-config + source: local + local_path: ./packages/agent-config + depth: 1 + deployed_files: [] + mcp_configs: + shadcn: + name: shadcn + registry: false + transport: stdio + command: echo + args: [ready] + mcp_config_provenance: + shadcn: agent-config + """), + ) + from apm_cli.deps.lockfile import LockFile, get_lockfile_path + from apm_cli.models.apm_package import APMPackage + + manifest = APMPackage.from_apm_yml(tmp_path / "apm.yml") + lock = LockFile.read(get_lockfile_path(tmp_path)) + result = _check_config_consistency(manifest, lock) + + assert not result.passed + assert result.details == ["shadcn: config differs from lockfile baseline"] + + def test_local_resolution_error_fails_gracefully(self, tmp_path): + """A corrupt local dependency graph must fail without crashing audit.""" + _write_apm_yml(tmp_path, deps=["./packages/agent-config"]) + _write_lockfile( + tmp_path, + textwrap.dedent("""\ + lockfile_version: '1' + generated_at: '2025-01-01T00:00:00Z' + dependencies: + - repo_url: _local/agent-config + source: local + local_path: ../agent-config + resolved_by: _local/missing-parent + depth: 2 + deployed_files: [] + """), + ) + from apm_cli.deps.lockfile import LockFile, get_lockfile_path + from apm_cli.models.apm_package import APMPackage + + manifest = APMPackage.from_apm_yml(tmp_path / "apm.yml") + lock = LockFile.read(get_lockfile_path(tmp_path)) + result = _check_config_consistency(manifest, lock) + + assert not result.passed + assert len(result.details) == 1 + assert "_local/agent-config: cannot resolve local package" in result.details[0] + assert "re-run 'apm install'" in result.details[0] + + def test_invalid_local_package_manifest_fails_gracefully(self, tmp_path): + """An invalid local package manifest must fail without crashing audit.""" + package = tmp_path / "packages" / "agent-config" + package.mkdir(parents=True) + _write_apm_yml(tmp_path, deps=["./packages/agent-config"]) + (package / "apm.yml").write_text("version: 1.0.0\n", encoding="utf-8") + _write_lockfile( + tmp_path, + textwrap.dedent("""\ + lockfile_version: '1' + generated_at: '2025-01-01T00:00:00Z' + dependencies: + - repo_url: _local/agent-config + source: local + local_path: ./packages/agent-config + depth: 1 + deployed_files: [] + """), + ) + from apm_cli.deps.lockfile import LockFile, get_lockfile_path + from apm_cli.models.apm_package import APMPackage + + manifest = APMPackage.from_apm_yml(tmp_path / "apm.yml") + lock = LockFile.read(get_lockfile_path(tmp_path)) + result = _check_config_consistency(manifest, lock) + + assert not result.passed + assert len(result.details) == 1 + assert "_local/agent-config: cannot parse local package manifest" in result.details[0] + assert "Missing required field 'name'" in result.details[0] + + def test_missing_local_package_manifest_fails_gracefully(self, tmp_path): + """A missing local package manifest must fail without a false pass.""" + _write_apm_yml(tmp_path, deps=["./packages/agent-config"]) + _write_lockfile( + tmp_path, + textwrap.dedent("""\ + lockfile_version: '1' + generated_at: '2025-01-01T00:00:00Z' + dependencies: + - repo_url: _local/agent-config + source: local + local_path: ./packages/agent-config + depth: 1 + deployed_files: [] + """), + ) + from apm_cli.deps.lockfile import LockFile, get_lockfile_path + from apm_cli.models.apm_package import APMPackage + + manifest = APMPackage.from_apm_yml(tmp_path / "apm.yml") + lock = LockFile.read(get_lockfile_path(tmp_path)) + result = _check_config_consistency(manifest, lock) + + assert not result.passed + assert len(result.details) == 1 + assert "_local/agent-config: local package manifest not found" in result.details[0] + assert "re-run 'apm install'" in result.details[0] + def test_transitive_mcp_server_not_flagged_as_orphan(self, tmp_path): """A server declared by a local-path sub-package is exempt from orphan.