Skip to content
7 changes: 7 additions & 0 deletions .apm/architecture/owners/install-deployment.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
{
"version": 1,
"owners": [
{
"id": "orphan-package-selection",
"decision": "Declaration-aware selection of orphaned installed packages",
"owner": "commands/_helpers.py (_find_orphaned_packages)",
"selectors": ["src/apm_cli/commands/_helpers.py"],
"guards": ["install-deployment-orphan-selection"]
},
{
"id": "effective-package-target-authorization",
"decision": "Effective package target authorization",
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `apm prune` removes orphaned manifestless skill installs while preserving skills inside declared or transitive skill bundles. (#3015)
- Autopilot maintainer canvas removes a Decide row as soon as GitHub confirms `status/accepted`, without waiting for a full issue/PR refetch.
- Issue and PR triage no longer skip bot-authored items (Copilot, Dependabot, github-actions). They stay in the queue like any other contribution. (#3024)
- PR-review scheduler no longer queues every open pull request. A fresh review requires the `panel-review` label (same trigger as the Agentic Workflow), `status/accepted` on the PR, or an explicit named PR list. The reviewing session also requires `status/accepted` on the PR or a linked issue; otherwise scheduler and review-worker stop with no comment. The worker may clear `panel-review`; the scheduler does not comment or change labels. Both also apply a CODEOWNERS last-comment gate: read the last CODEOWNER comment as conditions and evaluate them against later comments AND labels on the PR and linked issues. Drop or `noop` only when those conditions are unmet or unclear. Named list does not bypass that gate.
Expand Down
3 changes: 3 additions & 0 deletions docs/src/content/docs/reference/cli/prune.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ reconciles hooks for packages and targets still declared.

Notes:

- Manifestless installs containing `SKILL.md` are detected even after `apm install`
removes their lockfile entry. Skills inside a declared or retained transitive
package remain part of that package and are preserved.
- Packages that share an install root with a still-declared sibling subdirectory dependency are not falsely protected by ancestor expansion. The check uses lockfile membership (with `apm.yml` fallback) to identify genuine standalone packages.
- A manifest embedded at any depth inside an installed package is owned by that
package. It is not an independent dependency, orphan, or prune candidate.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,21 @@
check_source_plan,
)
from scripts.architecture_linter.checks.install_uninstall_and_resolution import (
_GUARD_ORPHAN_SELECTION,
_GUARD_RESOLUTION_REPLACEMENT,
_GUARD_UNINSTALL_SELECTION,
check_orphan_selection,
check_resolution_replacement,
check_uninstall_selection,
)
from scripts.architecture_linter.models import Rule

RULES: tuple[Rule, ...] = (
_rule(
_GUARD_ORPHAN_SELECTION,
"Prune and orphan warnings share declaration-aware package selection.",
check_orphan_selection,
),
_rule(
_GUARD_PACKAGE_TARGET,
"Restriction-only package target authorization has one owner (install/target_filter.py).",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,32 @@


_GUARD_UNINSTALL_SELECTION = "install-deployment-uninstall-selection"
_GUARD_ORPHAN_SELECTION = "install-deployment-orphan-selection"


def check_orphan_selection(provider: FactsProvider) -> tuple[Violation, ...]:
"""Prune and advisory warnings share declaration-aware orphan selection."""
findings: list[Violation] = []
for path, scope in (
("src/apm_cli/commands/_helpers.py", "_check_orphaned_packages"),
("src/apm_cli/commands/prune.py", "prune"),
):
facts, failures = _facts_for(provider, path, _GUARD_ORPHAN_SELECTION)
findings.extend(failures)
if failures:
continue
if not any(
call.qualname == "_find_orphaned_packages" and call.scope == scope
for call in facts.calls
):
findings.append(
_summary(
_GUARD_ORPHAN_SELECTION,
path,
f"{scope} must route orphan selection through _find_orphaned_packages",
)
)
return tuple(findings)


def _call_terminal_name(node: ast.Call) -> str | None:
Expand Down
24 changes: 20 additions & 4 deletions src/apm_cli/commands/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,14 +331,30 @@ def _check_orphaned_packages():
standalone_installed = _standalone_installed_packages(
installed, apm_modules_dir, lockfile=lockfile
)
expected_with_ancestors = _expand_with_ancestors(expected, standalone_installed)
# Sort for deterministic, diffable output across runs (rglob
# traversal order is filesystem-dependent).
return sorted(p for p in installed if p not in expected_with_ancestors)
return _find_orphaned_packages(installed, expected, standalone_installed)
except Exception:
return []


def _find_orphaned_packages(
installed: Iterable[str], expected: set[str], standalone: Iterable[str]
) -> list[str]:
"""Select orphans while preserving the contents of retained install roots.

Manifestless skill bundles have no root package marker. Their nested
skills belong to the declared or transitive install root, even though the
filesystem scan discovers each skill separately. Only actual expected
roots protect descendants; expanded ancestors must not protect siblings.
"""
expected_with_ancestors = _expand_with_ancestors(expected, standalone)
return sorted(
path
for path in installed
if path not in expected_with_ancestors
and not any(path.startswith(f"{root}/") for root in expected)
)


# ------------------------------------------------------------------
# Dependency-update helpers (shared by `apm update` and `apm deps update`)
# ------------------------------------------------------------------
Expand Down
13 changes: 9 additions & 4 deletions src/apm_cli/commands/deps/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
def _scan_installed_packages(apm_modules_dir: Path) -> list:
"""Scan *apm_modules_dir* for installed package paths.

Walks the tree to find top-level directories containing ``apm.yml`` or
``.apm``, supporting aliases (1-level), GitHub (2-level), ADO (3-level),
Walks the tree to find top-level directories containing ``apm.yml``,
``.apm``, or ``SKILL.md``, supporting aliases (1-level), GitHub (2-level), ADO (3-level),
and subdirectory packages. Manifests nested below another package are part of that
parent package and are excluded.

Expand All @@ -27,7 +27,11 @@ def _scan_installed_packages(apm_modules_dir: Path) -> list:
continue
if candidate.name.startswith(".") and candidate.parent != apm_modules_dir:
continue
if not ((candidate / APM_YML_FILENAME).exists() or (candidate / APM_DIR).exists()):
if not (
(candidate / APM_YML_FILENAME).exists()
or (candidate / APM_DIR).exists()
or (candidate / SKILL_MD_FILENAME).is_file()
):
continue
if _is_nested_under_package(candidate, apm_modules_dir):
continue
Expand All @@ -42,7 +46,7 @@ def _is_nested_under_package(candidate: Path, apm_modules_path: Path) -> bool:
When a package ships nested package or skill manifests, the ``rglob`` scan
would otherwise treat each sub-directory as an independent package. This
helper walks up from *candidate* towards *apm_modules_path* and returns
``True`` if any intermediate parent already contains ``apm.yml``, ``.apm``,
``True`` if any intermediate parent already contains ``apm.yml``, ``.apm``, ``SKILL.md``,
or a canonical Agent Plugin manifest -- meaning the candidate is part of
that package, not a standalone one.
"""
Expand All @@ -51,6 +55,7 @@ def _is_nested_under_package(candidate: Path, apm_modules_path: Path) -> bool:
if (
(parent / APM_YML_FILENAME).exists()
or (parent / APM_DIR).exists()
or (parent / SKILL_MD_FILENAME).is_file()
or _is_agent_plugin_root(parent)
):
return True
Expand Down
5 changes: 3 additions & 2 deletions src/apm_cli/commands/prune.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from ._helpers import (
_build_expected_install_paths,
_expand_with_ancestors,
_find_orphaned_packages,
_scan_installed_packages,
_standalone_installed_packages,
)
Expand Down Expand Up @@ -144,8 +145,8 @@ def prune(ctx, dry_run):
lock_keys_by_path = (
_lock_keys_by_install_path(lockfile, apm_modules_dir) if lockfile is not None else {}
)
orphaned_packages = sorted(
p for p in installed_packages if p not in expected_with_ancestors
orphaned_packages = _find_orphaned_packages(
installed_packages, expected_installed, standalone_installed
)
Comment thread
danielmeppiel marked this conversation as resolved.
Outdated
missing_orphaned_keys = sorted(
dep_key
Expand Down
8 changes: 8 additions & 0 deletions tests/integration/test_architecture_owner_rule_mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ class MutationCase:


MUTATIONS: tuple[MutationCase, ...] = (
MutationCase(
guard_id="install-deployment-orphan-selection",
rule_id="install-deployment-orphan-selection",
path="src/apm_cli/commands/prune.py",
old="orphaned_packages = _find_orphaned_packages(",
new="orphaned_packages = _find_orphaned_packages_disabled(",
intent="Prune bypasses the shared declaration-aware orphan selector.",
),
MutationCase(
guard_id="contracts-tests-taxonomy-classification",
rule_id="contracts-tests-taxonomy-classification",
Expand Down
174 changes: 174 additions & 0 deletions tests/integration/test_prune_skill_lifecycle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"""Real CLI install/removal/prune contracts for manifestless skill packages."""

from __future__ import annotations

import os
import sys
from pathlib import Path

import pytest

from apm_cli.deps.lockfile import LockFile
from apm_cli.utils.yaml_io import dump_yaml, load_yaml
from tests.utils.apm_lifecycle_runner import ApmLifecycleRunner
from tests.utils.artifact_snapshot import ArtifactSnapshot, assert_unchanged
from tests.utils.isolated_apm_environment import IsolatedApmEnvironment
from tests.utils.local_git_repository import LocalGitRepositoryFactory
from tests.utils.local_package import LocalPackageFactory

pytestmark = [pytest.mark.integration, pytest.mark.lifecycle_smoke, pytest.mark.windows_compat]
Comment thread
fangkangmi marked this conversation as resolved.
Outdated


@pytest.mark.parametrize("alias", [None, "azure-ai-alias"])
def test_install_remove_install_prune_skill(tmp_path: Path, alias: str | None) -> None:
"""Prune removes unlocked skill bytes while retaining a sibling and user files."""
isolated = IsolatedApmEnvironment.create(tmp_path / "isolated", base_env=os.environ)
repositories = LocalGitRepositoryFactory(
isolated.repository_root, env=isolated.subprocess_env()
)
repository = repositories.create("skills")
skill_parent = ".github/plugins/azure-skills/skills"
for name in ("azure-ai", "retained"):
skill = repository.worktree / skill_parent / name
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: Fixture skill\n---\n# {name}\n",
encoding="utf-8",
)
(skill / "references").mkdir()
(skill / "references" / "guide.md").write_text("Reference bytes\n", encoding="utf-8")
commit = repositories.commit(repository, message="Add fixture skills")
# Git transport is redirected to a real local repository; no downloader,
# install, lockfile, integration, or prune code is mocked.
remote = "https://gitlab.com/fixture/skills"
environment = repositories.url_rewrite_subprocess_env(repository, remote)
dependencies = [
{"git": remote, "path": f"{skill_parent}/{name}", "ref": commit.sha}
for name in ("azure-ai", "retained")
]
if alias is not None:
dependencies[0]["alias"] = alias
project = LocalPackageFactory(isolated.work_root).create(
"consumer", dependencies=dependencies, targets=("copilot",)
)
source_root = Path(__file__).resolve().parents[2] / "src"
runner = ApmLifecycleRunner(
(
sys.executable,
"-c",
f"import sys; sys.path.insert(0, {str(source_root)!r}); "
"from apm_cli.cli import main; main()",
),
timeout_seconds=30,
)

def run(*args: str) -> str:
result = runner.run(args, cwd=project.root, env=environment, scenario_id="prune-skill")
assert result.returncode == 0, result.stdout + result.stderr
return result.stdout + result.stderr

install_args = ("install", "--no-policy", "--parallel-downloads", "0")
run(*install_args)
lock_path = project.root / "apm.lock.yaml"
lock = LockFile.read(lock_path)
removed_key, removed = next(
(key, dep)
for key, dep in lock.dependencies.items()
if dep.virtual_path.endswith("azure-ai")
)
modules = project.root / "apm_modules"
removed_root = removed.to_dependency_ref().get_install_path(modules)
retained = next(
dep for dep in lock.dependencies.values() if dep.virtual_path.endswith("retained")
)
retained_root = retained.to_dependency_ref().get_install_path(modules)
assert (removed_root / "SKILL.md").is_file()
assert not (removed_root / "apm.yml").exists()
deployed = project.root / ".agents" / "skills"
removed_deployment = deployed / (alias or "azure-ai")
assert (removed_deployment / "SKILL.md").is_file()
retained_before = ArtifactSnapshot.capture(retained_root)
deployed_before = ArtifactSnapshot.capture(deployed / "retained")

manifest = load_yaml(project.manifest_path)
manifest["dependencies"]["apm"] = dependencies[1:]
dump_yaml(manifest, project.manifest_path)
run(*install_args)
assert removed_key not in LockFile.read(lock_path).dependencies
assert not removed_deployment.exists()
assert removed_root.exists(), "Install leaves orphan source bytes for prune"
sentinel = modules / "user-notes.txt"
sentinel.write_text("Keep these notes\n", encoding="utf-8")
before_dry_run = ArtifactSnapshot.capture(project.root)

preview = run("prune", "--dry-run")
assert "1 orphaned package(s)" in preview
assert_unchanged(before_dry_run, ArtifactSnapshot.capture(project.root))
assert "Pruned 1 orphaned package(s)" in run("prune")
assert not removed_root.exists()
assert_unchanged(retained_before, ArtifactSnapshot.capture(retained_root))
assert_unchanged(deployed_before, ArtifactSnapshot.capture(deployed / "retained"))
assert sentinel.read_text(encoding="utf-8") == "Keep these notes\n"
before_repeat = ArtifactSnapshot.capture(project.root)
assert "No orphaned packages" in run("prune")
assert_unchanged(before_repeat, ArtifactSnapshot.capture(project.root))


@pytest.mark.parametrize("alias", [None, "bundle-alias"])
def test_prune_preserves_declared_skill_bundle(tmp_path: Path, alias: str | None) -> None:
"""Real manifestless bundles survive prune and produce no compile orphan warning."""
isolated = IsolatedApmEnvironment.create(tmp_path / "isolated", base_env=os.environ)
repositories = LocalGitRepositoryFactory(
isolated.repository_root, env=isolated.subprocess_env()
)
repository = repositories.create("bundle")
for name in ("alpha", "beta"):
skill = repository.worktree / "skills" / name
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: Bundle fixture skill\n---\n# {name}\n",
encoding="utf-8",
)
commit = repositories.commit(repository, message="Add manifestless skill bundle")
remote = "https://gitlab.com/fixture/bundle"
environment = repositories.url_rewrite_subprocess_env(repository, remote)
dependency = {"git": remote, "ref": commit.sha}
if alias is not None:
dependency["alias"] = alias
project = LocalPackageFactory(isolated.work_root).create(
"consumer", dependencies=(dependency,), targets=("copilot",)
)
source_root = Path(__file__).resolve().parents[2] / "src"
runner = ApmLifecycleRunner(
(
sys.executable,
"-c",
f"import sys; sys.path.insert(0, {str(source_root)!r}); "
"from apm_cli.cli import main; main()",
),
timeout_seconds=30,
)

def run(*args: str) -> str:
result = runner.run(args, cwd=project.root, env=environment, scenario_id="prune-bundle")
assert result.returncode == 0, result.stdout + result.stderr
return result.stdout + result.stderr

run("install", "--no-policy", "--parallel-downloads", "0")
modules = project.root / "apm_modules"
bundle = modules / (alias or "fixture/bundle")
assert not (bundle / "apm.yml").exists()
assert not (bundle / "SKILL.md").exists()
assert not (bundle / ".apm").exists()
for name in ("alpha", "beta"):
assert (bundle / "skills" / name / "SKILL.md").is_file()
assert (project.root / ".agents" / "skills" / name / "SKILL.md").is_file()
before = ArtifactSnapshot.capture(project.root)
for args in (("prune", "--dry-run"), ("prune",)):
assert "No orphaned packages" in run(*args)
assert_unchanged(before, ArtifactSnapshot.capture(project.root))
output = run("compile")
assert "orphaned package(s)" not in output
assert "Run 'apm prune'" not in output
for name in ("alpha", "beta"):
assert (bundle / "skills" / name / "SKILL.md").is_file()
1 change: 1 addition & 0 deletions tests/unit/scripts/test_architecture_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,7 @@ def exiting_import(
install-deployment-lifecycle-serialization
install-deployment-mcp-ownership-migration
install-deployment-mcp-registry-resolution
install-deployment-orphan-selection
install-deployment-outcome
install-deployment-package-target-authorization
install-deployment-plugin-bin-eligibility
Expand Down
Loading