From 5c89f3e87fbcf3c986355b786d70c050c1bcb726 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Fri, 10 Jul 2026 23:46:45 +0200 Subject: [PATCH 1/3] fix: reconcile contracted deployment targets Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/content/docs/reference/cli/compile.md | 5 + docs/src/content/docs/reference/cli/update.md | 1 + .../.apm/skills/apm-usage/commands.md | 4 +- src/apm_cli/commands/compile/cli.py | 11 + src/apm_cli/commands/update.py | 13 ++ src/apm_cli/install/manifest_reconcile.py | 201 ++++++++++++++++++ src/apm_cli/install/phases/lockfile.py | 22 +- src/apm_cli/install/phases/post_deps_local.py | 17 +- src/apm_cli/install/phases/targets.py | 38 +--- .../test_inactive_target_ghost_e2e.py | 50 +++++ .../install/phases/test_lockfile_union.py | 26 +++ 11 files changed, 341 insertions(+), 47 deletions(-) diff --git a/docs/src/content/docs/reference/cli/compile.md b/docs/src/content/docs/reference/cli/compile.md index 6fbeb27cf..8e04cdde6 100644 --- a/docs/src/content/docs/reference/cli/compile.md +++ b/docs/src/content/docs/reference/cli/compile.md @@ -30,6 +30,11 @@ and are not touched by `apm compile`. See [Primitives and targets](../../../concepts/primitives-and-targets/) for the full reach map. +After a successful non-dry-run compile, APM also reconciles deployed-file +ownership with the current `targets:` declaration. If the declared target set +contracts, artifacts and lockfile entries owned by the removed target are +cleaned up with the same hash and user-edit safeguards as `apm install`. + **When you actually need it:** compile is **optional for the `copilot` target** -- GitHub Copilot natively reads `.github/instructions/*.instructions.md` (with their `applyTo:` diff --git a/docs/src/content/docs/reference/cli/update.md b/docs/src/content/docs/reference/cli/update.md index 7db2a237d..95681d802 100644 --- a/docs/src/content/docs/reference/cli/update.md +++ b/docs/src/content/docs/reference/cli/update.md @@ -98,6 +98,7 @@ apm update - **Consent gate.** The prompt defaults to **No**. Without `--yes`, declining (or running in a non-interactive context) aborts with a clean exit; the manifest, lockfile, and workspace are untouched. - **No partial consent.** A single prompt covers both revision-pin manifest rewrites and the normal update plan; declining leaves everything unchanged. - **`--dry-run` skips the prompt.** It computes and prints the plan, including revision-pin SHA/tag rewrites, but never writes and never asks. +- **Target contraction is reconciled.** A successful update removes unchanged dependencies' deployed files and lockfile ownership for targets no longer declared in `apm.yml`, even when no dependency ref changes. ## Back-compat: `apm update` used to be the self-updater diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 152404620..9076cc5bf 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -63,7 +63,7 @@ If no `--target`, no `targets:` in `apm.yml`, and no harness signal is present, | Command | Purpose | Key flags | |---------|---------|-----------| -| `apm compile` | Compile agent context | `-o` output, `-t` target (comma-separated; resolution chain `--target` > apm.yml `targets:` > auto-detect), `--all` compile for every canonical target (preferred over deprecated `--target all`), `-g`/`--global` (read global instructions from `~/.apm/apm_modules/`, write user-scope root files; cannot combine with project-output flags such as `--target`, `--all`, `--watch`, `--root`, or `--output`; critical hidden-character findings stop the write and exit 1), `--chatmode`, `--dry-run`, `--no-links`, `--watch`, `--validate`, `--single-agents`, `-v` verbose, `--local-only`, `--clean`, `--with-constitution/--no-constitution`, `--force-instructions` / `--no-dedup` (opt out of Claude/Copilot deduplication), `--root DIR` redirect generated artifacts under DIR while sources resolve from `$PWD` (mirrors `pip install --target`; not valid with `--watch`) | +| `apm compile` | Compile agent context; after a successful write, reconcile deployed artifacts and lockfile ownership when the declared target set contracts | `-o` output, `-t` target (comma-separated; resolution chain `--target` > apm.yml `targets:` > auto-detect), `--all` compile for every canonical target (preferred over deprecated `--target all`), `-g`/`--global` (read global instructions from `~/.apm/apm_modules/`, write user-scope root files; cannot combine with project-output flags such as `--target`, `--all`, `--watch`, `--root`, or `--output`; critical hidden-character findings stop the write and exit 1), `--chatmode`, `--dry-run`, `--no-links`, `--watch`, `--validate`, `--single-agents`, `-v` verbose, `--local-only`, `--clean`, `--with-constitution/--no-constitution`, `--force-instructions` / `--no-dedup` (opt out of Claude/Copilot deduplication), `--root DIR` redirect generated artifacts under DIR while sources resolve from `$PWD` (mirrors `pip install --target`; not valid with `--watch`) | `apm compile --watch` live-reloads `apm.yml`: editing `target:` / `targets:` mid-session takes effect on the next file event without restarting the watcher. The CLI `--target` flag, when passed to `apm compile --watch`, still outranks `apm.yml`. Re-resolution is gated on the changed file's basename being `apm.yml`, so `.instructions.md` edits do not pay an extra resolver round-trip and a stray `backup_apm.yml` cannot trigger a reload. `--clean` is ignored in watch mode and the watcher prints an explicit `[!]` warning at startup (`--clean is ignored in watch mode; run 'apm compile --clean' separately to remove orphaned outputs.`); run `apm compile --clean` separately between watch sessions to remove orphans. @@ -239,7 +239,7 @@ Experimental flags MUST NOT gate security-critical behaviour (content scanning, | `apm config unset KEY` | Remove a stored config value (`target`, `self-update.channel`, `self-update.install-dir`, `temp-dir`, `allow-protocol-fallback`, `prefer-ssh`, `copilot-cowork-skills-dir`, `mcp-registry-url`) | -- | | `apm lock` | Resolve all dependencies in `apm.yml` and write `apm.lock.yaml` **without** deploying any files to agent targets. Mirrors `cargo generate-lockfile` / `pnpm lock`. Use to bootstrap or refresh the lockfile before reviewing and applying changes. | `--update` re-resolve to latest SHAs, `--verbose`, `-g/--global`, `--no-policy`, `--target` (comma-separated), `--parallel-downloads N` | | `apm lock export` | Export an SBOM/inventory from the **existing** `apm.lock.yaml` -- reads the lockfile only (no re-resolve, no re-hash, no network). Emits component identity (purl), recorded hashes, and the declared license. Output is deterministic (components sorted by purl, pinned timestamp) for byte-identical reproducibility. This is an inventory export, not a security attestation. | `-f/--format [cyclonedx\|spdx]` (default `cyclonedx`), `-o/--output FILE` (default stdout), `-g/--global` read user-scope lockfile, `--timestamp ISO8601` pin the document timestamp (falls back to `SOURCE_DATE_EPOCH`, then the lockfile's `generated_at`) | -| `apm update [PKGS...]` | Refresh APM dependencies: resolves `apm.yml` against the latest refs, prints a structured plan (added/updated/removed/unchanged), and prompts before mutating anything (default `[y/N]`). Full-SHA pins are resolved against the latest annotated semver tag, rewritten to that tag's SHA, and annotated as `# ` in `apm.yml`. Pass `[PKGS...]` to refresh only those deps, or `-g` for user scope (`~/.apm/`). Strict superset of the deprecated `apm deps update`. Skips the prompt with `--yes`; previews with `--dry-run`. | `--yes`, `--dry-run`, `--verbose`, `-g/--global`, `--force`, `--parallel-downloads N`, `--target` (comma-separated) | +| `apm update [PKGS...]` | Refresh APM dependencies: resolves `apm.yml` against the latest refs, prints a structured plan (added/updated/removed/unchanged), and prompts before mutating anything (default `[y/N]`). Full-SHA pins are resolved against the latest annotated semver tag, rewritten to that tag's SHA, and annotated as `# ` in `apm.yml`. Pass `[PKGS...]` to refresh only those deps, or `-g` for user scope (`~/.apm/`). Successful no-op updates still reconcile deployed artifacts and lockfile ownership when the declared target set contracts. Strict superset of the deprecated `apm deps update`. Skips the prompt with `--yes`; previews with `--dry-run`. | `--yes`, `--dry-run`, `--verbose`, `-g/--global`, `--force`, `--parallel-downloads N`, `--target` (comma-separated) | | `apm self-update` | Update the APM CLI itself (or show distributor guidance when self-update is disabled at build time). | `--check` only check | `apm config set prefer-ssh true` and `apm config set allow-protocol-fallback true` persist transport preferences to `~/.apm/config.json` so SSH-only and corporate GHES users no longer need to re-pass `--ssh` / `--allow-protocol-fallback` on every `apm install`. Resolution order: CLI flag > `APM_GIT_PROTOCOL` / `APM_ALLOW_PROTOCOL_FALLBACK` env var > `apm config` value > built-in default (`false`). `apm config unset prefer-ssh` and `apm config unset allow-protocol-fallback` remove the persisted value. In `apm config` / `apm config list` / `apm config get` (no key), the two transport rows surface only when they have been enabled (the `false`-default rows are suppressed to keep the output noise-free); `apm config get ` always returns the effective value. Setting `allow-protocol-fallback=true` while `CI=1` emits a warning because the persisted value affects every subsequent `apm install` on a shared `$HOME`; prefer the env var in CI. diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index 4977e88fc..d8f693a61 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -880,6 +880,17 @@ def _coerce_provenance_targets(value): perf_stats.render_summary(logger, project_root=str(_src)) sys.exit(1) + if result.success and not dry_run: + from ...install.manifest_reconcile import reconcile_project_deployed_state + + reconcile_project_deployed_state( + Path(_src).resolve(), + explicit_target=target, + deploy_root=Path(".").resolve(), + lock_root=Path(".").resolve(), + verbose=verbose, + ) + perf_stats.render_summary(logger, project_root=str(_src)) diff --git a/src/apm_cli/commands/update.py b/src/apm_cli/commands/update.py index 7b719fc14..8b416284b 100644 --- a/src/apm_cli/commands/update.py +++ b/src/apm_cli/commands/update.py @@ -675,6 +675,19 @@ def _plan_callback(plan: UpdatePlan) -> bool: if plan is None or not isinstance(plan, UpdatePlan): return + reconcile_noop = not dry_run and not plan.has_changes and not revision_pin_updates + if plan_state.proceeded or reconcile_noop: + from apm_cli.install.manifest_reconcile import reconcile_project_deployed_state + + reconcile_project_deployed_state( + Path.cwd(), + explicit_target=target, + deploy_root=_mcp_lsp_project_root, + lock_root=_apm_dir, + user_scope=scope is InstallScope.USER, + verbose=verbose, + ) + if plan_state.proceeded: if revision_pin_updates: try: diff --git a/src/apm_cli/install/manifest_reconcile.py b/src/apm_cli/install/manifest_reconcile.py index 7f4ef5751..703c3cf14 100644 --- a/src/apm_cli/install/manifest_reconcile.py +++ b/src/apm_cli/install/manifest_reconcile.py @@ -26,10 +26,13 @@ from __future__ import annotations from collections.abc import Callable +from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: + from apm_cli.deps.lockfile import LockFile from apm_cli.integration.targets import TargetProfile + from apm_cli.utils.diagnostics import DiagnosticCollector def install_governance(targets: list[TargetProfile]) -> tuple[set[str], set[str]]: @@ -170,3 +173,201 @@ def union_preserving( if prior_hashes and path in prior_hashes: merged_hashes[path] = prior_hashes[path] return list(current_files or ()) + preserved, merged_hashes + + +def declared_target_profiles( + project_root: Path, + *, + user_scope: bool = False, +) -> list[TargetProfile] | None: + """Resolve the target universe declared by a project manifest.""" + from apm_cli.core.apm_yml import CANONICAL_TARGETS, parse_targets_field + from apm_cli.integration.targets import KNOWN_TARGETS + from apm_cli.utils.yaml_io import load_yaml + + try: + data = load_yaml(project_root / "apm.yml") + names = parse_targets_field(data) if isinstance(data, dict) else None + except (AttributeError, KeyError, OSError, TypeError, ValueError): + return None + if not names: + return None + + profiles: list[TargetProfile] = [] + for name in dict.fromkeys(names): + profile = KNOWN_TARGETS.get(name) + if profile is None: + continue + scoped = profile.for_scope(user_scope=user_scope) + if scoped is not None: + profiles.append(scoped) + for name, profile in KNOWN_TARGETS.items(): + if name in CANONICAL_TARGETS: + continue + scoped = profile.for_scope(user_scope=user_scope) + profiles.append(scoped if scoped is not None else profile) + return profiles or None + + +def reconcile_deployed_block( + *, + project_root: Path, + dep_key: str, + current_files: list[str], + current_hashes: dict[str, str], + prior_files: list[str], + prior_hashes: dict[str, str], + active_targets: list[TargetProfile], + declared_targets: list[TargetProfile] | None, + diagnostics: DiagnosticCollector, + on_ghost_drop: Callable[[str], None] | None = None, +) -> tuple[list[str], dict[str, str]]: + """Reconcile one deployed-state block and safely remove dropped paths.""" + files, hashes = union_preserving( + current_files, + current_hashes, + prior_files, + prior_hashes, + active_targets, + declared_targets=declared_targets, + on_ghost_drop=on_ghost_drop, + ) + dropped = set(prior_files) - set(files) + if not dropped: + return files, hashes + + from apm_cli.integration.base_integrator import BaseIntegrator + from apm_cli.integration.cleanup import remove_stale_deployed_files + + cleanup = remove_stale_deployed_files( + dropped, + project_root, + dep_key=dep_key, + targets=None, + diagnostics=diagnostics, + recorded_hashes=prior_hashes, + ) + for path in cleanup.failed: + if path not in files: + files.append(path) + if path in prior_hashes: + hashes[path] = prior_hashes[path] + if cleanup.deleted_targets: + BaseIntegrator.cleanup_empty_parents(cleanup.deleted_targets, project_root) + return files, hashes + + +def reconcile_deployed_state( + *, + project_root: Path, + lockfile: LockFile, + active_targets: list[TargetProfile], + declared_targets: list[TargetProfile] | None, + diagnostics: DiagnosticCollector, +) -> bool: + """Prune undeclared-target ownership from every lockfile deployment block.""" + from apm_cli.deps.lockfile import _SELF_KEY + from apm_cli.integration.targets import KNOWN_TARGETS + + allowed_targets = [*active_targets, *(declared_targets or [])] + allowed_prefixes, allowed_schemes = install_governance(allowed_targets) + known_prefixes, known_schemes = install_governance(list(KNOWN_TARGETS.values())) + + def _retained(files: list[str]) -> list[str]: + if declared_targets is None: + return list(files) + return [ + path + for path in files + if not ( + is_governed_by_install(path, known_prefixes, known_schemes) + and not is_governed_by_install(path, allowed_prefixes, allowed_schemes) + ) + ] + + changed = False + for dep_key, dependency in lockfile.dependencies.items(): + if dep_key == _SELF_KEY: + continue + prior_files = list(dependency.deployed_files) + prior_hashes = dict(dependency.deployed_file_hashes) + current_files = _retained(prior_files) + current_hashes = { + path: value for path, value in prior_hashes.items() if path in current_files + } + files, hashes = reconcile_deployed_block( + project_root=project_root, + dep_key=dep_key, + current_files=current_files, + current_hashes=current_hashes, + prior_files=prior_files, + prior_hashes=prior_hashes, + active_targets=active_targets, + declared_targets=declared_targets, + diagnostics=diagnostics, + ) + if files != prior_files or hashes != prior_hashes: + dependency.deployed_files = files + dependency.deployed_file_hashes = hashes + changed = True + + prior_local = list(lockfile.local_deployed_files) + prior_local_hashes = dict(lockfile.local_deployed_file_hashes) + current_local = _retained(prior_local) + current_local_hashes = { + path: value for path, value in prior_local_hashes.items() if path in current_local + } + local_files, local_hashes = reconcile_deployed_block( + project_root=project_root, + dep_key="", + current_files=current_local, + current_hashes=current_local_hashes, + prior_files=prior_local, + prior_hashes=prior_local_hashes, + active_targets=active_targets, + declared_targets=declared_targets, + diagnostics=diagnostics, + ) + if local_files != prior_local or local_hashes != prior_local_hashes: + lockfile.local_deployed_files = local_files + lockfile.local_deployed_file_hashes = local_hashes + changed = True + return changed + + +def reconcile_project_deployed_state( + manifest_root: Path, + *, + explicit_target: str | list[str] | None, + deploy_root: Path | None = None, + lock_root: Path | None = None, + user_scope: bool = False, + verbose: bool = False, +) -> bool: + """Reconcile and persist a project's deployed state after a command.""" + from apm_cli.deps.lockfile import LockFile, get_lockfile_path + from apm_cli.integration.targets import active_targets, active_targets_user_scope + from apm_cli.utils.diagnostics import DiagnosticCollector + + deploy_root = deploy_root or manifest_root + lock_path = get_lockfile_path(lock_root or manifest_root) + lockfile = LockFile.read(lock_path) + if lockfile is None: + return False + declared = declared_target_profiles(manifest_root, user_scope=user_scope) + if explicit_target is None and declared is not None: + targets = declared + elif user_scope: + targets = active_targets_user_scope(explicit_target=explicit_target) + else: + targets = active_targets(deploy_root, explicit_target=explicit_target) + changed = reconcile_deployed_state( + project_root=deploy_root, + lockfile=lockfile, + active_targets=targets, + declared_targets=declared, + diagnostics=DiagnosticCollector(verbose=verbose), + ) + if changed: + lockfile.save(lock_path) + return changed diff --git a/src/apm_cli/install/phases/lockfile.py b/src/apm_cli/install/phases/lockfile.py index 959e1635e..d4d2d5226 100644 --- a/src/apm_cli/install/phases/lockfile.py +++ b/src/apm_cli/install/phases/lockfile.py @@ -216,12 +216,17 @@ def _attach_deployed_files(self, lockfile: LockFile) -> None: committed lockfile and they stay covered by the audit gates (issue #1716). See :mod:`apm_cli.install.manifest_reconcile`. """ - from apm_cli.install.manifest_reconcile import union_preserving + from apm_cli.install.manifest_reconcile import reconcile_deployed_block from apm_cli.install.phases.targets import declared_target_profiles self._reconcile_cross_package_deployed_files() existing = self.ctx.existing_lockfile declared = declared_target_profiles(self.ctx) + diagnostics = getattr(self.ctx, "diagnostics", None) + if diagnostics is None: + from apm_cli.utils.diagnostics import DiagnosticCollector + + diagnostics = DiagnosticCollector() ghost_count = 0 for dep_key, locked_dep in lockfile.dependencies.items(): current = list(self.ctx.package_deployed_files.get(dep_key, [])) @@ -240,13 +245,16 @@ def _log_ghost_drop(path: str, package_key: str = dep_key) -> None: f"(target not declared in apm.yml) for {package_key}" ) - files, hashes = union_preserving( - current, - current_hashes, - prior_files, - prior_hashes, - self.ctx.targets, + files, hashes = reconcile_deployed_block( + project_root=self.ctx.project_root, + dep_key=dep_key, + current_files=current, + current_hashes=current_hashes, + prior_files=prior_files, + prior_hashes=prior_hashes, + active_targets=self.ctx.targets, declared_targets=declared, + diagnostics=diagnostics, on_ghost_drop=_log_ghost_drop, ) if not files: diff --git a/src/apm_cli/install/phases/post_deps_local.py b/src/apm_cli/install/phases/post_deps_local.py index 311d3ec57..911559934 100644 --- a/src/apm_cli/install/phases/post_deps_local.py +++ b/src/apm_cli/install/phases/post_deps_local.py @@ -112,7 +112,7 @@ def run(ctx: InstallContext) -> None: # the per-dependency reconciliation in phases/lockfile.py and with # on-disk stale cleanup, so a multi-target deploy keeps content-integrity # coverage for every committed deploy target (issue #1716). - from apm_cli.install.manifest_reconcile import union_preserving as _union + from apm_cli.install.manifest_reconcile import reconcile_deployed_block from apm_cli.install.phases.targets import declared_target_profiles _current_files = sorted(ctx.local_deployed_files) @@ -127,13 +127,16 @@ def _log_local_ghost_drop(path: str) -> None: f"Removed stale local lockfile path {path} (target not declared in apm.yml)" ) - _files, _hashes = _union( - _current_files, - _current_hashes, - list(_persist_lock.local_deployed_files), - dict(_persist_lock.local_deployed_file_hashes), - ctx.targets, + _files, _hashes = reconcile_deployed_block( + project_root=ctx.project_root, + dep_key="", + current_files=_current_files, + current_hashes=_current_hashes, + prior_files=list(_persist_lock.local_deployed_files), + prior_hashes=dict(_persist_lock.local_deployed_file_hashes), + active_targets=ctx.targets, declared_targets=declared_target_profiles(ctx), + diagnostics=ctx.diagnostics, on_ghost_drop=_log_local_ghost_drop, ) _persist_lock.local_deployed_files = sorted(_files) diff --git a/src/apm_cli/install/phases/targets.py b/src/apm_cli/install/phases/targets.py index 6904c7ac0..59fa329d4 100644 --- a/src/apm_cli/install/phases/targets.py +++ b/src/apm_cli/install/phases/targets.py @@ -133,9 +133,10 @@ def declared_target_profiles(ctx: InstallContext) -> list[TargetProfile] | None: permanently on fresh checkouts. Knowing the declared universe lets the union drop those ghosts while still honouring the #1716 multi-target contract. """ - from apm_cli.core.apm_yml import CANONICAL_TARGETS from apm_cli.core.scope import InstallScope - from apm_cli.integration.targets import KNOWN_TARGETS + from apm_cli.install.manifest_reconcile import ( + declared_target_profiles as profiles_for_project, + ) try: names = _read_yaml_targets(ctx) @@ -146,35 +147,10 @@ def declared_target_profiles(ctx: InstallContext) -> list[TargetProfile] | None: if not names: return None is_user = getattr(ctx, "scope", None) is InstallScope.USER - - profiles: list[TargetProfile] = [] - # Declared canonical targets: scope-applied, mirroring ``ctx.targets``. A - # scope that drops a target (``for_scope`` -> None) means it does not apply - # here, so it is skipped. - for name in dict.fromkeys(names): - profile = KNOWN_TARGETS.get(name) - if profile is None: - continue - scoped = profile.for_scope(user_scope=is_user) - if scoped is not None: - profiles.append(scoped) - - # Gated / dynamic targets (``copilot-app``, ``copilot-cowork``, ``openclaw``, - # ``hermes`` -- KNOWN but NON-canonical) are activated by flag or detection - # and can NEVER be declared in apm.yml, so their entries (e.g. - # ``copilot-app-db://`` rows) must always count as legitimate, not ghosts -- - # otherwise a run that does not activate them would wrongly drop their prior - # lockfile rows and regress the #1716 multi-target contract. Their - # governance is name/scheme based, so fall back to the raw profile when a - # dynamic target has no project-scope form (``copilot-app`` -> None). - for name in KNOWN_TARGETS: - if name in CANONICAL_TARGETS: - continue - profile = KNOWN_TARGETS[name] - scoped = profile.for_scope(user_scope=is_user) - profiles.append(scoped if scoped is not None else profile) - - return profiles or None + package_path = getattr(ctx.apm_package, "package_path", None) + if package_path is None: + return None + return profiles_for_project(Path(package_path), user_scope=is_user) def _create_target_dirs( diff --git a/tests/integration/test_inactive_target_ghost_e2e.py b/tests/integration/test_inactive_target_ghost_e2e.py index 95b11ff91..f7eec4a1b 100644 --- a/tests/integration/test_inactive_target_ghost_e2e.py +++ b/tests/integration/test_inactive_target_ghost_e2e.py @@ -8,6 +8,7 @@ from typing import Any from unittest.mock import patch +import pytest import yaml from click.testing import CliRunner @@ -154,3 +155,52 @@ def test_install_repairs_ghost_before_fresh_checkout_audit(tmp_path: Path, monke runner, checkout, monkeypatch, "audit", "--ci", "--no-policy", "--no-fail-fast" ) assert clean_audit.exit_code == 0, clean_audit.output + + +@pytest.mark.parametrize( + ("command", "args"), + [ + ("update", ("update", "--yes", "--target", "copilot")), + ("compile", ("compile", "--target", "copilot")), + ], +) +def test_materializing_command_reconciles_contracted_target( + tmp_path: Path, + monkeypatch, + command: str, + args: tuple[str, ...], +) -> None: + """Update and compile remove artifacts owned by an undeclared target.""" + project = _write_fixture(tmp_path / command) + + from apm_cli.deps import github_downloader + + downloader = _HermeticDownloader() + monkeypatch.setattr( + github_downloader.GitHubPackageDownloader, + "download_package", + downloader.download_package, + ) + runner = CliRunner() + + initial = _invoke(runner, project, monkeypatch, "install", "--target", "copilot,windsurf") + assert initial.exit_code == 0, initial.output + ghost_path = project / GHOST + assert ghost_path.is_file() + initial_lock = yaml.safe_load((project / "apm.lock.yaml").read_text(encoding="utf-8")) + initial_dep = (initial_lock.get("dependencies") or [])[0] + assert GHOST in (initial_dep.get("deployed_files") or []) + assert GHOST in (initial_dep.get("deployed_file_hashes") or {}) + + (project / "apm.yml").write_text( + (project / "apm.yml").read_text(encoding="utf-8").replace(" - windsurf\n", ""), + encoding="utf-8", + ) + + result = _invoke(runner, project, monkeypatch, *args) + assert result.exit_code == 0, result.output + assert not ghost_path.exists() + reconciled_lock = yaml.safe_load((project / "apm.lock.yaml").read_text(encoding="utf-8")) + reconciled_dep = (reconciled_lock.get("dependencies") or [])[0] + assert GHOST not in (reconciled_dep.get("deployed_files") or []) + assert GHOST not in (reconciled_dep.get("deployed_file_hashes") or {}) diff --git a/tests/unit/install/phases/test_lockfile_union.py b/tests/unit/install/phases/test_lockfile_union.py index 8782300ec..ceb82ad8d 100644 --- a/tests/unit/install/phases/test_lockfile_union.py +++ b/tests/unit/install/phases/test_lockfile_union.py @@ -357,6 +357,32 @@ def test_union_declared_universe_still_preserves_1716_sibling(self): assert "copilot-app-db://workflows/new" in files assert "copilot-app-db://workflows/old" not in files + def test_state_reconcile_without_declared_universe_preserves_sibling(self, tmp_path): + """Command entrypoints retain legacy multi-target state without targets.""" + from apm_cli.install.manifest_reconcile import reconcile_deployed_state + from apm_cli.utils.diagnostics import DiagnosticCollector + + sibling = ".windsurf/rules/keep.md" + lockfile = LockFile() + lockfile.add_dependency( + LockedDependency( + repo_url="owner/pkg", + deployed_files=[sibling], + deployed_file_hashes={sibling: "sha256:old"}, + ) + ) + + changed = reconcile_deployed_state( + project_root=tmp_path, + lockfile=lockfile, + active_targets=[_known("copilot")], + declared_targets=None, + diagnostics=DiagnosticCollector(), + ) + + assert not changed + assert lockfile.get_dependency("owner/pkg").deployed_files == [sibling] + def test_gated_dynamic_target_uri_never_treated_as_ghost(self, tmp_path): """A consumer that declares only canonical ``copilot`` but uses the gated ``copilot-app`` target (activated by flag/detection, NOT From d956781970f542fd587079cbc9c5b90834c41707 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sat, 11 Jul 2026 10:23:24 +0200 Subject: [PATCH 2/3] ci: document spec waiver for reliability fix apm-spec-waiver: Deployment reconciliation gives update/compile the parity install already has; enforces existing lockfile ownership semantics, no new OpenAPM wire behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> From 5d043ce663e6969ec90d5eabc9a70c68a43a70cb Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sat, 11 Jul 2026 10:34:21 +0200 Subject: [PATCH 3/3] fix: preserve deployed state after target parse errors Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/install/manifest_reconcile.py | 8 ++++- .../test_inactive_target_ghost_e2e.py | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/apm_cli/install/manifest_reconcile.py b/src/apm_cli/install/manifest_reconcile.py index 703c3cf14..bb40a2e08 100644 --- a/src/apm_cli/install/manifest_reconcile.py +++ b/src/apm_cli/install/manifest_reconcile.py @@ -182,14 +182,20 @@ def declared_target_profiles( ) -> list[TargetProfile] | None: """Resolve the target universe declared by a project manifest.""" from apm_cli.core.apm_yml import CANONICAL_TARGETS, parse_targets_field + from apm_cli.core.errors import TargetResolutionError from apm_cli.integration.targets import KNOWN_TARGETS from apm_cli.utils.yaml_io import load_yaml try: data = load_yaml(project_root / "apm.yml") - names = parse_targets_field(data) if isinstance(data, dict) else None except (AttributeError, KeyError, OSError, TypeError, ValueError): return None + if not isinstance(data, dict): + return None + try: + names = parse_targets_field(data) + except TargetResolutionError: + return None if not names: return None diff --git a/tests/integration/test_inactive_target_ghost_e2e.py b/tests/integration/test_inactive_target_ghost_e2e.py index f7eec4a1b..66f3edcd1 100644 --- a/tests/integration/test_inactive_target_ghost_e2e.py +++ b/tests/integration/test_inactive_target_ghost_e2e.py @@ -204,3 +204,39 @@ def test_materializing_command_reconciles_contracted_target( reconciled_dep = (reconciled_lock.get("dependencies") or [])[0] assert GHOST not in (reconciled_dep.get("deployed_files") or []) assert GHOST not in (reconciled_dep.get("deployed_file_hashes") or {}) + + +def test_compile_preserves_sibling_when_declared_targets_conflict( + tmp_path: Path, + monkeypatch, +) -> None: + """Post-compile reconciliation falls back safely for malformed targets.""" + project = _write_fixture(tmp_path / "compile-conflicting-targets") + + from apm_cli.deps import github_downloader + + downloader = _HermeticDownloader() + monkeypatch.setattr( + github_downloader.GitHubPackageDownloader, + "download_package", + downloader.download_package, + ) + runner = CliRunner() + + initial = _invoke(runner, project, monkeypatch, "install", "--target", "copilot,windsurf") + assert initial.exit_code == 0, initial.output + sibling_path = project / GHOST + assert sibling_path.is_file() + + with (project / "apm.yml").open("a", encoding="utf-8") as manifest: + manifest.write("target: copilot\n") + + monkeypatch.chdir(project) + with patch(_PATCH_UPDATES, return_value=None): + result = runner.invoke(cli, ["compile", "--target", "copilot"]) + + assert result.exit_code == 0, result.output + assert sibling_path.is_file() + lockfile = yaml.safe_load((project / "apm.lock.yaml").read_text(encoding="utf-8")) + dependency = (lockfile.get("dependencies") or [])[0] + assert GHOST in (dependency.get("deployed_files") or [])