From 71f6d56d3120bdbcf40316d68d552ebee58adeca Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Mon, 7 Sep 2026 14:48:48 +0200 Subject: [PATCH 01/15] fix(install): deploy skills through home directory aliases Normalize the skill source root on a shallow metadata copy while preserving source-plan authorization and descendant symlink rejection. Add global audit and skill layout regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 + .../src/content/docs/reference/cli/install.md | 1 + src/apm_cli/integration/skill_integrator.py | 11 +-- .../test_global_audit_deploy_root.py | 11 +++ .../unit/install/test_security_scan_scope.py | 78 ++++++++++++++++++- 5 files changed, 99 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53217a82d6..cdcb38074d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Global skill installs now deploy authorized files when `HOME` or `APM_HOME` uses a directory symlink alias, without changing environment settings or accepting symlinks inside packages. (by @DaveMeadAdjust, #2867) + ## [0.30.0] - 2026-09-07 ### Security diff --git a/docs/src/content/docs/reference/cli/install.md b/docs/src/content/docs/reference/cli/install.md index f691690a6e..187de03b03 100644 --- a/docs/src/content/docs/reference/cli/install.md +++ b/docs/src/content/docs/reference/cli/install.md @@ -152,6 +152,7 @@ in `apm.yml`, then run `apm install` again. - **No-op nudge.** When the lockfile is already satisfied and nothing needs deploying, install prints `[i] Run 'apm update' to check for newer versions.` so you know the silent success was not a missed refresh. - **Frozen mode.** With `--frozen`, install resolves only what is in `apm.lock.yaml`. A missing lockfile, a direct dependency missing from it, or MCP config state that differs from `apm.yml` exits `1` before lockfile, target config, deployment, or cache mutation. Cold-cache installs (empty `apm_modules/`) with git `apm_package` deps are tolerated: MCP checks are skipped for absent package directories (the packages will be hydrated by the pipeline), and their MCP server configs are restored from the lockfile so no false drift is reported. Remote `claude_skill` dependencies declared at a repository root or subdirectory are also accepted from their locked type before materialization; once present, the lock type and detected skill shape must agree. Missing local paths still fail. See [`config-consistency`](../../baseline-checks/#config-consistency) for the full manifest rule. Run normal `apm install` to create or repair MCP-only lock state, then retry frozen mode. Add-style invocations (`apm install PACKAGE` and `apm install --mcp NAME`) are rejected because they mutate `apm.yml`. Orphan package lock entries are tolerated; local-path deps are skipped. This is a structural check, not a content check -- run `apm audit --ci` for hash verification. - **Local `.apm/` deployment.** After dependencies are integrated, primitives in the project's own `.apm/` directory are deployed to the same targets. Local files win on collision. Skipped at `--global` and with `--only mcp`. +- **User-scope skill paths.** Global skill installation supports directory symlink aliases in `HOME` or `APM_HOME` without extra flags or environment changes. Symlinks inside the package remain rejected; root aliases deploy the same authorized content. - **User-scope root context hint.** Compilation stays explicit. After `apm install -g`, targets with native user-scope instruction files pick up global instructions during install. Targets whose user-scope instruction surface is a root context file require [`apm compile --global`](../compile/#global-compilation); install prints a one-line `[i]` hint and writes no root context file. - **OpenCode user scope.** `apm install -g --target opencode` deploys skills to `~/.config/opencode/skills/`. Run `apm compile -g` to refresh diff --git a/src/apm_cli/integration/skill_integrator.py b/src/apm_cli/integration/skill_integrator.py index 7827adb388..ed1e36f6e3 100644 --- a/src/apm_cli/integration/skill_integrator.py +++ b/src/apm_cli/integration/skill_integrator.py @@ -7,6 +7,7 @@ import shutil import stat from collections.abc import Callable, Iterable +from copy import copy from dataclasses import replace from pathlib import Path from typing import TYPE_CHECKING @@ -1459,12 +1460,12 @@ def integrate_package_skill( """ enforce_agent_plugin_deployment_boundary(package_info) - # Check if package type allows skill installation (T4 routing) - # SKILL and HYBRID -> install as skill - # INSTRUCTIONS and PROMPTS -> skip skill installation + # Canonicalize only the root; preserve descendant links and caller metadata. + package_info = copy(package_info) + package_info.install_path = package_info.install_path.resolve() + if not should_install_skill(package_info): - # Even non-skill packages may ship sub-skills under .apm/skills/. - # Promote them so Copilot can discover them independently. + # Non-skill packages may still ship sub-skills under .apm/skills/. sub_skills_count, sub_deployed = self._promote_sub_skills_standalone( package_info, project_root, diff --git a/tests/integration/test_global_audit_deploy_root.py b/tests/integration/test_global_audit_deploy_root.py index 08576024bf..ac5aae6464 100644 --- a/tests/integration/test_global_audit_deploy_root.py +++ b/tests/integration/test_global_audit_deploy_root.py @@ -47,12 +47,23 @@ def _tree_snapshot( return directories, files +@pytest.mark.parametrize("aliased_home", [False, True]) def test_global_install_audit_reads_home_deployment_root( + aliased_home: bool, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: isolated = IsolatedApmEnvironment.create(tmp_path / "global-audit", base_env=os.environ) environment = isolated.subprocess_env() + if aliased_home: + alias = isolated.root / "home-alias" + try: + alias.symlink_to(isolated.home, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("directory symlinks unavailable") + environment["HOME"] = str(alias) + environment["USERPROFILE"] = str(alias) + environment["APM_HOME"] = str(alias / ".apm") environment["APM_NO_CACHE"] = "1" for name, value in environment.items(): monkeypatch.setenv(name, value) diff --git a/tests/unit/install/test_security_scan_scope.py b/tests/unit/install/test_security_scan_scope.py index f89c874665..e307f53369 100644 --- a/tests/unit/install/test_security_scan_scope.py +++ b/tests/unit/install/test_security_scan_scope.py @@ -7,6 +7,7 @@ import pytest +from apm_cli.deps.plugin_parser import normalize_plugin_directory from apm_cli.install.deployable_source_plan import DeployableSourcePlan from apm_cli.install.helpers.security_scan import _pre_deploy_security_scan from apm_cli.integration.agent_integrator import AgentIntegrator @@ -14,7 +15,7 @@ from apm_cli.integration.hook_integrator import HookIntegrator from apm_cli.integration.instruction_integrator import InstructionIntegrator from apm_cli.integration.prompt_integrator import PromptIntegrator -from apm_cli.integration.skill_integrator import copy_skill_to_target +from apm_cli.integration.skill_integrator import SkillIntegrator, copy_skill_to_target from apm_cli.integration.targets import KNOWN_TARGETS from apm_cli.models.apm_package import APMPackage, PackageInfo, PackageType from apm_cli.security.gate import SecurityGate @@ -205,6 +206,81 @@ def test_source_only_canvas_content_is_not_authorized_for_scan(tmp_path: Path) - ) == ["source-only"] +@pytest.mark.parametrize( + ("layout", "package_type"), + [ + (".", PackageType.CLAUDE_SKILL), + ("skills/demo", PackageType.SKILL_BUNDLE), + (".apm/skills/demo", PackageType.APM_PACKAGE), + ("skills/demo", PackageType.MARKETPLACE_PLUGIN), + ], +) +def test_package_skill_root_alias_preserves_authorization( + tmp_path: Path, layout: str, package_type: PackageType +) -> None: + """Root aliases deploy real files without authorizing descendant links.""" + source = tmp_path / "demo" + skill = source / layout + skill.mkdir(parents=True) + alias = tmp_path / "source-alias" + try: + alias.symlink_to(source, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("directory symlinks unavailable") + if package_type is PackageType.MARKETPLACE_PLUGIN: + manifest = source / ".claude-plugin" / "plugin.json" + manifest.parent.mkdir() + manifest.write_text('{"name":"demo","skills":["./skills/demo"]}\n', encoding="utf-8") + (skill / "SKILL.md").write_text("# Demo\n", encoding="utf-8") + references = skill / "references" + references.mkdir() + (references / "safe.md").write_text("safe resource\n", encoding="utf-8") + if package_type is PackageType.MARKETPLACE_PLUGIN: + normalize_plugin_directory(source) + outside = tmp_path / "outside" + outside.mkdir() + secret = outside / "secret.md" + secret.write_text("outside resource\n", encoding="utf-8") + (references / "escape.md").symlink_to(secret) + (references / "internal-link.md").symlink_to(references / "safe.md") + (skill / "linked-directory").symlink_to(outside, target_is_directory=True) + project = tmp_path / "project" + project.mkdir() + package_info = PackageInfo( + package=APMPackage(name="demo", version="1.0.0"), + install_path=alias, + package_type=package_type, + ) + original_metadata = vars(package_info).copy() + targets = [KNOWN_TARGETS["codex"]] + plan = DeployableSourcePlan.create( + package_info, + targets, + skill_subset=None, + hooks_approved=False, + canvas_approved=False, + skip_bin=True, + ) + + result = SkillIntegrator().integrate_package_skill( + package_info, + project, + targets=targets, + source_plan=plan, + skip_bin=True, + ) + + assert vars(package_info) == original_metadata + assert len(result.target_paths) == 1 + deployed = result.target_paths[0] + assert (deployed / "SKILL.md").read_text(encoding="utf-8") == "# Demo\n" + assert (deployed / "references" / "safe.md").read_text(encoding="utf-8") == "safe resource\n" + assert not (deployed / "references" / "escape.md").exists() + assert not (deployed / "references" / "internal-link.md").exists() + assert not (deployed / "linked-directory").exists() + assert not plan.includes((Path(layout) / "references" / "escape.md").as_posix()) + + @pytest.mark.windows_compat def test_direct_skill_copy_normalizes_equivalent_source_alias( tmp_path: Path, From f864bdd572f320f2a69ca6b2d0eec09574be56ec Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Mon, 7 Sep 2026 14:56:37 +0200 Subject: [PATCH 02/15] docs(changelog): link home alias fix pull request Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdcb38074d..a1eaa9ab3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Global skill installs now deploy authorized files when `HOME` or `APM_HOME` uses a directory symlink alias, without changing environment settings or accepting symlinks inside packages. (by @DaveMeadAdjust, #2867) +- Global skill installs now deploy authorized files when `HOME` or `APM_HOME` uses a directory symlink alias, without changing environment settings or accepting symlinks inside packages. (by @DaveMeadAdjust, #2876) ## [0.30.0] - 2026-09-07 From 70e52a03dc40b971fed2e5e94c10e7c72287b011 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Mon, 7 Sep 2026 15:14:09 +0200 Subject: [PATCH 03/15] test(install): exercise alias lifecycle and declared plugin skills Addresses the panel lifecycle follow-up and Copilot plugin fixture comment. Run global install and audit through the installed CLI with aliased HOME/APM_HOME while preserving physical-root snapshots and user-owned sentinels; pass the written plugin manifest explicitly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../test_required_lifecycle_state_machine.py | 11 +++++++++++ tests/unit/install/test_security_scan_scope.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_required_lifecycle_state_machine.py b/tests/integration/test_required_lifecycle_state_machine.py index d4c35f4e0e..98c5f48081 100644 --- a/tests/integration/test_required_lifecycle_state_machine.py +++ b/tests/integration/test_required_lifecycle_state_machine.py @@ -1974,7 +1974,9 @@ def test_required_global_lock_ignores_inactive_experimental_resolver( assert not (mount / "Documents" / "Cowork" / "skills" / "inactive-resolver").exists() +@pytest.mark.parametrize("aliased_home", [False, True]) def test_required_global_audit_rule_matrix_for_external_roots( + aliased_home: bool, tmp_path: Path, apm_binary_path: Path, ) -> None: @@ -1995,6 +1997,15 @@ def test_required_global_audit_rule_matrix_for_external_roots( sentinel_path.write_bytes(f"{target}-owned-by-user\n".encode("ascii")) environment = dict(source.environment) + if aliased_home: + alias = scenario.isolated.root / "home-alias" + try: + alias.symlink_to(scenario.isolated.home, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("directory symlinks unavailable") + environment["HOME"] = str(alias) + environment["USERPROFILE"] = str(alias) + environment["APM_HOME"] = str(alias / ".apm") for target, root in external_roots.items(): environment[_EXTERNAL_USER_ROOT_ENV[target]] = str(root) diff --git a/tests/unit/install/test_security_scan_scope.py b/tests/unit/install/test_security_scan_scope.py index e307f53369..fee6c61d0d 100644 --- a/tests/unit/install/test_security_scan_scope.py +++ b/tests/unit/install/test_security_scan_scope.py @@ -236,7 +236,7 @@ def test_package_skill_root_alias_preserves_authorization( references.mkdir() (references / "safe.md").write_text("safe resource\n", encoding="utf-8") if package_type is PackageType.MARKETPLACE_PLUGIN: - normalize_plugin_directory(source) + normalize_plugin_directory(source, plugin_json_path=manifest) outside = tmp_path / "outside" outside.mkdir() secret = outside / "secret.md" From 22618cefe9a80482ced8a44f707d82d0a79f9fa8 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Mon, 7 Sep 2026 17:37:57 +0200 Subject: [PATCH 04/15] fix(lifecycle): complete alias-path revision and recovery transitions Exercise connected global canonical and aliased command traces and deterministic spines inside generated models. Preserve global skill ownership and canonical module paths across update, validate frozen identity, and refresh mutable refs when no lock exists. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/content/docs/reference/cli/install.md | 6 +- .../.apm/skills/apm-usage/commands.md | 2 +- src/apm_cli/deps/tiered_ref_resolver.py | 4 + src/apm_cli/install/phases/download.py | 6 +- src/apm_cli/install/phases/integrate.py | 7 +- src/apm_cli/install/plan.py | 26 +- src/apm_cli/integration/skill_integrator.py | 60 +-- .../test_frozen_host_qualified_git_e2e.py | 1 + .../test_generated_lifecycle_state_machine.py | 498 +++++++++++++++--- .../test_lifecycle_state_snapshot_contract.py | 66 ++- .../test_ownership_invariant_lifecycle.py | 92 ++++ .../test_ref_freshness_lifecycle.py | 26 +- .../test_required_lifecycle_state_machine.py | 280 +++++++++- tests/unit/deps/test_tiered_ref_resolver.py | 23 + tests/unit/install/test_frozen.py | 16 +- tests/unit/install/test_plan.py | 35 +- tests/utils/lifecycle_state.py | 35 +- 17 files changed, 1010 insertions(+), 173 deletions(-) diff --git a/docs/src/content/docs/reference/cli/install.md b/docs/src/content/docs/reference/cli/install.md index 187de03b03..87d21aded6 100644 --- a/docs/src/content/docs/reference/cli/install.md +++ b/docs/src/content/docs/reference/cli/install.md @@ -30,7 +30,7 @@ With no arguments it installs everything from `apm.yml`. With one or more `PACKA | Flag | Default | Description | |---|---|---| | `--update` | off | Re-resolve dependencies to the latest version or Git ref allowed by `apm.yml` and rewrite `apm.lock.yaml`. Mutable Git refs must resolve against upstream; APM does not fall back to stale refs from the local bare Git cache. Mutually exclusive with `--frozen`. For interactive use with a confirmation prompt, use [`apm update`](../update/) instead. | -| `--frozen` | off | Lockfile-only install: refuse to resolve anything new and fail before any project, config, deployment, or cache write if `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP state. Mirrors `npm ci`. Mutually exclusive with `--update`, positional package additions, and `--mcp`. | +| `--frozen` | off | Lockfile-only install: refuse to resolve anything new and fail before any project, config, deployment, or cache write if `apm.lock.yaml` is missing or no longer matches the manifest-declared refs/pins in `apm.yml`, including MCP state. This is a structural lockfile check, not a network freshness check. Mirrors `npm ci`. Mutually exclusive with `--update`, positional package additions, and `--mcp`. | | `--dry-run` | off | Print the install plan without deployment writes. Positional packages and ref changes appear in the preview after validation but do not change an existing `apm.yml`. Project auto-bootstrap still keeps its new manifest and any explicit `--target` selection for the next run; global dry-run bootstrap uses temporary preview state and does not create `~/.apm`. The `-g --mcp` path creates no user manifest, lockfile, or runtime configuration. | | `--force` | off | Overwrite locally-authored files on collision **and** bypass the security scan's critical-finding block. Does **not** suppress general install errors (any reported error still exits `1`, matching npm / pip / cargo) or select ref freshness. Add `--update` or `--refresh` to resolve mutable refs upstream; [`apm update`](../update/) does so with or without `--force`. Use only after independent verification. | | `--verbose`, `-v` | off | Show per-file paths and full error context in the diagnostic summary. | @@ -147,12 +147,12 @@ in `apm.yml`, then run `apm install` again. installation. See [Author primitives](../../../producer/author-primitives/) for fence and UTF-8 BOM syntax. - **MCP-only lock state.** A normal project install creates or updates `apm.lock.yaml` when `apm.yml` declares only MCP dependencies, records the resolved MCP configs and targets, and migrates a legacy `apm.lock` first. Repeating the same install leaves the lockfile and target configs byte-identical. If initial lock creation fails, install exits nonzero and warns with writable-directory and rerun guidance. -- **Lockfile replay and Git ref freshness.** Plain and `--frozen` installs may trust `apm.lock.yaml` and the local Git cache, reusing the locked commit for unchanged Git dependencies across the full resolved graph. In contrast, `apm install --update`, `apm install --refresh`, [`apm update`](../update/) with or without `--force`, [`apm lock --update`](../lock/), and [`apm outdated`](../outdated/) establish mutable Git refs from upstream instead of accepting stale refs from a local bare Git cache. APM picks up upstream changes to a transitive package's `apm.yml` only when you regenerate the graph -- run `apm update` or `apm lock --update`. See the [lockfile specification](../../lockfile-spec/) for the replay contract. +- **Lockfile replay and Git ref freshness.** Plain and `--frozen` installs may trust `apm.lock.yaml` and the local Git cache, reusing the locked commit for unchanged Git dependencies across the full resolved graph. When no lockfile exists, a mutable branch install resolves current upstream on first materialization instead of reusing a stale bare-cache ref. In contrast, `apm install --update`, `apm install --refresh`, [`apm update`](../update/) with or without `--force`, [`apm lock --update`](../lock/), and [`apm outdated`](../outdated/) establish mutable Git refs from upstream instead of accepting stale refs from a local bare Git cache. APM picks up upstream changes to a transitive package's `apm.yml` only when you regenerate the graph -- run `apm update` or `apm lock --update`. See the [lockfile specification](../../lockfile-spec/) for the replay contract. - **Semver ranges on git deps.** `ref:` accepts semver ranges (`^1.2.0`, `~1.4`, `>=2.0 <3`, `1.5.x`) for git-source deps, including positional virtual-subdirectory references. APM runs `git ls-remote` against the dep, picks the highest tag matching the range, and pins the resolved tag plus commit SHA, version, and original constraint in `apm.lock.yaml`. Subsequent installs replay the lockfile without network; use `--update` (or change the manifest constraint) to re-resolve. See [manage dependencies](../../../consumer/manage-dependencies/#pin-a-semver-range) for the supported syntax. - **No-op nudge.** When the lockfile is already satisfied and nothing needs deploying, install prints `[i] Run 'apm update' to check for newer versions.` so you know the silent success was not a missed refresh. - **Frozen mode.** With `--frozen`, install resolves only what is in `apm.lock.yaml`. A missing lockfile, a direct dependency missing from it, or MCP config state that differs from `apm.yml` exits `1` before lockfile, target config, deployment, or cache mutation. Cold-cache installs (empty `apm_modules/`) with git `apm_package` deps are tolerated: MCP checks are skipped for absent package directories (the packages will be hydrated by the pipeline), and their MCP server configs are restored from the lockfile so no false drift is reported. Remote `claude_skill` dependencies declared at a repository root or subdirectory are also accepted from their locked type before materialization; once present, the lock type and detected skill shape must agree. Missing local paths still fail. See [`config-consistency`](../../baseline-checks/#config-consistency) for the full manifest rule. Run normal `apm install` to create or repair MCP-only lock state, then retry frozen mode. Add-style invocations (`apm install PACKAGE` and `apm install --mcp NAME`) are rejected because they mutate `apm.yml`. Orphan package lock entries are tolerated; local-path deps are skipped. This is a structural check, not a content check -- run `apm audit --ci` for hash verification. - **Local `.apm/` deployment.** After dependencies are integrated, primitives in the project's own `.apm/` directory are deployed to the same targets. Local files win on collision. Skipped at `--global` and with `--only mcp`. -- **User-scope skill paths.** Global skill installation supports directory symlink aliases in `HOME` or `APM_HOME` without extra flags or environment changes. Symlinks inside the package remain rejected; root aliases deploy the same authorized content. +- **User-scope skill paths.** Global skill installation resolves the user-scope lockfile from the canonical `~/.apm/` root, even when `HOME` or `APM_HOME` points at a symlink alias. Root aliases work without extra flags or environment changes; symlinks inside the package remain rejected. - **User-scope root context hint.** Compilation stays explicit. After `apm install -g`, targets with native user-scope instruction files pick up global instructions during install. Targets whose user-scope instruction surface is a root context file require [`apm compile --global`](../compile/#global-compilation); install prints a one-line `[i]` hint and writes no root context file. - **OpenCode user scope.** `apm install -g --target opencode` deploys skills to `~/.config/opencode/skills/`. Run `apm compile -g` to refresh diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 1ec5ae5235..20ccccd617 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -11,7 +11,7 @@ | Command | Purpose | Key flags | |---------|---------|-----------| -| `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; plain/frozen installs replay locked refs and cache state, while `--update` and `--refresh` require current upstream mutable refs; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` CI-safe install that fails before any durable write when `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP config state (mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; positional packages and ref changes are previewed without changing an existing `apm.yml`; a newly bootstrapped manifest and explicit targets are kept), `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use explicit target `hermes` to deploy skills and home-scoped MCP servers to `$HERMES_HOME/config.yaml` (or `~/.hermes/config.yaml` when unset or blank); run `apm compile` separately for `AGENTS.md`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when selected explicitly), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or source-relative path under skills/; a CLI name or path that matches no declared skill fails before manifest or lockfile commit with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. | +| `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; dependency aliases materialize at the canonical install path, not duplicate noncanonical paths; plain/frozen installs replay locked refs and cache state, and a mutable branch install with no lockfile resolves current upstream instead of a stale bare-cache ref; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` CI-safe install that fails before any durable write when `apm.lock.yaml` is missing or no longer matches the manifest-declared refs/pins in `apm.yml`, including MCP config state (structural lockfile check, not network freshness; mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; positional packages and ref changes are previewed without changing an existing `apm.yml`; a newly bootstrapped manifest and explicit targets are kept), `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use explicit target `hermes` to deploy skills and home-scoped MCP servers to `$HERMES_HOME/config.yaml` (or `~/.hermes/config.yaml` when unset or blank); run `apm compile` separately for `AGENTS.md`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when selected explicitly), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or source-relative path under skills/; a CLI name or path that matches no declared skill fails before manifest or lockfile commit with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. | | `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; plain/frozen installs replay locked refs and cache state, while `--update` and `--refresh` require current upstream mutable refs; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` CI-safe install that fails before any durable write when `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP config state (mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; positional packages and ref changes are previewed without changing an existing `apm.yml`; a newly bootstrapped manifest and explicit targets are kept), `--verbose`, `--only [apm\|mcp]` (APM packages or MCP/LSP service dependencies), `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or source-relative path under skills/; a CLI name or path that matches no declared skill fails before manifest or lockfile commit with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. | | `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; plain/frozen installs replay locked refs and cache state, while `--update` and `--refresh` require current upstream mutable refs; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` CI-safe install that fails before any durable write when `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP config state (mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; positional packages and ref changes are previewed without changing an existing `apm.yml`; project bootstrap keeps its new manifest and explicit targets, while absent global `-g` bootstrap uses temporary preview state and does not create `~/.apm`), `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or source-relative path under skills/; a CLI name or path that matches no declared skill fails before manifest or lockfile commit with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. | | `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; plain/frozen installs replay locked refs and cache state, while `--update` and `--refresh` require current upstream mutable refs; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` CI-safe install that fails before any durable write when `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP config state (mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; positional packages and ref changes are previewed without changing an existing `apm.yml`; project bootstrap keeps its new manifest and explicit targets, while absent global `-g` bootstrap uses temporary preview state and does not create `~/.apm`), `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them, except absent global dry-runs only preview targets in temporary state; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or source-relative path under skills/; a CLI name or path that matches no declared skill fails before manifest or lockfile commit with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. | diff --git a/src/apm_cli/deps/tiered_ref_resolver.py b/src/apm_cli/deps/tiered_ref_resolver.py index 35dd8df23c..756381eaa0 100644 --- a/src/apm_cli/deps/tiered_ref_resolver.py +++ b/src/apm_cli/deps/tiered_ref_resolver.py @@ -61,6 +61,7 @@ from ..cache.git_cache import GitCache from ..deps.git_reference_resolver import GitReferenceResolver from ..deps.github_downloader import GitHubPackageDownloader + from ..deps.lockfile import LockFile _log = logging.getLogger(__name__) @@ -107,6 +108,7 @@ class RefFreshnessContext(Protocol): ref_freshness_policy: RefFreshnessPolicy | None update_refs: bool refresh: bool + existing_lockfile: LockFile | None def ref_freshness_policy_for_install( @@ -118,6 +120,8 @@ def ref_freshness_policy_for_install( if not isinstance(configured, RefFreshnessPolicy): raise TypeError("ref_freshness_policy must be a RefFreshnessPolicy") return configured + if context.existing_lockfile is None: + return RefFreshnessPolicy.CURRENT_REMOTE return RefFreshnessPolicy.for_install_intent( update_refs=context.update_refs, refresh=context.refresh, diff --git a/src/apm_cli/install/phases/download.py b/src/apm_cli/install/phases/download.py index 5a171520fb..f58c1a00a6 100644 --- a/src/apm_cli/install/phases/download.py +++ b/src/apm_cli/install/phases/download.py @@ -54,11 +54,7 @@ def run(ctx: InstallContext) -> None: _need_download = [] for _pd_ref in deps_to_install: _pd_key = _pd_ref.get_unique_key() - _pd_path = ( - (apm_modules_dir / _pd_ref.alias) - if _pd_ref.alias - else _pd_ref.get_install_path(apm_modules_dir) - ) + _pd_path = _pd_ref.get_install_path(apm_modules_dir) # Skip local packages -- they are copied, not downloaded if _pd_ref.is_local: continue diff --git a/src/apm_cli/install/phases/integrate.py b/src/apm_cli/install/phases/integrate.py index b9632ef81c..b23ee2c7a5 100644 --- a/src/apm_cli/install/phases/integrate.py +++ b/src/apm_cli/install/phases/integrate.py @@ -614,12 +614,7 @@ def run(ctx: InstallContext) -> None: # e.g., microsoft/apm-sample-package -> apm_modules/microsoft/apm-sample-package/ # For virtual packages: owner/repo/prompts/file.prompt.md -> apm_modules/owner/repo-file/ # For subdirectory packages: owner/repo/subdir -> apm_modules/owner/repo/subdir/ - if dep_ref.alias: - # If alias is provided, use it directly (assume user handles namespacing) - install_path = apm_modules_dir / dep_ref.alias - else: - # Use the canonical install path from DependencyReference - install_path = dep_ref.get_install_path(apm_modules_dir) + install_path = dep_ref.get_install_path(apm_modules_dir) # Skip deps that already failed during BFS resolution callback # to avoid a duplicate error entry in diagnostics. diff --git a/src/apm_cli/install/plan.py b/src/apm_cli/install/plan.py index aa72727a9d..f692328b2e 100644 --- a/src/apm_cli/install/plan.py +++ b/src/apm_cli/install/plan.py @@ -457,9 +457,10 @@ def lockfile_satisfies_manifest( """Structural satisfaction check for ``apm install --frozen``. Verifies that every direct dependency declared in the manifest has - a corresponding entry in the lockfile. Does NOT perform any - resolution or compare resolved refs against the remote -- those are - ``apm update``'s job. + a corresponding entry in the lockfile and that the manifest still + points at the same locked identity. Does NOT perform any remote + resolution; it only compares manifest-declared refs against the + already-recorded lockfile state. Args: lockfile: The on-disk lockfile. @@ -473,6 +474,8 @@ def lockfile_satisfies_manifest( ``satisfied`` is True. """ from apm_cli.deps.lockfile import _SELF_KEY + from apm_cli.deps.revision_pins import is_full_revision_pin + from apm_cli.drift import detect_ref_change locked_keys = {key for key in lockfile.dependencies if key != _SELF_KEY} @@ -483,6 +486,23 @@ def lockfile_satisfies_manifest( key = _dep_ref_key(dep) if key not in locked_keys: reasons.append(f" - {key} is declared in apm.yml but missing from apm.lock.yaml") + continue + locked_dep = lockfile.dependencies[key] + reference = dep.reference + if ( + reference + and is_full_revision_pin(reference) + and (locked_dep.resolved_commit or "").lower() != reference.lower() + ): + reasons.append( + f" - {key}: manifest commit '{dep.reference}' != " + f"lockfile resolved_commit '{locked_dep.resolved_commit or '(missing)'}'" + ) + continue + if detect_ref_change(dep, locked_dep): + reasons.append( + f" - {key}: declared source, ref, or transport differs from apm.lock.yaml" + ) return (not reasons, reasons) diff --git a/src/apm_cli/integration/skill_integrator.py b/src/apm_cli/integration/skill_integrator.py index ed1e36f6e3..1e621a5910 100644 --- a/src/apm_cli/integration/skill_integrator.py +++ b/src/apm_cli/integration/skill_integrator.py @@ -864,28 +864,18 @@ def _promote_sub_skills( return promoted, deployed @staticmethod - def _build_ownership_maps(project_root: Path) -> tuple[dict[str, str], dict[str, str]]: + def _build_ownership_maps(lockfile_root: Path) -> tuple[dict[str, str], dict[str, str]]: """Read the lockfile once and build two ownership maps. - Returns a tuple of: - - owned_by: skill_name -> dep.get_unique_key(), for sub-skill self-overwrite detection. - - native_owners: skill_name -> dep.get_unique_key(), for native-skill cross-package - collision detection. Only paths under a ``/skills/`` prefix are included to avoid - false attribution from non-skill deployed_files entries (prompts, hooks, commands, etc.). - - Both maps key on the full unique dependency identity (owner/repo, or the - equivalent durable key for local/registry deps), NOT the last path - segment. Two different packages can share a repo/leaf name (e.g. two - orgs each publishing a "shared-skill" or "utils" repo); comparing only - the last segment would treat them as the same owner and silently - suppress the cross-package collision warning precisely when it matters - most -- an unrelated package overwriting another's skill undetected. + Both maps associate skill names with full dependency identities, never + just repository leaf names. The native-owner map includes only skill + paths, avoiding false attribution from prompts, hooks, or commands. """ from apm_cli.deps.lockfile import LockFile, get_lockfile_path owned_by: dict[str, str] = {} native_owners: dict[str, str] = {} - lockfile = LockFile.read(get_lockfile_path(project_root)) + lockfile = LockFile.read(get_lockfile_path(lockfile_root)) if not lockfile: return owned_by, native_owners for dep in lockfile.get_package_dependencies(): @@ -902,28 +892,23 @@ def _build_ownership_maps(project_root: Path) -> tuple[dict[str, str], dict[str, return owned_by, native_owners @staticmethod - def _build_skill_ownership_map(project_root: Path) -> dict[str, str]: - """Build a map of skill_name -> owner_package_name from the lockfile. - - Used to distinguish self-overwrites (no warning) from cross-package - conflicts (warning) when promoting sub-skills. - """ - owned_by, _ = SkillIntegrator._build_ownership_maps(project_root) + def _build_skill_ownership_map(lockfile_root: Path) -> dict[str, str]: + """Map skill names to durable owners for sub-skill collision detection.""" + owned_by, _ = SkillIntegrator._build_ownership_maps(lockfile_root) return owned_by @staticmethod - def _build_native_skill_owner_map(project_root: Path) -> dict[str, str]: - """Build a map of skill_name -> dep.get_unique_key() from the lockfile. - - Scoped to ``/skills/`` paths only -- see ``_build_ownership_maps`` for details. - """ - _, native_owners = SkillIntegrator._build_ownership_maps(project_root) + def _build_native_skill_owner_map(lockfile_root: Path) -> dict[str, str]: + """Map native skill names to durable owners, excluding non-skill paths.""" + _, native_owners = SkillIntegrator._build_ownership_maps(lockfile_root) return native_owners def _promote_sub_skills_standalone( self, package_info, project_root: Path, + *, + lockfile_root: Path | None = None, diagnostics=None, managed_files=None, force: bool = False, @@ -965,7 +950,7 @@ def _promote_sub_skills_standalone( # _build_ownership_maps). _dep_ref = getattr(package_info, "dependency_ref", None) parent_name = _dep_ref.get_unique_key() if _dep_ref is not None else package_path.name - owned_by = self._build_skill_ownership_map(project_root) + owned_by = self._build_skill_ownership_map(lockfile_root or project_root) name_filter = ( source_plan.selected_skill_names if source_plan is not None @@ -1034,6 +1019,8 @@ def _integrate_native_skill( package_info, project_root: Path, source_skill_md: Path, + *, + lockfile_root: Path | None = None, diagnostics=None, managed_files=None, force: bool = False, @@ -1116,7 +1103,7 @@ def _integrate_native_skill( primary_skill_md: Path | None = None # Read lockfile once and derive both maps in a single pass. - owned_by, lockfile_native_owners = self._build_ownership_maps(project_root) + owned_by, lockfile_native_owners = self._build_ownership_maps(lockfile_root or project_root) sub_skills_dir = package_path / ".apm" / "skills" # Full unique key of the package currently being installed. @@ -1275,6 +1262,8 @@ def _integrate_skill_bundle( package_info, project_root: Path, skills_dir: Path, + *, + lockfile_root: Path | None = None, diagnostics=None, managed_files=None, force: bool = False, @@ -1321,7 +1310,7 @@ def _integrate_skill_bundle( parent_name = ( _dep_ref.get_unique_key() if _dep_ref is not None else package_info.install_path.name ) - owned_by, lockfile_native_owners = self._build_ownership_maps(project_root) # noqa: RUF059 + owned_by, _ = self._build_ownership_maps(lockfile_root or project_root) total_promoted = 0 all_deployed: list[Path] = [] @@ -1459,6 +1448,11 @@ def integrate_package_skill( SkillIntegrationResult: Results of the integration operation """ enforce_agent_plugin_deployment_boundary(package_info) + from apm_cli.core.scope import InstallScope, get_apm_dir + + lockfile_root = ( + get_apm_dir(InstallScope.USER) if scope is InstallScope.USER else project_root + ) # Canonicalize only the root; preserve descendant links and caller metadata. package_info = copy(package_info) @@ -1469,6 +1463,7 @@ def integrate_package_skill( sub_skills_count, sub_deployed = self._promote_sub_skills_standalone( package_info, project_root, + lockfile_root=lockfile_root, diagnostics=diagnostics, managed_files=managed_files, force=force, @@ -1546,6 +1541,7 @@ def integrate_package_skill( package_info, project_root, source_skill_md, + lockfile_root=lockfile_root, diagnostics=diagnostics, managed_files=managed_files, force=force, @@ -1600,6 +1596,7 @@ def integrate_package_skill( package_info, project_root, package_path / ".apm" / "skills" if _is_plugin else root_skills_dir, + lockfile_root=lockfile_root, source_paths=_source_paths, diagnostics=diagnostics, managed_files=managed_files, @@ -1619,6 +1616,7 @@ def integrate_package_skill( sub_skills_count, sub_deployed = self._promote_sub_skills_standalone( package_info, project_root, + lockfile_root=lockfile_root, diagnostics=diagnostics, managed_files=managed_files, force=force, diff --git a/tests/integration/test_frozen_host_qualified_git_e2e.py b/tests/integration/test_frozen_host_qualified_git_e2e.py index 80669519be..9f09842be5 100644 --- a/tests/integration/test_frozen_host_qualified_git_e2e.py +++ b/tests/integration/test_frozen_host_qualified_git_e2e.py @@ -53,6 +53,7 @@ def _cases() -> tuple[_FrozenGitCase, ...]: repo_url="org/private-skills", host="git.example.com", resolved_ref="2026.06.10", + constraint="2026.06.10", resolved_commit=_COMMIT, depth=1, name="private-skills", diff --git a/tests/integration/test_generated_lifecycle_state_machine.py b/tests/integration/test_generated_lifecycle_state_machine.py index 361f0ea354..76fe2d9014 100644 --- a/tests/integration/test_generated_lifecycle_state_machine.py +++ b/tests/integration/test_generated_lifecycle_state_machine.py @@ -9,35 +9,43 @@ import shutil import textwrap from dataclasses import dataclass -from pathlib import Path +from pathlib import Path, PurePosixPath import pytest import yaml from hypothesis import HealthCheck, Phase, settings from hypothesis.stateful import ( RuleBasedStateMachine, + initialize, invariant, precondition, rule, run_state_machine_as_test, ) +from apm_cli.deps.lockfile import LockFile +from apm_cli.utils.yaml_io import dump_yaml, load_yaml from tests.integration.test_required_lifecycle_state_machine import ( _INSTALL_ARGS, - _audit, + _assert_same_state, + _audit_at, + _external_root_specs, _new_scenario, _publish, + _publish_revision, + _PublishedPackage, _result_evidence, - _run_success, _Scenario, _skill, ) +from tests.utils.apm_lifecycle_runner import CommandResult from tests.utils.artifact_snapshot import ( ArtifactSnapshotSet, assert_only_snapshot_paths_changed, assert_snapshot_changes_within, assert_snapshot_set_unchanged, ) +from tests.utils.lifecycle_state import LifecycleStateRoot, LifecycleStateSnapshot from tests.utils.local_package import LocalPackage pytestmark = [ @@ -52,6 +60,7 @@ _SKILL_NAME = "model-skill" _PACKAGE_NAME = "model-kit" _SKILL_BYTES = _skill(_SKILL_NAME).encode() +_TAMPER_BYTES = b"# user tamper\n" _PROJECT_WRITE_PATHS = frozenset( { ".agents", @@ -100,7 +109,17 @@ "remove_declaration": frozenset({"ownership.preserve_unowned"}), "repair": frozenset({"source.ref_cache_coherent"}), "tamper": frozenset({"outcome.status_matches_state"}), + "publish": frozenset({"source.ref_cache_coherent"}), + "outdated": frozenset({"source.ref_cache_coherent"}), + "update": frozenset({"source.ref_cache_coherent"}), + "compile": frozenset({"routing.authorized_targets_only"}), + "lock": frozenset({"ownership.preserve_unowned"}), + "frozen_replay": frozenset({"idempotency.byte_stable"}), + "frozen_refusal": frozenset({"transaction.failed_command_preserves_state"}), + "uninstall": frozenset({"ownership.preserve_unowned"}), + "audit_empty": frozenset({"outcome.status_matches_state"}), } +_VARIANTS = ("project", "global-canonical", "global-aliased") def _phase_one_properties() -> frozenset[str]: @@ -117,11 +136,66 @@ class _ModelFixture: project: LocalPackage dependency: dict[str, object] environment: dict[str, str] + source: _PublishedPackage + variant: str + invoke_cwd: Path + audit_cwd: Path + skill_path: Path + external_roots: tuple[LifecycleStateRoot, ...] = () @classmethod - def create(cls, root: Path, apm_binary_path: Path) -> _ModelFixture: + def create(cls, root: Path, apm_binary_path: Path, variant: str = "project") -> _ModelFixture: scenario = _new_scenario(root, apm_binary_path) published = _publish(scenario, _PACKAGE_NAME, skill=_SKILL_NAME) + if variant != "project": + commit_a = _publish_revision(scenario, published, "a") + dependency = {**published.dependency, "ref": "main"} + environment = dict(published.environment) + target_root = scenario.isolated.root / "claude-home" + target_root.mkdir() + (target_root / "user-owned.txt").write_bytes(b"user-owned\n") + environment["CLAUDE_CONFIG_DIR"] = str(target_root) + if variant == "global-aliased": + alias = scenario.isolated.root / "home-alias" + alias.symlink_to(scenario.isolated.home, target_is_directory=True) + environment.update( + HOME=str(alias), USERPROFILE=str(alias), APM_HOME=str(alias / ".apm") + ) + state_root = scenario.isolated.config_root + state_root.mkdir(parents=True, exist_ok=True) + manifest_path = state_root / "apm.yml" + dump_yaml( + { + "name": "global-model-consumer", + "version": "0.1.0", + "dependencies": {"apm": [dependency]}, + "targets": ["claude"], + }, + manifest_path, + ) + source = _PublishedPackage( + published.package, + published.repository, + commit_a, + published.remote_url, + dependency, + environment, + ) + return cls( + scenario, + LocalPackage("global-model-consumer", state_root, manifest_path), + dependency, + environment, + source, + variant, + scenario.isolated.work_root, + state_root, + target_root / "skills" / _SKILL_NAME / "SKILL.md", + _external_root_specs( + {"claude": target_root}, + config_paths={"claude": (PurePosixPath("CLAUDE.md"),)}, + ), + ) project = scenario.consumers.create( "model-consumer", dependencies=(published.dependency,), @@ -132,8 +206,44 @@ def create(cls, root: Path, apm_binary_path: Path) -> _ModelFixture: project=project, dependency=published.dependency, environment=published.environment, + source=published, + variant=variant, + invoke_cwd=project.root, + audit_cwd=project.root, + skill_path=project.root / ".agents" / "skills" / _SKILL_NAME / "SKILL.md", ) + @property + def global_scope(self) -> bool: + return self.variant != "project" + + @property + def install_args(self) -> tuple[str, ...]: + return (*_INSTALL_ARGS, "--global") if self.global_scope else _INSTALL_ARGS + + @property + def exact_paths(self) -> dict[str, frozenset[str]]: + if not self.global_scope: + return _INSTALL_EXACT_PATHS + return { + "user": _USER_WRITE_PATHS, + "target": frozenset({"skills", "rules", "rules/revision.md", "CLAUDE.md"}), + } + + @property + def tree_prefixes(self) -> dict[str, frozenset[str]]: + if not self.global_scope: + return _INSTALL_TREE_PREFIXES + return { + "user": frozenset({".apm"}), + "target": frozenset({f"skills/{_SKILL_NAME}"}), + } + + def replace_dependencies(self, dependencies: tuple[dict[str, object], ...]) -> None: + manifest = load_yaml(self.project.manifest_path) + manifest.setdefault("dependencies", {})["apm"] = list(dependencies) + dump_yaml(manifest, self.project.manifest_path) + class _LifecycleReferenceModel(RuleBasedStateMachine): """Reference state independent of lockfile deployment records.""" @@ -145,7 +255,18 @@ def __init__(self, fixture: _ModelFixture) -> None: self.materialized = False self.clean = True self.locked = False + self.retained_user_file = False self.step = 0 + self.last_operation = "initial" + self.published_revision = "a" + self.installed_revision = "a" + self.published_commit = fixture.source.commit.sha + self.installed_commit = fixture.source.commit.sha + + @initialize() + def mandatory_spine(self) -> None: + """Guarantee connected commands inside every generated model execution.""" + _mandatory_replay(self) @property def scenario(self) -> _Scenario: @@ -157,89 +278,100 @@ def project(self) -> LocalPackage: @property def skill_path(self) -> Path: - return self.project.root / ".agents" / "skills" / _SKILL_NAME / "SKILL.md" + return self.fixture.skill_path + + def _run( + self, args: tuple[str, ...], operation: str, expected_returncode: int = 0 + ) -> CommandResult: + result = self.scenario.runner.run( + args, + cwd=self.fixture.invoke_cwd, + env=self.fixture.environment, + scenario_id=self._next_id(operation), + ) + assert result.returncode == expected_returncode, _result_evidence(result) + return result + + def _state(self) -> LifecycleStateSnapshot: + return LifecycleStateSnapshot.capture( + self.project.root, external_roots=self.fixture.external_roots + ) + + def _changed_path(self, relative_path: str) -> dict[str, set[str]]: + if self.fixture.global_scope: + return {"user": {f".apm/{relative_path}"}} + return {"project": {relative_path}} def _next_id(self, operation: str) -> str: self.step += 1 + self.last_operation = operation return f"generated-{self.step:02d}-{operation}" def _capture(self) -> ArtifactSnapshotSet: - return ArtifactSnapshotSet.capture( - { - "project": self.project.root, - "user": self.scenario.isolated.home, - } - ) + roots = {"project": self.fixture.invoke_cwd, "user": self.scenario.isolated.home} + if self.fixture.global_scope: + roots["target"] = self.fixture.external_roots[0].path + return ArtifactSnapshotSet.capture(roots) @rule() @precondition(lambda self: self.declared and not self.materialized) def install(self) -> None: before = self._capture() - _run_success( - self.scenario, - self.project, - _INSTALL_ARGS, - environment=self.fixture.environment, - scenario_id=self._next_id("install"), - ) + self._run(self.fixture.install_args, "install") assert_snapshot_changes_within( before, self._capture(), - exact_paths=_INSTALL_EXACT_PATHS, - tree_prefixes=_INSTALL_TREE_PREFIXES, + exact_paths=self.fixture.exact_paths, + tree_prefixes=self.fixture.tree_prefixes, ) self.materialized = True - self.clean = True + self.clean = not self.retained_user_file self.locked = True + self.installed_revision = self.published_revision + self.installed_commit = self.published_commit @rule() @precondition(lambda self: self.declared and self.materialized and self.clean) def reinstall(self) -> None: before = self._capture() - _run_success( - self.scenario, - self.project, - _INSTALL_ARGS, - environment=self.fixture.environment, - scenario_id=self._next_id("reinstall"), - ) + self._run(self.fixture.install_args, "reinstall") assert_snapshot_set_unchanged(before, self._capture()) @rule() @precondition(lambda self: self.declared) def dry_run(self) -> None: before = self._capture() - result = _run_success( - self.scenario, - self.project, - (*_INSTALL_ARGS, "--dry-run"), - environment=self.fixture.environment, - scenario_id=self._next_id("dry-run"), - ) - assert "[i] APM dependencies (1):" in result.stdout.splitlines(), ( - "dry-run must prove dependency resolution reached the fixture" - ) + result = self._run((*self.fixture.install_args, "--dry-run"), "dry-run") + assert "[i] APM dependencies (1):" in result.stdout.splitlines() assert_snapshot_set_unchanged(before, self._capture()) @rule() @precondition(lambda self: self.materialized and self.clean) def tamper(self) -> None: before = self._capture() - self.skill_path.write_bytes(b"# user tamper\n") + self.skill_path.write_bytes(_TAMPER_BYTES) assert_only_snapshot_paths_changed( before, self._capture(), - {"project": {f".agents/skills/{_SKILL_NAME}/SKILL.md"}}, + ( + {"target": {f"skills/{_SKILL_NAME}/SKILL.md"}} + if self.fixture.global_scope + else {"project": {f".agents/skills/{_SKILL_NAME}/SKILL.md"}} + ), ) self.clean = False @rule() - @precondition(lambda self: self.declared and self.materialized and not self.clean) + @precondition( + lambda self: ( + self.declared and self.materialized and not self.clean and not self.retained_user_file + ) + ) def audit_tampered(self) -> None: before = self._capture() - result, payload = _audit( + result, payload = _audit_at( self.scenario, - self.project, + self.fixture.audit_cwd, environment=self.fixture.environment, expected_returncode=1, scenario_id=self._next_id("audit-tampered"), @@ -251,28 +383,26 @@ def audit_tampered(self) -> None: @precondition(lambda self: self.declared and self.materialized and not self.clean) def repair(self) -> None: before = self._capture() - _run_success( - self.scenario, - self.project, - _INSTALL_ARGS, - environment=self.fixture.environment, - scenario_id=self._next_id("repair"), - ) + args = self.fixture.install_args + if self.retained_user_file: + args = (*args, "--force") + self._run(args, "repair") assert_snapshot_changes_within( before, self._capture(), - exact_paths=_INSTALL_EXACT_PATHS, - tree_prefixes=_INSTALL_TREE_PREFIXES, + exact_paths=self.fixture.exact_paths, + tree_prefixes=self.fixture.tree_prefixes, ) self.clean = True + self.retained_user_file = False @rule() @precondition(lambda self: self.declared and self.materialized and self.clean) def audit_clean(self) -> None: before = self._capture() - _result, payload = _audit( + _result, payload = _audit_at( self.scenario, - self.project, + self.fixture.audit_cwd, environment=self.fixture.environment, scenario_id=self._next_id("audit-clean"), ) @@ -283,14 +413,11 @@ def audit_clean(self) -> None: @precondition(lambda self: self.declared) def remove_declaration(self) -> None: before = self._capture() - assert self.scenario.consumers.remove_apm_dependency( - self.project, - self.fixture.dependency, - ) + self.fixture.replace_dependencies(()) assert_only_snapshot_paths_changed( before, self._capture(), - {"project": {"apm.yml"}}, + self._changed_path("apm.yml"), ) self.declared = False @@ -298,28 +425,22 @@ def remove_declaration(self) -> None: @precondition(lambda self: not self.declared) def readd_declaration(self) -> None: before = self._capture() - self.scenario.consumers.replace_apm_dependencies( - self.project, - (self.fixture.dependency,), - ) + self.fixture.replace_dependencies((self.fixture.dependency,)) assert_only_snapshot_paths_changed( before, self._capture(), - {"project": {"apm.yml"}}, + self._changed_path("apm.yml"), ) self.declared = True @rule() - @precondition(lambda self: not self.declared and self.materialized) + @precondition( + lambda self: not self.fixture.global_scope and not self.declared and self.materialized + ) def prune_removed(self) -> None: before = self._capture() - _run_success( - self.scenario, - self.project, - ("prune",), - environment=self.fixture.environment, - scenario_id=self._next_id("prune"), - ) + retained_bytes = self.skill_path.read_bytes() if not self.clean else None + self._run(("prune",), "prune") assert_snapshot_changes_within( before, self._capture(), @@ -327,18 +448,175 @@ def prune_removed(self) -> None: tree_prefixes=_INSTALL_TREE_PREFIXES, ) self.materialized = False + self.retained_user_file = retained_bytes is not None + if retained_bytes is not None: + assert self.skill_path.read_bytes() == retained_bytes self.clean = True self.locked = False + @rule() + @precondition(lambda self: self.fixture.global_scope and self.published_revision == "a") + def publish(self) -> None: + before = self._capture() + commit = _publish_revision(self.scenario, self.fixture.source, "b") + self.published_revision = "b" + self.published_commit = commit.sha + assert_snapshot_set_unchanged(before, self._capture()) + + @rule() + @precondition(lambda self: self.fixture.global_scope and self.declared and self.materialized) + def outdated(self) -> None: + before, state = self._capture(), self._state() + result = self._run( + ("outdated", "--global", "--parallel-checks", "0", "--verbose"), "outdated" + ) + if self.installed_commit != self.published_commit: + assert self.published_commit[:7] in result.stdout, _result_evidence(result) + _assert_same_state(state, self._state()) + assert_snapshot_set_unchanged(before, self._capture()) + + @rule() + @precondition(lambda self: self.fixture.global_scope and self.declared and self.materialized) + def update(self) -> None: + before = self._capture() + self._run(("update", "--global", "--yes", "--parallel-downloads", "0"), "update") + assert_snapshot_changes_within( + before, + self._capture(), + exact_paths=self.fixture.exact_paths, + tree_prefixes=self.fixture.tree_prefixes, + ) + self.installed_revision = self.published_revision + self.installed_commit = self.published_commit + self.clean = True + + @rule() + @precondition(lambda self: self.fixture.global_scope and self.materialized) + def compile(self) -> None: + before = self._capture() + self._run(("compile", "--global"), "compile") + text = (self.fixture.external_roots[0].path / "CLAUDE.md").read_text(encoding="ascii") + assert f"# revision-{self.installed_revision}" in text + assert_snapshot_changes_within( + before, self._capture(), exact_paths={"target": {"CLAUDE.md"}}, tree_prefixes={} + ) + + @rule() + @precondition( + lambda self: ( + self.fixture.global_scope + and self.declared + and self.materialized + and self.clean + and self.installed_commit == self.published_commit + ) + ) + def lock(self) -> None: + before, state = self._capture(), self._state() + self._run(("lock", "--global", "--no-policy", "--parallel-downloads", "0"), "lock") + after = self._state() + assert after.deployment_records == state.deployment_records + assert after.files == state.files + assert_snapshot_changes_within( + before, + self._capture(), + exact_paths=self._changed_path("apm.lock.yaml"), + tree_prefixes={}, + ) + + @rule() + @precondition( + lambda self: ( + self.fixture.global_scope and self.declared and self.materialized and self.clean + ) + ) + def frozen_replay(self) -> None: + before, state = self._capture(), self._state() + self._run((*self.fixture.install_args, "--frozen"), "frozen-replay") + _assert_same_state(state, self._state()) + assert_snapshot_set_unchanged(before, self._capture()) + + @rule() + @precondition( + lambda self: ( + self.fixture.global_scope and self.declared and self.materialized and self.clean + ) + ) + def frozen_refusal(self) -> None: + manifest_bytes = self.project.manifest_path.read_bytes() + self.fixture.replace_dependencies(({**self.fixture.dependency, "ref": "missing-ref"},)) + before, state = self._capture(), self._state() + self._run((*self.fixture.install_args, "--frozen"), "frozen-refusal", 1) + _assert_same_state(state, self._state()) + assert_snapshot_set_unchanged(before, self._capture()) + self.project.manifest_path.write_bytes(manifest_bytes) + + @rule() + @precondition(lambda self: self.fixture.global_scope and self.materialized and self.clean) + def uninstall(self) -> None: + before = self._capture() + self._run(("uninstall", "--global", self.fixture.source.remote_url), "uninstall") + assert_snapshot_changes_within( + before, + self._capture(), + exact_paths=self.fixture.exact_paths, + tree_prefixes=self.fixture.tree_prefixes, + ) + self.materialized = False + self.declared = False + self.locked = False + + @rule() + @precondition( + lambda self: self.fixture.global_scope and not self.materialized and not self.declared + ) + def audit_empty(self) -> None: + before = self._capture() + _, payload = _audit_at( + self.scenario, + self.fixture.audit_cwd, + environment=self.fixture.environment, + scenario_id=self._next_id("audit-empty"), + ) + assert payload["passed"] is True + assert_snapshot_set_unchanged(before, self._capture()) + @invariant() def durable_state_matches_reference_model(self) -> None: manifest = yaml.safe_load(self.project.manifest_path.read_text(encoding="utf-8")) assert isinstance(manifest, dict) dependencies = manifest.get("dependencies", {}).get("apm", []) assert bool(dependencies) is self.declared - assert self.skill_path.exists() is self.materialized + assert self.skill_path.exists() is (self.materialized or self.retained_user_file) + if self.retained_user_file: + assert self.skill_path.read_bytes() == _TAMPER_BYTES if self.materialized and self.clean: - assert self.skill_path.read_bytes() == _SKILL_BYTES + expected = _SKILL_BYTES + if self.fixture.global_scope: + expected += f"\nrevision-{self.installed_revision}\n".encode() + assert self.skill_path.read_bytes() == expected, ( + f"after {self.last_operation} step={self.step}; expected revision " + f"{self.installed_revision} at {self.skill_path}" + ) + if self.materialized and self.fixture.global_scope: + lock = LockFile.read(self.project.root / "apm.lock.yaml") + assert lock is not None + dependencies = lock.get_package_dependencies() + assert len(dependencies) == 1 + assert dependencies[0].resolved_commit == self.installed_commit + modules = self.project.root / "apm_modules" + assert ( + modules / "apm-fixture-org" / _PACKAGE_NAME / "skills" / _SKILL_NAME / "SKILL.md" + ).is_file() + assert not (modules / _PACKAGE_NAME).exists() + assert self._state().deployment_records + assert ( + self.fixture.external_roots[0].path / "user-owned.txt" + ).read_bytes() == b"user-owned\n" + if self.fixture.variant == "global-aliased": + alias = self.scenario.isolated.root / "home-alias" + assert alias.is_symlink() + assert alias.readlink() == self.scenario.isolated.home assert (self.project.root / "apm.lock.yaml").exists() is self.locked def teardown(self) -> None: @@ -346,16 +624,18 @@ def teardown(self) -> None: shutil.rmtree(self.scenario.isolated.root) +@pytest.mark.parametrize("variant", _VARIANTS) def test_generated_lifecycle_sequences_preserve_reference_model( tmp_path: Path, apm_binary_path: Path, + variant: str, ) -> None: """Generate and shrink guarded transition sequences over a real CLI.""" sequence = itertools.count() def factory() -> _LifecycleReferenceModel: case_root = tmp_path / f"case-{next(sequence):03d}" - return _LifecycleReferenceModel(_ModelFixture.create(case_root, apm_binary_path)) + return _LifecycleReferenceModel(_ModelFixture.create(case_root, apm_binary_path, variant)) run_state_machine_as_test( factory, @@ -363,7 +643,7 @@ def factory() -> _LifecycleReferenceModel: database=None, deadline=None, derandomize=True, - max_examples=6, + max_examples=6 if variant == "project" else 3, phases=(Phase.generate, Phase.shrink), print_blob=True, stateful_step_count=8, @@ -372,6 +652,72 @@ def factory() -> _LifecycleReferenceModel: ) +@pytest.mark.parametrize("variant", _VARIANTS) +def test_generated_lifecycle_mandatory_replay( + tmp_path: Path, apm_binary_path: Path, variant: str +) -> None: + """Exercise the model's guarded commands without relying on random selection.""" + model = _LifecycleReferenceModel( + _ModelFixture.create(tmp_path / "mandatory", apm_binary_path, variant) + ) + _mandatory_replay(model) + + +def _mandatory_replay(model: _LifecycleReferenceModel) -> None: + operations = [ + model.dry_run, + model.install, + model.reinstall, + ] + if model.fixture.global_scope: + operations.extend( + [ + model.compile, + model.lock, + model.publish, + model.outdated, + model.frozen_replay, + model.update, + model.compile, + model.frozen_replay, + model.frozen_refusal, + model.reinstall, + ] + ) + operations.extend( + [ + model.tamper, + model.audit_tampered, + model.repair, + model.audit_clean, + ] + ) + if model.fixture.global_scope: + operations.extend([model.uninstall, model.audit_empty]) + else: + operations.extend([model.remove_declaration, model.prune_removed]) + operations.extend([model.readd_declaration, model.install, model.audit_clean]) + if model.fixture.global_scope: + operations.extend([model.uninstall, model.audit_empty]) + else: + operations.extend( + [ + model.tamper, + model.remove_declaration, + model.prune_removed, + model.readd_declaration, + model.install, + model.repair, + model.audit_clean, + ] + ) + for operation in operations: + transition = operation.hypothesis_stateful_rule + assert all(condition(model) for condition in transition.preconditions) + operation() + model.durable_state_matches_reference_model() + + def test_generated_transition_catalog_covers_phase_one_properties() -> None: """Require every generated transition to invoke a filesystem oracle.""" rules = { diff --git a/tests/integration/test_lifecycle_state_snapshot_contract.py b/tests/integration/test_lifecycle_state_snapshot_contract.py index 80c5fc123f..c88e6aba3e 100644 --- a/tests/integration/test_lifecycle_state_snapshot_contract.py +++ b/tests/integration/test_lifecycle_state_snapshot_contract.py @@ -280,8 +280,10 @@ def test_capture_rejects_target_relative_deployment_without_bounded_root( LifecycleStateSnapshot.capture(workspace, targets=("claude",)) +@pytest.mark.parametrize("legacy_absolute", [False, True]) def test_capture_reads_two_bounded_roots_without_relative_path_collisions( tmp_path: Path, + legacy_absolute: bool, ) -> None: workspace = tmp_path / "workspace" workspace.mkdir() @@ -296,17 +298,19 @@ def test_capture_reads_two_bounded_roots_without_relative_path_collisions( claude_script.write_bytes(b"print('bounded')\n") records = ( _record( - kind=LocatorKind.TARGET_RELATIVE, + kind=LocatorKind.PROJECT_RELATIVE if legacy_absolute else LocatorKind.TARGET_RELATIVE, target="claude", - scope="user", - value="hooks/fixture/check.py", + scope="project" if legacy_absolute else "user", + value=claude_script.as_posix() if legacy_absolute else "hooks/fixture/check.py", content_hash="sha256:script", ), _record( - kind=LocatorKind.TARGET_RELATIVE, + kind=LocatorKind.PROJECT_RELATIVE if legacy_absolute else LocatorKind.TARGET_RELATIVE, target="cursor", - scope="user", - value="rules/missing.mdc", + scope="project" if legacy_absolute else "user", + value=(cursor_root / "rules/missing.mdc").as_posix() + if legacy_absolute + else "rules/missing.mdc", ), _record( kind=LocatorKind.URI, @@ -337,6 +341,8 @@ def test_capture_reads_two_bounded_roots_without_relative_path_collisions( ) snapshot = LifecycleStateSnapshot.capture(workspace, external_roots=roots) + assert set(snapshot.deployment_records) == set(records) + assert snapshot.lockfile_bytes == (workspace / "apm.lock.yaml").read_bytes() claude_config = snapshot.file("settings.json", root_id="claude-user") cursor_config = snapshot.file("settings.json", root_id="cursor-user") @@ -361,7 +367,10 @@ def test_capture_reads_two_bounded_roots_without_relative_path_collisions( snapshot.file("copilot-app-db://workflows/external", root_id="claude-user") -def test_bounded_roots_fail_closed_before_outside_reads(tmp_path: Path) -> None: +@pytest.mark.parametrize("legacy_absolute", [False, True]) +def test_bounded_roots_fail_closed_before_outside_reads( + tmp_path: Path, legacy_absolute: bool +) -> None: workspace = tmp_path / "workspace" workspace.mkdir() root = tmp_path / "user-root" @@ -375,10 +384,12 @@ def test_bounded_roots_fail_closed_before_outside_reads(tmp_path: Path) -> None: workspace, ( _record( - kind=LocatorKind.TARGET_RELATIVE, + kind=LocatorKind.PROJECT_RELATIVE + if legacy_absolute + else LocatorKind.TARGET_RELATIVE, target="claude", - scope="user", - value="linked.json", + scope="project" if legacy_absolute else "user", + value=(root / "linked.json").as_posix() if legacy_absolute else "linked.json", ), ), ) @@ -412,6 +423,41 @@ def test_bounded_roots_fail_closed_before_outside_reads(tmp_path: Path) -> None: assert outside_file.read_bytes() == b'{"secret":"unchanged"}\n' +@pytest.mark.parametrize("fault", ["unbounded", "wrong-target", "traversal", "linked-ancestor"]) +def test_legacy_absolute_deployments_require_matching_bounded_root( + tmp_path: Path, fault: str +) -> None: + workspace, root, outside = (tmp_path / name for name in ("workspace", "root", "outside")) + for directory in (workspace, root, outside): + directory.mkdir() + secret = outside / "secret.txt" + secret.write_bytes(b"outside-user-owned\n") + candidate = root / "missing.txt" + if fault == "unbounded": + candidate = secret + elif fault == "traversal": + candidate = root / ".." / "outside" / "secret.txt" + elif fault == "linked-ancestor": + (root / "link").symlink_to(outside, target_is_directory=True) + candidate = root / "link" / "secret.txt" + _write_lock( + workspace, + ( + _record( + kind=LocatorKind.PROJECT_RELATIVE, + target="cursor" if fault == "wrong-target" else "claude", + value=candidate.as_posix(), + ), + ), + ) + with pytest.raises(ValueError, match=r"bounded root|traversal sequence"): + LifecycleStateSnapshot.capture( + workspace, + external_roots=(LifecycleStateRoot("claude-user", "claude", "user", root),), + ) + assert secret.read_bytes() == b"outside-user-owned\n" + + def test_bounded_root_identity_and_path_collisions_are_rejected(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() diff --git a/tests/integration/test_ownership_invariant_lifecycle.py b/tests/integration/test_ownership_invariant_lifecycle.py index 47ecca3dfb..2a91a542aa 100644 --- a/tests/integration/test_ownership_invariant_lifecycle.py +++ b/tests/integration/test_ownership_invariant_lifecycle.py @@ -15,12 +15,15 @@ from apm_cli.integration.targets import KNOWN_TARGETS, PrimitiveMapping, TargetProfile from apm_cli.utils.yaml_io import dump_yaml from tests.integration.test_required_lifecycle_state_machine import ( + _EXTERNAL_USER_ROOT_ENV, _OWNER, _assert_same_state, _audit, _hook, _instruction, _new_scenario, + _publish, + _publish_revision, _result_evidence, _run_success, _skill, @@ -804,3 +807,92 @@ def test_apm_install_dry_run_preserves_complete_unowned_state( assert (scenario.isolated.config_root / "apm_modules").exists() is False _assert_same_state(before, after) assert_snapshot_set_unchanged(before_artifacts, after_artifacts) + + +def test_global_update_preserves_owned_external_skill_targets( + tmp_path: Path, + apm_binary_path: Path, +) -> None: + """User-scope update must reuse the user lockfile for external skill ownership.""" + scenario = _new_scenario(tmp_path / "global-update-external-skills", apm_binary_path) + published = _publish(scenario, "global-audit-kit", skill="global-audit") + commit_a = _publish_revision(scenario, published, "a") + dependency = {**published.dependency, "ref": "main"} + consumer = LocalPackage( + "global-update-consumer", + scenario.isolated.work_root, + scenario.isolated.config_root / "apm.yml", + ) + external_roots = { + target: scenario.isolated.root / f"{target}-home" for target in ("claude", "hermes") + } + for root in external_roots.values(): + root.mkdir(parents=True) + + environment = dict(published.environment) + for target, root in external_roots.items(): + environment[_EXTERNAL_USER_ROOT_ENV[target]] = str(root) + + dump_yaml( + { + "name": consumer.name, + "version": "0.1.0", + "dependencies": {"apm": [dependency]}, + "targets": ["claude", "hermes"], + }, + consumer.manifest_path, + ) + + install_args = ("install", "--global", "--no-policy", "--parallel-downloads", "0") + _run_success( + scenario, + consumer, + install_args, + environment=environment, + scenario_id="global-update-external-install", + ) + + commit_b = _publish_revision(scenario, published, "b") + update = _run_success( + scenario, + consumer, + ("update", "--global", "--yes", "--parallel-downloads", "0", "--verbose"), + environment=environment, + scenario_id="global-update-external-update", + ) + + expected_skill = (_skill("global-audit") + "\nrevision-b\n").encode() + claude_skill = external_roots["claude"] / "skills" / "global-audit" / "SKILL.md" + hermes_skill = external_roots["hermes"] / "skills" / "global-audit" / "SKILL.md" + assert claude_skill.read_bytes() == expected_skill + assert hermes_skill.read_bytes() == expected_skill + assert "file skipped (local files exist)" not in (update.stdout + update.stderr) + canonical_cache = scenario.isolated.config_root / "apm_modules" / _OWNER / "global-audit-kit" + alias_cache = scenario.isolated.config_root / "apm_modules" / "global-audit-kit" + assert (canonical_cache / "skills" / "global-audit" / "SKILL.md").read_bytes() == ( + expected_skill + ) + assert not alias_cache.exists() + + lock = LockFile.read(scenario.isolated.config_root / "apm.lock.yaml") + assert lock is not None + dependency_lock = lock.get_package_dependencies()[0] + assert dependency_lock.resolved_commit == commit_b.sha + deployed = set(dependency_lock.deployed_files) + assert str(claude_skill) in deployed + assert str(hermes_skill) in deployed + assert str(claude_skill.parent) in deployed + assert str(hermes_skill.parent) in deployed + assert str(external_roots["claude"] / "rules" / "revision.md") in deployed + + _run_success( + scenario, + consumer, + ("compile", "--global"), + environment=environment, + scenario_id="global-update-external-compile", + ) + compiled_claude = (external_roots["claude"] / "CLAUDE.md").read_text(encoding="utf-8") + assert "revision-b" in compiled_claude + assert "revision-a" not in compiled_claude + assert commit_a.sha != commit_b.sha diff --git a/tests/integration/test_ref_freshness_lifecycle.py b/tests/integration/test_ref_freshness_lifecycle.py index 1013bf33c3..9fc9c5bc61 100644 --- a/tests/integration/test_ref_freshness_lifecycle.py +++ b/tests/integration/test_ref_freshness_lifecycle.py @@ -75,7 +75,9 @@ def _skill_document(marker: str) -> str: ) -def _new_scenario(root: Path, apm_binary_path: Path) -> _Scenario: +def _new_scenario( + root: Path, apm_binary_path: Path, *, reference: str | None = "main" +) -> _Scenario: isolated = IsolatedApmEnvironment.create(root, base_env=dict(os.environ)) environment = isolated.subprocess_env(overrides={"APM_TIERED_RESOLVER": "1"}) packages = LocalPackageFactory(isolated.package_root) @@ -100,7 +102,7 @@ def _new_scenario(root: Path, apm_binary_path: Path) -> _Scenario: dependencies=( { "git": remote_url, - "ref": "main", + **({"ref": reference} if reference is not None else {}), }, ), targets=("copilot",), @@ -195,6 +197,26 @@ def _combined_output(result: CommandResult) -> str: return result.stdout + result.stderr +def test_frozen_default_ref_rehydrates_cold_cache_without_ref_drift( + tmp_path: Path, apm_binary_path: Path +) -> None: + """An omitted ref replays its locked SHA even after upstream advances.""" + scenario = _new_scenario(tmp_path / "frozen-default-ref", apm_binary_path, reference=None) + _run(scenario, _INSTALL_ARGS, scenario_id="default-ref-install-a") + installed = _capture(scenario) + assert _locked_commit(scenario) == scenario.commit_a.sha + _advance(scenario, "commit-b") + _run(scenario, ("deps", "clean", "--yes"), scenario_id="default-ref-remove-modules") + _run(scenario, ("cache", "clean", "--yes"), scenario_id="default-ref-clear-cache") + assert not (scenario.consumer.root / "apm_modules").exists() + replay = _run(scenario, (*_INSTALL_ARGS, "--frozen"), scenario_id="default-ref-frozen-a") + assert _module_skill_bytes(scenario) == _skill_document("commit-a").encode() + assert _locked_commit(scenario) == scenario.commit_a.sha + _assert_same_state(installed, _capture(scenario)) + assert "sha_passthrough=1" in _combined_output(replay) + _run(scenario, _AUDIT_ARGS, scenario_id="default-ref-audit-a") + + def test_installed_cli_current_state_commands_bypass_stale_bare_cache( tmp_path: Path, apm_binary_path: Path, diff --git a/tests/integration/test_required_lifecycle_state_machine.py b/tests/integration/test_required_lifecycle_state_machine.py index 98c5f48081..4e2664df4f 100644 --- a/tests/integration/test_required_lifecycle_state_machine.py +++ b/tests/integration/test_required_lifecycle_state_machine.py @@ -6,7 +6,7 @@ import os import shutil import zipfile -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass from pathlib import Path, PurePosixPath @@ -22,6 +22,7 @@ ArtifactSnapshotSet, assert_only_snapshot_paths_changed, assert_snapshot_changes_within, + assert_snapshot_set_unchanged, assert_unchanged, ) from tests.utils.isolated_apm_environment import IsolatedApmEnvironment @@ -113,6 +114,24 @@ def _skill(name: str) -> str: ) +def _revision_instruction(revision: str) -> str: + return f"---\ndescription: Global lifecycle revision\n---\n# revision-{revision}\n" + + +def _publish_revision(scenario: _Scenario, source: _PublishedPackage, revision: str) -> GitCommit: + """Publish in the Git worktree, not the original package authoring tree.""" + for skill_path in (source.repository.worktree / "skills").glob("*/SKILL.md"): + skill_path.write_text( + _skill(skill_path.parent.name) + f"\nrevision-{revision}\n", encoding="ascii" + ) + instruction_path = ( + source.repository.worktree / ".apm" / "instructions" / "revision.instructions.md" + ) + instruction_path.parent.mkdir(parents=True, exist_ok=True) + instruction_path.write_text(_revision_instruction(revision), encoding="ascii") + return scenario.repositories.commit(source.repository, message=f"publish revision {revision}") + + def _instruction(name: str) -> str: return ( "---\n" @@ -1974,6 +1993,101 @@ def test_required_global_lock_ignores_inactive_experimental_resolver( assert not (mount / "Documents" / "Cowork" / "skills" / "inactive-resolver").exists() +def _exercise_global_revision_commands( + scenario: _Scenario, + source: _PublishedPackage, + commit_a: GitCommit, + run: Callable[..., CommandResult], + capture: Callable[[], LifecycleStateSnapshot], + assert_revision: Callable[[GitCommit, str], None], + artifact_roots: Mapping[str, Path], + compiled_path: Path, + install_args: tuple[str, ...], + manifest_path: Path, +) -> GitCommit: + """Advance one installed workspace, retaining byte and ownership oracles.""" + installed = capture() + assert_revision(commit_a, "a") + before = ArtifactSnapshotSet.capture(artifact_roots) + run(install_args, "global-reinstall-a") + _assert_same_state(installed, capture()) + assert_snapshot_set_unchanged(before, ArtifactSnapshotSet.capture(artifact_roots)) + for args in ( + ("deps", "list", "--global"), + ("deps", "tree", "--global"), + ("deps", "why", source.package.name, "--global", "--json"), + ("view", source.package.name, "--global"), + ): + result = run(args, f"global-reader-{'-'.join(args[:2])}") + assert source.package.name in result.stdout, _result_evidence(result) + _assert_same_state(installed, capture()) + assert_snapshot_set_unchanged(before, ArtifactSnapshotSet.capture(artifact_roots)) + + run(("compile", "--global"), "global-compile-a") + assert "# revision-a" in compiled_path.read_text(encoding="ascii") + compiled_a = compiled_path.read_bytes() + before_lock = capture() + run(("lock", "--global", "--no-policy", "--parallel-downloads", "0"), "global-lock-a") + assert capture().deployment_records == before_lock.deployment_records + assert capture().files == before_lock.files + assert_revision(commit_a, "a") + + installed_a = capture() + commit_b = _publish_revision(scenario, source, "b") + outdated = run( + ("outdated", "--global", "--parallel-checks", "0", "--verbose"), "global-outdated-b" + ) + assert commit_b.sha[:7] in outdated.stdout, _result_evidence(outdated) + _assert_same_state(installed_a, capture()) + assert_revision(commit_a, "a") + before = ArtifactSnapshotSet.capture(artifact_roots) + run( + ("update", "--global", "--dry-run", "--parallel-downloads", "0"), + "global-update-preview-b", + ) + _assert_same_state(installed_a, capture()) + assert_snapshot_set_unchanged(before, ArtifactSnapshotSet.capture(artifact_roots)) + run((*install_args, "--frozen"), "global-frozen-replay-a") + _assert_same_state(installed_a, capture()) + run(("update", "--global", "--yes", "--parallel-downloads", "0"), "global-update-b") + assert_revision(commit_b, "b") + assert capture().deployment_records != installed_a.deployment_records + run(("compile", "--global"), "global-compile-b") + assert "# revision-b" in compiled_path.read_text(encoding="ascii") + assert "# revision-a" not in compiled_path.read_text(encoding="ascii") + assert compiled_path.read_bytes() != compiled_a + installed_b = capture() + for args, scenario_id in ( + (("compile", "--global", "--dry-run"), "global-compile-preview-b"), + (("compile", "--global"), "global-compile-noop-b"), + ((*install_args, "--dry-run"), "global-install-preview-b"), + ((*install_args, "--update"), "global-install-update-noop-b"), + (("deps", "update", "--global", "--parallel-downloads", "0"), "global-deps-update-noop-b"), + ( + ("lock", "--global", "--no-policy", "--parallel-downloads", "0", "--update"), + "global-lock-update-b", + ), + ): + run(args, scenario_id) + _assert_same_state(installed_b, capture()) + run((*install_args, "--frozen"), "global-frozen-replay-b") + _assert_same_state(installed_b, capture()) + + manifest_bytes = manifest_path.read_bytes() + manifest = load_yaml(manifest_path) + manifest["dependencies"]["apm"][0]["ref"] = commit_a.sha + dump_yaml(manifest, manifest_path) + stale = capture() + run((*install_args, "--frozen"), "global-frozen-refusal", expected_returncode=1) + _assert_same_state(stale, capture()) + manifest_path.write_bytes(manifest_bytes) + before = ArtifactSnapshotSet.capture(artifact_roots) + run(install_args, "global-reinstall-b") + _assert_same_state(installed_b, capture()) + assert_snapshot_set_unchanged(before, ArtifactSnapshotSet.capture(artifact_roots)) + return commit_b + + @pytest.mark.parametrize("aliased_home", [False, True]) def test_required_global_audit_rule_matrix_for_external_roots( aliased_home: bool, @@ -1982,6 +2096,8 @@ def test_required_global_audit_rule_matrix_for_external_roots( ) -> None: scenario = _new_scenario(tmp_path / "global-audit-matrix", apm_binary_path) source = _publish(scenario, "global-audit-kit", skill="global-audit") + commit_a = _publish_revision(scenario, source, "a") + dependency = {**source.dependency, "ref": "main"} cwd = scenario.isolated.work_root targets = ("claude", "hermes") external_roots = {target: scenario.isolated.root / f"{target}-home" for target in targets} @@ -2012,40 +2128,91 @@ def test_required_global_audit_rule_matrix_for_external_roots( skill_paths = { target: PurePosixPath(_skill_deploy_path(target, "global-audit")) for target in targets } - snapshot_paths = {target: (sentinel_paths[target], skill_paths[target]) for target in targets} + snapshot_paths = { + target: (sentinel_paths[target], skill_paths[target], PurePosixPath("CLAUDE.md")) + for target in targets + } capture_roots = _external_root_specs(external_roots, config_paths=snapshot_paths) + physical_apm_home = scenario.isolated.config_root + artifact_roots = { + "home": scenario.isolated.home, + "caller": cwd, + **external_roots, + } + home_sentinel = scenario.isolated.home / "user-owned.txt" + home_sentinel.write_bytes(b"home-user-owned\n") + caller_sentinel = cwd / "user-owned.txt" + caller_sentinel.write_bytes(b"caller-user-owned\n") + + def capture() -> LifecycleStateSnapshot: + return LifecycleStateSnapshot.capture(physical_apm_home, external_roots=capture_roots) + + def run( + args: tuple[str, ...], + scenario_id: str, + *, + expected_returncode: int = 0, + command_cwd: Path | None = None, + ) -> CommandResult: + before = ArtifactSnapshotSet.capture(artifact_roots) + result = scenario.runner.run( + args, scenario_id=scenario_id, cwd=command_cwd or cwd, env=environment + ) + assert result.returncode == expected_returncode, _result_evidence(result) + assert_snapshot_changes_within( + before, + ArtifactSnapshotSet.capture(artifact_roots), + exact_paths={ + "home": {".local", ".local/state", ".local/state/gh", ".local/state/gh/device-id"}, + **{ + target: { + "skills", + "rules", + "rules/revision.md", + "CLAUDE.md", + "AGENTS.md", + ".apm", + } + for target in targets + }, + }, + tree_prefixes={ + "home": {".apm"}, + **{target: {"skills/global-audit", ".apm"} for target in targets}, + }, + ) + assert home_sentinel.read_bytes() == b"home-user-owned\n" + assert caller_sentinel.read_bytes() == b"caller-user-owned\n" + for target in targets: + assert (external_roots[target] / sentinel_paths[target]).read_bytes() == ( + f"{target}-owned-by-user\n".encode("ascii") + ) + if aliased_home: + assert alias.is_symlink() + assert alias.readlink() == scenario.isolated.home + return result + scenario.isolated.config_root.mkdir(parents=True, exist_ok=True) dump_yaml( { "name": "global-audit-consumer", "version": "0.1.0", - "dependencies": {"apm": [source.dependency]}, + "dependencies": {"apm": [dependency]}, "targets": list(targets), }, scenario.isolated.config_root / "apm.yml", ) - install = scenario.runner.run( - ( - "install", - "--global", - "--no-policy", - "--parallel-downloads", - "0", - ), - scenario_id="global-audit-install", - cwd=cwd, - env=environment, - ) - assert install.returncode == 0, _result_evidence(install) - installed = LifecycleStateSnapshot.capture(cwd, external_roots=capture_roots) + install_args = ("install", "--global", "--no-policy", "--parallel-downloads", "0") + run(install_args, "global-audit-install") + installed = capture() installed_home = LifecycleStateSnapshot.capture( cwd, external_roots=(_apm_home_root(scenario),), ) assert installed_home.file("apm.lock.yaml", root_id="apm-home").kind == "file" - source_skill_bytes = _skill("global-audit").encode() + source_skill_bytes = (_skill("global-audit") + "\nrevision-a\n").encode() for target in targets: assert ( installed.file(skill_paths[target].as_posix(), root_id=f"{target}-home").content @@ -2055,6 +2222,42 @@ def test_required_global_audit_rule_matrix_for_external_roots( sentinel_paths[target].as_posix(), root_id=f"{target}-home" ).content == f"{target}-owned-by-user\n".encode("ascii") + lock_path = physical_apm_home / "apm.lock.yaml" + + def assert_revision(commit: GitCommit, revision: str) -> None: + lock = LockFile.read(lock_path) + assert lock is not None + dependencies = lock.get_package_dependencies() + assert len(dependencies) == 1 + assert dependencies[0].resolved_commit == commit.sha + assert dependencies[0].content_hash + modules = physical_apm_home / "apm_modules" + assert ( + modules / _OWNER / source.package.name / "skills" / "global-audit" / "SKILL.md" + ).is_file() + assert not (modules / source.package.name).exists() + for target in targets: + assert (external_roots[target] / skill_paths[target]).read_bytes() == ( + _skill("global-audit") + f"\nrevision-{revision}\n" + ).encode() + assert capture().deployment_records + + manifest_path = physical_apm_home / "apm.yml" + + commit_b = _exercise_global_revision_commands( + scenario, + source, + commit_a, + run, + capture, + assert_revision, + artifact_roots, + external_roots["claude"] / "CLAUDE.md", + install_args, + manifest_path, + ) + installed = capture() + def audit_row( scenario_id: str, *, @@ -2063,6 +2266,8 @@ def audit_row( ) -> dict[str, object]: if expected_returncode is None: expected_returncode = 1 if failed else 0 + before = ArtifactSnapshotSet.capture(artifact_roots) + state_before = capture() _, payload = _audit_at( scenario, scenario.isolated.config_root, @@ -2071,6 +2276,8 @@ def audit_row( scenario_id=scenario_id, ) _assert_global_audit_rules(payload, failed=failed) + _assert_same_state(state_before, capture()) + assert_snapshot_set_unchanged(before, ArtifactSnapshotSet.capture(artifact_roots)) return payload def assert_clean(scenario_id: str) -> dict[str, object]: @@ -2082,7 +2289,7 @@ def assert_clean(scenario_id: str) -> dict[str, object]: clean_audit = assert_clean("global-audit-clean") assert _check(clean_audit, "deployed-files-present")["passed"] is True assert _check(clean_audit, "content-integrity")["passed"] is True - post_clean_audit = LifecycleStateSnapshot.capture(cwd, external_roots=capture_roots) + post_clean_audit = capture() _assert_same_state(installed, post_clean_audit) claude_skill = external_roots["claude"] / skill_paths["claude"] @@ -2211,13 +2418,21 @@ def assert_clean(scenario_id: str) -> dict[str, object]: hermes_skill.write_bytes(hermes_skill_bytes) assert_clean("global-audit-after-combo-restore") - uninstall = scenario.runner.run( + before_clean = capture() + run(("deps", "clean", "--dry-run"), "global-deps-clean-preview", command_cwd=physical_apm_home) + _assert_same_state(before_clean, capture()) + run(("deps", "clean", "--yes"), "global-deps-clean", command_cwd=physical_apm_home) + assert not modules_dir.exists() + assert capture().deployment_records == before_clean.deployment_records + audit_row("global-audit-after-deps-clean", failed={"config-consistency", "drift"}) + run(install_args, "global-rehydrate-after-deps-clean") + assert_revision(commit_b, "b") + assert_clean("global-audit-after-rehydrate") + + run( ("uninstall", "--global", source.remote_url), - scenario_id="global-audit-uninstall", - cwd=cwd, - env=environment, + "global-audit-uninstall", ) - assert uninstall.returncode == 0, _result_evidence(uninstall) removed = LifecycleStateSnapshot.capture(cwd, external_roots=capture_roots) for target in targets: assert ( @@ -2240,6 +2455,23 @@ def assert_clean(scenario_id: str) -> dict[str, object]: ) assert final_audit["passed"] is True assert final_audit["summary"]["failed"] == 0 + manifest = load_yaml(manifest_path) + manifest["dependencies"]["apm"] = [dependency] + dump_yaml(manifest, manifest_path) + run(install_args, "global-reinstall-after-removal") + assert_revision(commit_b, "b") + assert_clean("global-audit-reinstalled") + run(("uninstall", "--global", source.remote_url), "global-uninstall-closure") + _, closure = _audit_at( + scenario, + physical_apm_home, + environment=environment, + scenario_id="global-audit-closure", + ) + assert closure["passed"] is True + assert not lock_path.exists() + for target in targets: + assert not (external_roots[target] / skill_paths[target]).exists() def test_required_failed_lock_write_bounds_partial_state_and_recovers( diff --git a/tests/unit/deps/test_tiered_ref_resolver.py b/tests/unit/deps/test_tiered_ref_resolver.py index cc86ce9b30..af8b387604 100644 --- a/tests/unit/deps/test_tiered_ref_resolver.py +++ b/tests/unit/deps/test_tiered_ref_resolver.py @@ -18,6 +18,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src")) from apm_cli.cache.url_normalize import cache_shard_key +from apm_cli.deps.lockfile import LockFile from apm_cli.deps.tiered_ref_resolver import ( L0PerRunCache, L1CommitsAPI, @@ -29,6 +30,7 @@ _repository_cache_identity, build_tiered_ref_resolver, is_tiered_resolver_enabled, + ref_freshness_policy_for_install, ) from apm_cli.models.dependency.reference import DependencyReference from apm_cli.models.dependency.types import GitReferenceType, ResolvedReference @@ -68,6 +70,27 @@ def test_freshness_policy_maps_install_intent_once(update_refs, refresh, expecte assert policy.allows_bare_cache is (expected is RefFreshnessPolicy.REPRODUCIBLE) +@pytest.mark.parametrize("has_lock", [False, True]) +@pytest.mark.parametrize("update_refs", [False, True]) +def test_install_freshness_requires_remote_without_replayable_lock( + has_lock: bool, update_refs: bool +) -> None: + context = types.SimpleNamespace( + ref_freshness_policy=None, + update_refs=update_refs, + refresh=False, + existing_lockfile=LockFile() if has_lock else None, + ) + policy = ref_freshness_policy_for_install(context) + assert policy is ( + RefFreshnessPolicy.REPRODUCIBLE + if has_lock and not update_refs + else RefFreshnessPolicy.CURRENT_REMOTE + ) + context.ref_freshness_policy = RefFreshnessPolicy.CURRENT_REMOTE + assert ref_freshness_policy_for_install(context) is RefFreshnessPolicy.CURRENT_REMOTE + + @pytest.mark.parametrize( "value,expected", [ diff --git a/tests/unit/install/test_frozen.py b/tests/unit/install/test_frozen.py index 259f1dae21..29593d1daa 100644 --- a/tests/unit/install/test_frozen.py +++ b/tests/unit/install/test_frozen.py @@ -72,7 +72,7 @@ def test_succeeds_when_lockfile_has_all_manifest_deps(self, tmp_path: Path): ), ], ) - dep = DependencyReference(repo_url="https://github.com/o/r") + dep = DependencyReference(repo_url="https://github.com/o/r", reference="main") req = _make_request(project_dir=tmp_path, manifest_deps=[dep]) InstallService.enforce_frozen(req) @@ -97,7 +97,7 @@ def test_orphan_lockfile_entries_dont_fail(self, tmp_path: Path): ), ], ) - dep = DependencyReference(repo_url="https://github.com/o/r") + dep = DependencyReference(repo_url="https://github.com/o/r", reference="main") req = _make_request(project_dir=tmp_path, manifest_deps=[dep]) InstallService.enforce_frozen(req) @@ -118,7 +118,7 @@ def test_mixed_project_rejects_stale_locked_mcp_state(self, tmp_path: Path): lock.mcp_servers = ["stale-mcp"] lock.mcp_configs = {"stale-mcp": {"name": "stale-mcp"}} lock.save(tmp_path / "apm.lock.yaml") - dep = DependencyReference(repo_url="https://github.com/o/r") + dep = DependencyReference(repo_url="https://github.com/o/r", reference="main") req = _make_request(project_dir=tmp_path, manifest_deps=[dep]) req.apm_package.get_all_mcp_dependencies.return_value = [] current = CurrentMcpConfigView( @@ -217,7 +217,7 @@ def test_cold_cache_git_apm_package_dep_passes(self, tmp_path: Path) -> None: lock.mcp_configs = {"root-mcp": {"name": "root-mcp"}} lock.save(tmp_path / "apm.lock.yaml") - manifest_dep = DependencyReference(repo_url="owner/some-pkg") + manifest_dep = DependencyReference(repo_url="owner/some-pkg", reference="v1.0.0") req = _make_request(project_dir=tmp_path, manifest_deps=[manifest_dep]) # Root manifest declares the MCP server directly. from apm_cli.models.dependency.mcp import MCPDependency @@ -246,7 +246,7 @@ def test_cold_cache_git_apm_package_with_own_mcp_passes(self, tmp_path: Path) -> lock.mcp_config_provenance = {"pkg-mcp": dep_name} lock.save(tmp_path / "apm.lock.yaml") - manifest_dep = DependencyReference(repo_url="owner/some-pkg") + manifest_dep = DependencyReference(repo_url="owner/some-pkg", reference="v1.0.0") req = _make_request(project_dir=tmp_path, manifest_deps=[manifest_dep]) req.apm_package.get_all_mcp_dependencies.return_value = [] @@ -275,7 +275,7 @@ def test_cold_cache_does_not_mask_real_mcp_drift_from_installed_dep( lock.mcp_config_provenance = {} lock.save(tmp_path / "apm.lock.yaml") - manifest_dep = DependencyReference(repo_url="owner/some-pkg") + manifest_dep = DependencyReference(repo_url="owner/some-pkg", reference="v1.0.0") req = _make_request(project_dir=tmp_path, manifest_deps=[manifest_dep]) req.apm_package.get_all_mcp_dependencies.return_value = [] current = CurrentMcpConfigView( @@ -324,7 +324,7 @@ def test_co_owned_server_not_exempted_by_cold_cache(self, tmp_path: Path) -> Non } lock.save(tmp_path / "apm.lock.yaml") - manifest_dep = DependencyReference(repo_url="owner/some-pkg") + manifest_dep = DependencyReference(repo_url="owner/some-pkg", reference="v1.0.0") req = _make_request(project_dir=tmp_path, manifest_deps=[manifest_dep]) req.apm_package.get_all_mcp_dependencies.return_value = [] current = CurrentMcpConfigView( @@ -374,7 +374,7 @@ def verbose_detail(self, message: str) -> None: lock.mcp_config_provenance = {"pkg-mcp": dep_name} lock.save(tmp_path / "apm.lock.yaml") - manifest_dep = DependencyReference(repo_url="owner/some-pkg") + manifest_dep = DependencyReference(repo_url="owner/some-pkg", reference="v1.0.0") recording_logger = _RecordingInstallLogger() pkg = MagicMock() pkg.package_path = tmp_path / "apm.yml" diff --git a/tests/unit/install/test_plan.py b/tests/unit/install/test_plan.py index f4c1c024bb..53fec03ca2 100644 --- a/tests/unit/install/test_plan.py +++ b/tests/unit/install/test_plan.py @@ -9,6 +9,8 @@ from __future__ import annotations +import pytest + from apm_cli.deps.lockfile import LockedDependency, LockFile from apm_cli.install.plan import ( PlanEntry, @@ -684,7 +686,7 @@ def test_satisfied_when_private_git_dep_lock_key_is_host_qualified(self): depth=1, ) ) - manifest = [DependencyReference.parse("git@git.example.com:org/private-skills.git")] + manifest = [DependencyReference.parse("git@git.example.com:org/private-skills.git#main")] ok, reasons = lockfile_satisfies_manifest(lock, manifest) @@ -720,7 +722,7 @@ def test_satisfied_when_github_git_dep_uses_default_host_key(self): depth=1, ) ) - manifest = [DependencyReference.parse("git@github.com:org/public-skills.git")] + manifest = [DependencyReference.parse("git@github.com:org/public-skills.git#main")] ok, reasons = lockfile_satisfies_manifest(lock, manifest) @@ -737,6 +739,35 @@ def test_unsatisfied_when_manifest_dep_missing_from_lock(self): assert len(reasons) == 1 assert "missing" in reasons[0] + @pytest.mark.parametrize( + ("manifest_ref", "locked_ref", "commit", "satisfied"), + [ + (None, None, "a" * 40, True), + ("main", "main", "a" * 40, True), + ("release", "main", "a" * 40, False), + (None, "main", "a" * 40, False), + ("main", None, "a" * 40, False), + ("a" * 40, "a" * 40, "a" * 40, True), + ("A" * 40, "A" * 40, "a" * 40, True), + ("a" * 40, "a" * 40, "b" * 40, False), + ], + ) + def test_frozen_compares_declared_identity_without_resolving_upstream( + self, manifest_ref: str | None, locked_ref: str | None, commit: str, satisfied: bool + ) -> None: + lock = _new_lockfile() + lock.add_dependency( + LockedDependency( + repo_url="owner/package", resolved_ref=locked_ref, resolved_commit=commit + ) + ) + manifest = [DependencyReference(repo_url="owner/package", reference=manifest_ref)] + before = lock.to_yaml() + ok, reasons = lockfile_satisfies_manifest(lock, manifest) + assert ok is satisfied + assert bool(reasons) is not satisfied + assert lock.to_yaml() == before + def test_local_deps_skipped(self): """Local file deps have no remote ref, so they're skipped.""" lock = _new_lockfile() diff --git a/tests/utils/lifecycle_state.py b/tests/utils/lifecycle_state.py index 93135afa13..d313cb0449 100644 --- a/tests/utils/lifecycle_state.py +++ b/tests/utils/lifecycle_state.py @@ -161,10 +161,16 @@ def capture( roles_by_path: dict[tuple[str, str], set[LifecycleFileRole]] = {} for record in records: if record.locator.kind is LocatorKind.PROJECT_RELATIVE: + if Path(record.locator.value).is_absolute(): + root_id, relative_path = _bounded_legacy_absolute_path( + record, root, normalized_roots + ) + else: + root_id, relative_path = _WORKSPACE_ROOT_ID, record.locator.value _add_role( roles_by_path, - _WORKSPACE_ROOT_ID, - record.locator.value, + root_id, + relative_path, "deployment", ) elif record.locator.kind is LocatorKind.TARGET_RELATIVE: @@ -306,6 +312,31 @@ def file( ) +def _bounded_legacy_absolute_path( + record: DeploymentRecord, + workspace_root: Path, + external_roots: tuple[LifecycleStateRoot, ...], +) -> tuple[str, str]: + """Observe legacy global locators without resolving descendant links.""" + candidate = Path(record.locator.value) + roots = [ + (_WORKSPACE_ROOT_ID, workspace_root), + *( + (external.root_id, external.path) + for external in external_roots + if external.target == record.locator.target + ), + ] + for root_id, root_path in roots: + if candidate.is_relative_to(root_path): + relative = candidate.relative_to(root_path).as_posix() + _validate_relative_state_path(relative) + return root_id, relative + raise ValueError( + f"Lifecycle snapshot has no bounded root for absolute deployment {record.locator.value!r}" + ) + + def _add_role( roles_by_path: dict[tuple[str, str], set[LifecycleFileRole]], root_id: str, From 9eeaf3785d06c70238628ca55dfbc7c81e57e79d Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Mon, 7 Sep 2026 17:53:26 +0200 Subject: [PATCH 05/15] test(lifecycle): record compatible readers and project-only command limits Exercise deps info, cache info/prune, project target inspection, and global absolute-file find refusal without inventing unsupported flags or weakening durable-state checks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../test_required_lifecycle_state_machine.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/integration/test_required_lifecycle_state_machine.py b/tests/integration/test_required_lifecycle_state_machine.py index 4e2664df4f..e5c4d654e9 100644 --- a/tests/integration/test_required_lifecycle_state_machine.py +++ b/tests/integration/test_required_lifecycle_state_machine.py @@ -2023,6 +2023,34 @@ def _exercise_global_revision_commands( _assert_same_state(installed, capture()) assert_snapshot_set_unchanged(before, ArtifactSnapshotSet.capture(artifact_roots)) + for args, expected_text in ( + (("deps", "info", source.package.name), source.package.name), + (("cache", "info"), "Git repositories"), + (("cache", "prune", "--days", "30"), "Pruned 0 SHA group(s)"), + ): + result = run( + args, + f"global-compatible-{'-'.join(args[:2])}", + command_cwd=manifest_path.parent, + ) + assert expected_text in result.stdout, _result_evidence(result) + _assert_same_state(installed, capture()) + assert_snapshot_set_unchanged(before, ArtifactSnapshotSet.capture(artifact_roots)) + # targets observes project markers, not the user-scope deployment inventory. + targets = run(("targets", "--json"), "global-compatible-targets") + target_rows = json.loads(targets.stdout) + assert target_rows and all(row["status"] == "inactive" for row in target_rows) + # find is a project-relative lookup; legacy absolute global records are not supported. + found = run( + ("find", str(compiled_path.parent / "skills" / "global-audit" / "SKILL.md")), + "global-find-absolute-refusal", + command_cwd=manifest_path.parent, + expected_returncode=1, + ) + assert "is not tracked by any installed package" in found.stdout, _result_evidence(found) + _assert_same_state(installed, capture()) + assert_snapshot_set_unchanged(before, ArtifactSnapshotSet.capture(artifact_roots)) + run(("compile", "--global"), "global-compile-a") assert "# revision-a" in compiled_path.read_text(encoding="ascii") compiled_a = compiled_path.read_bytes() From 060880e971c3b0c759946173882b510ce1af9c56 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Mon, 7 Sep 2026 18:40:18 +0200 Subject: [PATCH 06/15] test(lifecycle): cover hidden readers and exact recovery branches Bind frozen full-SHA validation to req-lk-003 and extend canonical/aliased trajectories with real lock exports and cache/source maintenance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../test_generated_lifecycle_state_machine.py | 115 +++++++++++++++++- .../test_required_lifecycle_state_machine.py | 100 ++++++++++++--- tests/spec_conformance/test_lockfile_reqs.py | 26 ++++ 3 files changed, 220 insertions(+), 21 deletions(-) diff --git a/tests/integration/test_generated_lifecycle_state_machine.py b/tests/integration/test_generated_lifecycle_state_machine.py index 76fe2d9014..adc7bb4a7a 100644 --- a/tests/integration/test_generated_lifecycle_state_machine.py +++ b/tests/integration/test_generated_lifecycle_state_machine.py @@ -118,6 +118,11 @@ "frozen_refusal": frozenset({"transaction.failed_command_preserves_state"}), "uninstall": frozenset({"ownership.preserve_unowned"}), "audit_empty": frozenset({"outcome.status_matches_state"}), + "inspect_installed": frozenset({"filesystem.open_world_observation"}), + "export_lock": frozenset({"source.ref_cache_coherent"}), + "clean_sources": frozenset({"source.ref_cache_coherent", "ownership.preserve_unowned"}), + "clean_cache": frozenset({"filesystem.open_world_observation"}), + "legacy_update": frozenset({"source.ref_cache_coherent"}), } _VARIANTS = ("project", "global-canonical", "global-aliased") @@ -281,11 +286,16 @@ def skill_path(self) -> Path: return self.fixture.skill_path def _run( - self, args: tuple[str, ...], operation: str, expected_returncode: int = 0 + self, + args: tuple[str, ...], + operation: str, + expected_returncode: int = 0, + *, + command_cwd: Path | None = None, ) -> CommandResult: result = self.scenario.runner.run( args, - cwd=self.fixture.invoke_cwd, + cwd=command_cwd or self.fixture.invoke_cwd, env=self.fixture.environment, scenario_id=self._next_id(operation), ) @@ -475,6 +485,101 @@ def outdated(self) -> None: _assert_same_state(state, self._state()) assert_snapshot_set_unchanged(before, self._capture()) + @rule() + @precondition(lambda self: self.fixture.global_scope and self.materialized and self.clean) + def inspect_installed(self) -> None: + before, state = self._capture(), self._state() + for args in ( + ("deps", "list", "--global"), + ("deps", "tree", "--global"), + ("deps", "why", _PACKAGE_NAME, "--global", "--json"), + ("view", _PACKAGE_NAME, "--global"), + ("info", _PACKAGE_NAME, "--global"), + ): + result = self._run(args, f"inspect-{'-'.join(args[:2])}") + assert _PACKAGE_NAME in result.stdout, _result_evidence(result) + _assert_same_state(state, self._state()) + assert_snapshot_set_unchanged(before, self._capture()) + info = self._run( + ("deps", "info", _PACKAGE_NAME), "deps-info", command_cwd=self.fixture.audit_cwd + ) + assert _PACKAGE_NAME in info.stdout, _result_evidence(info) + self._run(("cache", "info"), "cache-info") + pruned = self._run(("cache", "prune", "--days", "30"), "cache-prune") + assert "Pruned 0 SHA group(s)" in pruned.stdout, _result_evidence(pruned) + targets = self._run(("targets", "--json"), "targets-project-only") + rows = json.loads(targets.stdout) + assert rows and all(row["status"] == "inactive" for row in rows) + found = self._run( + ("find", str(self.skill_path)), + "find-global-refusal", + 1, + command_cwd=self.fixture.audit_cwd, + ) + assert "is not tracked by any installed package" in " ".join(found.stdout.split()) + _assert_same_state(state, self._state()) + assert_snapshot_set_unchanged(before, self._capture()) + + @rule() + @precondition(lambda self: self.fixture.global_scope and self.materialized and self.clean) + def export_lock(self) -> None: + before, state = self._capture(), self._state() + for format_name in ("cyclonedx", "spdx"): + result = self._run( + ("lock", "export", "--global", "--format", format_name), f"export-{format_name}" + ) + document = json.loads(result.stdout) + assert document.get("bomFormat") == "CycloneDX" or document.get("spdxVersion") + assert self.installed_commit in result.stdout, _result_evidence(result) + _assert_same_state(state, self._state()) + assert_snapshot_set_unchanged(before, self._capture()) + + @rule() + @precondition(lambda self: self.fixture.global_scope and self.materialized and self.clean) + def clean_cache(self) -> None: + before, state = self._capture(), self._state() + self._run(("cache", "clean", "--yes"), "clean-cache") + _assert_same_state(state, self._state()) + assert_snapshot_set_unchanged(before, self._capture()) + + @rule() + @precondition(lambda self: self.fixture.global_scope and self.materialized and self.clean) + def clean_sources(self) -> None: + before, state = self._capture(), self._state() + self._run( + ("deps", "clean", "--dry-run"), "clean-preview", command_cwd=self.fixture.audit_cwd + ) + assert_snapshot_set_unchanged(before, self._capture()) + self._run(("deps", "clean", "--yes"), "clean-sources", command_cwd=self.fixture.audit_cwd) + assert not (self.project.root / "apm_modules").exists() + assert self.skill_path.read_bytes() == ( + _SKILL_BYTES + f"\nrevision-{self.installed_revision}\n".encode("ascii") + ) + result = self._run( + ("audit", "--ci", "--no-policy", "--no-fail-fast", "--format", "json"), + "audit-missing-sources", + 1, + command_cwd=self.fixture.audit_cwd, + ) + assert json.loads(result.stdout)["passed"] is False + self._run(self.fixture.install_args, "rehydrate-sources") + _assert_same_state(state, self._state()) + assert_snapshot_set_unchanged(before, self._capture()) + + @rule() + @precondition(lambda self: self.fixture.global_scope and self.materialized and self.clean) + def legacy_update(self) -> None: + before = self._capture() + self._run(("deps", "update", "--global", "--parallel-downloads", "0"), "legacy-update") + self.installed_revision = self.published_revision + self.installed_commit = self.published_commit + assert_snapshot_changes_within( + before, + self._capture(), + exact_paths=self.fixture.exact_paths, + tree_prefixes=self.fixture.tree_prefixes, + ) + @rule() @precondition(lambda self: self.fixture.global_scope and self.declared and self.materialized) def update(self) -> None: @@ -674,14 +779,20 @@ def _mandatory_replay(model: _LifecycleReferenceModel) -> None: [ model.compile, model.lock, + model.inspect_installed, + model.export_lock, model.publish, model.outdated, model.frozen_replay, model.update, model.compile, + model.export_lock, + model.legacy_update, model.frozen_replay, model.frozen_refusal, model.reinstall, + model.clean_cache, + model.clean_sources, ] ) operations.extend( diff --git a/tests/integration/test_required_lifecycle_state_machine.py b/tests/integration/test_required_lifecycle_state_machine.py index e5c4d654e9..a36f3af958 100644 --- a/tests/integration/test_required_lifecycle_state_machine.py +++ b/tests/integration/test_required_lifecycle_state_machine.py @@ -1323,7 +1323,6 @@ def test_required_legacy_content_hash_upgrade_preserves_skills_and_converges( "legacy-hash-kit", skill="legacy-hash", ) - package = source.package consumer = scenario.consumers.create( "legacy-hash-consumer", dependencies=(source.dependency,), @@ -1358,7 +1357,10 @@ def test_required_legacy_content_hash_upgrade_preserves_skills_and_converges( prior_deployed_files = list(locked_dependency["deployed_files"]) prior_deployed_hashes = dict(locked_dependency["deployed_file_hashes"]) - cached_package = consumer.root / "apm_modules" / package.name + _, installed_dependency = _single_locked_dependency(consumer.root) + cached_package = installed_dependency.to_dependency_ref().get_install_path( + consumer.root / "apm_modules" + ) receipt = cached_package / ".apm" / ".plugin-skill-sources.json" assert receipt.is_file() receipt.unlink() @@ -1451,7 +1453,10 @@ def test_required_parallel_fresh_fetch_bypasses_legacy_cache_upgrade( scenario_id="parallel-fresh-plugin-establish", ) - cached_package = consumer.root / "apm_modules" / source.package.name + _, installed_dependency = _single_locked_dependency(consumer.root) + cached_package = installed_dependency.to_dependency_ref().get_install_path( + consumer.root / "apm_modules" + ) lock_path = consumer.root / "apm.lock.yaml" lock_document = load_yaml(lock_path) locked_dependency = lock_document["dependencies"][0] @@ -1508,7 +1513,7 @@ def test_required_invalid_receiptless_legacy_cache_fails_with_recovery( apm_binary_path: Path, invalid_cache: str, ) -> None: - """Reject invalid 0.28 cache state without mutating cache or deployments.""" + """Reject invalid 0.28 cache state, preserve deployments, then recover.""" scenario = _new_scenario( tmp_path / f"invalid-legacy-cache-{invalid_cache}", apm_binary_path, @@ -1531,7 +1536,10 @@ def test_required_invalid_receiptless_legacy_cache_fails_with_recovery( scenario_id="invalid-legacy-plugin-establish", ) - cached_package = consumer.root / "apm_modules" / source.package.name + _, installed_dependency = _single_locked_dependency(consumer.root) + cached_package = installed_dependency.to_dependency_ref().get_install_path( + consumer.root / "apm_modules" + ) receipt = cached_package / ".apm" / ".plugin-skill-sources.json" assert receipt.is_file() receipt.unlink() @@ -1593,6 +1601,7 @@ def test_required_invalid_receiptless_legacy_cache_fails_with_recovery( dump_yaml(lock_document, lock_path) before_state = LifecycleStateSnapshot.capture(consumer.root, targets=("claude", "codex")) + before_artifacts = ArtifactSnapshotSet.capture({"project": consumer.root}) before_cache = ArtifactSnapshot.capture(cached_package) before_external = ArtifactSnapshot.capture(external_root) if external_root is not None else None package_link_target = ( @@ -1608,29 +1617,68 @@ def test_required_invalid_receiptless_legacy_cache_fails_with_recovery( output = " ".join((result.stdout + result.stderr).split()) assert result.returncode != 0, _result_evidence(result) - assert source.package.name in output - assert str(cached_package) in "".join(output.split()) - assert "apm deps clean --yes" in output - if invalid_cache == "plugin-path": - assert "is invalid" in output - elif invalid_cache == "missing-hash": - assert "no content hash" in output - elif invalid_cache == "missing-apm-yml": - assert "required apm.yml is missing" in output - elif invalid_cache == "missing-apm-dir": - assert "required .apm directory is missing" in output + compact_output = "".join(output.split()) + assert source.package.name in compact_output, _result_evidence(result) + rejected_redownload = invalid_cache in {"missing-apm-yml", "missing-apm-dir"} + if rejected_redownload: + assert "Content hash mismatch" in output, _result_evidence(result) + assert "apm install --update" in output + assert not cached_package.exists() + elif invalid_cache == "package-root-symlink": + assert "Cannot verify containment" in output, _result_evidence(result) + assert str(cached_package) in compact_output else: - assert "cache metadata contains a symlink" in output + assert str(cached_package) in compact_output + assert "apm deps clean --yes" in output + if invalid_cache == "plugin-path": + assert "is invalid" in output + elif invalid_cache == "missing-hash": + assert "no content hash" in output + else: + assert "cache metadata contains a symlink" in output if package_link_target is not None: assert cached_package.is_symlink() assert cached_package.readlink() == package_link_target - assert_unchanged(before_cache, ArtifactSnapshot.capture(cached_package)) + if not rejected_redownload: + assert_unchanged(before_cache, ArtifactSnapshot.capture(cached_package)) + assert_snapshot_changes_within( + before_artifacts, + ArtifactSnapshotSet.capture({"project": consumer.root}), + exact_paths={}, + tree_prefixes={"project": {cached_package.relative_to(consumer.root).as_posix()}}, + ) if before_external is not None and external_root is not None: assert_unchanged(before_external, ArtifactSnapshot.capture(external_root)) after_state = LifecycleStateSnapshot.capture(consumer.root, targets=("claude", "codex")) _assert_same_state(before_state, after_state) + # Follow targeted-cache-removal guidance without following a bad root link. + if package_link_target is not None: + cached_package.unlink() + elif cached_package.exists(): + shutil.rmtree(cached_package) + _run_success( + scenario, + consumer, + (*_INSTALL_ARGS, "--update"), + environment=source.environment, + scenario_id=f"invalid-legacy-plugin-{invalid_cache}-recovery", + ) + assert receipt.is_file() + assert (consumer.root / ".claude/skills/legacy-skill/SKILL.md").read_text( + encoding="ascii" + ) == _skill("legacy-skill") + if before_external is not None and external_root is not None: + assert_unchanged(before_external, ArtifactSnapshot.capture(external_root)) + _, recovered_audit = _audit( + scenario, + consumer, + environment=source.environment, + scenario_id=f"invalid-legacy-plugin-{invalid_cache}-recovered-audit", + ) + assert recovered_audit["passed"] is True + def test_required_dependency_prune_then_uninstall_cascades_owned_state( tmp_path: Path, @@ -2017,6 +2065,7 @@ def _exercise_global_revision_commands( ("deps", "tree", "--global"), ("deps", "why", source.package.name, "--global", "--json"), ("view", source.package.name, "--global"), + ("info", source.package.name, "--global"), ): result = run(args, f"global-reader-{'-'.join(args[:2])}") assert source.package.name in result.stdout, _result_evidence(result) @@ -2047,7 +2096,9 @@ def _exercise_global_revision_commands( command_cwd=manifest_path.parent, expected_returncode=1, ) - assert "is not tracked by any installed package" in found.stdout, _result_evidence(found) + assert "is not tracked by any installed package" in " ".join(found.stdout.split()), ( + _result_evidence(found) + ) _assert_same_state(installed, capture()) assert_snapshot_set_unchanged(before, ArtifactSnapshotSet.capture(artifact_roots)) @@ -2059,6 +2110,11 @@ def _exercise_global_revision_commands( assert capture().deployment_records == before_lock.deployment_records assert capture().files == before_lock.files assert_revision(commit_a, "a") + locked_a = capture() + export_a = run(("lock", "export", "--global"), "global-lock-export-a") + assert json.loads(export_a.stdout)["bomFormat"] == "CycloneDX" + assert commit_a.sha in export_a.stdout + _assert_same_state(locked_a, capture()) installed_a = capture() commit_b = _publish_revision(scenario, source, "b") @@ -2085,6 +2141,10 @@ def _exercise_global_revision_commands( assert "# revision-a" not in compiled_path.read_text(encoding="ascii") assert compiled_path.read_bytes() != compiled_a installed_b = capture() + export_b = run(("lock", "export", "--global", "--format", "spdx"), "global-lock-export-b") + assert json.loads(export_b.stdout)["spdxVersion"].startswith("SPDX-") + assert commit_b.sha in export_b.stdout + _assert_same_state(installed_b, capture()) for args, scenario_id in ( (("compile", "--global", "--dry-run"), "global-compile-preview-b"), (("compile", "--global"), "global-compile-noop-b"), @@ -2447,6 +2507,8 @@ def assert_clean(scenario_id: str) -> dict[str, object]: assert_clean("global-audit-after-combo-restore") before_clean = capture() + run(("cache", "clean", "--yes"), "global-cache-clean") + _assert_same_state(before_clean, capture()) run(("deps", "clean", "--dry-run"), "global-deps-clean-preview", command_cwd=physical_apm_home) _assert_same_state(before_clean, capture()) run(("deps", "clean", "--yes"), "global-deps-clean", command_cwd=physical_apm_home) diff --git a/tests/spec_conformance/test_lockfile_reqs.py b/tests/spec_conformance/test_lockfile_reqs.py index b1d7b08273..390e984bba 100644 --- a/tests/spec_conformance/test_lockfile_reqs.py +++ b/tests/spec_conformance/test_lockfile_reqs.py @@ -87,6 +87,32 @@ def test_lockfile_v1_remains_parseable_under_v2_reader(): validate_against("lockfile-v0.1.schema.json", load_yaml_fixture(*V1)) +@pytest.mark.req("req-lk-003") +def test_frozen_manifest_pin_requires_the_exact_locked_commit(): + from apm_cli.deps.lockfile import LockedDependency, LockFile + from apm_cli.install.plan import lockfile_satisfies_manifest + from apm_cli.models.dependency import DependencyReference + + pin = "abcdef0123456789" * 2 + "abcdef01" + declared = DependencyReference.parse(f"fixture/frozen-pin#{pin}") + entry = LockedDependency( + repo_url=declared.repo_url, resolved_ref=pin, resolved_commit=pin.upper() + ) + lock = LockFile(dependencies={declared.get_unique_key(): entry}) + before = lock.to_yaml() + assert lockfile_satisfies_manifest(lock, [declared]) == (True, []) + assert lock.to_yaml() == before + + entry.resolved_commit = "0123456789abcdef" * 2 + "01234567" + mismatched = lock.to_yaml() + satisfied, reasons = lockfile_satisfies_manifest(lock, [declared]) + assert satisfied is False + assert len(reasons) == 1 + assert "manifest commit" in reasons[0] + assert "lockfile resolved_commit" in reasons[0] + assert lock.to_yaml() == mismatched + + @pytest.mark.req("req-lk-005") def test_lockfile_dependency_carries_resolved_field(): schema = load_schema("lockfile-v0.1.schema.json") From 1f917a150d0ba5c51c8c15a0611ef830097aab25 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Mon, 7 Sep 2026 18:41:27 +0200 Subject: [PATCH 07/15] test(architecture): guard alias lifecycle authority routing Extend the existing identity owner selectors and provenance consumer checks for canonical module paths, frozen drift validation, and user-scope lockfile roots, with focused bypass mutations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../owners/contracts-tooling.json | 5 +- .../checks/contracts_test_taxonomy.py | 49 ++++++++++++- ...install_base_integrator_and_contraction.py | 33 +++++++++ .../test_architecture_dependency_reference.py | 73 +++++++++++++++++++ ...est_architecture_skill_provenance_scope.py | 69 ++++++++++++++++++ 5 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 tests/integration/test_architecture_skill_provenance_scope.py diff --git a/.apm/architecture/owners/contracts-tooling.json b/.apm/architecture/owners/contracts-tooling.json index 31ef286093..af718251b1 100644 --- a/.apm/architecture/owners/contracts-tooling.json +++ b/.apm/architecture/owners/contracts-tooling.json @@ -8,7 +8,10 @@ "selectors": [ "src/apm_cli/models/dependency/identity.py", "src/apm_cli/models/dependency/materialization.py", - "src/apm_cli/models/dependency/reference.py" + "src/apm_cli/models/dependency/reference.py", + "src/apm_cli/install/phases/download.py", + "src/apm_cli/install/phases/integrate.py", + "src/apm_cli/install/plan.py" ], "guards": ["contracts-tooling-dependency-identity", "contracts-tooling-policy-identity"] }, diff --git a/scripts/architecture_linter/checks/contracts_test_taxonomy.py b/scripts/architecture_linter/checks/contracts_test_taxonomy.py index 785c8a4064..c98955dd4a 100644 --- a/scripts/architecture_linter/checks/contracts_test_taxonomy.py +++ b/scripts/architecture_linter/checks/contracts_test_taxonomy.py @@ -457,6 +457,15 @@ def check_taxonomy_classification(provider: FactsProvider) -> tuple[Violation, . _RESOLVE_PHASE = "src/apm_cli/install/phases/resolve.py" +_DOWNLOAD_PHASE = "src/apm_cli/install/phases/download.py" + + +_INTEGRATE_PHASE = "src/apm_cli/install/phases/integrate.py" + + +_INSTALL_PLAN = "src/apm_cli/install/plan.py" + + def check_dependency_identity(provider: FactsProvider) -> tuple[Violation, ...]: """Guard dependency identity, materialization, and embedded-subpath ownership.""" rule_id = _GUARD_DEPENDENCY_IDENTITY @@ -464,7 +473,18 @@ def check_dependency_identity(provider: FactsProvider) -> tuple[Violation, ...]: materialization, mat_fail = _facts_for(provider, _MATERIALIZATION_OWNER, rule_id) reference, ref_fail = _facts_for(provider, _REFERENCE_OWNER, rule_id) resolve, resolve_fail = _facts_for(provider, _RESOLVE_PHASE, rule_id) - failures = list(identity_fail) + list(mat_fail) + list(ref_fail) + list(resolve_fail) + download, download_fail = _facts_for(provider, _DOWNLOAD_PHASE, rule_id) + integrate, integrate_fail = _facts_for(provider, _INTEGRATE_PHASE, rule_id) + plan, plan_fail = _facts_for(provider, _INSTALL_PLAN, rule_id) + failures = ( + list(identity_fail) + + list(mat_fail) + + list(ref_fail) + + list(resolve_fail) + + list(download_fail) + + list(integrate_fail) + + list(plan_fail) + ) if failures: return tuple(failures) @@ -528,6 +548,33 @@ def check_dependency_identity(provider: FactsProvider) -> tuple[Violation, ...]: embedded_subpath_message, ) ) + install_path_message = "Install phase materialization paths must route through DependencyReference.get_install_path" + download_body = _awk_body(download, re.compile(r"^def run\("), re.compile(r"^def ")) + if not _body_has(download_body, "_pd_path = _pd_ref.get_install_path(apm_modules_dir)") or ( + _body_has(download_body, "apm_modules_dir / _pd_ref.alias") + or _body_has(download_body, "if _pd_ref.alias") + ): + findings.append(_summary(rule_id, _DOWNLOAD_PHASE, install_path_message)) + + integrate_body = _awk_body(integrate, re.compile(r"^def run\("), re.compile(r"^def ")) + if not _body_has( + integrate_body, "install_path = dep_ref.get_install_path(apm_modules_dir)" + ) or ( + _body_has(integrate_body, "apm_modules_dir / dep_ref.alias") + or _body_has(integrate_body, "if dep_ref.alias") + ): + findings.append(_summary(rule_id, _INTEGRATE_PHASE, install_path_message)) + + frozen_identity_message = "Frozen manifest drift checks must route through full-SHA comparison and drift.detect_ref_change" + plan_body = _awk_body( + plan, re.compile(r"^def lockfile_satisfies_manifest\("), re.compile(r"^def ") + ) + if ( + not _body_has(plan_body, "is_full_revision_pin(reference)") + or not _body_has(plan_body, "detect_ref_change(dep, locked_dep)") + or not _body_has(plan_body, "locked_dep.resolved_commit") + ): + findings.append(_summary(rule_id, _INSTALL_PLAN, frozen_identity_message)) primitive_dirs = re.compile(r"_APM_PRIMITIVE_DIRS") for path in _python_paths(provider, _SRC_PREFIX): if path == _REFERENCE_OWNER: diff --git a/scripts/architecture_linter/checks/install_base_integrator_and_contraction.py b/scripts/architecture_linter/checks/install_base_integrator_and_contraction.py index 59e0936ebe..169a80c5bf 100644 --- a/scripts/architecture_linter/checks/install_base_integrator_and_contraction.py +++ b/scripts/architecture_linter/checks/install_base_integrator_and_contraction.py @@ -283,6 +283,9 @@ def check_target_file_contraction(provider: FactsProvider) -> tuple[Violation, . } +_SKILL_INTEGRATOR = "src/apm_cli/integration/skill_integrator.py" + + _OWNED_STATE_FIELDS = frozenset( { "deployed_files", @@ -417,6 +420,35 @@ def report(node: ast.AST, message: str) -> None: return findings +def _skill_lockfile_root_findings(provider: FactsProvider, rule_id: str) -> list[Violation]: + """User-scope skill ownership must read provenance from the canonical lockfile root.""" + facts, fail = _facts_for(provider, _SKILL_INTEGRATOR, rule_id) + if fail: + return list(fail) + + route_message = "user-scope skill ownership must route lockfile provenance through get_apm_dir(InstallScope.USER)" + forward_message = "skill ownership consumers must forward the canonical lockfile_root through every ownership-map integration path" + required_import = "from apm_cli.core.scope import InstallScope, get_apm_dir" + required_route = ( + "get_apm_dir(InstallScope.USER) if scope is InstallScope.USER else project_root" + ) + required_forward_count = 4 + findings: list[Violation] = [] + if not _present(facts, required_import) or not _present(facts, required_route): + findings.append(_summary(rule_id, _SKILL_INTEGRATOR, route_message)) + if not _present(facts, "LockFile.read(get_lockfile_path(lockfile_root))"): + findings.append( + _summary( + rule_id, + _SKILL_INTEGRATOR, + "skill ownership maps must resolve deployment provenance from the selected lockfile_root", + ) + ) + if sum("lockfile_root=lockfile_root" in line for line in facts.lines) != required_forward_count: + findings.append(_summary(rule_id, _SKILL_INTEGRATOR, forward_message)) + return findings + + def _owned_state_attr(node: ast.AST) -> bool: """Return whether one AST node references codec-owned deployment state.""" return isinstance(node, ast.Attribute) and node.attr in _OWNED_STATE_FIELDS @@ -727,6 +759,7 @@ def check_provenance_state(provider: FactsProvider) -> tuple[Violation, ...]: findings: list[Violation] = [] for path in _REQUIRED_OWNER_CALLS: findings.extend(_deployment_owner_findings(provider, path, rule_id)) + findings.extend(_skill_lockfile_root_findings(provider, rule_id)) findings.extend(_legacy_scope_findings(provider, rule_id)) findings.extend(_state_mutation_findings(provider, rule_id)) findings.extend(_local_bundle_findings(provider, rule_id)) diff --git a/tests/integration/test_architecture_dependency_reference.py b/tests/integration/test_architecture_dependency_reference.py index e472905931..83ab2b38d1 100644 --- a/tests/integration/test_architecture_dependency_reference.py +++ b/tests/integration/test_architecture_dependency_reference.py @@ -11,6 +11,9 @@ ROOT = Path(__file__).parents[2] RULE_ID = "contracts-tooling-dependency-identity" REFERENCE = "src/apm_cli/models/dependency/reference.py" +DOWNLOAD = "src/apm_cli/install/phases/download.py" +INTEGRATE = "src/apm_cli/install/phases/integrate.py" +PLAN = "src/apm_cli/install/plan.py" def test_embedded_git_url_subpath_has_one_provider_aware_owner() -> None: @@ -20,12 +23,18 @@ def test_embedded_git_url_subpath_has_one_provider_aware_owner() -> None: encoding="utf-8" ) rule = next(rule for rule in registered_rules() if rule.id == RULE_ID) + report = run_selected_rules(ROOT, (RULE_ID,)) assert reference.count("def _check_no_embedded_subpath(") == 1 assert "classify_host_provider(host, host_type=host_type)" in reference assert 'provider.kind == "gitlab"' in reference + assert DOWNLOAD in owner_registry + assert INTEGRATE in owner_registry + assert PLAN in owner_registry assert "embedded git URL subpath validation" in owner_registry assert rule.guard_ids == (RULE_ID,) + assert report.failures == () + assert report.violations == () def test_embedded_git_url_subpath_guard_rejects_provider_bypass() -> None: @@ -44,3 +53,67 @@ def test_embedded_git_url_subpath_guard_rejects_provider_bypass() -> None: violation.rule_id == RULE_ID and "Embedded git URL subpath validation" in violation.message for violation in report.violations ) + + +@pytest.mark.parametrize( + ("path", "old", "new"), + [ + ( + DOWNLOAD, + "_pd_path = _pd_ref.get_install_path(apm_modules_dir)", + "_pd_path = (apm_modules_dir / _pd_ref.alias) if _pd_ref.alias else _pd_ref.get_install_path(apm_modules_dir)", + ), + ( + INTEGRATE, + "install_path = dep_ref.get_install_path(apm_modules_dir)", + "install_path = (apm_modules_dir / dep_ref.alias) if dep_ref.alias else dep_ref.get_install_path(apm_modules_dir)", + ), + ], +) +def test_install_phase_materialization_guard_rejects_alias_path_bypass( + path: str, old: str, new: str +) -> None: + """Download/integrate must not recompute materialization paths from alias branches.""" + source = (ROOT / path).read_text(encoding="utf-8") + mutated = source.replace(old, new, 1) + assert mutated != source + + report = run_selected_rules( + ROOT, + (RULE_ID,), + source_overrides={path: mutated}, + ) + + assert any( + violation.rule_id == RULE_ID + and "Install phase materialization paths must route through DependencyReference.get_install_path" + in violation.message + for violation in report.violations + ) + + +@pytest.mark.parametrize( + ("old", "new"), + [ + ("is_full_revision_pin(reference)", "False"), + ("detect_ref_change(dep, locked_dep)", "False"), + ], +) +def test_frozen_manifest_identity_guard_rejects_helper_bypass(old: str, new: str) -> None: + """Frozen plan drift checks must keep both the full-SHA and drift helper routes.""" + source = (ROOT / PLAN).read_text(encoding="utf-8") + mutated = source.replace(old, new, 1) + assert mutated != source + + report = run_selected_rules( + ROOT, + (RULE_ID,), + source_overrides={PLAN: mutated}, + ) + + assert any( + violation.rule_id == RULE_ID + and "Frozen manifest drift checks must route through full-SHA comparison and drift.detect_ref_change" + in violation.message + for violation in report.violations + ) diff --git a/tests/integration/test_architecture_skill_provenance_scope.py b/tests/integration/test_architecture_skill_provenance_scope.py new file mode 100644 index 0000000000..d826b9988e --- /dev/null +++ b/tests/integration/test_architecture_skill_provenance_scope.py @@ -0,0 +1,69 @@ +"""Architecture regression for user-scope skill provenance routing.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scripts.architecture_linter.runner import registered_rules, run_selected_rules + +pytestmark = pytest.mark.component + +ROOT = Path(__file__).resolve().parents[2] +RULE_ID = "install-deployment-provenance-state" +SKILL_INTEGRATOR = "src/apm_cli/integration/skill_integrator.py" + + +def test_user_scope_skill_provenance_guard_is_registered_and_clean() -> None: + """The provenance guard includes skill_integrator's user-scope lockfile route.""" + rule = next(rule for rule in registered_rules() if rule.id == RULE_ID) + + assert rule.guard_ids == (RULE_ID,) + report = run_selected_rules(ROOT, (RULE_ID,)) + assert report.failures == () + assert report.violations == () + + +def test_user_scope_skill_provenance_guard_rejects_project_root_bypass() -> None: + """User-scope skill ownership must not read provenance from the project lockfile.""" + source = (ROOT / SKILL_INTEGRATOR).read_text(encoding="utf-8") + mutated = source.replace( + "get_apm_dir(InstallScope.USER) if scope is InstallScope.USER else project_root", + "project_root", + 1, + ) + assert mutated != source + + report = run_selected_rules( + ROOT, + (RULE_ID,), + source_overrides={SKILL_INTEGRATOR: mutated}, + ) + + assert any( + violation.rule_id == RULE_ID + and "user-scope skill ownership must route lockfile provenance through get_apm_dir(InstallScope.USER)" + in violation.message + for violation in report.violations + ) + + +def test_user_scope_skill_provenance_guard_rejects_missing_forwarding() -> None: + """Every ownership-map integration path must receive the selected lockfile_root.""" + source = (ROOT / SKILL_INTEGRATOR).read_text(encoding="utf-8") + mutated = source.replace("lockfile_root=lockfile_root,", "", 1) + assert mutated != source + + report = run_selected_rules( + ROOT, + (RULE_ID,), + source_overrides={SKILL_INTEGRATOR: mutated}, + ) + + assert any( + violation.rule_id == RULE_ID + and "skill ownership consumers must forward the canonical lockfile_root through every ownership-map integration path" + in violation.message + for violation in report.violations + ) From 171a3ecbe2a35fdb1b93c9cbf6554fabd18347db Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Mon, 7 Sep 2026 19:11:17 +0200 Subject: [PATCH 08/15] docs(conformance): record frozen manifest pin requirement coverage Regenerate the conformance statements after adding the req-lk-003 frozen full-SHA equality/refusal test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CONFORMANCE.json | 3 ++- CONFORMANCE.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CONFORMANCE.json b/CONFORMANCE.json index b2fbc74e4d..7aede305bd 100644 --- a/CONFORMANCE.json +++ b/CONFORMANCE.json @@ -86,8 +86,9 @@ "keyword": "MUST", "section": "5.2", "status": "active", - "test_count": 2, + "test_count": 3, "tests": [ + "tests/spec_conformance/test_lockfile_reqs.py::test_frozen_manifest_pin_requires_the_exact_locked_commit", "tests/spec_conformance/test_lockfile_reqs.py::test_full_sha_pin_audit_rejects_resolved_commit_mismatch", "tests/spec_conformance/test_lockfile_reqs.py::test_lockfile_carries_dependencies_block" ] diff --git a/CONFORMANCE.md b/CONFORMANCE.md index a6c0ce79fd..d301bd497e 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -43,7 +43,7 @@ Repository-coordinate segments are case-insensitive for `github.com`, GitHub Ent | [req-ext-002](docs/src/content/docs/specs/openapm-v0.1.md#req-ext-002) | MUST | 4.1 | producer | active | 1 | - | | [req-lk-001](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-001) | MUST | 5.1 | consumer | active | 1 | - | | [req-lk-002](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-002) | MUST | 5.4 | consumer | active | 1 | - | -| [req-lk-003](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-003) | MUST | 5.2 | consumer | active | 2 | - | +| [req-lk-003](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-003) | MUST | 5.2 | consumer | active | 3 | - | | [req-lk-004](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-004) | MUST | 5.4 | consumer | active | 1 | - | | [req-lk-005](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-005) | MUST | 5.5 | consumer | active | 2 | - | | [req-lk-006](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-006) | MUST | 5.5 | consumer | active | 1 | - | From 7b32b6beec7c81b2a9bbbd0b1a7208dab169901e Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 30 Sep 2026 12:33:13 +0200 Subject: [PATCH 09/15] fix: qualify standalone HOME-alias lifecycle repair Adapt matching-lock freshness and provider-scoped seeds from de82061b7f66fe149ef240575d16165ca3ab631d and e30e4e59ef90f679309334ba87c4173ecbcbe09b; transport-before-semver from 843386ea1073373f95d5d8bc588fb057cd8747ab; connected real-tag trajectory from 60a9a5ca85829d0f83989f5aa5fc5b04cb0ae815. Adapt integration-phase ownership snapshots from 40c53a8ae1cdd0ca8ae675e5ea7c7770ff123461 while retaining current-main destination collision authority. Reuse narrowly scoped epoch, alias and receipt tests with provenance in the approved standalone plan. Native evidence mechanics adapted from be73ed139f3a73c19437a895dfd6d68e41df8bfc without restoring the closed governance stack. Fix the alias reader defect exposed by retained global lifecycle witnesses through existing placement authority. Original published repair and authorship remain in merge ancestry. No historical qualification results are reused. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../owners/contracts-tooling.json | 18 + .../owners/install-deployment.json | 8 +- .../owners/transport-auth-platform.json | 11 +- CHANGELOG.md | 2 +- .../docs/contributing/integration-testing.md | 35 + docs/src/content/docs/reference/cli/deps.md | 4 + .../src/content/docs/reference/cli/install.md | 12 + .../.apm/skills/apm-usage/commands.md | 14 +- .../.apm/skills/apm-usage/dependencies.md | 15 +- .../checks/contracts_test_taxonomy.py | 304 ++++++ ...install_base_integrator_and_contraction.py | 67 +- .../checks/repository_cache_identity.py | 130 ++- .../checks/transport_auth_platform.py | 90 +- .../checks/transport_cache_identity.py | 18 + .../checks/transport_network_and_runtime.py | 2 + scripts/check_lifecycle_evidence.py | 349 +++++++ scripts/lifecycle_contracts.py | 282 ++++++ src/apm_cli/commands/deps/cli.py | 31 +- src/apm_cli/commands/install.py | 3 +- src/apm_cli/core/host_providers.py | 10 + src/apm_cli/deps/tiered_ref_resolver.py | 58 +- src/apm_cli/drift.py | 49 +- src/apm_cli/install/helpers/ref_seed.py | 10 +- src/apm_cli/install/pipeline.py | 17 +- src/apm_cli/install/plan.py | 3 +- src/apm_cli/integration/skill_integrator.py | 90 +- src/apm_cli/integration/skill_support.py | 21 + tests/fixtures/lifecycle_bug_ledger.json | 243 +++++ .../fixtures/lifecycle_completion.schema.json | 37 + ...test_architecture_cache_identity_linter.py | 17 +- .../test_architecture_dependency_reference.py | 16 + ...est_architecture_host_provider_identity.py | 76 ++ ..._architecture_native_lifecycle_evidence.py | 141 +++ .../test_architecture_owner_rule_mutations.py | 8 + .../test_architecture_ref_freshness.py | 33 + ...est_architecture_skill_provenance_scope.py | 54 ++ .../test_frozen_host_qualified_git_e2e.py | 109 ++- .../test_generated_lifecycle_state_machine.py | 76 +- .../test_ref_freshness_lifecycle.py | 36 +- .../test_required_lifecycle_state_machine.py | 144 ++- tests/unit/cache/test_git_cache.py | 35 + .../unit/commands/test_deps_alias_readers.py | 104 ++ tests/unit/core/test_host_providers.py | 21 +- .../deps/test_github_downloader_phase3.py | 120 +++ tests/unit/deps/test_tiered_ref_resolver.py | 132 ++- .../test_pipeline_ownership_snapshot.py | 508 ++++++++++ tests/unit/install/test_plan.py | 85 ++ .../test_skill_ownership_snapshot.py | 572 +++++++++++ .../unit/scripts/test_architecture_runner.py | 1 + tests/unit/scripts/test_lifecycle_evidence.py | 901 ++++++++++++++++++ tests/unit/test_drift_detection.py | 54 +- tests/unit/test_install_command.py | 6 +- tests/utils/isolated_apm_environment.py | 9 +- tests/utils/lifecycle_evidence.py | 303 ++++++ 54 files changed, 5319 insertions(+), 175 deletions(-) create mode 100644 scripts/check_lifecycle_evidence.py create mode 100644 scripts/lifecycle_contracts.py create mode 100644 tests/fixtures/lifecycle_completion.schema.json create mode 100644 tests/integration/test_architecture_host_provider_identity.py create mode 100644 tests/integration/test_architecture_native_lifecycle_evidence.py create mode 100644 tests/integration/test_architecture_ref_freshness.py create mode 100644 tests/unit/commands/test_deps_alias_readers.py create mode 100644 tests/unit/install/test_pipeline_ownership_snapshot.py create mode 100644 tests/unit/integration/test_skill_ownership_snapshot.py create mode 100644 tests/unit/scripts/test_lifecycle_evidence.py create mode 100644 tests/utils/lifecycle_evidence.py diff --git a/.apm/architecture/owners/contracts-tooling.json b/.apm/architecture/owners/contracts-tooling.json index 81d1b5ff26..a0be2692e4 100644 --- a/.apm/architecture/owners/contracts-tooling.json +++ b/.apm/architecture/owners/contracts-tooling.json @@ -25,12 +25,30 @@ "src/apm_cli/models/dependency/object_fields.py", "src/apm_cli/models/dependency/reference.py", "src/apm_cli/deps/apm_resolver.py", + "src/apm_cli/commands/deps/cli.py", "src/apm_cli/install/phases/download.py", "src/apm_cli/install/phases/integrate.py", "src/apm_cli/install/plan.py" ], "guards": ["contracts-tooling-dependency-identity", "contracts-tooling-policy-identity"] }, + { + "id": "native-lifecycle-evidence", + "decision": "Bounded #2867 fresh source-Python lifecycle evidence, immutable witness bindings and candidate-bound native completion; not repository shipping policy", + "owner": "scripts/lifecycle_contracts.py (authored obligations and execution validation) + scripts/check_lifecycle_evidence.py (candidate identity and completion) + tests/utils/lifecycle_evidence.py (fresh runner/Hypothesis observation)", + "selectors": [ + "scripts/check_lifecycle_evidence.py", + "scripts/lifecycle_contracts.py", + "tests/utils/lifecycle_evidence.py", + "tests/utils/apm_lifecycle_runner.py", + "tests/utils/isolated_apm_environment.py", + "tests/fixtures/lifecycle_bug_ledger.json", + "tests/fixtures/lifecycle_completion.schema.json", + "tests/unit/scripts/test_lifecycle_evidence.py", + "tests/integration/test_architecture_native_lifecycle_evidence.py" + ], + "guards": ["contracts-tooling-native-lifecycle-evidence"] + }, { "id": "generated-python-artifact-membership", "decision": "Generated Python bytecode membership across copy, inventory, and cleanup", diff --git a/.apm/architecture/owners/install-deployment.json b/.apm/architecture/owners/install-deployment.json index 62edeefbb5..746f2b5f18 100644 --- a/.apm/architecture/owners/install-deployment.json +++ b/.apm/architecture/owners/install-deployment.json @@ -32,8 +32,12 @@ { "id": "deployment-provenance-state", "decision": "Deployment provenance / state", - "owner": "deployment_ledger.py", - "selectors": ["src/apm_cli/core/deployment_ledger.py"], + "owner": "deployment_ledger.py; read-only index: skill_support.build_skill_ownership_maps, exposed by SkillIntegrator._build_ownership_maps with integrate-phase snapshot lifetime", + "selectors": [ + "src/apm_cli/core/deployment_ledger.py", + "src/apm_cli/integration/skill_support.py", + "src/apm_cli/install/pipeline.py" + ], "guards": ["install-deployment-provenance-state"] }, { diff --git a/.apm/architecture/owners/transport-auth-platform.json b/.apm/architecture/owners/transport-auth-platform.json index 82e77772ca..21bee24007 100644 --- a/.apm/architecture/owners/transport-auth-platform.json +++ b/.apm/architecture/owners/transport-auth-platform.json @@ -27,11 +27,12 @@ }, { "id": "host-credential-resolution", - "decision": "Host + credential resolution", - "owner": "core/auth.py (AuthResolver), core/host_providers.py", + "decision": "Host + credential resolution and effective provider identity consumed by dependency drift and dependency-scoped lock seeds", + "owner": "core/auth.py (AuthResolver), core/host_providers.py (effective_host_provider_identity); consumers: drift.py and tiered_ref_resolver.py", "selectors": [ "src/apm_cli/core/auth.py", - "src/apm_cli/core/host_providers.py" + "src/apm_cli/core/host_providers.py", + "src/apm_cli/drift.py" ], "guards": [ "transport-platform-ado-validation-bearer-fallback", @@ -135,8 +136,8 @@ }, { "id": "git-ref-freshness", - "decision": "Git ref freshness and cache eligibility", - "owner": "deps/tiered_ref_resolver.py (RefFreshnessPolicy)", + "decision": "Git ref freshness, cache eligibility, and dependency-scoped lock-seed identity", + "owner": "deps/tiered_ref_resolver.py (RefFreshnessPolicy, _lock_seed_key consuming effective_host_provider_identity)", "selectors": ["src/apm_cli/deps/tiered_ref_resolver.py"], "guards": ["transport-platform-ref-freshness"] }, diff --git a/CHANGELOG.md b/CHANGELOG.md index a9383f90cb..49e2f4e229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Global skill installs now deploy authorized files when `HOME` or `APM_HOME` uses a directory symlink alias, without changing environment settings or accepting symlinks inside packages. (by @DaveMeadAdjust, #2876) +- Global skill installs work through `HOME`/`APM_HOME` directory aliases without relaxing package or destination safety; frozen installs reject ref, pin, provider and transport drift, and unseeded mutable refs resolve upstream. After an intentional declaration change, review it and run `apm install --update`. (by @DaveMeadAdjust, #2876) ### Added - Codex MCP headers now use native runtime environment references instead of literal placeholders; unsupported references are skipped with a warning. Unchanged reinstalls preserve existing entries; switch the affected header between `${VAR}` and `${env:VAR}` and reinstall with the same scope and targets to refresh an older managed entry. -- by @edenfunf (#3042) diff --git a/docs/src/content/docs/contributing/integration-testing.md b/docs/src/content/docs/contributing/integration-testing.md index da36dde54f..8c056f4da3 100644 --- a/docs/src/content/docs/contributing/integration-testing.md +++ b/docs/src/content/docs/contributing/integration-testing.md @@ -62,6 +62,41 @@ APM uses a tiered approach to integration testing: ## Running Tests Locally +### Exact-head HOME-alias lifecycle qualification + +The #2867 contract uses real CLI subprocesses in isolated project, canonical-home +and aliased-home fixtures. Its eight required/generated/replay witnesses retain +the mandatory lifecycle inside Hypothesis execution; an additional project +scenario checks semver transport refusal and recovery. Ownership snapshot tests +also enforce one lockfile read per used root and integration phase, zero unused +reads, and at most 15x record visits when fixture size grows from N to 10N. +These counts do not claim a wall-clock speedup. + +Use a clean committed checkout and put reports outside it: + +```bash +uv run --frozen --extra dev python scripts/check_lifecycle_evidence.py \ + --base "$BASE_SHA" --head "$(git rev-parse HEAD)" --lane full \ + --report "$REPORT_PATH" --completion-output "$COMPLETION_PATH" +``` + +Both output paths must be new and outside the checkout. The command emits the +schema-validated native completion sidecar only after fresh success. +It verifies source/interpreter identity and freshly executes the authored +contract; a skipped witness or disconnected sequence is not acceptance. +It is source-Python coverage, not packaged-binary parity. Ordinary hosted +platform checks remain separate. + +For independent acceptance, use another clean checkout of the same head and +its own interpreter and disposable environment. Pass the driver's native +completion sidecar with `--completion "$DRIVER_COMPLETION_PATH"` and a different +`--report` path. Completion mode verifies the driver's report digest and +reruns the contract; it does not reuse that report as execution. Keep this +native sidecar separate from the merge-worker completion JSON, whose schema +is unchanged. + +### Selecting integration tests + Integration tests live under `tests/integration/` and run via `pytest` directly. Each test module declares the preconditions it needs as standard pytest markers; the registry in diff --git a/docs/src/content/docs/reference/cli/deps.md b/docs/src/content/docs/reference/cli/deps.md index 3e9725b6f4..2170fbf0df 100644 --- a/docs/src/content/docs/reference/cli/deps.md +++ b/docs/src/content/docs/reference/cli/deps.md @@ -38,6 +38,10 @@ installed package's source tree are parent-owned content, not separate dependencies. Real lockfile-resolved dependencies install at their own package roots and remain visible regardless of graph depth. +Declared or locked aliases are found at `apm_modules/` and displayed +under their logical dependency identity. The selected project or user store +remains authoritative; listing does not search another store to fill a gap. + Local dependencies are shown as portable `_local/` keys rather than machine-specific absolute paths. For a direct local declaration with matching `apm.lock.yaml` metadata, copy that key into `apm uninstall` (with `-g` for user diff --git a/docs/src/content/docs/reference/cli/install.md b/docs/src/content/docs/reference/cli/install.md index 04437963a0..31415a52d0 100644 --- a/docs/src/content/docs/reference/cli/install.md +++ b/docs/src/content/docs/reference/cli/install.md @@ -116,6 +116,18 @@ in `apm.yml`, then run `apm install` again. ## Behavior +- **Lock replay and freshness.** A matching dependency, declared ref and host + provider reuse the locked commit. A mutable ref without a matching lock entry + resolves upstream, even if another package or sibling path is locked. + `--frozen` rejects changed refs, full commit pins, host providers and HTTP/HTTPS + transport before the pipeline, including transport changes under an unchanged + semver range. Review the change and run `apm install --update` to refresh the + lock. Unchanged frozen installs do not check whether an upstream branch moved. +- **Home aliases.** Global skill deployment works when `HOME` or `APM_HOME` + names a directory symlink. This does not permit escaping package descendants + or symlinked skill destinations. Explicit dependency `alias:` placement is a + separate feature; unrelated user files remain protected. + - **Immutable dependency conflicts.** Install fails when two paths require different immutable commits for one package identity, showing both paths and requested refs as ordered `owner/repo@ref -> owner/repo@ref` chains. diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 679bada8d1..fad0cfcad9 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -19,13 +19,17 @@ replay without ref discovery; new names may require a lookup. Align manifest refs and regenerate the lockfile with a normal install to repair a collapsed graph; `--force` does not bypass this check. +`apm deps list` recognizes declared or locked aliases at their canonical +install paths and displays logical dependency identities, including in user +scope. It reads only the selected store; it does not substitute another store. + | Command | Purpose | Key flags | |---------|---------|-----------| -| `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; dependency aliases materialize at the canonical install path, not duplicate noncanonical paths; plain/frozen installs replay locked refs and cache state, and a mutable branch install with no lockfile resolves current upstream instead of a stale bare-cache ref; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` CI-safe install that fails before any durable write when `apm.lock.yaml` is missing or no longer matches the manifest-declared refs/pins in `apm.yml`, including MCP config state (structural lockfile check, not network freshness; mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; positional packages and ref changes are previewed without changing an existing `apm.yml`; a newly bootstrapped manifest and explicit targets are kept), `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use explicit target `hermes` to deploy skills and home-scoped MCP servers to `$HERMES_HOME/config.yaml` (or `~/.hermes/config.yaml` when unset or blank); run `apm compile` separately for `AGENTS.md`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when selected explicitly), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or source-relative path under skills/; a CLI name or path that matches no declared skill fails before manifest or lockfile commit with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. | -| `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; plain/frozen installs replay locked refs and cache state, while `--update` and `--refresh` require current upstream mutable refs; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` CI-safe install that fails before any durable write when `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP config state (mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; positional packages and ref changes are previewed without changing an existing `apm.yml`; a newly bootstrapped manifest and explicit targets are kept), `--verbose`, `--only [apm\|mcp]` (APM packages or MCP/LSP service dependencies), `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or source-relative path under skills/; a CLI name or path that matches no declared skill fails before manifest or lockfile commit with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. | -| `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; plain/frozen installs replay locked refs and cache state, while `--update` and `--refresh` require current upstream mutable refs; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` CI-safe install that fails before any durable write when `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP config state (mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; positional packages and ref changes are previewed without changing an existing `apm.yml`; project bootstrap keeps its new manifest and explicit targets, while absent global `-g` bootstrap uses temporary preview state and does not create `~/.apm`), `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or source-relative path under skills/; a CLI name or path that matches no declared skill fails before manifest or lockfile commit with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. | -| `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; plain/frozen installs replay locked refs and cache state, while `--update` and `--refresh` require current upstream mutable refs; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` CI-safe install that fails before any durable write when `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP config state (mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; positional packages and ref changes are previewed without changing an existing `apm.yml`; project bootstrap keeps its new manifest and explicit targets, while absent global `-g` bootstrap uses temporary preview state and does not create `~/.apm`), `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them, except absent global dry-runs only preview targets in temporary state; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or source-relative path under skills/; a CLI name or path that matches no declared skill fails before manifest or lockfile commit with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. | -| `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; plain/frozen installs replay locked refs and cache state, while `--update` and `--refresh` require current upstream mutable refs; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` CI-safe install that fails before any durable write when `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP config state (mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; positional packages and ref changes are previewed without changing an existing `apm.yml`; project bootstrap keeps its new manifest and explicit targets, while absent global `-g` bootstrap uses temporary preview state and does not create `~/.apm`), `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them, except absent global dry-runs only preview targets in temporary state; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or source-relative path under skills/; a CLI name or path that matches no declared skill fails before manifest or lockfile commit with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; direct `--mcp` additions are project-scoped and are not valid with `-g`/`--global`), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. | +| `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; dependency aliases materialize at the canonical install path, not duplicate noncanonical paths; plain/frozen installs replay matching dependency and provider lock entries, and a mutable ref without a matching lock entry resolves upstream instead of stale bare-cache refs, including with unrelated or sibling lock entries; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` structural install check that rejects before the pipeline when `apm.lock.yaml` is missing or no longer matches the manifest-declared refs, full commit pins, host providers or HTTP/HTTPS transport in `apm.yml`, including MCP config state (structural lockfile check, not network freshness; mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; positional packages and ref changes are previewed without changing an existing `apm.yml`; a newly bootstrapped manifest and explicit targets are kept), `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use explicit target `hermes` to deploy skills and home-scoped MCP servers to `$HERMES_HOME/config.yaml` (or `~/.hermes/config.yaml` when unset or blank); run `apm compile` separately for `AGENTS.md`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when selected explicitly), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or source-relative path under skills/; a CLI name or path that matches no declared skill fails before manifest or lockfile commit with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. | +| `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; plain/frozen installs replay matching dependency and provider lock entries, while `--update` and `--refresh` require current upstream mutable refs; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` structural install check that rejects before the pipeline when `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP config state (mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; positional packages and ref changes are previewed without changing an existing `apm.yml`; a newly bootstrapped manifest and explicit targets are kept), `--verbose`, `--only [apm\|mcp]` (APM packages or MCP/LSP service dependencies), `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or source-relative path under skills/; a CLI name or path that matches no declared skill fails before manifest or lockfile commit with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. | +| `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; plain/frozen installs replay matching dependency and provider lock entries, while `--update` and `--refresh` require current upstream mutable refs; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` structural install check that rejects before the pipeline when `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP config state (mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; positional packages and ref changes are previewed without changing an existing `apm.yml`; project bootstrap keeps its new manifest and explicit targets, while absent global `-g` bootstrap uses temporary preview state and does not create `~/.apm`), `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or source-relative path under skills/; a CLI name or path that matches no declared skill fails before manifest or lockfile commit with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. | +| `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; plain/frozen installs replay matching dependency and provider lock entries, while `--update` and `--refresh` require current upstream mutable refs; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` structural install check that rejects before the pipeline when `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP config state (mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; positional packages and ref changes are previewed without changing an existing `apm.yml`; project bootstrap keeps its new manifest and explicit targets, while absent global `-g` bootstrap uses temporary preview state and does not create `~/.apm`), `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them, except absent global dry-runs only preview targets in temporary state; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or source-relative path under skills/; a CLI name or path that matches no declared skill fails before manifest or lockfile commit with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. | +| `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; plain/frozen installs replay matching dependency and provider lock entries, while `--update` and `--refresh` require current upstream mutable refs; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` structural install check that rejects before the pipeline when `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP config state (mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; positional packages and ref changes are previewed without changing an existing `apm.yml`; project bootstrap keeps its new manifest and explicit targets, while absent global `-g` bootstrap uses temporary preview state and does not create `~/.apm`), `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them, except absent global dry-runs only preview targets in temporary state; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or source-relative path under skills/; a CLI name or path that matches no declared skill fails before manifest or lockfile commit with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; direct `--mcp` additions are project-scoped and are not valid with `-g`/`--global`), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. | | `apm targets` | Show resolved deployment targets for the current project (Click group; reads filesystem signals; works with or without `apm.yml`) | `--all` also include the `agent-skills` meta-target (only meaningful with `--json`), `--json` machine-readable output. No provenance line is printed (the table is the provenance). | | `apm uninstall PKGS...` | Remove packages; identifier selection is atomic. Accepts `owner/repo`, `name@marketplace`, exact declared local paths, or portable `_local/` keys for direct local declarations with matching lock metadata. A missing or ambiguous identifier exits nonzero before scripts or APM writes. MCP cleanup uses recorded target ownership, attempts every owning runtime, and exits nonzero if any target fails. | `--dry-run`, `-g` global | | `apm uninstall PKGS...` | Remove packages and reconcile their tracked files, MCP servers, and LSP servers; identifier selection is atomic. Accepts `owner/repo`, `name@marketplace`, exact declared local paths, or portable `_local/` keys for direct local declarations with matching lock metadata. A missing or ambiguous identifier exits nonzero before scripts or APM writes. If safe LSP cleanup fails after package removal, the command exits nonzero and preserves the conflicting config; repair it, then run `apm install` (or `apm install --global` for user scope). | `--dry-run`, `-g` global | diff --git a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md index 2a66d2bbb5..87bfa16ea8 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md +++ b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md @@ -696,12 +696,23 @@ enterprise security guide for the threat model. ## What the lockfile pins `apm.lock.yaml` records the exact commit SHA for every dependency, regardless -of the ref format in apm.yml. Running `apm install` without `--update` always -uses the locked SHA, ensuring reproducible installs across machines. +of the ref format in apm.yml. Running `apm install` without `--update` reuses +the locked SHA when the dependency identity, declared ref and effective host +provider match. An unseeded mutable ref resolves upstream even when another +package or sibling path has a lock entry. `apm install --update`, `apm install --refresh`, `apm update` (including `--force`), `apm lock --update`, and `apm outdated` establish mutable refs from upstream instead of using a persistent bare-cache ref as current-state evidence. +`--frozen` checks declared refs, full commit pins, host providers and HTTP/HTTPS +transport against the lock, including transport changes under an unchanged +semver range. It does not check whether an upstream branch moved. Review an +intentional declaration change and run `apm install --update` to refresh the lock. + +Directory symlink aliases in `HOME` or `APM_HOME` work for global skill deployment. +They do not relax package-descendant containment or destination-symlink checks, +and are unrelated to explicit dependency `alias:` placement. + Lockfile keys keep `github.com` implicit for migration stability while non-default hosts add the lowercased host segment. See the [lockfile spec](https://microsoft.github.io/apm/reference/lockfile-spec/#lockfile-identity-keys) for the full keying rules. diff --git a/scripts/architecture_linter/checks/contracts_test_taxonomy.py b/scripts/architecture_linter/checks/contracts_test_taxonomy.py index 6e19a140bd..a75ab199bf 100644 --- a/scripts/architecture_linter/checks/contracts_test_taxonomy.py +++ b/scripts/architecture_linter/checks/contracts_test_taxonomy.py @@ -86,6 +86,8 @@ _GUARD_GENERATION_FOOTER = "contracts-tooling-generation-footer" +_GUARD_NATIVE_LIFECYCLE = "contracts-tooling-native-lifecycle-evidence" + _SRC_PREFIX = "src/apm_cli/" @@ -517,6 +519,10 @@ def check_dependency_identity(provider: FactsProvider) -> tuple[Violation, ...]: "src/apm_cli/install/phases/integrate.py": ( "install_path = dep_ref.get_install_path(apm_modules_dir)", ), + "src/apm_cli/commands/deps/cli.py": ( + "install_path = dep.get_install_path(apm_modules_path)", + "install_path = dep.to_dependency_ref().get_install_path(apm_modules_path)", + ), _RESOLVE_PHASE: ( " cache_validation_callback=partial(", "validate_cached_legacy_plugin,", @@ -641,6 +647,7 @@ def check_dependency_identity(provider: FactsProvider) -> tuple[Violation, ...]: not _body_has(plan_body, "is_full_revision_pin(reference)") or not _body_has(plan_body, "detect_ref_change(dep, locked_dep)") or not _body_has(plan_body, "locked_dep.resolved_commit") + or _body_has(plan_body, "is_insecure") ): findings.append(_summary(rule_id, _INSTALL_PLAN, frozen_identity_message)) primitive_dirs = re.compile(r"_APM_PRIMITIVE_DIRS") @@ -1372,6 +1379,298 @@ def check_project_yaml_write_delegation(provider: FactsProvider) -> tuple[Violat _LIFECYCLE_CONTRACT = "tests/quality/test_ci_topology.py" +_NATIVE_PROVIDER = "scripts/check_lifecycle_evidence.py" +_NATIVE_CONTRACTS = "scripts/lifecycle_contracts.py" +_NATIVE_OBSERVER = "tests/utils/lifecycle_evidence.py" +_NATIVE_RUNNER = "tests/utils/apm_lifecycle_runner.py" + + +def check_native_lifecycle_evidence(provider: FactsProvider) -> tuple[Violation, ...]: + """Guard delegation and proof wiring, not a second lifecycle selection policy.""" + rule_id = _GUARD_NATIVE_LIFECYCLE + bindings = ( + ( + _NATIVE_RUNNER, + "ApmLifecycleRunner._run_with_timeout", + ( + "process.communicate(timeout=timeout_seconds)", + "CommandResult(command=command, returncode=process.returncode, " + "stdout=stdout, stderr=stderr, cwd=cwd)", + ), + ), + ( + _NATIVE_PROVIDER, + "candidate", + ( + "git(root, 'for-each-ref', '--format=%(refname)', 'refs/replace')", + "resolved_head != git(root, 'rev-parse', 'HEAD')", + "git(root, 'status', '--porcelain', '--untracked-files=all')", + "git(root, 'rev-parse', 'HEAD^{tree}')", + "git(root, 'merge-base', '--is-ancestor', resolved_base, resolved_head)", + ), + ), + ( + _NATIVE_PROVIDER, + "source_profile", + ( + "Path(apm_cli.__file__).resolve().parent != source", + "interpreter.samefile(sys.executable)", + "interpreter.parent.resolve() != Path(sys.executable).parent.resolve()", + "fingerprint(source / 'cli.py')", + "fingerprint(Path(sys.executable).resolve())", + ), + ), + ( + _NATIVE_PROVIDER, + "execute", + ( + "candidate_contract(ROOT, identity['base'], inventory)", + "sorted(REQUIRED_WITNESSES)", + "validate_execution(witness, plugin.records[witness['nodeid']])", + "candidate(ROOT, args.base, args.head) != identity", + "source_profile(ROOT)[1] != report['profile']", + "set(plugin.records) != set(REQUIRED_WITNESSES)", + ), + ), + ( + _NATIVE_PROVIDER, + "main", + ( + "report = execute(args)", + "validate_completion(args.completion, report, args.report)", + "completion_summary(report, args.report, raw)", + ), + ), + ( + _NATIVE_PROVIDER, + "_validate_completion_summary", + ( + "Draft202012Validator(schema).iter_errors(summary)", + "set(summary['witnesses']) != set(REQUIRED_WITNESSES)", + "(ROOT / 'tests/fixtures/lifecycle_completion.schema.json').read_text(encoding='utf-8')", + ), + ), + ( + _NATIVE_PROVIDER, + "_completion_input", + ("_validate_completion_summary(summary)", "_same_file(output, driver)"), + ), + ( + _NATIVE_PROVIDER, + "completion_summary", + ( + "_validate_completion_summary(summary)", + "report.get('status') != 'passed'", + "json.loads(raw) != report", + "hashlib.sha256(raw).hexdigest()", + ), + ), + ( + _NATIVE_PROVIDER, + "validate_completion", + ( + "_completion_input(path, output)", + "summary[claim] != native.get(field)", + "summary['report_sha256'] != hashlib.sha256(raw).hexdigest()", + "set(report.get('witnesses', {})) != set(REQUIRED_WITNESSES)", + "fields = {'version': 'version', 'contract_id': 'contract_id', " + "'base_sha': 'base', 'head_sha': 'head', 'tested_tree': 'tested_tree', " + "'lane': 'lane', 'status': 'status'}", + "driver['profile'].get('cli_sha256') != native['profile'].get('cli_sha256')", + ), + ), + ( + _NATIVE_CONTRACTS, + "validate_contracts", + ("{w['nodeid'] for w in witnesses.values()} == set(REQUIRED_WITNESSES)",), + ), + ( + _NATIVE_CONTRACTS, + "candidate_contract", + ("validate_contracts(current, inventory)",), + ), + ( + _NATIVE_OBSERVER, + "LifecycleEvidencePlugin.pytest_sessionstart", + ( + "original = ApmLifecycleRunner._run_with_timeout", + "model = stateful.run_state_machine_as_test", + "self.patch.setattr(ApmLifecycleRunner, '_run_with_timeout', observe)", + "self.patch.setattr(stateful, 'run_state_machine_as_test', observe_model)", + ), + ), + ( + _NATIVE_OBSERVER, + "LifecycleEvidencePlugin.pytest_sessionstart.observe", + ( + "self._source_identity(cwd, env, kwargs['timeout_seconds'], command)", + "self._domain(cwd, env)", + "before = snapshot(roots)", + "result = original(runner, args, **kwargs)", + "fingerprint(Path(sys.executable).resolve()) != self.python_hash", + ), + ), + ( + _NATIVE_OBSERVER, + "LifecycleEvidencePlugin.pytest_sessionstart.observe_model", + ("result = model(*args, **kwargs)",), + ), + ( + _NATIVE_OBSERVER, + "LifecycleEvidencePlugin._source_identity", + ("source != expected",), + ), + ( + _NATIVE_OBSERVER, + "LifecycleEvidencePlugin.pytest_collection_finish", + ("set(actual) != self.nodeids",), + ), + ( + _NATIVE_OBSERVER, + "LifecycleEvidencePlugin.pytest_runtest_logreport", + ( + "hasattr(report, 'wasxfail')", + "phases[report.when] = 'repeated' if report.when in phases else outcome", + ), + ), + ) + findings: list[Violation] = [] + for path, function_name, expressions in bindings: + facts, failures = _facts_for(provider, path, rule_id) + findings.extend(failures) + if failures: + continue + index = facts.tree_index + function = index.function(function_name) if index else None + if function is None: + findings.append( + _summary(rule_id, path, f"Missing native evidence seam {function_name}") + ) + continue + nodes = index.own_scope(function) + observed = { + ast.unparse(node) + for node in nodes + if isinstance(node, (ast.Call, ast.Compare, ast.Assign)) + } + if not set(expressions) <= observed: + findings.append( + _summary( + rule_id, path, f"{function_name} must retain native evidence owner bindings" + ) + ) + + facts, failures = _facts_for(provider, _NATIVE_PROVIDER, rule_id) + if not failures and (index := facts.tree_index) is not None: + execute = index.function("execute") + nodes = index.own_scope(execute) if execute else () + candidates = _named_calls(nodes, "candidate") + profiles = _named_calls(nodes, "source_profile") + validations = _named_calls(nodes, "validate_execution") + plugins = _named_calls(nodes, "LifecycleEvidencePlugin") + pytest_calls = [ + node + for node in nodes + if isinstance(node, ast.Call) and ast.unparse(node.func) == "pytest.main" + ] + final_nodes = { + child + for node in nodes + if isinstance(node, ast.Try) + for statement in node.finalbody + for child in index.walk(statement) + } + wired = ( + len(candidates) == len(profiles) == 2 + and len(pytest_calls) == len(validations) == len(plugins) == 1 + and candidates[-1] in final_nodes + and profiles[-1] in final_nodes + ) + if wired: + run = pytest_calls[0] + wired = candidates[0].lineno < profiles[0].lineno < plugins[ + 0 + ].lineno < run.lineno < validations[0].lineno < candidates[-1].lineno and any( + keyword.arg == "plugins" and ast.unparse(keyword.value) == "[plugin]" + for keyword in run.keywords + ) + if not wired: + findings.append( + _summary( + rule_id, + _NATIVE_PROVIDER, + "Fresh observed pytest must be bracketed by candidate/source checks including finally", + ) + ) + + facts, failures = _facts_for(provider, _NATIVE_OBSERVER, rule_id) + if not failures and (index := facts.tree_index) is not None: + observe = index.function("LifecycleEvidencePlugin.pytest_sessionstart.observe") + nodes = index.own_scope(observe) if observe else () + snapshots = _named_calls(nodes, "snapshot") + invocations = [ + call + for call in _named_calls(nodes, "original") + if isinstance(index.parent(call), ast.Assign) + ] + if not ( + len(snapshots) == 2 + and len(invocations) == 1 + and snapshots[0].lineno < invocations[0].lineno < snapshots[1].lineno + ): + findings.append( + _summary( + rule_id, + _NATIVE_OBSERVER, + "Runner invocation must connect before/after snapshots", + ) + ) + probe = index.function("LifecycleEvidencePlugin._source_identity") + nodes = index.own_scope(probe) if probe else () + if not any( + isinstance(node, ast.Call) and ast.unparse(node.func) == "subprocess.run" + for node in nodes + ): + findings.append( + _summary( + rule_id, _NATIVE_OBSERVER, "Source identity must probe the actual child process" + ) + ) + + facts, failures = _facts_for(provider, _NATIVE_CONTRACTS, rule_id) + if not failures and (index := facts.tree_index) is not None: + pins = [ + node + for node in index.module_children() + if isinstance(node, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "REQUIRED_WITNESSES" + for target in node.targets + ) + ] + if len(pins) != 1 or not isinstance(pins[0].value, ast.Tuple): + findings.append( + _summary( + rule_id, + _NATIVE_CONTRACTS, + "Witness pins must be independent of ledger selection", + ) + ) + facts, failures = _facts_for(provider, _NATIVE_RUNNER, rule_id) + if not failures and (index := facts.tree_index) is not None: + runner = index.function("ApmLifecycleRunner._run_with_timeout") + nodes = index.own_scope(runner) if runner else () + if not any( + isinstance(node, ast.Call) and ast.unparse(node.func) == "subprocess.Popen" + for node in nodes + ): + findings.append( + _summary( + rule_id, _NATIVE_RUNNER, "Canonical lifecycle runner must start a real process" + ) + ) + return tuple(findings) + def check_lifecycle_partition(provider: FactsProvider) -> tuple[Violation, ...]: """Lifecycle marker partitions must be collection-derived, never pinned.""" @@ -1445,6 +1744,11 @@ def _structural_rule(rule_id: str, description: str, check) -> Rule: RULES: tuple[Rule, ...] = ( + _owner_rule( + _GUARD_NATIVE_LIFECYCLE, + "Bounded native lifecycle proof routes through fresh observation and candidate-bound completion.", + check_native_lifecycle_evidence, + ), _owner_rule( _GUARD_TAXONOMY, "Behavioral test taxonomy classification stays owned by module-level pytestmark.", diff --git a/scripts/architecture_linter/checks/install_base_integrator_and_contraction.py b/scripts/architecture_linter/checks/install_base_integrator_and_contraction.py index 3a4423591a..8b98cd826a 100644 --- a/scripts/architecture_linter/checks/install_base_integrator_and_contraction.py +++ b/scripts/architecture_linter/checks/install_base_integrator_and_contraction.py @@ -456,7 +456,13 @@ def _skill_lockfile_root_findings(provider: FactsProvider, rule_id: str) -> list findings: list[Violation] = [] if not _present(facts, required_import) or not _present(facts, required_route): findings.append(_summary(rule_id, _SKILL_INTEGRATOR, route_message)) - if not _present(facts, "LockFile.read(get_lockfile_path(lockfile_root))"): + support_path = "src/apm_cli/integration/skill_support.py" + support, support_failures = _facts_for(provider, support_path, rule_id) + findings.extend(support_failures) + if not _present(facts, "_build_ownership_maps = staticmethod(build_skill_ownership_maps)") or ( + not support_failures + and not _present(support, "LockFile.read(get_lockfile_path(lockfile_root))") + ): findings.append( _summary( rule_id, @@ -466,6 +472,65 @@ def _skill_lockfile_root_findings(provider: FactsProvider, rule_id: str) -> list ) if sum("lockfile_root=lockfile_root" in line for line in facts.lines) != required_forward_count: findings.append(_summary(rule_id, _SKILL_INTEGRATOR, forward_message)) + snapshot_body = _awk_body( + facts, re.compile(r"^ def ownership_snapshot\("), re.compile(r"^ def ") + ) + accessor_body = _awk_body( + facts, re.compile(r"^ def _ownership_maps\("), re.compile(r"^ def ") + ) + if not re.search( + r"finally:\s*\n\s+self\._ownership_snapshots = None", + "\n".join(snapshot_body), + ) or not all( + _body_has(accessor_body, snippet) + for snippet in ( + "if self._ownership_snapshots is None:", + "return self._build_ownership_maps(lockfile_root)", + "root = lockfile_root.resolve()", + "self._build_ownership_maps(root)", + "MappingProxyType(owned_by)", + "MappingProxyType(native_owners)", + ) + ): + findings.append( + _summary( + rule_id, + _SKILL_INTEGRATOR, + "skill ownership snapshots must reuse the canonical builder immutably and expire on phase exit", + ) + ) + if ( + sum("self._ownership_maps(lockfile_root or project_root)" in line for line in facts.lines) + != 3 + ): + findings.append( + _summary( + rule_id, + _SKILL_INTEGRATOR, + "all skill layouts must consume the phase-scoped ownership accessor", + ) + ) + pipeline_path = "src/apm_cli/install/pipeline.py" + pipeline, pipeline_failures = _facts_for(provider, pipeline_path, rule_id) + findings.extend(pipeline_failures) + if not pipeline_failures and ( + not re.search( + r"with \(\s*contextlib\.nullcontext\(\)\s*if ctx\.lockfile_only\s*" + r'else ctx\.integrators\["skill"\]\.ownership_snapshot\(\)\s*\):\s*' + r'_run_phase\("integrate", _integrate_phase, ctx\)', + "\n".join(pipeline.lines), + ) + or sum("_run_integration_phase(ctx)" in line for line in pipeline.lines) != 1 + or sum('_run_phase("integrate", _integrate_phase, ctx)' in line for line in pipeline.lines) + != 1 + ): + findings.append( + _summary( + rule_id, + pipeline_path, + "ownership snapshot lifetime must enclose only integration and bypass lockfile-only mode", + ) + ) return findings diff --git a/scripts/architecture_linter/checks/repository_cache_identity.py b/scripts/architecture_linter/checks/repository_cache_identity.py index 6480024e79..bb8c8294a3 100644 --- a/scripts/architecture_linter/checks/repository_cache_identity.py +++ b/scripts/architecture_linter/checks/repository_cache_identity.py @@ -19,9 +19,10 @@ * ``_repository_cache_identity`` *directly returns* the composition ``normalize_repo_url(dep_ref.to_github_url())`` -- one return, no keywords, and no intermediate binding that could truncate the value first. -* ``L0PerRunCache.try_resolve``, ``TieredRefResolver.resolve``, and - ``TieredRefResolver.seed`` consume that helper directly, with the exact - call/assignment counts and argument positions the helper requires. +* ``L0PerRunCache.try_resolve`` and fresh ``TieredRefResolver.resolve`` results + consume that helper directly. Lockfile seeds remain isolated in a + dependency-scoped key that composes package identity, repository/transport + identity, effective host-provider identity, and ref. * Every fail-closed case: a missing owner path, an unparseable owner source, and each individually missing method or function. @@ -81,6 +82,7 @@ RESOLVER_CLASS = "TieredRefResolver" RESOLVE_METHOD = "resolve" SEED_METHOD = "seed" +LOCK_SEED_KEY_METHOD = "_lock_seed_key" _NORMALIZER = "normalize_repo_url" _REPOSITORY_URL = "repository_url" @@ -91,7 +93,9 @@ _GITHUB_URL = "dep_ref.to_github_url" _BARE_LOOKUP = "self._find_repo_bare" _L0_GET = "self.cache.get" -_SEED_PUT = "self._cache.put" +_LOCK_SEED_STORE = "self._lock_seeds" +_LOCK_SEED_GET = "self._lock_seeds.get" +_EFFECTIVE_HOST_PROVIDER_IDENTITY = "effective_host_provider_identity" # -------------------------------------------------------------------------- # Diagnostic messages (verbatim from the helper). @@ -128,9 +132,16 @@ ) SEED_MISSING_MESSAGE = f"{RESOLVER_CLASS}.{SEED_METHOD} is missing" SEED_DUPLICATE_MESSAGE = f"{RESOLVER_CLASS}.{SEED_METHOD} has duplicate definitions" -SEED_PUT_MESSAGE = ( - f"lockfile seed must call _cache.put({IDENTITY_FUNCTION}({_DEP_REF}), {_REF}, sha)" +LOCK_SEED_KEY_MISSING_MESSAGE = f"{RESOLVER_CLASS}.{LOCK_SEED_KEY_METHOD} is missing" +LOCK_SEED_KEY_DUPLICATE_MESSAGE = ( + f"{RESOLVER_CLASS}.{LOCK_SEED_KEY_METHOD} has duplicate definitions" ) +LOCK_SEED_KEY_MESSAGE = ( + "lockfile seed key must combine dependency identity, full repository cache identity, " + "effective host-provider identity, and ref" +) +SEED_STORE_MESSAGE = "lockfile seed must store by the canonical dependency-scoped seed key" +SEED_LOOKUP_MESSAGE = "lockfile replay must retrieve by the canonical dependency-scoped seed key" # The complete vocabulary this analyzer can emit. Callers (and the mutation # suite) use it to tell a structural finding apart from the rule's retained @@ -157,7 +168,11 @@ RESOLVE_KEY_MESSAGE, SEED_MISSING_MESSAGE, SEED_DUPLICATE_MESSAGE, - SEED_PUT_MESSAGE, + LOCK_SEED_KEY_MISSING_MESSAGE, + LOCK_SEED_KEY_DUPLICATE_MESSAGE, + LOCK_SEED_KEY_MESSAGE, + SEED_STORE_MESSAGE, + SEED_LOOKUP_MESSAGE, ) # ``str(SyntaxError)`` renders as ``invalid syntax (module.py, line 12)``; the @@ -255,6 +270,50 @@ def _identity_ref_tuple(node: ast.AST) -> bool: ) +def _dep_attribute(node: ast.AST, attribute: str) -> bool: + """Return whether ``node`` is exactly ``dep_ref.``.""" + return isinstance(node, ast.Attribute) and _call_name(node) == f"dep_ref.{attribute}" + + +def _effective_provider_identity_call(node: ast.AST) -> bool: + """Return whether the canonical provider identity receives host and host_type.""" + if ( + not isinstance(node, ast.Call) + or _call_name(node.func) != _EFFECTIVE_HOST_PROVIDER_IDENTITY + or len(node.args) != 1 + or len(node.keywords) != 1 + ): + return False + host = node.args[0] + keyword = node.keywords[0] + return ( + isinstance(host, ast.BoolOp) + and isinstance(host.op, ast.Or) + and len(host.values) == 2 + and _dep_attribute(host.values[0], "host") + and _is_call(host.values[1], "default_host", ()) + and keyword.arg == "host_type" + and _dep_attribute(keyword.value, "host_type") + ) + + +def _lock_seed_identity_tuple(node: ast.AST) -> bool: + """Return whether ``node`` is the complete dependency-scoped seed identity.""" + return ( + isinstance(node, ast.Tuple) + and len(node.elts) == 4 + and _is_call(node.elts[0], "dep_ref.get_unique_key", ()) + and _repository_identity_call(node.elts[1]) + and _effective_provider_identity_call(node.elts[2]) + and _is_name(node.elts[3], _REF) + ) + + +def _lock_seed_key_call(node: ast.AST) -> bool: + """Return whether ``node`` calls ``self._lock_seed_key(dep_ref, ref)``.""" + return _is_call(node, "self._lock_seed_key", (_DEP_REF, _REF)) + + def _direct_identity_composition(node: ast.AST) -> bool: """Return whether `node` is ``normalize_repo_url(dep_ref.to_github_url())``.""" if not isinstance(node, ast.Call) or _call_name(node.func) != _NORMALIZER: @@ -331,6 +390,7 @@ def _duplicate_definition_findings( (L0_CLASS, L0_METHOD): L0_DUPLICATE_MESSAGE, (RESOLVER_CLASS, RESOLVE_METHOD): RESOLVE_DUPLICATE_MESSAGE, (RESOLVER_CLASS, SEED_METHOD): SEED_DUPLICATE_MESSAGE, + (RESOLVER_CLASS, LOCK_SEED_KEY_METHOD): LOCK_SEED_KEY_DUPLICATE_MESSAGE, } for class_name, method_names in class_methods: owners = direct_definitions(index, class_name, kinds=(ast.ClassDef,)) @@ -517,7 +577,7 @@ def _l0_findings(index: TreeIndex) -> tuple[IdentityFinding, ...]: def _resolve_findings(index: TreeIndex) -> tuple[IdentityFinding, ...]: - """Return the resolver coalescing-key violations.""" + """Return fresh-result coalescing and lock-seed lookup violations.""" path = TIERED_RESOLVER_PATH resolve = _find_function(index, RESOLVE_METHOD, class_name=RESOLVER_CLASS) if resolve is None: @@ -525,22 +585,59 @@ def _resolve_findings(index: TreeIndex) -> tuple[IdentityFinding, ...]: key_value = _sole_assignment_value(index, resolve, _KEY) if key_value is None or not _identity_ref_tuple(key_value): return (IdentityFinding(path, _line_of(resolve), RESOLVE_KEY_MESSAGE),) + lock_key = _sole_assignment_value(index, resolve, "lock_key") + lookups = _calls_to(index, resolve, _LOCK_SEED_GET) + if ( + lock_key is None + or not _lock_seed_key_call(lock_key) + or len(lookups) != 1 + or len(lookups[0].args) != 1 + or not _is_name(lookups[0].args[0], "lock_key") + ): + return (IdentityFinding(path, _line_of(resolve), SEED_LOOKUP_MESSAGE),) + return () + + +def _lock_seed_key_findings(index: TreeIndex) -> tuple[IdentityFinding, ...]: + """Return dependency-scoped lock-seed key violations.""" + path = TIERED_RESOLVER_PATH + method = _find_function(index, LOCK_SEED_KEY_METHOD, class_name=RESOLVER_CLASS) + if method is None: + return (IdentityFinding(path, 1, LOCK_SEED_KEY_MISSING_MESSAGE),) + returns = _returns_in(index, method) + if ( + len(returns) != 1 + or returns[0].value is None + or not _lock_seed_identity_tuple(returns[0].value) + or assignment_nodes(index, method) + ): + return (IdentityFinding(path, _line_of(method), LOCK_SEED_KEY_MESSAGE),) return () def _seed_findings(index: TreeIndex) -> tuple[IdentityFinding, ...]: - """Return the lockfile-seed violations.""" + """Return lockfile-seed storage violations.""" path = TIERED_RESOLVER_PATH seed = _find_function(index, SEED_METHOD, class_name=RESOLVER_CLASS) if seed is None: return (IdentityFinding(path, 1, SEED_MISSING_MESSAGE),) - puts = _calls_to(index, seed, _SEED_PUT) - if len(puts) != 1 or not ( - len(puts[0].args) >= 2 - and _repository_identity_call(puts[0].args[0]) - and _is_name(puts[0].args[1], _REF) + key_value = _sole_assignment_value(index, seed, _KEY) + stores = [ + node + for node in index.walk(seed) + if isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Subscript) + and _call_name(node.targets[0].value) == _LOCK_SEED_STORE + ] + if ( + key_value is None + or not _lock_seed_key_call(key_value) + or len(stores) != 1 + or not _is_name(stores[0].targets[0].slice, _KEY) + or not _is_call(stores[0].value, "sha.lower", ()) ): - return (IdentityFinding(path, _line_of(seed), SEED_PUT_MESSAGE),) + return (IdentityFinding(path, _line_of(seed), SEED_STORE_MESSAGE),) return () @@ -552,7 +649,7 @@ def tiered_resolver_findings(index: TreeIndex) -> tuple[IdentityFinding, ...]: module_functions=(IDENTITY_FUNCTION,), class_methods=( (L0_CLASS, (L0_METHOD,)), - (RESOLVER_CLASS, (RESOLVE_METHOD, SEED_METHOD)), + (RESOLVER_CLASS, (RESOLVE_METHOD, SEED_METHOD, LOCK_SEED_KEY_METHOD)), ), ) identity = _identity_findings(index) @@ -563,6 +660,7 @@ def tiered_resolver_findings(index: TreeIndex) -> tuple[IdentityFinding, ...]: *identity, *_l0_findings(index), *_resolve_findings(index), + *_lock_seed_key_findings(index), *_seed_findings(index), ) diff --git a/scripts/architecture_linter/checks/transport_auth_platform.py b/scripts/architecture_linter/checks/transport_auth_platform.py index 79909dc2fa..cdf62cc7c7 100644 --- a/scripts/architecture_linter/checks/transport_auth_platform.py +++ b/scripts/architecture_linter/checks/transport_auth_platform.py @@ -85,6 +85,9 @@ _AUTH_OWNER = "src/apm_cli/core/auth.py" +_HOST_PROVIDER_OWNER = "src/apm_cli/core/host_providers.py" +_HOST_PROVIDER_IDENTITY_CONSUMER = "src/apm_cli/drift.py" +_HOST_PROVIDER_LOCK_SEED_CONSUMER = "src/apm_cli/deps/tiered_ref_resolver.py" _HOST_REFERENCE_OWNER = "src/apm_cli/models/dependency/host_virtual.py" _ARTIFACTORY_NETRC_OWNER = "src/apm_cli/deps/artifactory_entry.py" _ARTIFACTORY_NETRC_CONSUMER = "src/apm_cli/deps/download_strategies.py" @@ -147,6 +150,91 @@ def _check_host_credential_resolution(provider: FactsProvider) -> tuple[Violatio inv = frozenset(provider.inventory) findings: list[Violation] = [] + findings.extend( + _count_checks( + provider, + inv, + _RID_HOST_CRED, + _HOST_PROVIDER_OWNER, + (("sub", "def effective_host_provider_identity(", 1, "eq"),), + "Effective host-provider identity must have one canonical owner", + parse=True, + ) + ) + findings.extend( + _require_subs( + provider, + inv, + _RID_HOST_CRED, + _HOST_PROVIDER_OWNER, + ( + "provider = classify_host_provider(host, host_type=host_type)", + "return provider.kind, provider.credential_purpose", + ), + "Effective host-provider identity must derive backend and credential route " + "from the canonical registry", + parse=True, + ) + ) + findings.extend( + _count_checks( + provider, + inv, + _RID_HOST_CRED, + _HOST_PROVIDER_IDENTITY_CONSUMER, + (("sub", "effective_host_provider_identity(", 2, "eq"),), + "Dependency drift must compare both effective host-provider identities", + parse=True, + ) + ) + findings.extend( + _require_subs( + provider, + inv, + _RID_HOST_CRED, + _HOST_PROVIDER_IDENTITY_CONSUMER, + ( + "from apm_cli.core.host_providers import effective_host_provider_identity", + "manifest_provider = effective_host_provider_identity(", + "locked_provider = effective_host_provider_identity(", + "if manifest_provider != locked_provider:", + ), + "Dependency drift must route provider comparison through the canonical " + "effective identity owner", + parse=True, + ) + ) + findings.extend( + _count_checks( + provider, + inv, + _RID_HOST_CRED, + _HOST_PROVIDER_LOCK_SEED_CONSUMER, + (("sub", "effective_host_provider_identity(", 1, "eq"),), + "Dependency-scoped lock seeds must consume one canonical host-provider identity", + parse=True, + ) + ) + findings.extend( + _require_subs( + provider, + inv, + _RID_HOST_CRED, + _HOST_PROVIDER_LOCK_SEED_CONSUMER, + ( + "def _lock_seed_key(", + "from ..core.host_providers import effective_host_provider_identity", + "dep_ref.get_unique_key()", + "_repository_cache_identity(dep_ref)", + "effective_host_provider_identity(", + "dep_ref.host or default_host(), host_type=dep_ref.host_type", + ), + "Dependency-scoped lock-seed identity must route provider classification " + "through the canonical effective identity owner", + parse=True, + ) + ) + # AC5 -- AuthResolver must scrub inherited Git authorization state. findings.extend( _require_subs( @@ -176,7 +264,7 @@ def _check_host_credential_resolution(provider: FactsProvider) -> tuple[Violatio provider, inv, _RID_HOST_CRED, - "src/apm_cli/core/host_providers.py", + _HOST_PROVIDER_OWNER, ('if host_kind == "ado":', "suppress_credential_helpers=True"), "ADO transport policy must reject native credential helpers", ) diff --git a/scripts/architecture_linter/checks/transport_cache_identity.py b/scripts/architecture_linter/checks/transport_cache_identity.py index 7b40c0adaa..87d2948f25 100644 --- a/scripts/architecture_linter/checks/transport_cache_identity.py +++ b/scripts/architecture_linter/checks/transport_cache_identity.py @@ -147,6 +147,24 @@ def _check_git_cache_identity(provider: FactsProvider) -> tuple[Violation, ...]: "Per-run ref resolution must reuse the full repository cache identity", ) ) + findings.extend( + _require_subs( + provider, + inv, + _RID_CACHE, + _TIERED, + ( + "self._lock_seeds[key] = sha.lower()", + "locked = self._lock_seeds.get(lock_key)", + "key = (_repository_cache_identity(dep_ref), ref)", + "cached = self._cache.get(*key)", + "existing = self._coalesce.get(key)", + "self._cache.put(*key, sha)", + ), + "Lock seeds must stay dependency-scoped while fresh results retain " + "repository-wide cache and coalescing identity", + ) + ) findings.extend( _forbid_scan( provider, diff --git a/scripts/architecture_linter/checks/transport_network_and_runtime.py b/scripts/architecture_linter/checks/transport_network_and_runtime.py index 2d1cf60f87..84722a26f3 100644 --- a/scripts/architecture_linter/checks/transport_network_and_runtime.py +++ b/scripts/architecture_linter/checks/transport_network_and_runtime.py @@ -142,6 +142,8 @@ def _check_ref_freshness(provider: FactsProvider) -> tuple[Violation, ...]: ( re.compile(r"^class RefFreshnessPolicy\(Enum\):"), re.compile(r"^def ref_freshness_policy_for_install\("), + re.compile(r"^ return cls\.LOCKED_OR_CURRENT$"), + re.compile(r"^ dep_ref\.get_unique_key\(\),$"), re.compile(r"^ if freshness_policy\.allows_bare_cache:"), ), "tiered_ref_resolver must own RefFreshnessPolicy and its install policy", diff --git a/scripts/check_lifecycle_evidence.py b/scripts/check_lifecycle_evidence.py new file mode 100644 index 0000000000..6dec59055f --- /dev/null +++ b/scripts/check_lifecycle_evidence.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +"""Execute #2867 lifecycle witnesses from this clean candidate, never replay proof. + +Adapted mechanical provenance: be73ed139f3a73c19437a895dfd6d68e41df8bfc. +The optional completion is a dedicated native sidecar, not merge-worker output. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from scripts.lifecycle_contracts import ( # noqa: E402 + CONTRACT_ID, + REQUIRED_WITNESSES, + EvidenceError, + candidate_contract, + command_inventory, + git, + validate_execution, +) + +LIMITATIONS = [ + "Source-Python execution is not packaged-binary or exhaustive platform parity.", + "Assertions, applicability and model semantics require independent code-owner review.", + "Candidate code and its verifier are not tamper-proof; review is the trust boundary.", +] + + +def _same_file(left: Path, right: Path) -> bool: + """Include symlink and hardlink aliases when protecting driver evidence.""" + return left.resolve() == right.resolve() or ( + left.exists() and right.exists() and left.samefile(right) + ) + + +def source_profile(root: Path) -> tuple[Path | None, dict[str, Any]]: + """Pin this checkout's source and the precise interpreter/launcher bytes.""" + import apm_cli + from tests.utils.lifecycle_evidence import fingerprint + + source = root.resolve() / "src/apm_cli" + if Path(apm_cli.__file__).resolve().parent != source: + raise EvidenceError("Installed source does not resolve to the candidate checkout") + launcher = Path(sys.executable).parent / "apm" + executable = launcher.absolute() if os.name != "nt" and launcher.is_file() else None + if executable: + script = executable.read_text(encoding="utf-8") + interpreter = Path(script.splitlines()[0][2:]) if script.startswith("#!") else None + if ( + "apm_cli.cli" not in script + or interpreter is None + or not interpreter.is_absolute() + or not interpreter.is_file() + or not interpreter.samefile(sys.executable) + or interpreter.parent.resolve() != Path(sys.executable).parent.resolve() + ): + raise EvidenceError("Installed apm entrypoint is not this Python environment") + return executable, { + "kind": "source-python", + "source_root": str(source), + "cli_sha256": fingerprint(source / "cli.py"), + "executable": str(executable) if executable else None, + "executable_sha256": fingerprint(executable) if executable else None, + "python": str(Path(sys.executable).resolve()), + "python_environment": str(Path(sys.executable).parent.resolve()), + "python_sha256": fingerprint(Path(sys.executable).resolve()), + } + + +def candidate(root: Path, base: str, head: str) -> dict[str, str]: + """Require exact revisions, real ancestry and a clean tracked/untracked checkout.""" + if git(root, "for-each-ref", "--format=%(refname)", "refs/replace"): + raise EvidenceError("Replacement refs are forbidden for lifecycle evidence") + if os.environ.get("GIT_REPLACE_REF_BASE") or os.environ.get("GIT_GRAFTS_FILE"): + raise EvidenceError("Replacement/graft environment is forbidden") + resolved_base = git(root, "rev-parse", "--verify", "--end-of-options", f"{base}^{{commit}}") + resolved_head = git(root, "rev-parse", "--verify", "--end-of-options", f"{head}^{{commit}}") + if base != resolved_base or head != resolved_head: + raise EvidenceError("Explicit full base/head commit SHAs are required") + if resolved_head != git(root, "rev-parse", "HEAD"): + raise EvidenceError("Requested head is not the checked-out candidate") + if resolved_base == resolved_head: + raise EvidenceError("Base and head must describe a candidate change") + git(root, "merge-base", "--is-ancestor", resolved_base, resolved_head) + if git(root, "status", "--porcelain", "--untracked-files=all"): + raise EvidenceError("Candidate must be clean, including untracked files") + return { + "base": resolved_base, + "head": resolved_head, + "tested_tree": git(root, "rev-parse", "HEAD^{tree}"), + } + + +def _validate_completion_summary(summary: Any) -> None: + """Use the same native schema for emission and independent consumption.""" + from jsonschema import Draft202012Validator + + schema = json.loads( + (ROOT / "tests/fixtures/lifecycle_completion.schema.json").read_text(encoding="utf-8") + ) + errors = list(Draft202012Validator(schema).iter_errors(summary)) + if errors or type(summary.get("version")) is not int: + raise EvidenceError(f"Malformed lifecycle completion schema: {errors[:1]}") + if set(summary["witnesses"]) != set(REQUIRED_WITNESSES): + raise EvidenceError("Completion requires exact immutable witnesses") + + +def _completion_input(path: Path, output: Path) -> tuple[dict[str, Any], Path]: + """Validate the dedicated sidecar and refuse all output/input aliases.""" + if _same_file(output, path): + raise EvidenceError("Independent report must not overwrite completion") + summary = json.loads(path.read_text(encoding="utf-8")) + _validate_completion_summary(summary) + driver = Path(summary["report_path"]) + if not driver.is_absolute(): + driver = path.parent / driver + if _same_file(output, driver): + raise EvidenceError("Independent report must not overwrite driver evidence") + if driver.resolve().is_relative_to(ROOT) or path.resolve().is_relative_to(ROOT): + raise EvidenceError("Completion and driver report must be outside the checkout") + return summary, driver + + +def completion_summary(report: dict[str, Any], report_path: Path, raw: bytes) -> dict[str, Any]: + """Derive success claims solely from the freshly executed report and its bytes.""" + if ( + report.get("status") != "passed" + or report.get("lane") != "full" + or report.get("profile", {}).get("kind") != "source-python" + or set(report.get("witnesses", {})) != set(REQUIRED_WITNESSES) + or json.loads(raw) != report + ): + raise EvidenceError("Completion emission requires the exact successful native report") + summary = { + "version": report["version"], + "contract_id": report["contract_id"], + "base_sha": report["base"], + "head_sha": report["head"], + "tested_tree": report["tested_tree"], + "lane": report["lane"], + "status": report["status"], + "report_path": str(report_path.resolve()), + "report_sha256": hashlib.sha256(raw).hexdigest(), + "witnesses": sorted(report["witnesses"]), + } + _validate_completion_summary(summary) + return summary + + +def _json_bytes(value: dict[str, Any]) -> bytes: + """Produce deterministic ASCII JSON with LF on every platform.""" + return (json.dumps(value, indent=2, sort_keys=True, ensure_ascii=True) + "\n").encode("ascii") + + +def _write_new(path: Path, raw: bytes) -> None: + """Never replace an existing receipt, even if it appeared after preflight.""" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("xb") as handle: + handle.write(raw) + + +def validate_completion(path: Path, native: dict[str, Any], output: Path) -> None: + """Bind driver claims to independent fresh execution, allowing another source path.""" + summary, driver_path = _completion_input(path, output) + fields = { + "version": "version", + "contract_id": "contract_id", + "base_sha": "base", + "head_sha": "head", + "tested_tree": "tested_tree", + "lane": "lane", + "status": "status", + } + for claim, field in fields.items(): + if summary[claim] != native.get(field): + raise EvidenceError(f"Completion disagrees with fresh execution: {claim}") + raw = driver_path.read_bytes() + if summary["report_sha256"] != hashlib.sha256(raw).hexdigest(): + raise EvidenceError("Completion driver report digest mismatch") + driver = json.loads(raw) + for report in (driver, native): + if ( + not isinstance(report, dict) + or type(report.get("version")) is not int + or any(report.get(field) != native.get(field) for field in fields.values()) + or set(report.get("witnesses", {})) != set(REQUIRED_WITNESSES) + or report.get("profile", {}).get("kind") != "source-python" + ): + raise EvidenceError("Driver report header/witnesses disagree with fresh execution") + if driver["profile"].get("cli_sha256") != native["profile"].get("cli_sha256"): + raise EvidenceError("Driver source digest disagrees with fresh execution") + + +def execute(args: argparse.Namespace) -> dict[str, Any]: + """Execute all witnesses now; no selected-contract or lane waiver exists.""" + identity = candidate(ROOT, args.base, args.head) + report: dict[str, Any] = { + "version": 1, + "contract_id": CONTRACT_ID, + **identity, + "lane": args.lane, + "status": "blocked", + "profile": {"kind": "source-python"}, + "witnesses": {}, + "limitations": LIMITATIONS, + } + if args.lane != "full": + raise EvidenceError("Only the full lifecycle lane is supported") + plugin = None + try: + import pytest + + from tests.utils.lifecycle_evidence import LifecycleEvidencePlugin + + inventory = command_inventory() + contract = candidate_contract(ROOT, identity["base"], inventory) + report["command_inventory"] = sorted(inventory) + executable, report["profile"] = source_profile(ROOT) + witnesses = contract["witnesses"] + nodeids = sorted(REQUIRED_WITNESSES) + contexts = { + w["nodeid"]: {t.get("context", "initial") for t in w["transitions"]} for w in witnesses + } + plugin = LifecycleEvidencePlugin( + nodeids, executable, contexts, Path(report["profile"]["source_root"]) + ) + # Never let pytest delete a pre-existing caller directory via --basetemp. + scratch = args.report.absolute().parent / f".{args.report.name}.pytest" + scratch.mkdir(parents=True, exist_ok=False) + with pytest.MonkeyPatch.context() as environment: + environment.setenv("APM_E2E_TESTS", "1") + if executable: + environment.setenv("APM_BINARY_PATH", str(executable)) + else: + environment.delenv("APM_BINARY_PATH", raising=False) + environment.delenv("PYTEST_ADDOPTS", raising=False) + environment.setenv("HYPOTHESIS_STORAGE_DIRECTORY", str(scratch / "hypothesis")) + exitcode = pytest.main( + [ + "-p", + "no:cacheprovider", + "-o", + "addopts=", + "--strict-markers", + "--basetemp", + str(scratch / "cases"), + "-q", + *nodeids, + ], + plugins=[plugin], + ) + report["witnesses"] = plugin.records + if exitcode != 0: + raise EvidenceError(f"Lifecycle pytest execution failed with exit code {exitcode}") + if set(plugin.records) != set(REQUIRED_WITNESSES): + raise EvidenceError("Execution did not collect every immutable witness") + for witness in witnesses: + validate_execution(witness, plugin.records[witness["nodeid"]]) + report["status"] = "passed" + except (EvidenceError, OSError, KeyError, TypeError, ValueError) as exc: + report["error"] = str(exc) + finally: + if plugin is not None: + plugin.patch.undo() + try: + if candidate(ROOT, args.base, args.head) != identity: + raise EvidenceError("Candidate identity changed during execution") + if source_profile(ROOT)[1] != report["profile"]: + raise EvidenceError("Source executable identity changed during execution") + except (EvidenceError, OSError, subprocess.CalledProcessError) as exc: + report["status"] = "blocked" + report["error"] = str(exc) + return report + + +def main() -> int: + """Write a new external report only, never clobber an existing receipt.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", required=True) + parser.add_argument("--head", required=True) + parser.add_argument("--lane", choices=("full",), required=True) + parser.add_argument("--report", type=Path, required=True) + completion = parser.add_mutually_exclusive_group() + completion.add_argument( + "--completion", type=Path, help="Independently verify a driver sidecar with fresh execution" + ) + completion.add_argument( + "--completion-output", + type=Path, + help="Emit a native completion sidecar only after successful fresh execution", + ) + args = parser.parse_args() + if args.report.resolve().is_relative_to(ROOT): + parser.error("--report must be outside the candidate checkout") + if args.completion_output and args.completion_output.resolve().is_relative_to(ROOT): + parser.error("--completion-output must be outside the candidate checkout") + try: + if args.report.exists() or args.report.is_symlink(): + raise EvidenceError("Report already exists; refusing to overwrite evidence") + if args.completion_output: + if _same_file(args.completion_output, args.report): + raise EvidenceError("Report and completion output must be distinct") + if args.completion_output.exists() or args.completion_output.is_symlink(): + raise EvidenceError( + "Completion output already exists; refusing to overwrite evidence" + ) + if args.completion: + _completion_input(args.completion, args.report) + report = execute(args) + if args.completion: + validate_completion(args.completion, report, args.report) + raw = _json_bytes(report) + summary = ( + completion_summary(report, args.report, raw) + if args.completion_output and report["status"] == "passed" + else None + ) + _write_new(args.report, raw) + print(f"Lifecycle evidence: {report['status']} ({args.report})") + if report["status"] != "passed": + raise EvidenceError(report.get("error", "Lifecycle execution did not pass")) + if summary is not None: + _write_new(args.completion_output, _json_bytes(summary)) + print(f"Lifecycle completion: {args.completion_output}") + except ( + EvidenceError, + OSError, + KeyError, + TypeError, + ValueError, + subprocess.CalledProcessError, + ) as exc: + print(f"Lifecycle evidence blocked: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lifecycle_contracts.py b/scripts/lifecycle_contracts.py new file mode 100644 index 0000000000..5d577e805b --- /dev/null +++ b/scripts/lifecycle_contracts.py @@ -0,0 +1,282 @@ +"""Mechanical validation of the single authored #2867 native lifecycle contract. + +Adapted from be73ed139f3a73c19437a895dfd6d68e41df8bfc. This is not a +runtime-change classifier, waiver system, or repository shipping policy. +""" + +from __future__ import annotations + +import copy +import json +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import click + +LEDGER = "tests/fixtures/lifecycle_bug_ledger.json" +CONTRACT_ID = "pr2876-alias-path-complete-lifecycle" +_REQUIRED = "tests/integration/test_required_lifecycle_state_machine.py" +_GENERATED = "tests/integration/test_generated_lifecycle_state_machine.py" +# Deliberately independent of editable ledger membership: deletion cannot waive proof. +REQUIRED_WITNESSES = ( + *( + f"{_REQUIRED}::test_required_global_audit_rule_matrix_for_external_roots[{value}]" + for value in ("False", "True") + ), + *( + f"{_GENERATED}::{name}[{variant}]" + for name in ( + "test_generated_lifecycle_mandatory_replay", + "test_generated_lifecycle_sequences_preserve_reference_model", + ) + for variant in ("project", "global-canonical", "global-aliased") + ), + f"{_REQUIRED}::test_required_frozen_semver_transport_refusal_recovers", +) + + +class EvidenceError(ValueError): + """A candidate has not discharged its lifecycle obligations.""" + + +def _require(condition: object, message: str) -> None: + if not condition: + raise EvidenceError(message) + + +def git(root: Path, *args: str) -> str: + """Run a read-only Git query without replacement-object interpretation.""" + executable = shutil.which("git") + _require(executable, "git is required for candidate identity") + result = subprocess.check_output( # noqa: S603 - resolved executable, argument vector, no shell + [executable, "--no-replace-objects", *args], cwd=root, text=True + ) + return result if "-z" in args else result.strip() + + +def command_inventory() -> dict[str, click.Command]: + """Walk actual Click registrations, including hidden names and aliases.""" + from apm_cli.cli import cli + + result: dict[str, click.Command] = {} + + def walk(command: click.Command, path: str, parent: click.Context | None) -> None: + result[path] = command + if isinstance(command, click.Group): + with click.Context(command, parent=parent, info_name=path) as context: + for name in command.list_commands(context): + child = command.get_command(context, name) + _require(child is not None, f"Unresolved Click registration: {path} {name}") + walk(child, f"{path} {name}".strip(), context) + + walk(cli, "", None) + return result + + +def command_path(args: list[str], inventory: dict[str, click.Command]) -> str: + """Resolve real command tokens using Click rather than argv substring guesses.""" + path, remaining, parent = "", args[:], None + while True: + command = inventory[path] + context = command.make_context(path, remaining, parent=parent, resilient_parsing=True) + if not isinstance(command, click.Group): + return path + remaining = [*context._protected_args, *context.args] + if not remaining: + return path + candidate = f"{path} {remaining[0]}".strip() + if candidate not in inventory: + return path + path, remaining, parent = candidate, remaining[1:], context + + +def load_ledger(text: str) -> dict[str, Any]: + """Decode the additive ledger, expanding authored shared trajectory segments.""" + value = json.loads(text) + _require(isinstance(value, dict) and value.get("schema_version") == 1, "Unsupported ledger") + for contract in value.get("lifecycle_contracts", []): + trajectories = contract.get("trajectories", {}) + for witness in contract["witnesses"]: + witness["transitions"] = [ + copy.deepcopy(transition) + for segment in witness["trajectory"] + for transition in trajectories[segment] + ] + return value + + +def validate_contracts( + ledger: dict[str, Any], inventory: dict[str, click.Command] +) -> dict[str, dict[str, Any]]: + """Require the bounded contract, exact witnesses and complete resource assessment.""" + rows = ledger.get("lifecycle_contracts", []) + _require(len(rows) == 1 and rows[0]["id"] == CONTRACT_ID, "Exactly one #2867 contract required") + row = rows[0] + _require(row.get("assessment"), "Missing reviewed assessment") + properties = {item["id"] for item in ledger["property_catalog"]} + _require(row["properties"] and set(row["properties"]) <= properties, "Unknown properties") + _require(set(row["commands"]) == set(inventory), "Incomplete current command inventory") + witnesses = {item["id"]: item for item in row["witnesses"]} + _require(len(witnesses) == len(REQUIRED_WITNESSES), "Duplicate/missing witnesses") + _require( + {w["nodeid"] for w in witnesses.values()} == set(REQUIRED_WITNESSES), + "Exact immutable witness identities required", + ) + for witness in witnesses.values(): + nodeid = witness["nodeid"] + generated = "::test_generated_lifecycle_sequences_" in nodeid + _require( + witness["kind"] == ("generated" if generated else "deterministic"), + f"{nodeid}: wrong execution kind", + ) + if nodeid.startswith(_GENERATED): + dimensions = {"variant": nodeid.rsplit("[", 1)[1][:-1]} + elif nodeid.endswith(("[False]", "[True]")): + dimensions = {"aliased_home": nodeid.endswith("[True]")} + else: + dimensions = {} + _require( + json.dumps(witness["dimensions"], sort_keys=True) + == json.dumps(dimensions, sort_keys=True), + f"{nodeid}: changed dimensions", + ) + _require(len(witness["transitions"]) >= 2, f"{nodeid}: missing trajectory") + for transition in witness["transitions"]: + _require(transition["command"] in inventory, f"{nodeid}: unknown command") + _require(type(transition["returncode"]) is int, f"{nodeid}: invalid outcome") + _require( + transition["state"] in {"observed", "changed", "unchanged"}, + f"{nodeid}: invalid state", + ) + _require( + transition.get("context", "initial") in {"initial", "APM_HOME"}, + f"{nodeid}: invalid context", + ) + _require( + isinstance(transition["argv_contains"], list) + and all(isinstance(arg, str) for arg in transition["argv_contains"]), + f"{nodeid}: invalid command arguments", + ) + if "preparation" in transition: + _require( + isinstance(transition["preparation"], str) + and transition["preparation"].strip(), + f"{nodeid}: preparation needs an authored rationale", + ) + for command, entry in row["commands"].items(): + _require(isinstance(entry["reason"], str) and entry["reason"].strip(), "Missing reason") + _require(entry["disposition"] in {"applicable", "semantic_na"}, "Unknown applicability") + if entry["disposition"] == "semantic_na": + continue + for kind in ("deterministic", "generated"): + refs = entry[kind] + _require(refs, f"{command}: missing {kind} witness") + for ref in refs: + _require( + ref in witnesses and witnesses[ref]["kind"] == kind, + f"{command}: invalid witness reference", + ) + _require( + any(t["command"] == command for t in witnesses[ref]["transitions"]), + f"{command}: not exercised by {ref}", + ) + exercised = {t["command"] for w in witnesses.values() for t in w["transitions"]} + _require( + exercised == {c for c, e in row["commands"].items() if e["disposition"] == "applicable"}, + "Command applicability must match actual authored transitions", + ) + return {CONTRACT_ID: row} + + +def candidate_contract( + root: Path, base: str, inventory: dict[str, click.Command] +) -> dict[str, Any]: + """Always select #2867; preserve legacy property, bug and known-gap records.""" + previous = json.loads(git(root, "show", f"{base}:{LEDGER}")) + current = load_ledger((root / LEDGER).read_text(encoding="utf-8")) + for field in ("property_catalog", "bugs", "known_gaps"): + for row in previous.get(field, []): + _require(row in current[field], f"Removed/changed legacy {field} record") + return validate_contracts(current, inventory)[CONTRACT_ID] + + +def validate_execution(witness: dict[str, Any], evidence: dict[str, Any]) -> None: + """Validate actual phases and a connected, ordered command/root/state trajectory.""" + nodeid = witness["nodeid"] + _require(evidence.get("collected") is True, f"{nodeid}: not collected") + _require( + evidence.get("phases") == {"setup": "passed", "call": "passed", "teardown": "passed"}, + f"{nodeid}: setup/call/teardown must pass without skip or xfail", + ) + _require( + json.dumps(evidence.get("dimensions"), sort_keys=True) + == json.dumps(witness["dimensions"], sort_keys=True), + f"{nodeid}: wrong dimensions", + ) + generated = witness["kind"] == "generated" + if generated: + _require( + type(evidence.get("models")) is int and evidence["models"] > 0, + f"{nodeid}: no executed generated model", + ) + grouped: dict[str, list[dict[str, Any]]] = {} + for event in evidence.get("events", []): + roots, environment = event.get("roots"), event.get("environment") + _require( + isinstance(roots, dict) + and roots.get("workspace") + and isinstance(environment, dict) + and environment + and set(environment) == set(roots) - {"workspace"} + and all(isinstance(v, str) and v for v in [*roots.values(), *environment.values()]), + f"{nodeid}: missing durable-root/environment identity", + ) + context = event.get("context", "initial") + _require(context in {"initial", "APM_HOME"}, f"{nodeid}: unknown context") + _require( + event["cwd"] == roots.get("workspace" if context == "initial" else context), + f"{nodeid}: cwd outside observed context", + ) + _require( + all(isinstance(event.get(k), str) and event[k] for k in ("before", "after")), + f"{nodeid}: missing state observation", + ) + if generated and not ( + type(event.get("model")) is int and 0 < event["model"] <= evidence["models"] + ): + continue + identity = json.dumps([roots, environment, event.get("model")], sort_keys=True) + grouped.setdefault(identity, []).append(event) + + def matches(event: dict[str, Any], expected: dict[str, Any]) -> bool: + return ( + event.get("context", "initial") == expected.get("context", "initial") + and event["command"] == expected["command"] + and type(event["returncode"]) is int + and event["returncode"] == expected["returncode"] + and all(arg in event["args"] for arg in expected["argv_contains"]) + and ( + expected["state"] == "observed" + or (event["before"] == event["after"]) == (expected["state"] == "unchanged") + ) + ) + + for events in grouped.values(): + cursor, previous_after = 0, None + for event in events: + expected = witness["transitions"][cursor] + if ( + previous_after is not None + and previous_after != event["before"] + and not (expected.get("preparation") and matches(event, expected)) + ): + cursor = 0 + expected = witness["transitions"][cursor] + previous_after = event["after"] + if matches(event, expected): + cursor += 1 + if cursor == len(witness["transitions"]): + return + raise EvidenceError(f"{nodeid}: required ordered command/state trajectory not executed") diff --git a/src/apm_cli/commands/deps/cli.py b/src/apm_cli/commands/deps/cli.py index 63dd5627e6..1da35f46ab 100644 --- a/src/apm_cli/commands/deps/cli.py +++ b/src/apm_cli/commands/deps/cli.py @@ -18,6 +18,7 @@ from ...core.target_detection import TargetParamType from ...deps.lockfile import LockedDependency from ...models.apm_package import APMPackage +from ...utils.path_security import PathTraversalError from .._helpers import ( UnknownPackageError, _expand_with_ancestors, @@ -232,6 +233,7 @@ def _resolve_scope_deps(apm_dir, logger, insecure_only=False): # GitHub: owner/repo or owner/virtual-pkg-name (2 levels) # Azure DevOps: org/project/repo or org/project/virtual-pkg-name (3 levels) declared_sources = {} # dep_path -> 'github' | 'gitlab' | 'azure-devops' | 'local' + physical_to_logical: dict[str, str] = {} try: apm_yml_path = apm_dir / APM_YML_FILENAME if apm_yml_path.exists(): @@ -242,6 +244,13 @@ def _resolve_scope_deps(apm_dir, logger, insecure_only=False): source = _deps_list_source_label( dep.host, is_local=dep.is_local, lockfile_source=dep.source ) + if dep.alias is not None: + install_path = dep.get_install_path(apm_modules_path) + physical_key = install_path.relative_to(apm_modules_path).as_posix() + dep_key = dep.get_canonical_dependency_string() + physical_to_logical[physical_key] = dep_key + declared_sources[dep_key] = source + continue is_ado = dep.is_azure_devops() and len(repo_parts) >= 3 is_gh = len(repo_parts) >= 2 @@ -273,6 +282,8 @@ def _resolve_scope_deps(apm_dir, logger, insecure_only=False): declared_sources[f"{repo_parts[0]}/{repo_parts[1]}/{package_name}"] = source elif is_gh: declared_sources[f"{repo_parts[0]}/{package_name}"] = source + except PathTraversalError: + raise except Exception: pass # Continue without orphan detection if apm.yml parsing fails @@ -283,18 +294,20 @@ def _resolve_scope_deps(apm_dir, logger, insecure_only=False): # Key orphan/insecure state on the logical id and remember each physical # slot -> logical id so the scan below can report the logical name instead # of leaking the hash slot into user-facing output. - physical_to_logical: dict[str, str] = {} try: lockfile_path = get_lockfile_path(apm_dir) if lockfile_path.exists(): lockfile = LockFile.read(lockfile_path) for dep in lockfile.dependencies.values(): - # Local deps: key on the logical lockfile identity - # (``repo_url``) and map the physical install slot back to it. - if dep.source == "local": + # Local slots and explicit aliases retain their logical lockfile identity. + if dep.source == "local" or dep.alias is not None: install_path = dep.to_dependency_ref().get_install_path(apm_modules_path) physical_key = install_path.relative_to(apm_modules_path).as_posix() - dep_key = dep.repo_url + dep_key = ( + dep.repo_url + if dep.source == "local" + else dep.get_canonical_dependency_string() + ) physical_to_logical[physical_key] = dep_key else: dep_key = dep.get_canonical_dependency_string() @@ -305,6 +318,8 @@ def _resolve_scope_deps(apm_dir, logger, insecure_only=False): ) if getattr(dep, "is_insecure", False): insecure_lock_deps[dep_key] = dep + except PathTraversalError: + raise except Exception: pass # Continue without lockfile if it can't be read @@ -314,7 +329,9 @@ def _resolve_scope_deps(apm_dir, logger, insecure_only=False): # First pass: collect valid candidate paths for ancestor-aware orphan check. scanned_candidates = [] for candidate in apm_modules_path.rglob("*"): - if not candidate.is_dir() or candidate.name.startswith("."): + if candidate.is_symlink() or not candidate.is_dir(): + continue + if candidate.name.startswith(".") and candidate.parent != apm_modules_path: continue has_apm_yml = (candidate / APM_YML_FILENAME).exists() has_skill_md = (candidate / SKILL_MD_FILENAME).exists() @@ -324,7 +341,7 @@ def _resolve_scope_deps(apm_dir, logger, insecure_only=False): if not has_apm_yml and not has_skill_md and not has_plugin_manifest: continue rel_parts = candidate.relative_to(apm_modules_path).parts - if len(rel_parts) < 2: + if len(rel_parts) < 2 and "/".join(rel_parts) not in physical_to_logical: continue # Skip sub-skills inside .apm/ directories -- they belong to the parent package if ".apm" in rel_parts: diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index 84b5a9efad..22de7a6c0d 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -889,7 +889,8 @@ def _handle_mcp_install( # noqa: PLR0913 is_flag=True, help=( "Refuse to install when apm.lock.yaml is missing or out of sync with " - "apm.yml, including MCP config state (CI-safe; mutually exclusive with " + "apm.yml, including Git source, ref, host provider, transport, and MCP " + "config state (CI-safe; mutually exclusive with " "--update, --mcp, and positional packages). Use 'apm audit' for on-disk " "integrity." ), diff --git a/src/apm_cli/core/host_providers.py b/src/apm_cli/core/host_providers.py index 37f2bf60a2..336270be29 100644 --- a/src/apm_cli/core/host_providers.py +++ b/src/apm_cli/core/host_providers.py @@ -258,6 +258,16 @@ def classify_host_provider( raise RuntimeError(f"No host provider registered for {host!r}") +def effective_host_provider_identity( + host: str, + *, + host_type: str | None = None, +) -> tuple[str, str]: + """Return the backend and credential-route identity for one remote host.""" + provider = classify_host_provider(host, host_type=host_type) + return provider.kind, provider.credential_purpose + + def register_host_backend(kind: str, backend_factory: type[Any]) -> None: """Register a native backend for one canonical host provider.""" if kind not in HOST_PROVIDERS: diff --git a/src/apm_cli/deps/tiered_ref_resolver.py b/src/apm_cli/deps/tiered_ref_resolver.py index 0e83a0d488..ad478b1c13 100644 --- a/src/apm_cli/deps/tiered_ref_resolver.py +++ b/src/apm_cli/deps/tiered_ref_resolver.py @@ -72,6 +72,7 @@ class RefFreshnessPolicy(Enum): """Canonical authority for which resolver caches may establish a ref.""" REPRODUCIBLE = "reproducible" + LOCKED_OR_CURRENT = "locked-or-current" CURRENT_REMOTE = "current-remote" @classmethod @@ -84,12 +85,12 @@ def for_install_intent( """Map install command intent to one resolver freshness policy.""" if update_refs or refresh: return cls.CURRENT_REMOTE - return cls.REPRODUCIBLE + return cls.LOCKED_OR_CURRENT @property def allows_lock_seed(self) -> bool: - """Return whether a lockfile SHA may seed the per-run L0 cache.""" - return self is self.REPRODUCIBLE + """Return whether a matching dependency may replay a lockfile SHA.""" + return self is not self.CURRENT_REMOTE @property def allows_bare_cache(self) -> bool: @@ -98,7 +99,7 @@ def allows_bare_cache(self) -> bool: @property def requires_remote(self) -> bool: - """Return whether the ref must be established from a remote tier.""" + """Return whether even locked refs must be refreshed from upstream.""" return self is self.CURRENT_REMOTE @@ -419,6 +420,7 @@ def __init__( self._cache = cache self._legacy = legacy self.freshness_policy = freshness_policy + self._lock_seeds: dict[tuple[str, str, tuple[str, str], str], str] = {} self._coalesce: dict[tuple[str, str], threading.Event] = {} self._coalesce_lock = threading.Lock() self._remote_resolutions: dict[tuple[str, str], str] = {} @@ -436,7 +438,7 @@ def remotely_resolved(self, dep_ref: DependencyReference, sha: str) -> bool: return self._remote_resolutions.get(key) == sha def seed(self, repo_ref: str | DependencyReference, ref: str, sha: str) -> bool: - """Pre-populate the L0 per-run cache with a known ``ref -> sha``. + """Record a dependency-scoped lockfile ``ref -> sha``. Used by the resolve phase to inject a lockfile-recorded commit (``resolved_commit``) for a named ``resolved_ref`` -- a branch OR a @@ -447,16 +449,38 @@ def seed(self, repo_ref: str | DependencyReference, ref: str, sha: str) -> bool: unless ``sha`` is a full 40-char hex commit and ``ref`` is non-empty. Returns ``True`` when a value was stored. - Safe because the seeded SHA is the lockfile's own trust anchor -- - the same value ``resolve()`` would otherwise fetch from the network - and cache. No behavior change beyond eliminating the round-trip. + Lock seeds must not enter the repository-wide fresh-result cache: + a sibling virtual package without a matching lock entry must still + establish the current upstream ref. """ - if not ref or not sha or not _SHA_RE.match(sha): + if ( + not self.freshness_policy.allows_lock_seed + or not ref + or not sha + or not _SHA_RE.match(sha) + ): return False dep_ref = self._normalize(repo_ref) - self._cache.put(_repository_cache_identity(dep_ref), ref, sha.lower()) + key = self._lock_seed_key(dep_ref, ref) + with self._coalesce_lock: + self._lock_seeds[key] = sha.lower() return True + @staticmethod + def _lock_seed_key( + dep_ref: DependencyReference, ref: str + ) -> tuple[str, str, tuple[str, str], str]: + from ..core.host_providers import effective_host_provider_identity + + return ( + dep_ref.get_unique_key(), + _repository_cache_identity(dep_ref), + effective_host_provider_identity( + dep_ref.host or default_host(), host_type=dep_ref.host_type + ), + ref, + ) + def resolve(self, repo_ref: str | DependencyReference) -> ResolvedReference: """Resolve a git reference, dispatching through the tier waterfall. @@ -481,6 +505,14 @@ def resolve(self, repo_ref: str | DependencyReference) -> ResolvedReference: self.stats["sha_passthrough"] = self.stats.get("sha_passthrough", 0) + 1 return self._build_result(dep_ref, ref, ref.lower(), tier_name="sha_passthrough") + if self._lock_seeds: + lock_key = self._lock_seed_key(dep_ref, ref) + with self._coalesce_lock: + locked = self._lock_seeds.get(lock_key) + if locked is not None: + self.stats["per_run_cache"] += 1 + return self._build_result(dep_ref, ref, locked, tier_name="lock_seed") + key = (_repository_cache_identity(dep_ref), ref) # Fast path: cache hit avoids both tier dispatch and the @@ -598,9 +630,9 @@ def build_tiered_ref_resolver( ) -> TieredRefResolver | None: """Construct the production tier stack, or ``None`` if disabled. - The default is deliberately ``REPRODUCIBLE`` so ordinary installs retain - lockfile and local-cache behavior. Callers that report or change current - state must opt into ``CURRENT_REMOTE`` explicitly. + The compatibility default permits bare-cache answers. Install callers use + ``LOCKED_OR_CURRENT`` to replay matching lock seeds while resolving every + unseeded ref upstream. Commands changing current state use ``CURRENT_REMOTE``. Returns ``None`` when ``APM_TIERED_RESOLVER`` is disabled so callers can opt out by simply leaving ``downloader._tiered_resolver = None``; diff --git a/src/apm_cli/drift.py b/src/apm_cli/drift.py index f562ef5486..f871682818 100644 --- a/src/apm_cli/drift.py +++ b/src/apm_cli/drift.py @@ -38,10 +38,9 @@ formatting-only change that produces the same unique key and is correctly treated as no drift. -* **Host changes** — *not* detected. If a user changes the host of an otherwise - identical package, the unique key may not change and ``detect_ref_change()`` - will not signal a re-download. Host-level changes still require the user to - ``apm remove`` + ``apm install`` the package, or use ``--update``. +* **Host-provider changes** -- detected through the canonical host-provider + registry. Equivalent explicit/inferred provider forms remain unchanged, + while a backend or credential-route change forces a re-download. * **HTTP transport flips** — detected. Switching between HTTPS and insecure HTTP toggles ``is_insecure`` and forces a re-download even when the package identity and ref are otherwise unchanged. @@ -53,7 +52,9 @@ from dataclasses import replace as _dataclass_replace from typing import TYPE_CHECKING, Any +from apm_cli.core.host_providers import effective_host_provider_identity from apm_cli.install.phases._skip_logic import _should_use_locked_ref +from apm_cli.utils.github_host import default_host if TYPE_CHECKING: from apm_cli.deps.lockfile import LockedDependency, LockFile @@ -85,6 +86,12 @@ def _normalize_dep_source(dep: Any) -> str: return raw +def _optional_string_attr(dep: Any, name: str) -> str | None: + """Read an optional string field without accepting dynamic mock attributes.""" + value = getattr(dep, name, None) + return value if isinstance(value, str) and value else None + + def detect_ref_change( dep_ref: DependencyReference, locked_dep: LockedDependency | None, @@ -102,8 +109,8 @@ def detect_ref_change( .. note:: - Host changes (e.g. github.com -> ghes.corp.net) are a known non-goal - for this function. A future enhancement may detect host drift. + Host names are normally part of the dependency key. Provider changes + for the same host are detected through canonical host classification. Args: dep_ref: The dependency as declared in the current manifest. @@ -132,6 +139,24 @@ def detect_ref_change( if manifest_source != locked_source: return True + # Compare effective providers, not raw host_type: explicit and inferred + # forms selecting the same backend and credential route remain equivalent. + if manifest_source == "git": + try: + manifest_provider = effective_host_provider_identity( + _optional_string_attr(dep_ref, "host") or default_host(), + host_type=_optional_string_attr(dep_ref, "host_type"), + ) + locked_provider = effective_host_provider_identity( + _optional_string_attr(locked_dep, "host") or default_host(), + host_type=_optional_string_attr(locked_dep, "host_type"), + ) + except (ValueError, RuntimeError): + # An unclassifiable lock cannot safely authorize replay. + return True + if manifest_provider != locked_provider: + return True + # Registry-sourced deps: the manifest carries a semver range # (e.g. ``^1.2.0``) while the lockfile records an exact version # (e.g. ``1.5.3``). Plain string comparison would be a false @@ -150,6 +175,11 @@ def detect_ref_change( return ref != locked_dep.version return not _registry_range_covers_locked_version(ref, locked_dep.version) + if (getattr(dep_ref, "is_insecure", False) is True) != ( + getattr(locked_dep, "is_insecure", False) is True + ): + return True + # Git-source semver-range deps (issue #1488): the manifest carries # a semver range (``^1.2.0``) while the lockfile records the # resolved tag (``v1.5.3``) plus the original constraint. Direct @@ -163,12 +193,7 @@ def detect_ref_change( # Git/local deps: direct ref comparison. Handles None→value, value→None, # and value→value. No truthiness guard on locked_dep.resolved_ref — # None != "v1.0.0" is True. - if dep_ref.reference != locked_dep.resolved_ref: - return True - - return (getattr(dep_ref, "is_insecure", False) is True) != ( - getattr(locked_dep, "is_insecure", False) is True - ) + return dep_ref.reference != locked_dep.resolved_ref def should_force_ref_recheck( diff --git a/src/apm_cli/install/helpers/ref_seed.py b/src/apm_cli/install/helpers/ref_seed.py index 07ce8b650b..3f1baf8155 100644 --- a/src/apm_cli/install/helpers/ref_seed.py +++ b/src/apm_cli/install/helpers/ref_seed.py @@ -17,8 +17,8 @@ def seed_ref_resolver_from_lockfile(ctx: InstallContext) -> None: For every locked dep that records a concrete ``resolved_commit`` for a named ``resolved_ref`` (a branch or tag), inject ``ref -> commit`` into - the per-run cache BEFORE resolution runs. Any later - ``resolve_git_reference()`` for that (repo, ref) then hits L0 and never + the dependency-scoped lock-seed cache BEFORE resolution runs. Any later + ``resolve_git_reference()`` for that dependency and ref hits L0 and never fires the commits-API tier (L1) or a clone. This closes a gap the semver lockfile-replay path @@ -35,8 +35,10 @@ def seed_ref_resolver_from_lockfile(ctx: InstallContext) -> None: changed pin is still caught and re-resolved. Cache-key invariant: the locked dependency is reconstructed as a - ``DependencyReference`` so host, port, and complete repository path flow - through the same normalized identity as ``resolve()``. + ``DependencyReference`` so its complete package identity, repository + transport, and effective host provider match ``resolve()``. A sibling + virtual package cannot inherit this seed; only fresh upstream answers + are shared repository-wide. """ from apm_cli.deps.tiered_ref_resolver import ref_freshness_policy_for_install diff --git a/src/apm_cli/install/pipeline.py b/src/apm_cli/install/pipeline.py index 8dd273ff78..201bbe9acf 100644 --- a/src/apm_cli/install/pipeline.py +++ b/src/apm_cli/install/pipeline.py @@ -61,6 +61,7 @@ from ..core.auth import AuthResolver from ..core.command_logger import InstallLogger from ..core.target_detection import EffectiveTargetDecision + from .context import InstallContext # CRITICAL: Shadow Python builtins that share names with Click commands. @@ -98,6 +99,18 @@ def _run_phase(name: str, phase, ctx): logger.verbose_detail(f"Phase: {name} -> {elapsed:.3f}s") +def _run_integration_phase(ctx: InstallContext) -> None: + """Bound durable ownership reads to integration, before lockfile persistence.""" + from .phases import integrate as _integrate_phase + + with ( + contextlib.nullcontext() + if ctx.lockfile_only + else ctx.integrators["skill"].ownership_snapshot() + ): + _run_phase("integrate", _integrate_phase, ctx) + + def _preflight_auth_check(ctx, auth_resolver, verbose: bool) -> None: """Verify auth for every distinct (host, org) before write phases. @@ -784,10 +797,8 @@ def run_install_pipeline( # noqa: C901, PLR0913, RUF100 ctx.managed_files = managed_files ctx.installed_packages = installed_packages - from .phases import integrate as _integrate_phase - ctx.tui.start_phase("integrate", total=len(ctx.deps_to_install) or 1) - _run_phase("integrate", _integrate_phase, ctx) + _run_integration_phase(ctx) # Fail-loud: if any direct dependency failed validation or # download, render the diagnostic summary and raise so the diff --git a/src/apm_cli/install/plan.py b/src/apm_cli/install/plan.py index f692328b2e..bb10428972 100644 --- a/src/apm_cli/install/plan.py +++ b/src/apm_cli/install/plan.py @@ -501,7 +501,8 @@ def lockfile_satisfies_manifest( continue if detect_ref_change(dep, locked_dep): reasons.append( - f" - {key}: declared source, ref, or transport differs from apm.lock.yaml" + f" - {key}: declared source, ref, host provider, or transport " + "differs from apm.lock.yaml" ) return (not reasons, reasons) diff --git a/src/apm_cli/integration/skill_integrator.py b/src/apm_cli/integration/skill_integrator.py index b95c547972..02ce1584f9 100644 --- a/src/apm_cli/integration/skill_integrator.py +++ b/src/apm_cli/integration/skill_integrator.py @@ -6,10 +6,12 @@ import re import shutil import stat -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Iterator, Mapping +from contextlib import contextmanager from copy import copy from dataclasses import replace from pathlib import Path +from types import MappingProxyType from typing import TYPE_CHECKING from apm_cli.install.deployed_paths import deployed_path_entry @@ -27,6 +29,7 @@ from apm_cli.integration.skill_support import ( SkillIntegrationResult, build_copy_ignore, + build_skill_ownership_maps, clean_orphaned_skills, get_lockfile_owned_agent_skills, ) @@ -391,6 +394,33 @@ def __init__(self) -> None: # Only successful writes establish same-run ownership, keyed by the # actual destination rather than a basename shared by several targets. self._native_skill_session_owners: dict[Path, str | None] = {} + self._ownership_snapshots: ( + dict[Path, tuple[Mapping[str, str], Mapping[str, str]]] | None + ) = None + + @contextmanager + def ownership_snapshot(self) -> Iterator[None]: + """Reuse immutable durable ownership during one sequential deployment batch.""" + if self._ownership_snapshots is not None: + raise RuntimeError("A skill ownership snapshot is already active") + self._ownership_snapshots = {} + try: + yield + finally: + self._ownership_snapshots = None + + def _ownership_maps(self, lockfile_root: Path) -> tuple[Mapping[str, str], Mapping[str, str]]: + """Read durable ownership once per used root in the current integration phase.""" + if self._ownership_snapshots is None: + return self._build_ownership_maps(lockfile_root) + root = lockfile_root.resolve() + if root not in self._ownership_snapshots: + owned_by, native_owners = self._build_ownership_maps(root) + self._ownership_snapshots[root] = ( + MappingProxyType(owned_by), + MappingProxyType(native_owners), + ) + return self._ownership_snapshots[root] @staticmethod def _target_skills_root(target: TargetProfile, project_root: Path) -> Path: @@ -726,7 +756,7 @@ def _promote_sub_skills( *, warn: bool = True, skip_bin: bool = False, - owned_by: dict[str, str] | None = None, + owned_by: Mapping[str, str] | None = None, diagnostics=None, managed_files=None, force: bool = False, @@ -863,33 +893,7 @@ def _promote_sub_skills( deployed.append(target) return promoted, deployed - @staticmethod - def _build_ownership_maps(lockfile_root: Path) -> tuple[dict[str, str], dict[str, str]]: - """Read sub-skill name and native-skill destination ownership once. - - Both maps store full dependency identities, never ambiguous basenames. - Native ownership includes only ``/skills/`` paths, so unrelated - artifacts or another target's same-named skill cannot confer ownership. - """ - from apm_cli.deps.lockfile import LockFile, get_lockfile_path - - owned_by: dict[str, str] = {} - native_owners: dict[str, str] = {} - lockfile = LockFile.read(get_lockfile_path(lockfile_root)) - if not lockfile: - return owned_by, native_owners - for dep in lockfile.get_package_dependencies(): - unique_key = dep.get_unique_key() - for deployed_path in dep.deployed_files: - normalized = deployed_path.rstrip("/").replace("\\", "/") - skill_name = normalized.rsplit("/", 1)[-1] - # Both maps cover all paths for sub-skill self-overwrite tracking. - owned_by[skill_name] = unique_key - # Native-owner map is scoped to skill paths only to avoid false - # attribution from prompts/hooks/commands that share a leaf name. - if "/skills/" in normalized: - native_owners[normalized] = unique_key - return owned_by, native_owners + _build_ownership_maps = staticmethod(build_skill_ownership_maps) @staticmethod def _build_skill_ownership_map(lockfile_root: Path) -> dict[str, str]: @@ -899,10 +903,7 @@ def _build_skill_ownership_map(lockfile_root: Path) -> dict[str, str]: @staticmethod def _build_native_skill_owner_map(lockfile_root: Path) -> dict[str, str]: - """Build a map of skill_name -> dep.get_unique_key() from the lockfile. - - Scoped to ``/skills/`` paths only -- see ``_build_ownership_maps`` for details. - """ + """Project native destination ownership into the legacy skill-name view.""" _, native_owners = SkillIntegrator._build_ownership_maps(lockfile_root) return {path.rsplit("/", 1)[-1]: owner for path, owner in native_owners.items()} @@ -921,21 +922,9 @@ def _promote_sub_skills_standalone( skip_bin: bool = False, source_plan: "DeployableSourcePlan | None" = None, ) -> tuple[int, list[Path]]: - """Promote sub-skills from a package that is NOT itself a skill. + """Promote an instruction package's .apm/skills without a parent skill entry. - Packages typed as INSTRUCTIONS may still ship sub-skills under - ``.apm/skills/``. This method promotes them to all active targets - that support skills, without creating a top-level skill entry for - the parent package. - - Args: - package_info: PackageInfo object with package metadata. - project_root: Root directory of the project. - targets: Optional explicit list of TargetProfile objects. - skill_subset: Optional tuple of skill names or paths to install (None = all). - - Returns: - tuple[int, list[Path]]: (count of promoted sub-skills, list of deployed dirs) + Respect explicit targets and skill subsets; return count and deployed paths. """ self.init_link_resolver(package_info, project_root) package_path = package_info.install_path @@ -953,7 +942,7 @@ def _promote_sub_skills_standalone( # _build_ownership_maps). _dep_ref = getattr(package_info, "dependency_ref", None) parent_name = _dep_ref.get_unique_key() if _dep_ref is not None else package_path.name - owned_by = self._build_skill_ownership_map(lockfile_root or project_root) + owned_by, _ = self._ownership_maps(lockfile_root or project_root) name_filter = ( source_plan.selected_skill_names if source_plan is not None @@ -1105,8 +1094,7 @@ def _integrate_native_skill( all_target_paths: list[Path] = [] primary_skill_md: Path | None = None - # Read lockfile once and derive both maps in a single pass. - owned_by, lockfile_native_owners = self._build_ownership_maps(lockfile_root or project_root) + owned_by, lockfile_native_owners = self._ownership_maps(lockfile_root or project_root) # Install supplies the pre-normalized set; do not rescan it per package. collision_managed = ( managed_files if managed_files is not None else set(lockfile_native_owners) @@ -1324,7 +1312,7 @@ def _integrate_skill_bundle( parent_name = ( _dep_ref.get_unique_key() if _dep_ref is not None else package_info.install_path.name ) - owned_by, _ = self._build_ownership_maps(lockfile_root or project_root) + owned_by, _ = self._ownership_maps(lockfile_root or project_root) total_promoted = 0 all_deployed: list[Path] = [] diff --git a/src/apm_cli/integration/skill_support.py b/src/apm_cli/integration/skill_support.py index 4d901979b7..40c81165dc 100644 --- a/src/apm_cli/integration/skill_support.py +++ b/src/apm_cli/integration/skill_support.py @@ -8,6 +8,27 @@ from apm_cli.core.deployment_state import MaterializationResult +def build_skill_ownership_maps(lockfile_root: Path) -> tuple[dict[str, str], dict[str, str]]: + """Read sub-skill names and native destinations into full-identity ownership maps.""" + from apm_cli.deps.lockfile import LockFile, get_lockfile_path + + owned_by: dict[str, str] = {} + native_owners: dict[str, str] = {} + lockfile = LockFile.read(get_lockfile_path(lockfile_root)) + if not lockfile: + return owned_by, native_owners + for dep in lockfile.get_package_dependencies(): + unique_key = dep.get_unique_key() + for deployed_path in dep.deployed_files: + normalized = deployed_path.rstrip("/").replace("\\", "/") + skill_name = normalized.rsplit("/", 1)[-1] + owned_by[skill_name] = unique_key + # Another target's same-named skill cannot establish destination ownership. + if "/skills/" in normalized: + native_owners[normalized] = unique_key + return owned_by, native_owners + + def build_copy_ignore( *, skip_bin: bool = False, diff --git a/tests/fixtures/lifecycle_bug_ledger.json b/tests/fixtures/lifecycle_bug_ledger.json index 89b68161cd..54e267b080 100644 --- a/tests/fixtures/lifecycle_bug_ledger.json +++ b/tests/fixtures/lifecycle_bug_ledger.json @@ -280,6 +280,17 @@ "tests/integration/test_cache_prune_age_lifecycle.py::test_negative_prune_preserves_installed_cache_and_valid_ages" ] }, + { + "issue": 2867, + "fixed_in": "Unreleased", + "summary": "Lexical HOME/APM_HOME aliases must deploy the same authorized bytes as canonical roots.", + "failure_modes": ["routing", "ownership", "reference", "transaction"], + "properties": ["routing.authorized_targets_only", "ownership.preserve_unowned", "source.ref_cache_coherent", "transaction.failed_command_preserves_state"], + "regression_tests": [ + "tests/integration/test_required_lifecycle_state_machine.py::test_required_global_audit_rule_matrix_for_external_roots", + "tests/integration/test_required_lifecycle_state_machine.py::test_required_frozen_semver_transport_refusal_recovers" + ] + }, { "issue": 2888, "fixed_in": "Unreleased", @@ -293,6 +304,238 @@ ] } ], + "lifecycle_contracts": [ + { + "id": "pr2876-alias-path-complete-lifecycle", + "assessment": "Bounded #2867 contract adapted from a42fe808128d5866c17d93fe63ed4b57d0426ea0 and d2f90486e748b21091403c288e2db6457b4ce7c6, not an execution receipt. Real canonical/aliased HOME/APM_HOME and project trajectories observe CLI subprocesses, immutable fixture revisions and user-owned bytes. State observed permits cache effects; scenario assertions independently enforce expected bytes, identities and write bounds. Project-only find refusal and targets observations do not claim global lookup support. The ninth project witness extends the historical eight with real semver transport refusal/recovery from 60a9a5ca85829d0f83989f5aa5fc5b04cb0ae815.", + "properties": ["filesystem.open_world_observation", "transaction.failed_command_preserves_state", "ownership.preserve_unowned", "idempotency.byte_stable", "routing.authorized_targets_only", "source.ref_cache_coherent", "outcome.status_matches_state"], + "commands": { + "": {"disposition": "semantic_na", "reason": "Root dispatch/help has no standalone dependency transition."}, + "approve": {"disposition": "semantic_na", "reason": "The passive package has no executable primitive grant to approve; scenario-local hook sentinels are preauthorized controls."}, + "audit": {"disposition": "applicable", "reason": "Checks damaged, repaired and removed installed state, using physical APM_HOME globally.", "deterministic": ["deterministic-project", "deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-project", "generated-global-canonical", "generated-global-aliased"]}, + "cache": {"disposition": "semantic_na", "reason": "Dispatch only; cache leaves are assessed individually."}, + "cache clean": {"disposition": "applicable", "reason": "Removes disposable cache without removing installed B; source rehydration proves recovery.", "deterministic": ["deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-global-canonical", "generated-global-aliased"]}, + "cache info": {"disposition": "applicable", "reason": "Reads cache bookkeeping without changing installed dependency bytes.", "deterministic": ["deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-global-canonical", "generated-global-aliased"]}, + "cache prune": {"disposition": "applicable", "reason": "Fresh entries survive supported no-op pruning.", "deterministic": ["deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-global-canonical", "generated-global-aliased"]}, + "compile": {"disposition": "applicable", "reason": "Instruction outputs at A and B must track installed revision changes.", "deterministic": ["deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-global-canonical", "generated-global-aliased"]}, + "config": {"disposition": "semantic_na", "reason": "Shows CLI preferences; fixed fixture path/target inputs are not preference resources."}, + "config get": {"disposition": "semantic_na", "reason": "Reads preference keys, not installed dependency ownership."}, + "config list": {"disposition": "semantic_na", "reason": "Lists preferences, not deployed package revisions."}, + "config set": {"disposition": "semantic_na", "reason": "Mutates preference resources outside the fixed-environment lifecycle."}, + "config unset": {"disposition": "semantic_na", "reason": "Removes preference overrides absent from this fixture."}, + "deny": {"disposition": "semantic_na", "reason": "Passive package executable grants are absent; hook sentinels are fixed controls, not grant lifecycle subjects."}, + "deps": {"disposition": "semantic_na", "reason": "Dependency dispatch only; leaves are individually assessed."}, + "deps clean": {"disposition": "applicable", "reason": "Removes modules but retains deployment/lock, then audit fails and install repairs.", "deterministic": ["deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-global-canonical", "generated-global-aliased"]}, + "deps info": {"disposition": "applicable", "reason": "Reads installed metadata from physical APM_HOME without an invented global flag.", "deterministic": ["deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-global-canonical", "generated-global-aliased"]}, + "deps list": {"disposition": "applicable", "reason": "Reads supported user-scope installed dependency inventory.", "deterministic": ["deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-global-canonical", "generated-global-aliased"]}, + "deps tree": {"disposition": "applicable", "reason": "Reads user-scope dependency graph without changing deployment.", "deterministic": ["deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-global-canonical", "generated-global-aliased"]}, + "deps update": {"disposition": "applicable", "reason": "Exercises the legacy update registration on real installed revisions.", "deterministic": ["deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-global-canonical", "generated-global-aliased"]}, + "deps why": {"disposition": "applicable", "reason": "Reads dependency provenance from the user lock.", "deterministic": ["deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-global-canonical", "generated-global-aliased"]}, + "discover": {"disposition": "semantic_na", "reason": "Inventories existing native/local packages and optionally adopts their references into a consumer manifest; these fixtures install explicitly declared Git dependencies, not undeclared native-package adoption."}, + "doctor": {"disposition": "semantic_na", "reason": "Diagnoses host tools/network/auth rather than installed passive dependency state."}, + "experimental": {"disposition": "semantic_na", "reason": "Established Claude/Hermes targets require no feature-toggle resource."}, + "experimental disable": {"disposition": "semantic_na", "reason": "No experimental feature is enabled to remove."}, + "experimental enable": {"disposition": "semantic_na", "reason": "Enabling a separate experimental capability is not passive-package materialization."}, + "experimental list": {"disposition": "semantic_na", "reason": "Lists feature flags, not package ownership."}, + "experimental reset": {"disposition": "semantic_na", "reason": "Resets feature preferences the fixture does not own."}, + "find": {"disposition": "applicable", "reason": "Project-relative lookup refuses absolute global records with unchanged state and exit 1; not successful global lookup.", "deterministic": ["deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-global-canonical", "generated-global-aliased"]}, + "info": {"disposition": "applicable", "reason": "Hidden installed-metadata alias is actually invoked with supported global scope.", "deterministic": ["deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-global-canonical", "generated-global-aliased"]}, + "init": {"disposition": "semantic_na", "reason": "Scaffolds a manifest or discovers/adopts existing native packages; this lifecycle starts from an explicit Git dependency declaration and does not perform native-package adoption."}, + "install": {"disposition": "applicable", "reason": "Materialization, replay, refusal, repair and reinstall advance one durable trajectory.", "deterministic": ["deterministic-project", "deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true", "deterministic-project-semver"], "generated": ["generated-project", "generated-global-canonical", "generated-global-aliased"]}, + "lifecycle": {"disposition": "semantic_na", "reason": "Lifecycle-script dispatch is not a dependency materialization command; script sentinels are fixed test controls."}, + "lifecycle init": {"disposition": "semantic_na", "reason": "Authors lifecycle scripts, not installed skills/instructions."}, + "lifecycle test": {"disposition": "semantic_na", "reason": "Synthetic script event execution differs from the real install hooks observed here."}, + "lifecycle trust": {"disposition": "semantic_na", "reason": "Changes script trust; sentinels are preauthorized controls, not changing trust resources."}, + "lifecycle untrust": {"disposition": "semantic_na", "reason": "Revokes script trust, outside fixed sentinel authorization."}, + "lifecycle validate": {"disposition": "semantic_na", "reason": "Validates authored script files, not installed primitive revision identity."}, + "list": {"disposition": "semantic_na", "reason": "Lists project run scripts; none are declared by this passive package."}, + "lock": {"disposition": "applicable", "reason": "Resolves lock state while preserving deployed files and ownership.", "deterministic": ["deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-global-canonical", "generated-global-aliased"]}, + "lock export": {"disposition": "applicable", "reason": "Exports real A/B commit identities as CycloneDX and SPDX.", "deterministic": ["deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-global-canonical", "generated-global-aliased"]}, + "marketplace": {"disposition": "semantic_na", "reason": "Marketplace dispatch; no marketplace catalog participates in direct Git dependencies."}, + "marketplace add": {"disposition": "semantic_na", "reason": "Adds a discovery catalog source, not a direct dependency."}, + "marketplace audit": {"disposition": "semantic_na", "reason": "Audits against marketplace sources absent from this fixture."}, + "marketplace browse": {"disposition": "semantic_na", "reason": "Browses catalog entries; no catalog is declared."}, + "marketplace check": {"disposition": "semantic_na", "reason": "Checks marketplace entries absent from the manifest."}, + "marketplace init": {"disposition": "semantic_na", "reason": "Authors a catalog rather than a direct Git package."}, + "marketplace list": {"disposition": "semantic_na", "reason": "Lists configured catalogs, not installed direct dependencies."}, + "marketplace migrate": {"disposition": "semantic_na", "reason": "Migrates a legacy catalog; no such resource is present."}, + "marketplace outdated": {"disposition": "semantic_na", "reason": "Checks catalog freshness, not the covered installed dependency freshness."}, + "marketplace package": {"disposition": "semantic_na", "reason": "Catalog-package editing dispatch; no catalog is authored."}, + "marketplace package add": {"disposition": "semantic_na", "reason": "Adds producer catalog entries, not consumer dependencies."}, + "marketplace package remove": {"disposition": "semantic_na", "reason": "Removes producer catalog entries; consumer removal uses uninstall/prune."}, + "marketplace package set": {"disposition": "semantic_na", "reason": "Edits catalog metadata, absent for direct Git references."}, + "marketplace remove": {"disposition": "semantic_na", "reason": "Removes discovery catalogs, not installed direct dependencies."}, + "marketplace update": {"disposition": "semantic_na", "reason": "Refreshes catalog state, distinct from dependency revision update."}, + "marketplace validate": {"disposition": "semantic_na", "reason": "Validates catalog authoring documents absent here."}, + "mcp": {"disposition": "semantic_na", "reason": "Service dispatch; package declares no MCP service."}, + "mcp install": {"disposition": "semantic_na", "reason": "Configures executable MCP servers absent here."}, + "mcp list": {"disposition": "semantic_na", "reason": "Lists MCP services, not passive skills."}, + "mcp search": {"disposition": "semantic_na", "reason": "Searches MCP registries, not the explicit Git source."}, + "mcp show": {"disposition": "semantic_na", "reason": "Reads MCP registry records absent from the fixture."}, + "outdated": {"disposition": "applicable", "reason": "Observes upstream B while deployment remains A; cache effects are allowed.", "deterministic": ["deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-global-canonical", "generated-global-aliased"]}, + "pack": {"disposition": "semantic_na", "reason": "Produces an archive; this lifecycle consumes Git rather than a bundle."}, + "plugin": {"disposition": "semantic_na", "reason": "Plugin authoring dispatch; the fixture is an existing passive package."}, + "plugin init": {"disposition": "semantic_na", "reason": "Scaffolds a producer rather than transitioning an installed dependency."}, + "policy": {"disposition": "semantic_na", "reason": "Policy dispatch; no changing policy document participates."}, + "policy explain": {"disposition": "semantic_na", "reason": "Explains executable trust, not passive deployment ownership."}, + "policy status": {"disposition": "semantic_na", "reason": "Inspects policy posture; no policy source changes here."}, + "preview": {"disposition": "semantic_na", "reason": "Previews run-script prompts; no project run script is declared."}, + "prune": {"disposition": "applicable", "reason": "Project declaration removal; global removal uses uninstall instead.", "deterministic": ["deterministic-project"], "generated": ["generated-project"]}, + "publish": {"disposition": "semantic_na", "reason": "Publishes registry artifacts; fixture revisions are local Git commits."}, + "run": {"disposition": "semantic_na", "reason": "Executes project run scripts, absent from this passive package."}, + "runtime": {"disposition": "semantic_na", "reason": "AI-runtime dispatch; runtime binaries are not package primitives."}, + "runtime list": {"disposition": "semantic_na", "reason": "Lists AI runtime binaries, not dependency revisions."}, + "runtime remove": {"disposition": "semantic_na", "reason": "Removes runtime binaries; dependency removal is separately covered."}, + "runtime setup": {"disposition": "semantic_na", "reason": "Installs AI runtime binaries outside this passive package lifecycle."}, + "runtime status": {"disposition": "semantic_na", "reason": "Checks runtime availability rather than installed package state."}, + "search": {"disposition": "semantic_na", "reason": "Searches marketplace catalogs absent from the direct Git fixture."}, + "self-update": {"disposition": "semantic_na", "reason": "Replaces APM itself; this verifier holds its source interpreter and launcher fixed."}, + "targets": {"disposition": "applicable", "reason": "Observes empty caller project markers, not global deployment inventory.", "deterministic": ["deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-global-canonical", "generated-global-aliased"]}, + "uninstall": {"disposition": "applicable", "reason": "Removes globally owned bytes, permits clean audit and fresh reinstall.", "deterministic": ["deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-global-canonical", "generated-global-aliased"]}, + "unpack": {"disposition": "semantic_na", "reason": "Extracts bundles; none are declared in the Git fixture."}, + "update": {"disposition": "applicable", "reason": "Advances installed bytes, resolved commit and ownership to B.", "deterministic": ["deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-global-canonical", "generated-global-aliased"]}, + "view": {"disposition": "applicable", "reason": "Reads installed package metadata, not remote registry discovery.", "deterministic": ["deterministic-global-canonical", "deterministic-global-aliased", "deterministic-external-roots-false", "deterministic-external-roots-true"], "generated": ["generated-global-canonical", "generated-global-aliased"]} + }, + "trajectories": { + "project": [ + {"command":"install","argv_contains":["--dry-run"],"returncode":0,"state":"observed"}, + {"command":"install","argv_contains":[],"returncode":0,"state":"changed"}, + {"command":"install","argv_contains":[],"returncode":0,"state":"observed"}, + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":1,"state":"unchanged","preparation":"Replace the owned project skill with tampered user bytes."}, + {"command":"install","argv_contains":[],"returncode":0,"state":"observed"}, + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":0,"state":"unchanged"}, + {"command":"prune","argv_contains":[],"returncode":0,"state":"observed","preparation":"Remove the dependency declaration from this manifest."}, + {"command":"install","argv_contains":[],"returncode":0,"state":"observed","preparation":"Restore the pinned dependency declaration."}, + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":0,"state":"unchanged"}, + {"command":"prune","argv_contains":[],"returncode":0,"state":"observed","preparation":"Tamper with the skill and remove its declaration; preserve user bytes."}, + {"command":"install","argv_contains":[],"returncode":0,"state":"observed","preparation":"Restore the declaration, retaining collision bytes."}, + {"command":"install","argv_contains":["--force"],"returncode":0,"state":"observed"}, + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":0,"state":"unchanged"} + ], + "model-global-start": [ + {"command":"install","argv_contains":["--global","--dry-run"],"returncode":0,"state":"observed"}, + {"command":"install","argv_contains":["--global"],"returncode":0,"state":"changed"}, + {"command":"install","argv_contains":["--global"],"returncode":0,"state":"observed"}, + {"command":"compile","argv_contains":["--global"],"returncode":0,"state":"changed"}, + {"command":"lock","argv_contains":["--global"],"returncode":0,"state":"observed"} + ], + "required-global-start": [ + {"command":"install","argv_contains":["--global"],"returncode":0,"state":"changed"}, + {"command":"install","argv_contains":["--global"],"returncode":0,"state":"observed"} + ], + "global-readers": [ + {"command":"deps list","argv_contains":["--global"],"returncode":0,"state":"observed"}, + {"command":"deps tree","argv_contains":["--global"],"returncode":0,"state":"observed"}, + {"command":"deps why","argv_contains":["--global"],"returncode":0,"state":"observed"}, + {"command":"view","argv_contains":["--global"],"returncode":0,"state":"observed"}, + {"command":"info","argv_contains":["--global"],"returncode":0,"state":"observed"}, + {"command":"deps info","argv_contains":[],"returncode":0,"state":"observed","context":"APM_HOME"} + ], + "model-cache-readers": [ + {"command":"cache info","argv_contains":[],"returncode":0,"state":"observed"}, + {"command":"cache prune","argv_contains":["--days","30"],"returncode":0,"state":"observed"} + ], + "required-cache-readers": [ + {"command":"cache info","argv_contains":[],"returncode":0,"state":"observed","context":"APM_HOME"}, + {"command":"cache prune","argv_contains":["--days","30"],"returncode":0,"state":"observed","context":"APM_HOME"} + ], + "project-only-readers": [ + {"command":"targets","argv_contains":["--json"],"returncode":0,"state":"unchanged"}, + {"command":"find","argv_contains":[],"returncode":1,"state":"unchanged","context":"APM_HOME"} + ], + "model-global-update": [ + {"command":"lock export","argv_contains":["--global","--format","cyclonedx"],"returncode":0,"state":"unchanged"}, + {"command":"lock export","argv_contains":["--global","--format","spdx"],"returncode":0,"state":"unchanged"}, + {"command":"outdated","argv_contains":["--global"],"returncode":0,"state":"observed","preparation":"Publish B outside observed roots while installed A remains intact."}, + {"command":"install","argv_contains":["--global","--frozen"],"returncode":0,"state":"observed"}, + {"command":"update","argv_contains":["--global","--yes"],"returncode":0,"state":"changed"}, + {"command":"compile","argv_contains":["--global"],"returncode":0,"state":"changed"}, + {"command":"lock export","argv_contains":["--global","--format","cyclonedx"],"returncode":0,"state":"unchanged"}, + {"command":"lock export","argv_contains":["--global","--format","spdx"],"returncode":0,"state":"unchanged"}, + {"command":"deps update","argv_contains":["--global"],"returncode":0,"state":"observed"}, + {"command":"install","argv_contains":["--global","--frozen"],"returncode":0,"state":"observed"}, + {"command":"install","argv_contains":["--global","--frozen"],"returncode":1,"state":"unchanged","preparation":"Declare a missing ref to require frozen refusal."}, + {"command":"install","argv_contains":["--global","--frozen"],"returncode":1,"state":"unchanged","preparation":"Restore ref then explicitly select GitLab for the same custom host."}, + {"command":"install","argv_contains":["--global","--frozen"],"returncode":0,"state":"observed","preparation":"Restore exact generic-provider manifest bytes."}, + {"command":"install","argv_contains":["--global"],"returncode":0,"state":"observed"} + ], + "required-global-update": [ + {"command":"compile","argv_contains":["--global"],"returncode":0,"state":"changed"}, + {"command":"lock","argv_contains":["--global"],"returncode":0,"state":"observed"}, + {"command":"lock export","argv_contains":["--global"],"returncode":0,"state":"unchanged"}, + {"command":"outdated","argv_contains":["--global"],"returncode":0,"state":"observed","preparation":"Publish B outside observed roots while installed A remains intact."}, + {"command":"update","argv_contains":["--global","--dry-run"],"returncode":0,"state":"observed"}, + {"command":"install","argv_contains":["--global","--frozen"],"returncode":0,"state":"observed"}, + {"command":"update","argv_contains":["--global","--yes"],"returncode":0,"state":"changed"}, + {"command":"compile","argv_contains":["--global"],"returncode":0,"state":"changed"}, + {"command":"lock export","argv_contains":["--global","--format","spdx"],"returncode":0,"state":"unchanged"}, + {"command":"deps update","argv_contains":["--global"],"returncode":0,"state":"observed"}, + {"command":"lock","argv_contains":["--global","--update"],"returncode":0,"state":"observed"}, + {"command":"install","argv_contains":["--global","--frozen"],"returncode":0,"state":"observed"}, + {"command":"install","argv_contains":["--global","--frozen"],"returncode":1,"state":"unchanged","preparation":"Declare A while lock records B."}, + {"command":"install","argv_contains":["--global"],"returncode":0,"state":"observed","preparation":"Restore original main declaration."} + ], + "global-clean-audit": [ + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":0,"state":"unchanged","context":"APM_HOME"} + ], + "required-audit-matrix": [ + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":1,"state":"unchanged","context":"APM_HOME","preparation":"Delete an owned skill."}, + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":0,"state":"unchanged","context":"APM_HOME","preparation":"Restore exact B bytes after deletion."}, + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":1,"state":"unchanged","context":"APM_HOME","preparation":"Edit an owned skill."}, + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":0,"state":"unchanged","context":"APM_HOME","preparation":"Restore exact B bytes after modification."}, + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":1,"state":"unchanged","context":"APM_HOME","preparation":"Move source modules outside state root."}, + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":0,"state":"unchanged","context":"APM_HOME","preparation":"Restore source modules."}, + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":1,"state":"unchanged","context":"APM_HOME","preparation":"Replace ledger ownership with unknown owner."}, + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":0,"state":"unchanged","context":"APM_HOME","preparation":"Restore exact ownership metadata."}, + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":1,"state":"unchanged","context":"APM_HOME","preparation":"Replace skill with escaping symlink."}, + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":0,"state":"unchanged","context":"APM_HOME","preparation":"Restore skill bytes after symlink control."}, + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":1,"state":"unchanged","context":"APM_HOME","preparation":"Delete Claude skill and modify Hermes skill together."}, + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":0,"state":"unchanged","context":"APM_HOME","preparation":"Restore both target deployments."} + ], + "global-clean-repair": [ + {"command":"cache clean","argv_contains":["--yes"],"returncode":0,"state":"observed"}, + {"command":"deps clean","argv_contains":["--dry-run"],"returncode":0,"state":"unchanged","context":"APM_HOME"}, + {"command":"deps clean","argv_contains":["--yes"],"returncode":0,"state":"changed","context":"APM_HOME"}, + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":1,"state":"unchanged","context":"APM_HOME"}, + {"command":"install","argv_contains":["--global"],"returncode":0,"state":"observed"} + ], + "model-tamper-repair": [ + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":1,"state":"unchanged","context":"APM_HOME","preparation":"Tamper with deployed skill bytes."}, + {"command":"install","argv_contains":["--global"],"returncode":0,"state":"observed"} + ], + "global-remove": [ + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":0,"state":"unchanged","context":"APM_HOME"}, + {"command":"uninstall","argv_contains":["--global"],"returncode":0,"state":"changed"}, + {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":0,"state":"unchanged","context":"APM_HOME"} + ], + "global-reinstall": [ + {"command":"install","argv_contains":["--global"],"returncode":0,"state":"observed","preparation":"Restore removed declaration in the same user manifest; model variants publish C and seed only a sibling virtual-package lock entry, requiring current upstream rather than cached B."} + ], + "semver": [ + {"command":"install","argv_contains":["--allow-insecure"],"returncode":0,"state":"observed","preparation":"Declare real tagged semver range with initial transport."}, + {"command":"install","argv_contains":["--allow-insecure","--frozen"],"returncode":0,"state":"unchanged"}, + {"command":"install","argv_contains":["--allow-insecure","--frozen"],"returncode":1,"state":"unchanged","preparation":"Flip transport while retaining semver range."}, + {"command":"install","argv_contains":["--allow-insecure","--frozen"],"returncode":0,"state":"unchanged","preparation":"Restore accepted manifest bytes."}, + {"command":"install","argv_contains":["--allow-insecure"],"returncode":0,"state":"observed","preparation":"Declare inverse initial transport with same real tagged semver range."}, + {"command":"install","argv_contains":["--allow-insecure","--frozen"],"returncode":0,"state":"unchanged"}, + {"command":"install","argv_contains":["--allow-insecure","--frozen"],"returncode":1,"state":"unchanged","preparation":"Flip inverse transport while retaining semver range."}, + {"command":"install","argv_contains":["--allow-insecure","--frozen"],"returncode":0,"state":"unchanged","preparation":"Restore accepted manifest bytes."}, + {"command":"install","argv_contains":[],"returncode":0,"state":"observed","preparation":"Restore original dependency declaration."} + ] + }, + "witnesses": [ + {"id":"deterministic-project","nodeid":"tests/integration/test_generated_lifecycle_state_machine.py::test_generated_lifecycle_mandatory_replay[project]","kind":"deterministic","dimensions":{"variant":"project"},"trajectory":["project"]}, + {"id":"deterministic-global-canonical","nodeid":"tests/integration/test_generated_lifecycle_state_machine.py::test_generated_lifecycle_mandatory_replay[global-canonical]","kind":"deterministic","dimensions":{"variant":"global-canonical"},"trajectory":["model-global-start","global-readers","model-cache-readers","project-only-readers","model-global-update","global-clean-repair","model-tamper-repair","global-remove","global-reinstall","global-remove"]}, + {"id":"deterministic-global-aliased","nodeid":"tests/integration/test_generated_lifecycle_state_machine.py::test_generated_lifecycle_mandatory_replay[global-aliased]","kind":"deterministic","dimensions":{"variant":"global-aliased"},"trajectory":["model-global-start","global-readers","model-cache-readers","project-only-readers","model-global-update","global-clean-repair","model-tamper-repair","global-remove","global-reinstall","global-remove"]}, + {"id":"generated-project","nodeid":"tests/integration/test_generated_lifecycle_state_machine.py::test_generated_lifecycle_sequences_preserve_reference_model[project]","kind":"generated","dimensions":{"variant":"project"},"trajectory":["project"]}, + {"id":"generated-global-canonical","nodeid":"tests/integration/test_generated_lifecycle_state_machine.py::test_generated_lifecycle_sequences_preserve_reference_model[global-canonical]","kind":"generated","dimensions":{"variant":"global-canonical"},"trajectory":["model-global-start","global-readers","model-cache-readers","project-only-readers","model-global-update","global-clean-repair","model-tamper-repair","global-remove","global-reinstall","global-remove"]}, + {"id":"generated-global-aliased","nodeid":"tests/integration/test_generated_lifecycle_state_machine.py::test_generated_lifecycle_sequences_preserve_reference_model[global-aliased]","kind":"generated","dimensions":{"variant":"global-aliased"},"trajectory":["model-global-start","global-readers","model-cache-readers","project-only-readers","model-global-update","global-clean-repair","model-tamper-repair","global-remove","global-reinstall","global-remove"]}, + {"id":"deterministic-external-roots-false","nodeid":"tests/integration/test_required_lifecycle_state_machine.py::test_required_global_audit_rule_matrix_for_external_roots[False]","kind":"deterministic","dimensions":{"aliased_home":false},"trajectory":["required-global-start","global-readers","required-cache-readers","project-only-readers","required-global-update","semver","global-clean-audit","required-audit-matrix","global-clean-repair","global-remove","global-reinstall","global-remove"]}, + {"id":"deterministic-external-roots-true","nodeid":"tests/integration/test_required_lifecycle_state_machine.py::test_required_global_audit_rule_matrix_for_external_roots[True]","kind":"deterministic","dimensions":{"aliased_home":true},"trajectory":["required-global-start","global-readers","required-cache-readers","project-only-readers","required-global-update","semver","global-clean-audit","required-audit-matrix","global-clean-repair","global-remove","global-reinstall","global-remove"]}, + {"id":"deterministic-project-semver","nodeid":"tests/integration/test_required_lifecycle_state_machine.py::test_required_frozen_semver_transport_refusal_recovers","kind":"deterministic","dimensions":{},"trajectory":["semver"]} + ] + } + ], "known_gaps": [ { "id": "partial-uninstall-deletion", diff --git a/tests/fixtures/lifecycle_completion.schema.json b/tests/fixtures/lifecycle_completion.schema.json new file mode 100644 index 0000000000..6c00bc4669 --- /dev/null +++ b/tests/fixtures/lifecycle_completion.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Bounded #2867 source-Python native lifecycle completion", + "type": "object", + "additionalProperties": false, + "required": ["version", "contract_id", "base_sha", "head_sha", "tested_tree", "lane", "status", "report_path", "report_sha256", "witnesses"], + "properties": { + "version": {"type": "integer", "const": 1}, + "contract_id": {"const": "pr2876-alias-path-complete-lifecycle"}, + "base_sha": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "head_sha": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "tested_tree": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "lane": {"const": "full"}, + "status": {"const": "passed"}, + "report_path": {"type": "string", "minLength": 1, "pattern": "\\S"}, + "report_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "witnesses": { + "type": "array", + "uniqueItems": true, + "minItems": 9, + "maxItems": 9, + "items": { + "enum": [ + "tests/integration/test_required_lifecycle_state_machine.py::test_required_global_audit_rule_matrix_for_external_roots[False]", + "tests/integration/test_required_lifecycle_state_machine.py::test_required_global_audit_rule_matrix_for_external_roots[True]", + "tests/integration/test_generated_lifecycle_state_machine.py::test_generated_lifecycle_mandatory_replay[project]", + "tests/integration/test_generated_lifecycle_state_machine.py::test_generated_lifecycle_mandatory_replay[global-canonical]", + "tests/integration/test_generated_lifecycle_state_machine.py::test_generated_lifecycle_mandatory_replay[global-aliased]", + "tests/integration/test_generated_lifecycle_state_machine.py::test_generated_lifecycle_sequences_preserve_reference_model[project]", + "tests/integration/test_generated_lifecycle_state_machine.py::test_generated_lifecycle_sequences_preserve_reference_model[global-canonical]", + "tests/integration/test_generated_lifecycle_state_machine.py::test_generated_lifecycle_sequences_preserve_reference_model[global-aliased]", + "tests/integration/test_required_lifecycle_state_machine.py::test_required_frozen_semver_transport_refusal_recovers" + ] + } + } + } +} diff --git a/tests/integration/test_architecture_cache_identity_linter.py b/tests/integration/test_architecture_cache_identity_linter.py index 3fcc4786bd..c3d58868fb 100644 --- a/tests/integration/test_architecture_cache_identity_linter.py +++ b/tests/integration/test_architecture_cache_identity_linter.py @@ -326,14 +326,23 @@ class Mutation: ), expected="TieredRefResolver.seed is missing", ), - "seed-put-truncated": Mutation( + "seed-key-dependency-truncated": Mutation( apply=lambda root: _rewrite( root, TIERED_RESOLVER, - " self._cache.put(_repository_cache_identity(dep_ref), ref, sha.lower())\n", - " self._cache.put(dep_ref.repo_url, ref, sha.lower())\n", + " dep_ref.get_unique_key(),\n", + " dep_ref.repo_url,\n", ), - expected="lockfile seed must call _cache.put(", + expected="lockfile seed key must combine dependency identity", + ), + "seed-key-repository-truncated": Mutation( + apply=lambda root: _rewrite( + root, + TIERED_RESOLVER, + " _repository_cache_identity(dep_ref),\n", + " dep_ref.repo_url,\n", + ), + expected="lockfile seed key must combine dependency identity", ), # -- Fail-closed cases --------------------------------------------------- "shared-source-missing": Mutation( diff --git a/tests/integration/test_architecture_dependency_reference.py b/tests/integration/test_architecture_dependency_reference.py index 11c3d56e7a..c7b5a3535a 100644 --- a/tests/integration/test_architecture_dependency_reference.py +++ b/tests/integration/test_architecture_dependency_reference.py @@ -14,6 +14,7 @@ DOWNLOAD = "src/apm_cli/install/phases/download.py" INTEGRATE = "src/apm_cli/install/phases/integrate.py" PLAN = "src/apm_cli/install/plan.py" +DEPS_CLI = "src/apm_cli/commands/deps/cli.py" def test_embedded_git_url_subpath_has_one_provider_aware_owner() -> None: @@ -31,6 +32,7 @@ def test_embedded_git_url_subpath_has_one_provider_aware_owner() -> None: assert DOWNLOAD in owner_registry assert INTEGRATE in owner_registry assert PLAN in owner_registry + assert DEPS_CLI in owner_registry assert "embedded git URL subpath validation" in owner_registry assert rule.guard_ids == (RULE_ID,) assert report.failures == () @@ -97,6 +99,16 @@ def test_alias_consumers_share_validation_and_materialization() -> None: "install_path = dep_ref.get_install_path(apm_modules_dir)", "install_path = apm_modules_dir / dep_ref.alias", ), + ( + DEPS_CLI, + "install_path = dep.get_install_path(apm_modules_path)", + "install_path = apm_modules_path / dep.alias", + ), + ( + DEPS_CLI, + "install_path = dep.to_dependency_ref().get_install_path(apm_modules_path)", + "install_path = apm_modules_path / dep.alias", + ), ( "src/apm_cli/install/phases/resolve.py", "cache_validation_callback=partial(", @@ -177,6 +189,10 @@ def test_install_phase_materialization_guard_rejects_alias_path_bypass( [ ("is_full_revision_pin(reference)", "False"), ("detect_ref_change(dep, locked_dep)", "False"), + ( + "if detect_ref_change(dep, locked_dep):", + "if dep.is_insecure != locked_dep.is_insecure or detect_ref_change(dep, locked_dep):", + ), ], ) def test_frozen_manifest_identity_guard_rejects_helper_bypass(old: str, new: str) -> None: diff --git a/tests/integration/test_architecture_host_provider_identity.py b/tests/integration/test_architecture_host_provider_identity.py new file mode 100644 index 0000000000..b9f51adb46 --- /dev/null +++ b/tests/integration/test_architecture_host_provider_identity.py @@ -0,0 +1,76 @@ +"""Architecture coverage for effective host-provider identity ownership.""" + +import json +from pathlib import Path + +import pytest + +from scripts.architecture_linter.runner import registered_rules, run_selected_rules + +pytestmark = pytest.mark.component + +ROOT = Path(__file__).resolve().parents[2] +RULE_ID = "transport-platform-host-credential-resolution" +OWNER = "src/apm_cli/core/host_providers.py" +CONSUMER = "src/apm_cli/drift.py" +LOCK_SEED_CONSUMER = "src/apm_cli/deps/tiered_ref_resolver.py" +REGISTRY = ".apm/architecture/owners/transport-auth-platform.json" + + +def test_effective_host_provider_identity_has_registered_owner_and_consumer() -> None: + owner = (ROOT / OWNER).read_text(encoding="utf-8") + consumer = (ROOT / CONSUMER).read_text(encoding="utf-8") + lock_seed_consumer = (ROOT / LOCK_SEED_CONSUMER).read_text(encoding="utf-8") + registry = json.loads((ROOT / REGISTRY).read_text(encoding="utf-8")) + owners = {entry["id"]: entry for entry in registry["owners"]} + rule = next(rule for rule in registered_rules() if rule.id == RULE_ID) + report = run_selected_rules(ROOT, (RULE_ID,)) + + assert owner.count("def effective_host_provider_identity(") == 1 + assert "return provider.kind, provider.credential_purpose" in owner + assert consumer.count("effective_host_provider_identity(") == 2 + assert lock_seed_consumer.count("effective_host_provider_identity(") == 1 + assert OWNER in owners["host-credential-resolution"]["selectors"] + assert CONSUMER in owners["host-credential-resolution"]["selectors"] + assert LOCK_SEED_CONSUMER in owners["git-ref-freshness"]["selectors"] + assert "dependency-scoped lock seeds" in owners["host-credential-resolution"]["decision"] + assert rule.guard_ids[-1] == RULE_ID + assert report.failures == () + assert report.violations == () + + +@pytest.mark.parametrize( + ("path", "old", "new", "message"), + [ + ( + OWNER, + "return provider.kind, provider.credential_purpose", + "return provider.kind, provider.kind", + "Effective host-provider identity must derive backend and credential route", + ), + ( + CONSUMER, + "locked_provider = effective_host_provider_identity(", + "locked_provider = manifest_provider_identity(", + "Dependency drift must", + ), + ( + LOCK_SEED_CONSUMER, + "effective_host_provider_identity(", + "raw_host_provider_identity(", + "Dependency-scoped lock-seed identity must", + ), + ], +) +def test_effective_host_provider_identity_guard_rejects_owner_or_consumer_bypass( + path: str, old: str, new: str, message: str +) -> None: + source = (ROOT / path).read_text(encoding="utf-8") + mutated = source.replace(old, new, 1) + assert mutated != source + report = run_selected_rules(ROOT, (RULE_ID,), source_overrides={path: mutated}) + assert report.failures == () + assert any( + violation.rule_id == RULE_ID and message in violation.message + for violation in report.violations + ) diff --git a/tests/integration/test_architecture_native_lifecycle_evidence.py b/tests/integration/test_architecture_native_lifecycle_evidence.py new file mode 100644 index 0000000000..cfe01e7f69 --- /dev/null +++ b/tests/integration/test_architecture_native_lifecycle_evidence.py @@ -0,0 +1,141 @@ +"""Static dual guard for bounded native lifecycle proof, never a native campaign.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from scripts.architecture_linter.runner import registered_rules, run_selected_rules + +pytestmark = pytest.mark.component + +ROOT = Path(__file__).resolve().parents[2] +RULE = "contracts-tooling-native-lifecycle-evidence" +PROVIDER = "scripts/check_lifecycle_evidence.py" +CONTRACTS = "scripts/lifecycle_contracts.py" +OBSERVER = "tests/utils/lifecycle_evidence.py" +RUNNER = "tests/utils/apm_lifecycle_runner.py" +REGISTRY = ".apm/architecture/owners/contracts-tooling.json" + + +def test_native_evidence_has_one_registered_owner_and_bounded_guard() -> None: + registry = json.loads((ROOT / REGISTRY).read_text(encoding="ascii")) + owners = {entry["id"]: entry for entry in registry["owners"]} + native = owners["native-lifecycle-evidence"] + rules = [rule for rule in registered_rules() if RULE in rule.guard_ids] + assert len(rules) == 1 + assert rules[0].id == RULE + assert native["guards"] == [RULE] + assert {PROVIDER, CONTRACTS, OBSERVER} <= set(native["selectors"]) + assert ( + "src/apm_cli/commands/deps/cli.py" + in owners["dependency-identity-materialization"]["selectors"] + ) + assert not any(path.startswith("src/") for path in native["selectors"]) + report = run_selected_rules(ROOT, (RULE,)) + assert report.failures == () + assert report.violations == () + + +@pytest.mark.parametrize( + ("path", "old", "new"), + [ + (RUNNER, "subprocess.Popen(", "fabricated_process("), + (RUNNER, "returncode=process.returncode", "returncode=0"), + (PROVIDER, '"refs/replace"', '"refs/unrelated"'), + ( + PROVIDER, + 'resolved_head != git(root, "rev-parse", "HEAD")', + "resolved_head != resolved_head", + ), + (PROVIDER, '"--untracked-files=all"', '"--untracked-files=no"'), + (PROVIDER, '"HEAD^{tree}"', '"HEAD"'), + (PROVIDER, "Path(apm_cli.__file__).resolve().parent != source", "False"), + (PROVIDER, "interpreter.samefile(sys.executable)", "True"), + (PROVIDER, 'fingerprint(source / "cli.py")', '"unverified"'), + ( + PROVIDER, + "identity = candidate(ROOT, args.base, args.head)", + "identity = cached_candidate()", + ), + (PROVIDER, "if candidate(ROOT, args.base, args.head) != identity:", "if False:"), + (PROVIDER, 'if source_profile(ROOT)[1] != report["profile"]:', "if False:"), + ( + PROVIDER, + "finally:\n if plugin is not None:", + "else:\n if plugin is not None:", + ), + (PROVIDER, "pytest.main(", "cached_report("), + (PROVIDER, "plugins=[plugin]", "plugins=[]"), + ( + PROVIDER, + 'validate_execution(witness, plugin.records[witness["nodeid"]])', + "accept_record(witness)", + ), + (PROVIDER, "nodeids = sorted(REQUIRED_WITNESSES)", "nodeids = sorted([])"), + (PROVIDER, "report = execute(args)", "report = load_saved_report(args)"), + (PROVIDER, "Draft202012Validator(schema).iter_errors(summary)", "iter(())"), + ( + PROVIDER, + "validate_completion(args.completion, report, args.report)", + "accept_completion()", + ), + (PROVIDER, "completion_summary(report, args.report, raw)", "authored_summary()"), + ( + PROVIDER, + "_validate_completion_summary(summary)\n driver", + "accept_summary(summary)\n driver", + ), + (PROVIDER, 'summary["report_sha256"] != hashlib.sha256(raw).hexdigest()', "False"), + (PROVIDER, '"tested_tree": "tested_tree"', '"tested_tree": "head"'), + (PROVIDER, "summary[claim] != native.get(field)", "False"), + (CONTRACTS, "REQUIRED_WITNESSES = (", "REMOVED_REQUIRED_WITNESSES = ("), + (CONTRACTS, "== set(REQUIRED_WITNESSES)", "== set()"), + (CONTRACTS, "validate_contracts(current, inventory)", "unvalidated_contracts(current)"), + ( + OBSERVER, + "original = ApmLifecycleRunner._run_with_timeout", + "original = fabricated_result", + ), + (OBSERVER, 'self.patch.setattr(ApmLifecycleRunner, "_run_with_timeout", observe)', "pass"), + (OBSERVER, "result = original(runner, args, **kwargs)", "result = fabricated_result()"), + (OBSERVER, "before = snapshot(roots)", 'before = "constant"'), + (OBSERVER, '"after": snapshot(roots)', '"after": "constant"'), + ( + OBSERVER, + 'self._source_identity(cwd, env, kwargs["timeout_seconds"], command)', + "cached_source()", + ), + (OBSERVER, "probe = subprocess.run(", "probe = cached_probe("), + (OBSERVER, "if source != expected:", "if False:"), + (OBSERVER, "model = stateful.run_state_machine_as_test", "model = fabricated_model"), + (OBSERVER, "result = model(*args, **kwargs)", "result = None"), + (OBSERVER, "set(actual) != self.nodeids", "False"), + (OBSERVER, 'hasattr(report, "wasxfail")', "False"), + ], +) +def test_native_evidence_guard_rejects_proof_bypass(path: str, old: str, new: str) -> None: + source = (ROOT / path).read_text(encoding="utf-8") + if old == "probe = subprocess.run(": + prefix, suffix = source.rsplit(old, 1) + mutated = prefix + new + suffix + else: + mutated = source.replace(old, new, 1) + assert mutated != source + report = run_selected_rules(ROOT, (RULE,), source_overrides={path: mutated}) + assert report.failures == () + assert any(item.rule_id == RULE and item.path == path for item in report.violations) + + +@pytest.mark.parametrize("path", [PROVIDER, CONTRACTS, OBSERVER]) +def test_native_evidence_guard_rejects_missing_and_invalid_owner_source(path: str) -> None: + for mutation in ("", "def broken("): + report = run_selected_rules(ROOT, (RULE,), source_overrides={path: mutation}) + if mutation: + assert report.failures + assert all(item.stage == f"parse:{path}" for item in report.failures) + else: + assert report.failures == () + assert any(item.rule_id == RULE and item.path == path for item in report.violations) diff --git a/tests/integration/test_architecture_owner_rule_mutations.py b/tests/integration/test_architecture_owner_rule_mutations.py index cc38df9a8e..b939f2902c 100644 --- a/tests/integration/test_architecture_owner_rule_mutations.py +++ b/tests/integration/test_architecture_owner_rule_mutations.py @@ -181,6 +181,14 @@ class MutationCase: new='import os\n\nos.environ.get("SOURCE_DATE_EPOCH")', intent="An Agent Plugin consumer reimplements the reproducible timestamp fallback.", ), + MutationCase( + guard_id="contracts-tooling-native-lifecycle-evidence", + rule_id="contracts-tooling-native-lifecycle-evidence", + path="scripts/check_lifecycle_evidence.py", + old="plugins=[plugin]", + new="plugins=[]", + intent="Native lifecycle execution disconnects pytest from the fresh subprocess observer.", + ), MutationCase( guard_id="contracts-tooling-policy-content-hash", rule_id="contracts-tooling-policy-content-hash", diff --git a/tests/integration/test_architecture_ref_freshness.py b/tests/integration/test_architecture_ref_freshness.py new file mode 100644 index 0000000000..e73af72b83 --- /dev/null +++ b/tests/integration/test_architecture_ref_freshness.py @@ -0,0 +1,33 @@ +"""Static guard for lock-specific install freshness policy.""" + +from pathlib import Path + +import pytest + +from scripts.architecture_linter.runner import run_selected_rules + +pytestmark = pytest.mark.component + +ROOT = Path(__file__).resolve().parents[2] +OWNER = "src/apm_cli/deps/tiered_ref_resolver.py" +RULE = "transport-platform-ref-freshness" + + +@pytest.mark.parametrize( + ("old", "new"), + [ + ("return cls.LOCKED_OR_CURRENT", "return cls.REPRODUCIBLE"), + ("dep_ref.get_unique_key(),", '"",'), + ], +) +def test_install_freshness_guard_rejects_blanket_bare_cache_replay(old: str, new: str) -> None: + """An unrelated lock must not authorize a stale mutable-ref cache answer.""" + baseline = run_selected_rules(ROOT, (RULE,)) + assert baseline.failures == () + assert baseline.violations == () + source = (ROOT / OWNER).read_text(encoding="utf-8") + mutated = source.replace(old, new, 1) + assert mutated != source + report = run_selected_rules(ROOT, (RULE,), source_overrides={OWNER: mutated}) + assert report.failures == () + assert any(violation.rule_id == RULE for violation in report.violations) diff --git a/tests/integration/test_architecture_skill_provenance_scope.py b/tests/integration/test_architecture_skill_provenance_scope.py index d826b9988e..1ce6148dde 100644 --- a/tests/integration/test_architecture_skill_provenance_scope.py +++ b/tests/integration/test_architecture_skill_provenance_scope.py @@ -13,6 +13,60 @@ ROOT = Path(__file__).resolve().parents[2] RULE_ID = "install-deployment-provenance-state" SKILL_INTEGRATOR = "src/apm_cli/integration/skill_integrator.py" +PIPELINE = "src/apm_cli/install/pipeline.py" + + +@pytest.mark.parametrize( + ("path", "old", "new", "message"), + [ + ( + SKILL_INTEGRATOR, + "finally:\n self._ownership_snapshots = None", + "finally:\n pass", + "snapshots must reuse the canonical builder immutably", + ), + ( + SKILL_INTEGRATOR, + "MappingProxyType(owned_by)", + "owned_by", + "snapshots must reuse the canonical builder immutably", + ), + ( + SKILL_INTEGRATOR, + "self._ownership_maps(lockfile_root or project_root)", + "self._build_ownership_maps(lockfile_root or project_root)", + "all skill layouts must consume", + ), + ( + PIPELINE, + 'else ctx.integrators["skill"].ownership_snapshot()', + "else contextlib.nullcontext()", + "snapshot lifetime must enclose only integration", + ), + ( + PIPELINE, + "if ctx.lockfile_only\n", + "if False\n", + "snapshot lifetime must enclose only integration", + ), + ( + PIPELINE, + " _run_integration_phase(ctx)", + " pass", + "snapshot lifetime must enclose only integration", + ), + ], +) +def test_skill_snapshot_guard_rejects_lifetime_and_owner_bypasses( + path: str, old: str, new: str, message: str +) -> None: + source = (ROOT / path).read_text(encoding="utf-8") + mutated = source.replace(old, new, 1) + assert mutated != source + report = run_selected_rules(ROOT, (RULE_ID,), source_overrides={path: mutated}) + assert any( + finding.rule_id == RULE_ID and message in finding.message for finding in report.violations + ) def test_user_scope_skill_provenance_guard_is_registered_and_clean() -> None: diff --git a/tests/integration/test_frozen_host_qualified_git_e2e.py b/tests/integration/test_frozen_host_qualified_git_e2e.py index 9f09842be5..5c62d81212 100644 --- a/tests/integration/test_frozen_host_qualified_git_e2e.py +++ b/tests/integration/test_frozen_host_qualified_git_e2e.py @@ -1,11 +1,11 @@ -"""Hermetic E2E coverage for frozen installs with host-qualified git locks. +"""Hermetic component coverage for frozen installs with host-qualified git locks. Issue #1996 failed in the real ``apm install --frozen`` entry point before the download pipeline ran: manifest-side git dependency keys were host-blind, while lockfile entries for private git hosts are host-qualified. These tests drive the real install command through Click and stub only network/token seams so the frozen structural check and install service boundary are exercised -end-to-end without a private server or PAT. +in-process without a private server or PAT. """ from __future__ import annotations @@ -14,6 +14,7 @@ from dataclasses import dataclass from pathlib import Path from unittest.mock import patch +from urllib.parse import urlparse import pytest import yaml @@ -27,7 +28,7 @@ from apm_cli.models.dependency.reference import DependencyReference from apm_cli.models.dependency.types import GitReferenceType, ResolvedReference -pytestmark = [pytest.mark.integration, pytest.mark.requires_e2e_mode] +pytestmark = [pytest.mark.component, pytest.mark.integration, pytest.mark.requires_e2e_mode] _COMMIT = "a" * 40 _TOKEN_VARS = ("GITHUB_APM_PAT", "GITHUB_TOKEN", "GH_TOKEN", "ADO_APM_PAT") @@ -36,7 +37,7 @@ @dataclass(frozen=True) class _FrozenGitCase: name: str - manifest_dep: dict[str, str] + manifest_dep: dict[str, str | bool] locked_dep: LockedDependency expected_key: str @@ -283,3 +284,103 @@ def test_frozen_install_rejects_host_mismatched_lock_key( result.output ) download.assert_not_called() + + +@pytest.mark.parametrize("drift", ["provider", "https-to-http", "http-to-https"]) +def test_frozen_install_refuses_provider_or_semver_transport_change_then_recovers( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, drift: str +) -> None: + """Frozen refusal happens before downloads; restoring identity permits replay.""" + provider_drift = drift == "provider" + locked_insecure = drift == "http-to-https" + declared_scheme = "http" if drift == "https-to-http" else "https" + reference = "main" if provider_drift else "^1.2.0" + case = _FrozenGitCase( + name=drift, + manifest_dep={ + "git": f"{declared_scheme}://code.example.com/org/private-skills.git", + "ref": reference, + "allow_insecure": True, + **({"type": "gitlab"} if provider_drift else {}), + }, + locked_dep=LockedDependency( + repo_url="org/private-skills", + host="code.example.com", + resolved_ref="main" if provider_drift else "v1.2.3", + constraint=None if provider_drift else reference, + resolved_commit=_COMMIT, + is_insecure=locked_insecure, + depth=1, + name="private-skills", + ), + expected_key="code.example.com/org/private-skills", + ) + project = tmp_path / case.name + _write_project(project, case) + monkeypatch.chdir(project) + monkeypatch.setenv("APM_NO_CACHE", "1") + lock_path = project / "apm.lock.yaml" + before_lock = lock_path.read_bytes() + sentinel = project / "user-owned.txt" + sentinel.write_text("preserve\n", encoding="ascii") + args = [ + "install", + "--frozen", + "--no-policy", + "--parallel-downloads", + "0", + "--target", + "copilot", + "--allow-insecure", + ] + clean_env = {key: value for key, value in os.environ.items() if key not in _TOKEN_VARS} + with ( + patch.dict(os.environ, clean_env, clear=True), + patch("apm_cli.commands._helpers.check_for_updates", return_value=None), + patch.object(GitHubPackageDownloader, "download_package", autospec=True) as download, + ): + refused = CliRunner().invoke(cli, args, catch_exceptions=False) + assert refused.exit_code != 0 + assert "--frozen: apm.lock.yaml is out of sync with apm.yml" in refused.output + assert "differs from apm.lock.yaml" in refused.output + download.assert_not_called() + assert lock_path.read_bytes() == before_lock + assert sentinel.read_text(encoding="ascii") == "preserve\n" + + manifest = yaml.safe_load((project / "apm.yml").read_text(encoding="utf-8")) + declared = manifest["dependencies"]["apm"][0] + if provider_drift: + del declared["type"] + else: + scheme = "http" if locked_insecure else "https" + declared["git"] = f"{scheme}://code.example.com/org/private-skills.git" + (project / "apm.yml").write_text(yaml.safe_dump(manifest, sort_keys=False), encoding="utf-8") + clear_apm_yml_cache() + with ( + patch.dict(os.environ, clean_env, clear=True), + patch("apm_cli.commands._helpers.check_for_updates", return_value=None), + patch("apm_cli.install.phases.resolve._maybe_resolve_git_semver", return_value=None), + patch.object(GitHubPackageDownloader, "download_package", autospec=True) as download, + patch.object( + GitHubPackageDownloader, + "resolve_git_reference", + autospec=True, + return_value=ResolvedReference( + original_ref=case.locked_dep.resolved_ref, + ref_type=GitReferenceType.COMMIT, + resolved_commit=_COMMIT, + ref_name=case.locked_dep.resolved_ref, + ), + ), + patch.object(AuthResolver, "resolve", autospec=True, side_effect=_stub_auth_resolve), + ): + download.side_effect = _stub_download_package + recovered = CliRunner().invoke(cli, args, catch_exceptions=False) + assert recovered.exit_code == 0, recovered.output + assert download.call_count >= 1 + downloaded = download.call_args_list[0].args[1] + assert downloaded.get_unique_key() == case.expected_key + parsed_url = urlparse(downloaded.to_github_url()) + assert parsed_url.hostname == "code.example.com" + assert parsed_url.scheme == ("http" if locked_insecure else "https") + assert sentinel.read_text(encoding="ascii") == "preserve\n" diff --git a/tests/integration/test_generated_lifecycle_state_machine.py b/tests/integration/test_generated_lifecycle_state_machine.py index adc7bb4a7a..dd75da0b2f 100644 --- a/tests/integration/test_generated_lifecycle_state_machine.py +++ b/tests/integration/test_generated_lifecycle_state_machine.py @@ -23,7 +23,7 @@ run_state_machine_as_test, ) -from apm_cli.deps.lockfile import LockFile +from apm_cli.deps.lockfile import LockedDependency, LockFile from apm_cli.utils.yaml_io import dump_yaml, load_yaml from tests.integration.test_required_lifecycle_state_machine import ( _INSTALL_ARGS, @@ -103,6 +103,7 @@ } ), "install": frozenset({"routing.authorized_targets_only", "source.ref_cache_coherent"}), + "install_unlocked_revision": frozenset({"source.ref_cache_coherent"}), "prune_removed": frozenset({"ownership.preserve_unowned"}), "readd_declaration": frozenset({"transaction.failed_command_preserves_state"}), "reinstall": frozenset({"idempotency.byte_stable"}), @@ -154,8 +155,13 @@ def create(cls, root: Path, apm_binary_path: Path, variant: str = "project") -> published = _publish(scenario, _PACKAGE_NAME, skill=_SKILL_NAME) if variant != "project": commit_a = _publish_revision(scenario, published, "a") - dependency = {**published.dependency, "ref": "main"} - environment = dict(published.environment) + remote_url = f"https://code.example.com/apm-fixture-org/{_PACKAGE_NAME}" + dependency = {**published.dependency, "git": remote_url, "ref": "main"} + environment = scenario.repositories.url_rewrite_subprocess_env( + published.repository, remote_url + ) + for setting in ("GITHUB_HOST", "GITLAB_HOST", "APM_GITLAB_HOSTS"): + environment.pop(setting, None) target_root = scenario.isolated.root / "claude-home" target_root.mkdir() (target_root / "user-owned.txt").write_bytes(b"user-owned\n") @@ -182,7 +188,7 @@ def create(cls, root: Path, apm_binary_path: Path, variant: str = "project") -> published.package, published.repository, commit_a, - published.remote_url, + remote_url, dependency, environment, ) @@ -340,6 +346,37 @@ def install(self) -> None: self.installed_revision = self.published_revision self.installed_commit = self.published_commit + @rule() + @precondition( + lambda self: ( + self.fixture.global_scope + and self.declared + and not self.materialized + and self.published_revision == "b" + ) + ) + def install_unlocked_revision(self) -> None: + """A sibling virtual-package lock cannot authorize the removed revision's cache.""" + before = self._capture() + commit = _publish_revision(self.scenario, self.fixture.source, "c") + self.published_revision = "c" + self.published_commit = commit.sha + assert_snapshot_set_unchanged(before, self._capture()) + lock = LockFile() + lock.add_dependency( + LockedDependency( + repo_url=f"apm-fixture-org/{_PACKAGE_NAME}", + host="code.example.com", + virtual_path="skills/sibling", + is_virtual=True, + resolved_ref="main", + resolved_commit=self.installed_commit, + ) + ) + lock.save(self.project.root / "apm.lock.yaml") + self.install() + assert self._state().deployment_records + @rule() @precondition(lambda self: self.declared and self.materialized and self.clean) def reinstall(self) -> None: @@ -655,6 +692,17 @@ def frozen_refusal(self) -> None: _assert_same_state(state, self._state()) assert_snapshot_set_unchanged(before, self._capture()) self.project.manifest_path.write_bytes(manifest_bytes) + self.fixture.replace_dependencies(({**self.fixture.dependency, "type": "gitlab"},)) + before, state = self._capture(), self._state() + refused = self._run((*self.fixture.install_args, "--frozen"), "frozen-provider-refusal", 1) + assert "host provider" in " ".join(refused.stdout.split()), _result_evidence(refused) + _assert_same_state(state, self._state()) + assert_snapshot_set_unchanged(before, self._capture()) + self.project.manifest_path.write_bytes(manifest_bytes) + before, state = self._capture(), self._state() + self._run((*self.fixture.install_args, "--frozen"), "frozen-provider-recovery") + _assert_same_state(state, self._state()) + assert_snapshot_set_unchanged(before, self._capture()) @rule() @precondition(lambda self: self.fixture.global_scope and self.materialized and self.clean) @@ -709,11 +757,17 @@ def durable_state_matches_reference_model(self) -> None: dependencies = lock.get_package_dependencies() assert len(dependencies) == 1 assert dependencies[0].resolved_commit == self.installed_commit + assert self.fixture.dependency["alias"] == _PACKAGE_NAME + assert dependencies[0].alias == _PACKAGE_NAME + assert ( + LockedDependency.from_dict(dependencies[0].to_dict()).to_dependency_ref().alias + == _PACKAGE_NAME + ) modules = self.project.root / "apm_modules" assert ( - modules / "apm-fixture-org" / _PACKAGE_NAME / "skills" / _SKILL_NAME / "SKILL.md" - ).is_file() - assert not (modules / _PACKAGE_NAME).exists() + modules / _PACKAGE_NAME / "skills" / _SKILL_NAME / "SKILL.md" + ).read_bytes() == _SKILL_BYTES + f"\nrevision-{self.installed_revision}\n".encode() + assert not (modules / "apm-fixture-org" / _PACKAGE_NAME).exists() assert self._state().deployment_records assert ( self.fixture.external_roots[0].path / "user-owned.txt" @@ -807,7 +861,13 @@ def _mandatory_replay(model: _LifecycleReferenceModel) -> None: operations.extend([model.uninstall, model.audit_empty]) else: operations.extend([model.remove_declaration, model.prune_removed]) - operations.extend([model.readd_declaration, model.install, model.audit_clean]) + operations.extend( + [ + model.readd_declaration, + model.install_unlocked_revision if model.fixture.global_scope else model.install, + model.audit_clean, + ] + ) if model.fixture.global_scope: operations.extend([model.uninstall, model.audit_empty]) else: diff --git a/tests/integration/test_ref_freshness_lifecycle.py b/tests/integration/test_ref_freshness_lifecycle.py index 9fc9c5bc61..f6c4518609 100644 --- a/tests/integration/test_ref_freshness_lifecycle.py +++ b/tests/integration/test_ref_freshness_lifecycle.py @@ -9,7 +9,7 @@ import pytest -from apm_cli.deps.lockfile import LockFile +from apm_cli.deps.lockfile import LockedDependency, LockFile from tests.utils.apm_lifecycle_runner import ApmLifecycleRunner, CommandResult from tests.utils.isolated_apm_environment import IsolatedApmEnvironment from tests.utils.lifecycle_state import LifecycleStateSnapshot @@ -217,6 +217,40 @@ def test_frozen_default_ref_rehydrates_cold_cache_without_ref_drift( _run(scenario, _AUDIT_ARGS, scenario_id="default-ref-audit-a") +@pytest.mark.parametrize("unrelated_lock_entry", [False, True]) +def test_reinstall_uses_current_ref_without_a_matching_lock_entry( + tmp_path: Path, apm_binary_path: Path, unrelated_lock_entry: bool +) -> None: + """An empty or unrelated lock cannot authorize the removed package's stale cache.""" + scenario = _new_scenario(tmp_path / "unlocked-ref", apm_binary_path) + manifest = scenario.consumer.root / "apm.yml" + original_manifest = manifest.read_bytes() + sentinel = scenario.consumer.root / "user-owned.txt" + sentinel.write_text("preserve\n", encoding="ascii") + _run(scenario, _INSTALL_ARGS, scenario_id="unlocked-install-a") + commit_b = _advance(scenario, "commit-b") + _run( + scenario, + ("uninstall", "https://github.com/apm-fixture-org/ref-freshness-source"), + scenario_id="unlocked-remove-a", + ) + manifest.write_bytes(original_manifest) + lock = LockFile() + if unrelated_lock_entry: + lock.dependencies["unrelated/package"] = LockedDependency( + repo_url="unrelated/package", resolved_ref="main", resolved_commit=scenario.commit_a.sha + ) + lock.save(scenario.consumer.root / "apm.lock.yaml") + _run(scenario, _INSTALL_ARGS, scenario_id="unlocked-reinstall-b") + assert _locked_commit(scenario) == commit_b.sha + assert _deployed_bytes(scenario) == _skill_document("commit-b").encode() + assert sentinel.read_text(encoding="ascii") == "preserve\n" + _run(scenario, _AUDIT_ARGS, scenario_id="unlocked-audit-b") + converged = _capture(scenario) + _run(scenario, _INSTALL_ARGS, scenario_id="unlocked-repeat-b") + _assert_same_state(converged, _capture(scenario)) + + def test_installed_cli_current_state_commands_bypass_stale_bare_cache( tmp_path: Path, apm_binary_path: Path, diff --git a/tests/integration/test_required_lifecycle_state_machine.py b/tests/integration/test_required_lifecycle_state_machine.py index c288a24b1e..9db2fdcead 100644 --- a/tests/integration/test_required_lifecycle_state_machine.py +++ b/tests/integration/test_required_lifecycle_state_machine.py @@ -18,6 +18,7 @@ from apm_cli.deps.lockfile import LockedDependency, LockFile from apm_cli.integration.targets import KNOWN_TARGETS from apm_cli.utils.content_hash import compute_package_hash +from apm_cli.utils.git_env import get_git_executable from apm_cli.utils.yaml_io import dump_yaml, load_yaml from tests.utils.apm_lifecycle_runner import ApmLifecycleRunner, CommandResult from tests.utils.artifact_snapshot import ( @@ -28,6 +29,7 @@ assert_snapshot_set_unchanged, assert_unchanged, ) +from tests.utils.git_credential_shim import GitCredentialShimFactory from tests.utils.isolated_apm_environment import IsolatedApmEnvironment from tests.utils.lifecycle_state import LifecycleStateRoot, LifecycleStateSnapshot from tests.utils.local_git_repository import ( @@ -1858,10 +1860,8 @@ def test_required_legacy_content_hash_upgrade_preserves_skills_and_converges( prior_deployed_files = list(locked_dependency["deployed_files"]) prior_deployed_hashes = dict(locked_dependency["deployed_file_hashes"]) - _, installed_dependency = _single_locked_dependency(consumer.root) - cached_package = installed_dependency.to_dependency_ref().get_install_path( - consumer.root / "apm_modules" - ) + assert source.dependency["alias"] == source.package.name + cached_package = consumer.root / "apm_modules" / source.package.name receipt = cached_package / ".apm" / ".plugin-skill-sources.json" assert receipt.is_file() receipt.unlink() @@ -1954,10 +1954,8 @@ def test_required_parallel_fresh_fetch_bypasses_legacy_cache_upgrade( scenario_id="parallel-fresh-plugin-establish", ) - _, installed_dependency = _single_locked_dependency(consumer.root) - cached_package = installed_dependency.to_dependency_ref().get_install_path( - consumer.root / "apm_modules" - ) + assert source.dependency["alias"] == source.package.name + cached_package = consumer.root / "apm_modules" / source.package.name lock_path = consumer.root / "apm.lock.yaml" lock_document = load_yaml(lock_path) locked_dependency = lock_document["dependencies"][0] @@ -2045,10 +2043,8 @@ def test_required_invalid_receiptless_legacy_cache_fails_with_recovery( scenario_id="invalid-legacy-plugin-establish", ) - _, installed_dependency = _single_locked_dependency(consumer.root) - cached_package = installed_dependency.to_dependency_ref().get_install_path( - consumer.root / "apm_modules" - ) + assert source.dependency["alias"] == source.package.name + cached_package = consumer.root / "apm_modules" / source.package.name receipt = cached_package / ".apm" / ".plugin-skill-sources.json" assert receipt.is_file() receipt.unlink() @@ -2707,6 +2703,116 @@ def _exercise_global_revision_commands( return commit_b +def _semver_transport_environment(scenario: _Scenario, source: _PublishedPackage) -> dict[str, str]: + """Map real Git operations locally without changing selected transport.""" + shim = GitCredentialShimFactory(scenario.isolated.root / "semver-git-shim").create( + base_env=dict(scenario.environment), + real_git=Path(get_git_executable()).resolve(), + remote_map={urlparse(source.remote_url).path.lstrip("/"): source.repository.file_url}, + credential="", + ) + return dict(shim.environment) + + +def _exercise_frozen_semver_transport_commands( + run: Callable[..., CommandResult], + capture: Callable[[], LifecycleStateSnapshot], + manifest_path: Path, + lock_path: Path, + install_args: tuple[str, ...], + artifact_roots: Mapping[str, Path], + secure_url: str, + expected_commit: GitCommit, +) -> None: + """Refuse both transport flips without conflating a range with its selected tag.""" + original_manifest = manifest_path.read_bytes() + insecure_url = urlparse(secure_url)._replace(scheme="http").geturl() + hostname = urlparse(secure_url).hostname + assert hostname is not None + approved_args = (*install_args, "--allow-insecure", "--allow-insecure-host", hostname) + for initial_url, changed_url, direction in ( + (secure_url, insecure_url, "https-to-http"), + (insecure_url, secure_url, "http-to-https"), + ): + manifest = load_yaml(manifest_path) + dependency = manifest["dependencies"]["apm"][0] + dependency.update(git=initial_url, ref="^1.0.0", allow_insecure=True) + dump_yaml(manifest, manifest_path) + run(approved_args, f"semver-{direction}-install") + lock = LockFile.read(lock_path) + assert lock is not None + locked = lock.get_package_dependencies() + assert len(locked) == 1 + assert locked[0].constraint == "^1.0.0" + assert locked[0].resolved_ref == "v1.0.0" + assert locked[0].resolved_commit == expected_commit.sha + assert locked[0].is_insecure == (urlparse(initial_url).scheme == "http") + accepted_manifest = manifest_path.read_bytes() + accepted = capture() + before = ArtifactSnapshotSet.capture(artifact_roots) + run((*approved_args, "--frozen"), f"semver-{direction}-replay") + _assert_same_state(accepted, capture()) + assert_snapshot_set_unchanged(before, ArtifactSnapshotSet.capture(artifact_roots)) + + dependency["git"] = changed_url + dump_yaml(manifest, manifest_path) + refused = capture() + before = ArtifactSnapshotSet.capture(artifact_roots) + result = run( + (*approved_args, "--frozen"), + f"semver-{direction}-refusal", + expected_returncode=1, + ) + assert "transport differs from apm.lock.yaml" in result.stdout + result.stderr + _assert_same_state(refused, capture()) + assert_snapshot_set_unchanged(before, ArtifactSnapshotSet.capture(artifact_roots)) + + manifest_path.write_bytes(accepted_manifest) + before = ArtifactSnapshotSet.capture(artifact_roots) + run((*approved_args, "--frozen"), f"semver-{direction}-recovery") + _assert_same_state(accepted, capture()) + assert_snapshot_set_unchanged(before, ArtifactSnapshotSet.capture(artifact_roots)) + + manifest_path.write_bytes(original_manifest) + run(install_args, "semver-restore-original-declaration") + + +def test_required_frozen_semver_transport_refusal_recovers( + tmp_path: Path, apm_binary_path: Path +) -> None: + scenario = _new_scenario(tmp_path / "frozen-semver-transport", apm_binary_path) + source = _publish(scenario, "semver-kit", skill="semver-kit") + scenario.repositories.tag(source.repository, "v1.0.0", source.commit) + environment = _semver_transport_environment(scenario, source) + consumer = scenario.consumers.create( + "semver-consumer", dependencies=(source.dependency,), targets=("claude",) + ) + user_file = consumer.root / "user-owned.txt" + user_file.write_bytes(b"preserve user content\n") + roots = {"project": consumer.root, "home": scenario.isolated.home} + + def run( + args: tuple[str, ...], scenario_id: str, *, expected_returncode: int = 0 + ) -> CommandResult: + result = scenario.runner.run( + args, scenario_id=scenario_id, cwd=consumer.root, env=environment + ) + assert result.returncode == expected_returncode, _result_evidence(result) + assert user_file.read_bytes() == b"preserve user content\n" + return result + + _exercise_frozen_semver_transport_commands( + run, + lambda: LifecycleStateSnapshot.capture(consumer.root, targets=("claude",)), + consumer.manifest_path, + consumer.root / "apm.lock.yaml", + _INSTALL_ARGS, + roots, + source.remote_url, + source.commit, + ) + + @pytest.mark.parametrize("aliased_home", [False, True]) def test_required_global_audit_rule_matrix_for_external_roots( aliased_home: bool, @@ -2731,7 +2837,7 @@ def test_required_global_audit_rule_matrix_for_external_roots( sentinel_path.parent.mkdir(parents=True) sentinel_path.write_bytes(f"{target}-owned-by-user\n".encode("ascii")) - environment = dict(source.environment) + environment = _semver_transport_environment(scenario, source) if aliased_home: alias = scenario.isolated.root / "home-alias" try: @@ -2880,6 +2986,18 @@ def assert_revision(commit: GitCommit, revision: str) -> None: install_args, manifest_path, ) + scenario.repositories.tag(source.repository, "v1.0.0", commit_b) + _exercise_frozen_semver_transport_commands( + run, + capture, + manifest_path, + lock_path, + install_args, + artifact_roots, + source.remote_url, + commit_b, + ) + assert_revision(commit_b, "b") installed = capture() def audit_row( diff --git a/tests/unit/cache/test_git_cache.py b/tests/unit/cache/test_git_cache.py index 85358ce670..3ffb202212 100644 --- a/tests/unit/cache/test_git_cache.py +++ b/tests/unit/cache/test_git_cache.py @@ -11,6 +11,8 @@ from apm_cli.cache.git_cache import CachePruneError, GitCache from apm_cli.cache.url_normalize import cache_shard_key +from tests.utils.isolated_apm_environment import IsolatedApmEnvironment +from tests.utils.local_git_repository import LocalGitRepositoryFactory @pytest.mark.parametrize("ref", ["main", "release/v1", "v1.0"]) @@ -57,6 +59,39 @@ def test_corrupt_remote_ref_receipt_does_not_revive_stale_bare_ref(tmp_path: Pat bare.assert_not_called() +@pytest.mark.parametrize("receipt_state", ["valid", "absent", "corrupt"]) +def test_receipt_precedence_with_actual_stale_bare_reference( + tmp_path: Path, receipt_state: str +) -> None: + """An actual stale Git branch cannot replace a present or corrupt receipt.""" + from apm_cli.deps.tiered_ref_resolver import L2BareRevParse + from apm_cli.models.apm_package import DependencyReference + + isolated = IsolatedApmEnvironment.create(tmp_path / "fixture", base_env=dict(os.environ)) + repositories = LocalGitRepositoryFactory( + isolated.repository_root, env=isolated.subprocess_env() + ) + repository = repositories.create("receipt-history") + payload = repository.worktree / "payload.txt" + payload.write_text("old\n", encoding="ascii") + old_commit = repositories.commit(repository, message="Old observation") + cache = GitCache(tmp_path / "cache") + dependency = DependencyReference.parse("owner/repo#main") + url = dependency.to_github_url() + bare = cache._db_root / cache_shard_key(url) + shutil.copytree(repository.origin, bare) + payload.write_text("current\n", encoding="ascii") + current_commit = repositories.commit(repository, message="Current observation") + assert L2BareRevParse._rev_parse(bare, "main") == old_commit.sha + if receipt_state != "absent": + cache.remember_resolved_ref(url, "main", current_commit.sha) + if receipt_state == "corrupt": + cache._resolved_ref_path(url, "main").write_text("broken", encoding="ascii") + expected = {"valid": current_commit.sha, "absent": old_commit.sha, "corrupt": None} + assert L2BareRevParse(cache).try_resolve(dependency, "main") == expected[receipt_state] + assert L2BareRevParse._rev_parse(bare, "main") == old_commit.sha + + @pytest.mark.windows_compat def test_prune_reports_only_completed_deletions( tmp_path: Path, monkeypatch: pytest.MonkeyPatch diff --git a/tests/unit/commands/test_deps_alias_readers.py b/tests/unit/commands/test_deps_alias_readers.py new file mode 100644 index 0000000000..af23ef203d --- /dev/null +++ b/tests/unit/commands/test_deps_alias_readers.py @@ -0,0 +1,104 @@ +"""Dependency readers honor canonical alias placement without crossing stores.""" + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from apm_cli.commands.deps.cli import _resolve_scope_deps +from apm_cli.deps.lockfile import LockedDependency, LockFile +from apm_cli.utils.path_security import PathTraversalError +from apm_cli.utils.yaml_io import dump_yaml +from tests.utils.artifact_snapshot import ArtifactSnapshotSet, assert_snapshot_set_unchanged + +pytestmark = pytest.mark.component + + +def _installed_store(root: Path, alias: str, declarations: str, owner: str = "owner") -> Path: + """Create expected physical paths independently of the materialization helper.""" + installed = root / "apm_modules" / alias + ordinary = root / "apm_modules" / "other" / "ordinary" + for path, name in ((installed, "reader-bundle"), (ordinary, "ordinary")): + path.mkdir(parents=True) + dump_yaml({"name": name, "version": "1.0.0"}, path / "apm.yml") + nested = installed / "skills" / "nested" + nested.mkdir(parents=True) + (nested / "SKILL.md").write_bytes(b"---\nname: nested\n---\nNested package content.\n") + if declarations in {"manifest", "both"}: + dump_yaml( + { + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "apm": [ + {"git": f"{owner}/bundle", "alias": alias}, + "other/ordinary", + ] + }, + }, + root / "apm.yml", + ) + if declarations in {"lock", "both"}: + lock = LockFile() + lock.add_dependency( + LockedDependency(repo_url=f"{owner}/bundle", alias=alias, resolved_commit="a" * 40) + ) + lock.add_dependency(LockedDependency(repo_url="other/ordinary", resolved_commit="b" * 40)) + lock.save(root / "apm.lock.yaml") + return installed + + +@pytest.mark.parametrize("alias", ["reader-kit", "reader.v2", ".reader-kit"]) +@pytest.mark.parametrize("declarations", ["manifest", "lock", "both"]) +def test_alias_readers_use_authorized_placement_and_preserve_nested_packages( + tmp_path: Path, alias: str, declarations: str +) -> None: + installed = _installed_store(tmp_path, alias, declarations) + before = ArtifactSnapshotSet.capture({"store": tmp_path}) + packages, orphans = _resolve_scope_deps(tmp_path, MagicMock()) + assert orphans == [] + assert {package["name"] for package in packages} == {"owner/bundle", "other/ordinary"} + aliased = next(package for package in packages if package["name"] == "owner/bundle") + assert aliased["path"] == str(installed) + assert aliased["source"] == "github" + assert aliased["version"] == "1.0.0" + assert aliased["is_orphaned"] is False + assert not (tmp_path / "apm_modules" / "owner" / "bundle").exists() + assert_snapshot_set_unchanged(before, ArtifactSnapshotSet.capture({"store": tmp_path})) + + +def test_alias_reader_uses_only_selected_store(tmp_path: Path) -> None: + project = tmp_path / "project" + user = tmp_path / "user" + _installed_store(project, "same-alias", "both", owner="project") + expected = _installed_store(user, "same-alias", "both", owner="user") + before = ArtifactSnapshotSet.capture({"project": project, "user": user}) + packages, orphans = _resolve_scope_deps(user, MagicMock()) + assert orphans == [] + assert {package["name"] for package in packages} == {"user/bundle", "other/ordinary"} + aliased = next(package for package in packages if package["name"] == "user/bundle") + assert aliased["path"] == str(expected) + assert_snapshot_set_unchanged( + before, ArtifactSnapshotSet.capture({"project": project, "user": user}) + ) + + +def test_alias_reader_refuses_escape_without_inspecting_external_package(tmp_path: Path) -> None: + store = tmp_path / "store" + external = tmp_path / "external" + external.mkdir() + dump_yaml({"name": "private", "version": "1.0.0"}, external / "apm.yml") + modules = store / "apm_modules" + modules.mkdir(parents=True) + (modules / "escaped").symlink_to(external, target_is_directory=True) + lock = LockFile() + lock.add_dependency( + LockedDependency(repo_url="owner/bundle", alias="escaped", resolved_commit="a" * 40) + ) + lock.save(store / "apm.lock.yaml") + before = ArtifactSnapshotSet.capture({"store": store, "external": external}) + with pytest.raises(PathTraversalError): + _resolve_scope_deps(store, MagicMock()) + assert_snapshot_set_unchanged( + before, ArtifactSnapshotSet.capture({"store": store, "external": external}) + ) diff --git a/tests/unit/core/test_host_providers.py b/tests/unit/core/test_host_providers.py index 1e2eac8ad1..5ebadfb6fd 100644 --- a/tests/unit/core/test_host_providers.py +++ b/tests/unit/core/test_host_providers.py @@ -8,7 +8,7 @@ import pytest from apm_cli.core.auth import AuthResolver -from apm_cli.core.host_providers import classify_host_provider +from apm_cli.core.host_providers import classify_host_provider, effective_host_provider_identity def test_gitlab_hint_does_not_route_global_pat_to_untrusted_host() -> None: @@ -49,6 +49,25 @@ def test_user_trusted_gitlab_host_uses_global_pat() -> None: assert context.source == "GITLAB_APM_PAT" +def test_effective_host_provider_identity_captures_backend_and_credential_route() -> None: + with patch.dict(os.environ, {}, clear=True): + assert effective_host_provider_identity("code.example.com") == ( + "generic", + "generic_modules", + ) + assert effective_host_provider_identity("code.example.com", host_type="gitlab") == ( + "gitlab", + "generic_modules", + ) + + +def test_effective_host_provider_identity_equates_explicit_and_inferred_gitlab() -> None: + with patch.dict(os.environ, {}, clear=True): + assert effective_host_provider_identity("gitlab.com") == effective_host_provider_identity( + "gitlab.com", host_type="gitlab" + ) + + class TestCustomAdoServerHostClassification: """Regression tests for #2338 -- on-prem ADO Server must not be misclassified as GHES.""" diff --git a/tests/unit/deps/test_github_downloader_phase3.py b/tests/unit/deps/test_github_downloader_phase3.py index 4ae2dece8b..35f8db5c68 100644 --- a/tests/unit/deps/test_github_downloader_phase3.py +++ b/tests/unit/deps/test_github_downloader_phase3.py @@ -17,12 +17,14 @@ import pytest from git import RemoteProgress +from apm_cli.cache.git_cache import GitCache from apm_cli.deps.github_downloader import ( GitHubPackageDownloader, GitProgressReporter, _close_repo, _debug, ) +from apm_cli.deps.tiered_ref_resolver import RefFreshnessPolicy, build_tiered_ref_resolver from apm_cli.models.apm_package import ( DependencyReference, GitReferenceType, @@ -116,6 +118,124 @@ def downloader() -> GitHubPackageDownloader: return GitHubPackageDownloader(auth_resolver=auth) +@pytest.mark.parametrize("anonymous_first", [True, False]) +@pytest.mark.parametrize( + "case", + [ + "locked-match", + "locked-sibling", + "current-api", + "current-legacy", + "sha-passthrough", + "failed-resolution", + "mismatched-repository", + "mismatched-ref", + "mismatched-sha", + "failed-checkout", + ], +) +def test_scoped_lock_seeds_and_current_remote_receipts_remain_distinct( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + downloader: GitHubPackageDownloader, + anonymous_first: bool, + case: str, +) -> None: + """A real resolver and receipt store agree across both checkout auth routes.""" + monkeypatch.setenv("APM_TIERED_RESOLVER", "1") + old_sha, prior_sha, current_sha = "a" * 40, "b" * 40, "c" * 40 + dependency = _make_dep() + cache = GitCache(tmp_path / "cache") + original_url = dependency.to_github_url() + cache.remember_resolved_ref(original_url, "main", prior_sha) + downloader.auth_resolver.uses_public_github_anonymous_first.return_value = anonymous_first + downloader.auth_resolver.try_with_fallback.side_effect = lambda host, operation, **kwargs: ( + operation(None, {}) + ) + downloader._cache_git_env = MagicMock(return_value={}) + downloader._refs = MagicMock() + downloader._refs.resolve_commit_sha_for_ref.return_value = ( + None if case in {"current-legacy", "failed-resolution"} else current_sha + ) + downloader._refs.resolve.return_value = _make_resolved(commit=current_sha) + if case == "failed-resolution": + downloader._refs.resolve.side_effect = RuntimeError("fixture remote unavailable") + resolver = build_tiered_ref_resolver( + downloader=downloader, + git_cache=cache, + freshness_policy=( + RefFreshnessPolicy.LOCKED_OR_CURRENT + if case.startswith("locked-") + else RefFreshnessPolicy.CURRENT_REMOTE + ), + ) + assert resolver is not None + downloader._tiered_resolver = resolver + if case.startswith("locked-"): + seeded = ( + dependency + if case == "locked-match" + else _make_dep(virtual_path="skills/sibling", is_virtual=True) + ) + assert resolver.seed(seeded, "main", old_sha) + if case == "sha-passthrough": + dependency = _make_dep(reference=old_sha) + if case == "failed-resolution": + with pytest.raises(RuntimeError, match="fixture remote unavailable"): + resolver.resolve(dependency) + assert resolver.remotely_resolved(dependency, current_sha) is False + assert cache.read_resolved_ref(original_url, "main") == (True, prior_sha) + return + resolved = resolver.resolve(dependency) + expected_sha = old_sha if case in {"locked-match", "sha-passthrough"} else current_sha + assert resolved.resolved_commit == expected_sha + if case == "locked-match": + downloader._refs.resolve_commit_sha_for_ref.assert_not_called() + downloader._refs.resolve.assert_not_called() + if case == "locked-sibling": + downloader._refs.resolve_commit_sha_for_ref.assert_called_once_with(dependency, "main") + assert resolver.resolve(seeded).resolved_commit == old_sha + assert resolver.remotely_resolved(seeded, old_sha) is False + if case == "mismatched-repository": + dependency = _make_dep(repo_url="other/repository") + elif case == "mismatched-ref": + dependency = _make_dep(reference="other") + elif case == "mismatched-sha": + expected_sha = old_sha + url = dependency.to_github_url() + before_receipt = cache.read_resolved_ref(url, dependency.reference) + authorized = case in {"current-api", "current-legacy", "failed-checkout"} + assert resolver.remotely_resolved(dependency, expected_sha) is authorized + + def checkout(*args: object, **kwargs: object) -> Path: + assert cache.read_resolved_ref(url, dependency.reference) == before_receipt + if case == "failed-checkout": + raise RuntimeError("fixture checkout failed") + return tmp_path + + with patch.object(cache, "get_checkout", side_effect=checkout) as get_checkout: + if case == "failed-checkout": + with pytest.raises(RuntimeError, match="fixture checkout failed"): + downloader._persistent_cache_checkout( + cache, dependency, url, expected_sha, locked_sha=expected_sha + ) + else: + assert ( + downloader._persistent_cache_checkout( + cache, dependency, url, expected_sha, locked_sha=expected_sha + ) + == tmp_path + ) + get_checkout.assert_called_once() + assert cache.read_resolved_ref(url, dependency.reference) == ( + (True, current_sha) if case in {"current-api", "current-legacy"} else before_receipt + ) + assert cache.read_resolved_ref(original_url, "main") == ( + True, + current_sha if case in {"current-api", "current-legacy"} else prior_sha, + ) + + # --------------------------------------------------------------------------- # _debug # --------------------------------------------------------------------------- diff --git a/tests/unit/deps/test_tiered_ref_resolver.py b/tests/unit/deps/test_tiered_ref_resolver.py index 1852a532f8..3597992d50 100644 --- a/tests/unit/deps/test_tiered_ref_resolver.py +++ b/tests/unit/deps/test_tiered_ref_resolver.py @@ -18,7 +18,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src")) from apm_cli.cache.url_normalize import cache_shard_key -from apm_cli.deps.lockfile import LockFile +from apm_cli.deps.lockfile import LockedDependency, LockFile from apm_cli.deps.tiered_ref_resolver import ( L0PerRunCache, L1CommitsAPI, @@ -52,7 +52,7 @@ def _dep(repo: str = "owner/repo", ref: str = "main") -> DependencyReference: @pytest.mark.parametrize( ("update_refs", "refresh", "expected"), [ - (False, False, RefFreshnessPolicy.REPRODUCIBLE), + (False, False, RefFreshnessPolicy.LOCKED_OR_CURRENT), (True, False, RefFreshnessPolicy.CURRENT_REMOTE), (False, True, RefFreshnessPolicy.CURRENT_REMOTE), (True, True, RefFreshnessPolicy.CURRENT_REMOTE), @@ -66,8 +66,8 @@ def test_freshness_policy_maps_install_intent_once(update_refs, refresh, expecte assert policy is expected assert policy.requires_remote is (expected is RefFreshnessPolicy.CURRENT_REMOTE) - assert policy.allows_lock_seed is (expected is RefFreshnessPolicy.REPRODUCIBLE) - assert policy.allows_bare_cache is (expected is RefFreshnessPolicy.REPRODUCIBLE) + assert policy.allows_lock_seed is (expected is RefFreshnessPolicy.LOCKED_OR_CURRENT) + assert policy.allows_bare_cache is False @pytest.mark.parametrize("has_lock", [False, True]) @@ -83,7 +83,7 @@ def test_install_freshness_requires_remote_without_replayable_lock( ) policy = ref_freshness_policy_for_install(context) assert policy is ( - RefFreshnessPolicy.REPRODUCIBLE + RefFreshnessPolicy.LOCKED_OR_CURRENT if has_lock and not update_refs else RefFreshnessPolicy.CURRENT_REMOTE ) @@ -436,6 +436,40 @@ def test_seed_normalizes_sha_case(): assert result.resolved_commit == "a" * 40 +@pytest.mark.parametrize("host", ["gitlab.com", "code.example.com"]) +def test_lock_seed_requires_equivalent_effective_provider(host: str) -> None: + """Equivalent hints replay; switching provider must establish a fresh ref.""" + cache = PerRunRefCache() + legacy = _make_legacy_with(SHA_B) + resolver = TieredRefResolver( + tiers=[L0PerRunCache(cache=cache), legacy], + cache=cache, + legacy=legacy, + freshness_policy=RefFreshnessPolicy.LOCKED_OR_CURRENT, + ) + locked = DependencyReference(repo_url="owner/repo", host=host, reference="main") + declared = DependencyReference( + repo_url="owner/repo", host=host, host_type="gitlab", reference="main" + ) + assert resolver.seed(locked, "main", SHA_A) + assert cache.size() == 0 + assert resolver.resolve(declared).resolved_commit == (SHA_A if host == "gitlab.com" else SHA_B) + + +def test_current_policy_rejects_lock_seed() -> None: + """Refresh intent never accepts a lock seed, even through a direct caller.""" + cache = PerRunRefCache() + legacy = _make_legacy_with(SHA_B) + resolver = TieredRefResolver( + tiers=[L0PerRunCache(cache=cache), legacy], + cache=cache, + legacy=legacy, + freshness_policy=RefFreshnessPolicy.CURRENT_REMOTE, + ) + assert resolver.seed(_dep(), "main", SHA_A) is False + assert resolver.resolve(_dep()).resolved_commit == SHA_B + + def test_orchestrator_collapses_concurrent_resolves(): cache = PerRunRefCache() legacy = _make_legacy_with(SHA_A) @@ -588,11 +622,11 @@ def test_factory_excludes_l2_when_update_refs_true(monkeypatch): assert tier_names == ["per_run_cache", "commits_api", "legacy_clone"] -def test_factory_includes_l2_when_update_refs_false(monkeypatch): - """update_refs=False (default install) must retain L2BareRevParse. +def test_factory_preserves_l2_for_explicit_compatibility_policy(monkeypatch): + """Legacy callers explicitly selecting REPRODUCIBLE retain L2BareRevParse. This is a regression trap: the bare-rev-parse tier is a performance - optimisation for non-update runs and must not be accidentally removed. + optimisation retained by that policy, not by ordinary install intent. """ monkeypatch.setenv("APM_TIERED_RESOLVER", "1") downloader = MagicMock() @@ -653,8 +687,8 @@ def test_stale_bare_bypassed_on_update(monkeypatch, tmp_path): assert "bare_rev_parse" not in resolver.stats -def test_normal_policy_uses_l2_when_api_unavailable_without_clone(monkeypatch, tmp_path): - """A normal warm install keeps the zero-network L2 performance boundary.""" +def test_compatibility_policy_uses_l2_when_api_unavailable_without_clone(monkeypatch, tmp_path): + """Explicit compatibility policy retains legacy bare-cache behavior.""" monkeypatch.setenv("APM_TIERED_RESOLVER", "1") bare = tmp_path / cache_shard_key(_dep().to_github_url()) bare.mkdir(parents=True) @@ -681,7 +715,77 @@ def test_normal_policy_uses_l2_when_api_unavailable_without_clone(monkeypatch, t assert resolver.stats["legacy_clone"] == 0 -def test_current_policy_fails_closed_when_remote_tiers_fail(monkeypatch, tmp_path): +@pytest.mark.parametrize("lock_shape", ["matching", "unrelated", "sibling", "empty"]) +def test_install_lock_presence_cannot_authorize_unseeded_bare_refs( + monkeypatch, tmp_path, lock_shape: str +) -> None: + """Only a concrete matching lock seed can avoid current upstream resolution.""" + from apm_cli.install.helpers.ref_seed import seed_ref_resolver_from_lockfile + + monkeypatch.setenv("APM_TIERED_RESOLVER", "1") + dep = _dep() + bare = tmp_path / cache_shard_key(dep.to_github_url()) + bare.mkdir(parents=True) + locked_ref = _dep(repo="other/package") if lock_shape == "unrelated" else _dep() + if lock_shape == "sibling": + locked_ref = DependencyReference( + repo_url="owner/repo", reference="main", virtual_path="skills/sibling", is_virtual=True + ) + locked = LockedDependency( + repo_url=locked_ref.repo_url, + virtual_path=locked_ref.virtual_path, + is_virtual=locked_ref.is_virtual, + resolved_ref="main", + resolved_commit=SHA_A, + ) + context = types.SimpleNamespace( + ref_freshness_policy=None, + update_refs=False, + refresh=False, + existing_lockfile=LockFile( + dependencies={} if lock_shape == "empty" else {locked_ref.get_unique_key(): locked} + ), + logger=None, + ) + downloader = MagicMock() + downloader._refs.resolve_commit_sha_for_ref.return_value = None + downloader._refs.resolve.return_value = ResolvedReference( + original_ref=str(dep), + ref_type=GitReferenceType.BRANCH, + resolved_commit=SHA_B, + ref_name="main", + ) + resolver = build_tiered_ref_resolver( + downloader=downloader, + git_cache=types.SimpleNamespace( + _db_root=tmp_path, read_resolved_ref=lambda *_: (False, None) + ), + freshness_policy=ref_freshness_policy_for_install(context), + ) + assert resolver is not None + context.ref_resolver = resolver + seed_ref_resolver_from_lockfile(context) + with patch.object(L2BareRevParse, "_rev_parse", return_value=SHA_A) as stale_l2: + result = resolver.resolve(dep) + assert result.resolved_commit == (SHA_A if lock_shape == "matching" else SHA_B) + stale_l2.assert_not_called() + if lock_shape == "matching": + downloader._refs.resolve.assert_not_called() + else: + downloader._refs.resolve.assert_called_once_with(dep) + if lock_shape == "sibling": + assert resolver.resolve(locked_ref).resolved_commit == SHA_A + other_unlocked = DependencyReference( + repo_url="owner/repo", reference="main", virtual_path="skills/new", is_virtual=True + ) + assert resolver.resolve(other_unlocked).resolved_commit == SHA_B + downloader._refs.resolve.assert_called_once_with(dep) + + +@pytest.mark.parametrize( + "policy", [RefFreshnessPolicy.CURRENT_REMOTE, RefFreshnessPolicy.LOCKED_OR_CURRENT] +) +def test_current_policy_fails_closed_when_remote_tiers_fail(monkeypatch, tmp_path, policy): """Freshness-required resolution never substitutes a stale L2 answer.""" monkeypatch.setenv("APM_TIERED_RESOLVER", "1") bare = tmp_path / cache_shard_key(_dep().to_github_url()) @@ -693,8 +797,10 @@ def test_current_policy_fails_closed_when_remote_tiers_fail(monkeypatch, tmp_pat downloader._refs = fake_refs resolver = build_tiered_ref_resolver( downloader=downloader, - git_cache=types.SimpleNamespace(_db_root=tmp_path), - freshness_policy=RefFreshnessPolicy.CURRENT_REMOTE, + git_cache=types.SimpleNamespace( + _db_root=tmp_path, read_resolved_ref=lambda *_: (False, None) + ), + freshness_policy=policy, ) assert isinstance(resolver, TieredRefResolver) diff --git a/tests/unit/install/test_pipeline_ownership_snapshot.py b/tests/unit/install/test_pipeline_ownership_snapshot.py new file mode 100644 index 0000000000..1262d5848c --- /dev/null +++ b/tests/unit/install/test_pipeline_ownership_snapshot.py @@ -0,0 +1,508 @@ +"""Real local installs qualify the ownership snapshot's pipeline boundary.""" + +from __future__ import annotations + +import shutil +import tempfile +from collections import Counter +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from types import ModuleType +from typing import Any +from unittest.mock import MagicMock + +import pytest +import yaml + +from apm_cli.core.scope import InstallScope +from apm_cli.deps.lockfile import LockFile, get_lockfile_path +from apm_cli.install import pipeline +from apm_cli.install.context import InstallContext +from apm_cli.install.phases import integrate +from apm_cli.install.phases.lockfile import LockfileBuilder +from apm_cli.integration.skill_integrator import SkillIntegrator +from apm_cli.models.apm_package import APMPackage, clear_apm_yml_cache +from apm_cli.models.results import InstallDisposition + +pytestmark = pytest.mark.component + + +@dataclass +class LocalInstall: + """A filesystem-only dependency and colliding root-local skill.""" + + root: Path + metadata: Path + source: Path + package: APMPackage + scope: InstallScope + + @property + def deployed(self) -> Path: + return self.root / ".agents" / "skills" / "shared" / "SKILL.md" + + @property + def local_skill(self) -> Path: + return self.root / ".apm" / "skills" / "shared" / "SKILL.md" + + +@pytest.fixture +def local_install_factory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> Callable[..., LocalInstall]: + """Use real resolver/local-source fixtures; only remote downloading is mocked.""" + home = tmp_path / "home" + home.mkdir() + metadata = home / ".apm" + metadata.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("APM_HOME", str(metadata)) + monkeypatch.setenv("APM_TEMP_DIR", str(tmp_path)) + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + from apm_cli import config + + monkeypatch.setattr(config, "CONFIG_DIR", str(metadata)) + monkeypatch.setattr(config, "CONFIG_FILE", str(metadata / "config.json")) + monkeypatch.setattr(config, "_config_cache", None) + for name in ("CLAUDE_CONFIG_DIR", "HERMES_HOME", "CODEX_HOME", "APM_LEGACY_SKILL_PATHS"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("APM_NO_CACHE", "1") + monkeypatch.setenv("APM_PROGRESS", "never") + downloader = MagicMock() + downloader.persistent_git_cache = None + downloader._tiered_resolver = None + downloader.download_package.side_effect = AssertionError("Local fixture attempted a download") + monkeypatch.setattr( + "apm_cli.deps.github_downloader.GitHubPackageDownloader", + MagicMock(return_value=downloader), + ) + + def create(scope: InstallScope = InstallScope.PROJECT) -> LocalInstall: + root = home if scope is InstallScope.USER else tmp_path / "project" + root.mkdir(exist_ok=True) + metadata = root / ".apm" if scope is InstallScope.USER else root + metadata.mkdir(exist_ok=True) + source = tmp_path / "sources" / "shared" + source.mkdir(parents=True) + (source / "SKILL.md").write_bytes( + b"---\nname: shared\ndescription: Dependency skill\n---\nDependency version one.\n" + ) + local_skill = root / ".apm" / "skills" / "shared" / "SKILL.md" + local_skill.parent.mkdir(parents=True, exist_ok=True) + local_skill.write_bytes( + b"---\nname: shared\ndescription: Root skill\n---\nRoot version one.\n" + ) + manifest = metadata / "apm.yml" + manifest.write_text( + yaml.safe_dump( + { + "name": "snapshot-consumer", + "version": "1.0.0", + "description": "Ownership phase fixture", + "dependencies": {"apm": [str(source)]}, + } + ), + encoding="utf-8", + ) + monkeypatch.chdir(root) + package = APMPackage.from_apm_yml(manifest) + return LocalInstall(root, metadata, source, package, scope) + + return create + + +@dataclass +class PhaseEvidence: + """Capture forwarding observations without replacing phase execution.""" + + contexts: list[InstallContext] = field(default_factory=list) + events: list[str] = field(default_factory=list) + builds: Counter[Path] = field(default_factory=Counter) + root_checks: int = 0 + lock_writes: int = 0 + + +def _set_dependencies(fixture: LocalInstall, dependencies: list[str | dict[str, Any]]) -> None: + """Change the real manifest before the next command reads it.""" + manifest = fixture.metadata / "apm.yml" + data = yaml.safe_load(manifest.read_text(encoding="utf-8")) + data["dependencies"]["apm"] = dependencies + manifest.write_text(yaml.safe_dump(data), encoding="utf-8") + clear_apm_yml_cache() + fixture.package = APMPackage.from_apm_yml(manifest) + + +def _observe_pipeline( + monkeypatch: pytest.MonkeyPatch, + fixture: LocalInstall, + *, + lockfile_only: bool = False, + expect_skill_deployment: bool = True, + failed_dependency: Callable[[], bool] | None = None, +) -> PhaseEvidence: + """Observe actual phase, root-local deployment, and durable writer boundaries.""" + evidence = PhaseEvidence() + run_phase = pipeline._run_phase + root_project = integrate._integrate_root_project + ownership_build = SkillIntegrator._build_ownership_maps + build_and_save = LockfileBuilder.build_and_save + write = LockFile.write + phase_lock_bytes: bytes | None = None + + def lock_bytes() -> bytes | None: + path = get_lockfile_path(fixture.metadata) + return path.read_bytes() if path.exists() else None + + def observed_phase(name: str, phase: ModuleType, ctx: InstallContext) -> Any: + nonlocal phase_lock_bytes + evidence.events.append(name) + if name == "integrate": + evidence.contexts.append(ctx) + phase_lock_bytes = lock_bytes() + if lockfile_only: + assert ctx.integrators == {} + assert ctx.targets == [] + else: + assert ctx.integrators["skill"]._ownership_snapshots == {} + assert ctx.integrators["skill"]._native_skill_session_owners == {} + result = run_phase(name, phase, ctx) + assert lock_bytes() == phase_lock_bytes + assert ctx.installed_packages + if lockfile_only: + assert ctx.package_deployed_files == {} + assert ctx.local_deployed_files == [] + else: + assert ctx.package_deployed_files, ctx.diagnostics.by_category() + assert bool(ctx.local_deployed_files) is expect_skill_deployment + return result + if "skill" in ctx.integrators: + assert ctx.integrators["skill"]._ownership_snapshots is None + return run_phase(name, phase, ctx) + + def observed_root(ctx: InstallContext) -> dict[str, int] | None: + if lockfile_only: + return root_project(ctx) + assert ctx.integrators["skill"]._ownership_snapshots is not None + assert lock_bytes() == phase_lock_bytes + if not expect_skill_deployment: + result = root_project(ctx) + assert ctx.integrators["skill"]._ownership_snapshots == {} + assert lock_bytes() == phase_lock_bytes + assert not fixture.deployed.exists() + evidence.root_checks += 1 + return result + if failed_dependency is not None and failed_dependency(): + dep_key = fixture.package.get_apm_dependencies()[0].get_unique_key() + assert dep_key not in ctx.package_deployed_files + assert ctx.diagnostics.error_count == 1 + assert not fixture.deployed.exists() + result = root_project(ctx) + assert fixture.deployed.read_bytes() == fixture.local_skill.read_bytes() + assert lock_bytes() == phase_lock_bytes + evidence.root_checks += 1 + return result + assert fixture.deployed.read_bytes() == (fixture.source / "SKILL.md").read_bytes() + dep_key = fixture.package.get_apm_dependencies()[0].get_unique_key() + assert ".agents/skills/shared" in ctx.package_deployed_files[dep_key] + assert ctx.local_deployed_files == [] + result = root_project(ctx) + assert lock_bytes() == phase_lock_bytes + assert fixture.deployed.read_bytes() == fixture.local_skill.read_bytes() + assert ".agents/skills/shared" in ctx.local_deployed_files + evidence.root_checks += 1 + return result + + def observed_build(root: Path) -> tuple[dict[str, str], dict[str, str]]: + evidence.builds[root.resolve()] += 1 + return ownership_build(root) + + def observed_save(builder: LockfileBuilder) -> None: + ctx = builder.ctx + evidence.events.append("lockfile") + if not lockfile_only: + assert ctx.integrators["skill"]._ownership_snapshots is None + return build_and_save(builder) + + def observed_write(lock: LockFile, path: Path, **kwargs: Any) -> None: + if path.resolve() == get_lockfile_path(fixture.metadata).resolve(): + assert evidence.contexts + ctx = evidence.contexts[-1] + if not lockfile_only: + assert ctx.integrators["skill"]._ownership_snapshots is None + evidence.lock_writes += 1 + return write(lock, path, **kwargs) + + monkeypatch.setattr(pipeline, "_run_phase", observed_phase) + monkeypatch.setattr(integrate, "_integrate_root_project", observed_root) + monkeypatch.setattr(SkillIntegrator, "_build_ownership_maps", staticmethod(observed_build)) + monkeypatch.setattr(LockfileBuilder, "build_and_save", observed_save) + monkeypatch.setattr(LockFile, "write", observed_write) + return evidence + + +@pytest.mark.parametrize("scope", [InstallScope.PROJECT, InstallScope.USER]) +def test_real_reinstall_changes_deployments_but_not_lock_inside_phase( + monkeypatch: pytest.MonkeyPatch, + local_install_factory: Callable[..., LocalInstall], + scope: InstallScope, +) -> None: + """Dependency/root writes share an epoch; durable ownership writes follow exit.""" + fixture = local_install_factory(scope) + evidence = _observe_pipeline(monkeypatch, fixture) + previous_lock: bytes | None = None + for epoch in range(2): + if epoch: + (fixture.source / "SKILL.md").write_bytes( + b"---\nname: shared\ndescription: Updated dependency\n---\nNew dependency bytes.\n" + ) + fixture.local_skill.write_bytes( + b"---\nname: shared\ndescription: Updated root\n---\nNew root bytes.\n" + ) + start = len(evidence.events) + result = pipeline.run_install_pipeline( + fixture.package, + scope=fixture.scope, + target="copilot", + parallel_downloads=0, + no_policy=True, + audit_override="off", + ) + assert result.disposition is InstallDisposition.SUCCESS + assert result.exit_code == 0 + events = evidence.events[start:] + assert events.index("download") < events.index("integrate") < events.index("lockfile") + assert events.index("lockfile") < events.index("post_deps_local") < events.index("audit") + assert evidence.root_checks == epoch + 1 + assert evidence.builds == {fixture.metadata.resolve(): epoch + 1} + assert fixture.deployed.read_bytes() == fixture.local_skill.read_bytes() + current_lock = get_lockfile_path(fixture.metadata).read_bytes() + assert current_lock != previous_lock + previous_lock = current_lock + persisted = LockFile.read(get_lockfile_path(fixture.metadata)) + assert persisted is not None + if scope is InstallScope.PROJECT: + assert ".agents/skills/shared" in persisted.local_deployed_files + else: + dependency = persisted.get_dependency( + fixture.package.get_apm_dependencies()[0].get_unique_key() + ) + assert dependency is not None + assert ".agents/skills/shared" in dependency.deployed_files + assert persisted.local_deployed_files == [] + + first, second = evidence.contexts + assert first is not second + assert first.integrators["skill"] is not second.integrators["skill"] + assert first.integrators["skill"]._ownership_snapshots is None + assert second.integrators["skill"]._ownership_snapshots is None + assert evidence.lock_writes >= 2 + + +def test_exception_after_real_integration_exits_before_any_lock_writer( + monkeypatch: pytest.MonkeyPatch, local_install_factory: Callable[..., LocalInstall] +) -> None: + """A phase failure discards populated maps even after files were deployed.""" + fixture = local_install_factory() + evidence = _observe_pipeline(monkeypatch, fixture) + run = integrate.run + + def fail_after_deployment(ctx: InstallContext) -> None: + run(ctx) + assert fixture.deployed.read_bytes() == fixture.local_skill.read_bytes() + raise RuntimeError("injected failure after deployment") + + monkeypatch.setattr(integrate, "run", fail_after_deployment) + with pytest.raises(RuntimeError, match="injected failure after deployment"): + pipeline.run_install_pipeline( + fixture.package, + target="copilot", + parallel_downloads=0, + no_policy=True, + audit_override="off", + ) + assert len(evidence.contexts) == 1 + assert evidence.contexts[0].integrators["skill"]._ownership_snapshots is None + assert evidence.builds == {fixture.metadata.resolve(): 1} + assert evidence.root_checks == 1 + assert evidence.lock_writes == 0 + assert "lockfile" not in evidence.events + assert "post_deps_local" not in evidence.events + assert not get_lockfile_path(fixture.metadata).exists() + + +def test_real_template_catches_package_failure_and_retry_gets_fresh_epoch( + monkeypatch: pytest.MonkeyPatch, local_install_factory: Callable[..., LocalInstall] +) -> None: + """A failed copy is caught by the real template; later packages still deploy.""" + fixture = local_install_factory() + later = fixture.source.parent / "zz-later" + later.mkdir() + later_content = b"---\nname: zz-later\ndescription: Later package\n---\nStill deployed.\n" + (later / "SKILL.md").write_bytes(later_content) + _set_dependencies(fixture, [str(fixture.source), str(later)]) + failed_this_run = False + inject_failure = True + copies: list[str] = [] + copytree = shutil.copytree + evidence = _observe_pipeline(monkeypatch, fixture, failed_dependency=lambda: failed_this_run) + + def fail_first_skill_copy(src: Any, dst: Any, *args: Any, **kwargs: Any) -> Any: + nonlocal failed_this_run, inject_failure + if Path(dst).parent == fixture.deployed.parent.parent: + copies.append(Path(dst).name) + if inject_failure and Path(dst) == fixture.deployed.parent: + assert evidence.contexts[-1].integrators["skill"]._ownership_snapshots + inject_failure = False + failed_this_run = True + raise OSError("injected package copy failure") + return copytree(src, dst, *args, **kwargs) + + monkeypatch.setattr(shutil, "copytree", fail_first_skill_copy) + failed = pipeline.run_install_pipeline( + fixture.package, + target="copilot", + parallel_downloads=0, + no_policy=True, + audit_override="off", + ) + assert failed.disposition is InstallDisposition.FAILED + assert failed.exit_code == 1 + assert failed_this_run + assert copies[:2] == ["shared", "zz-later"] + first = evidence.contexts[0] + later_key = fixture.package.get_apm_dependencies()[1].get_unique_key() + assert ".agents/skills/zz-later" in first.package_deployed_files[later_key] + assert ( + fixture.root / ".agents" / "skills" / "zz-later" / "SKILL.md" + ).read_bytes() == later_content + assert fixture.deployed.read_bytes() == fixture.local_skill.read_bytes() + assert first.diagnostics.error_count == 1 + assert first.integrators["skill"]._ownership_snapshots is None + assert evidence.builds == {fixture.metadata.resolve(): 1} + assert evidence.lock_writes == 0 + assert not get_lockfile_path(fixture.metadata).exists() + + failed_this_run = False + # The failed run left root-local bytes without a lock; retry needs explicit overwrite. + retried = pipeline.run_install_pipeline( + fixture.package, + target="copilot", + force=True, + parallel_downloads=0, + no_policy=True, + audit_override="off", + ) + assert retried.disposition is InstallDisposition.SUCCESS + second = evidence.contexts[1] + assert second is not first + assert second.integrators["skill"] is not first.integrators["skill"] + assert second.diagnostics.error_count == 0 + assert second.integrators["skill"]._ownership_snapshots is None + assert evidence.builds == {fixture.metadata.resolve(): 2} + assert evidence.root_checks == 2 + assert evidence.lock_writes >= 1 + locked = LockFile.read(get_lockfile_path(fixture.metadata)) + assert locked is not None + assert {dep.get_unique_key() for dep in locked.get_package_dependencies()} == { + dep.get_unique_key() for dep in fixture.package.get_apm_dependencies() + } + + +@pytest.mark.parametrize("mode", ["no-skills", "excluded-target"]) +def test_real_pipeline_without_skill_consumers_keeps_snapshot_unused( + monkeypatch: pytest.MonkeyPatch, + local_install_factory: Callable[..., LocalInstall], + mode: str, +) -> None: + """Empty per-package target selection and prompt-only packages never read owners.""" + fixture = local_install_factory() + shutil.rmtree(fixture.local_skill.parent.parent) + if mode == "no-skills": + (fixture.source / "SKILL.md").unlink() + (fixture.source / "apm.yml").write_bytes( + b"name: shared\nversion: 1.0.0\ndescription: Prompt-only package\n" + ) + prompt = fixture.source / ".apm" / "prompts" / "review.prompt.md" + prompt.parent.mkdir(parents=True) + prompt.write_bytes(b"---\ndescription: Review changes\n---\nReview carefully.\n") + else: + _set_dependencies(fixture, [{"path": str(fixture.source), "targets": ["claude"]}]) + evidence = _observe_pipeline(monkeypatch, fixture, expect_skill_deployment=False) + owner_access = MagicMock(wraps=SkillIntegrator._ownership_maps) + monkeypatch.setattr( + SkillIntegrator, + "_ownership_maps", + lambda self, root: owner_access(self, root), + ) + result = pipeline.run_install_pipeline( + fixture.package, + target="copilot", + parallel_downloads=0, + no_policy=True, + audit_override="off", + ) + assert result.disposition is InstallDisposition.SUCCESS + owner_access.assert_not_called() + assert evidence.builds == {} + assert evidence.events.count("integrate") == 1 + ctx = evidence.contexts[0] + assert ctx.integrators["skill"]._ownership_snapshots is None + assert not fixture.deployed.exists() + assert ctx.local_deployed_files == [] + dep_key = fixture.package.get_apm_dependencies()[0].get_unique_key() + if mode == "excluded-target": + assert ctx.package_deployed_files[dep_key] == [] + assert not (fixture.root / ".claude").exists() + else: + assert ctx.total_prompts_integrated == 1 + deployed = ctx.package_deployed_files[dep_key] + assert len(deployed) == 1 + assert (fixture.root / deployed[0]).is_file() + + +@pytest.mark.parametrize("scope", [InstallScope.PROJECT, InstallScope.USER]) +def test_lockfile_only_runs_real_integrate_without_targets_or_skill_lookup( + monkeypatch: pytest.MonkeyPatch, + local_install_factory: Callable[..., LocalInstall], + scope: InstallScope, +) -> None: + """Lock-only still materializes dependencies and locks them without deployments.""" + fixture = local_install_factory(scope) + root_before = fixture.local_skill.read_bytes() + evidence = _observe_pipeline(monkeypatch, fixture, lockfile_only=True) + result = pipeline.run_install_pipeline( + fixture.package, + scope=scope, + parallel_downloads=0, + no_policy=True, + lockfile_only=True, + ) + assert result.disposition is InstallDisposition.SUCCESS + assert result.exit_code == 0 + assert evidence.events.count("integrate") == 1 + assert evidence.events.index("integrate") < evidence.events.index("lockfile") + assert "targets" not in evidence.events + assert "post_deps_local" not in evidence.events + assert "cleanup" not in evidence.events + assert evidence.builds == {} + assert evidence.lock_writes >= 1 + ctx = evidence.contexts[0] + assert ctx.integrators == {} + assert ctx.targets == [] + assert ctx.package_deployed_files == {} + assert fixture.local_skill.read_bytes() == root_before + assert not (fixture.root / ".agents").exists() + assert not (fixture.root / ".github").exists() + assert not (fixture.root / ".claude").exists() + assert not (fixture.root / ".gitignore").exists() + locked = LockFile.read(get_lockfile_path(fixture.metadata)) + assert locked is not None + dep_ref = fixture.package.get_apm_dependencies()[0] + dependency = locked.get_dependency(dep_ref.get_unique_key()) + assert dependency is not None + assert dependency.source == "local" + assert dependency.deployed_files == [] + installed = dep_ref.get_install_path(ctx.apm_modules_dir) + assert (installed / "SKILL.md").read_bytes() == (fixture.source / "SKILL.md").read_bytes() diff --git a/tests/unit/install/test_plan.py b/tests/unit/install/test_plan.py index 53fec03ca2..acfcdbd2f9 100644 --- a/tests/unit/install/test_plan.py +++ b/tests/unit/install/test_plan.py @@ -768,6 +768,91 @@ def test_frozen_compares_declared_identity_without_resolving_upstream( assert bool(reasons) is not satisfied assert lock.to_yaml() == before + def test_frozen_rejects_custom_host_backend_change(self): + """The same host/ref cannot reuse a lock from a different backend.""" + lock = _new_lockfile() + lock.add_dependency( + LockedDependency( + repo_url="owner/package", + host="code.example.com", + resolved_ref="main", + resolved_commit="a" * 40, + ) + ) + manifest = [ + DependencyReference( + repo_url="owner/package", + host="code.example.com", + host_type="gitlab", + reference="main", + ) + ] + before = lock.to_yaml() + assert lockfile_satisfies_manifest(lock, manifest) == ( + False, + [ + " - code.example.com/owner/package: declared source, ref, host provider, " + "or transport differs from apm.lock.yaml" + ], + ) + assert lock.to_yaml() == before + + @pytest.mark.parametrize("locked_insecure", [False, True]) + def test_frozen_git_semver_refuses_transport_change_and_recovers( + self, locked_insecure: bool + ) -> None: + lock = _new_lockfile() + lock.add_dependency( + LockedDependency( + repo_url="owner/package", + resolved_ref="v1.2.3", + constraint="^1.2.0", + resolved_commit="a" * 40, + is_insecure=locked_insecure, + ) + ) + manifest = DependencyReference( + repo_url="owner/package", reference="^1.2.0", is_insecure=not locked_insecure + ) + before = lock.to_yaml() + assert lockfile_satisfies_manifest(lock, [manifest]) == ( + False, + [ + " - owner/package: declared source, ref, host provider, " + "or transport differs from apm.lock.yaml" + ], + ) + assert lock.to_yaml() == before + manifest.is_insecure = locked_insecure + assert lockfile_satisfies_manifest(lock, [manifest]) == (True, []) + assert lock.to_yaml() == before + + @pytest.mark.parametrize( + ("manifest_host_type", "locked_host_type"), [(None, "gitlab"), ("gitlab", None)] + ) + def test_frozen_accepts_explicit_and_inferred_equivalent_provider_forms( + self, manifest_host_type: str | None, locked_host_type: str | None + ) -> None: + lock = _new_lockfile() + lock.add_dependency( + LockedDependency( + repo_url="owner/package", + host="gitlab.com", + host_type=locked_host_type, + resolved_ref="main", + resolved_commit="a" * 40, + ) + ) + manifest = [ + DependencyReference( + repo_url="owner/package", + host="gitlab.com", + host_type=manifest_host_type, + reference="main", + ) + ] + assert lockfile_satisfies_manifest(lock, manifest) == (True, []) + def test_local_deps_skipped(self): """Local file deps have no remote ref, so they're skipped.""" lock = _new_lockfile() diff --git a/tests/unit/integration/test_skill_ownership_snapshot.py b/tests/unit/integration/test_skill_ownership_snapshot.py new file mode 100644 index 0000000000..f8dee3454f --- /dev/null +++ b/tests/unit/integration/test_skill_ownership_snapshot.py @@ -0,0 +1,572 @@ +"""Real skill consumers exercise the bounded durable-ownership epoch.""" + +from __future__ import annotations + +import tempfile +from collections import Counter +from collections.abc import Iterable, Iterator +from dataclasses import dataclass, field +from pathlib import Path +from types import MappingProxyType + +import pytest + +from apm_cli.core.scope import InstallScope +from apm_cli.deps.lockfile import LockedDependency, LockFile, get_lockfile_path +from apm_cli.integration.skill_integrator import SkillIntegrationResult, SkillIntegrator +from apm_cli.integration.targets import KNOWN_TARGETS, apply_legacy_skill_paths +from apm_cli.models.apm_package import ( + APMPackage, + DependencyReference, + PackageContentType, + PackageInfo, + PackageType, +) +from apm_cli.utils.diagnostics import CATEGORY_OVERWRITE, DiagnosticCollector + +pytestmark = pytest.mark.component + + +@pytest.fixture(autouse=True) +def _fence_fixture_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Keep inherited home, target, config, and scratch locations out of the test.""" + from apm_cli import config + + home = tmp_path / "home" + metadata = home / ".apm" + metadata.mkdir(parents=True) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("APM_HOME", str(metadata)) + monkeypatch.setenv("APM_TEMP_DIR", str(tmp_path)) + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + monkeypatch.setattr(config, "CONFIG_DIR", str(metadata)) + monkeypatch.setattr(config, "CONFIG_FILE", str(metadata / "config.json")) + monkeypatch.setattr(config, "_config_cache", None) + for name in ("CLAUDE_CONFIG_DIR", "HERMES_HOME", "CODEX_HOME", "APM_LEGACY_SKILL_PATHS"): + monkeypatch.delenv(name, raising=False) + + +@dataclass +class OwnershipWork: + """Count actual canonical reads and yielded records, not elapsed time.""" + + builds: Counter[Path] = field(default_factory=Counter) + reads: Counter[Path] = field(default_factory=Counter) + dependencies: int = 0 + paths: int = 0 + building: bool = False + + +class _CountedPaths: + """Forward the existing path iterable without copying or pre-traversing it.""" + + def __init__(self, paths: list[str], work: OwnershipWork) -> None: + self.paths = paths + self.work = work + + def __iter__(self) -> Iterator[str]: + for path in self.paths: + self.work.paths += 1 + yield path + + +@pytest.fixture +def ownership_work(monkeypatch: pytest.MonkeyPatch) -> OwnershipWork: + """Instrument only work inside the real canonical ownership builder.""" + work = OwnershipWork() + build = SkillIntegrator._build_ownership_maps + read = LockFile.read + dependencies = LockFile.get_package_dependencies + + def counted_build(root: Path) -> tuple[dict[str, str], dict[str, str]]: + work.builds[root.resolve()] += 1 + work.building = True + try: + return build(root) + finally: + work.building = False + + def counted_read(cls: type[LockFile], path: Path) -> LockFile | None: + if work.building: + work.reads[path.resolve()] += 1 + return read(path) + + def counted_dependencies(lock: LockFile) -> Iterable[LockedDependency]: + records = dependencies(lock) + if not work.building: + return records + + def visit() -> Iterator[LockedDependency]: + for dep in records: + work.dependencies += 1 + dep.deployed_files = _CountedPaths(dep.deployed_files, work) + yield dep + + return visit() + + monkeypatch.setattr(SkillIntegrator, "_build_ownership_maps", staticmethod(counted_build)) + monkeypatch.setattr(LockFile, "read", classmethod(counted_read)) + monkeypatch.setattr(LockFile, "get_package_dependencies", counted_dependencies) + return work + + +def _skill_file(directory: Path, name: str, body: str) -> bytes: + """Write a valid skill with byte-stable content.""" + directory.mkdir(parents=True, exist_ok=True) + content = f"---\nname: {name}\ndescription: Ownership fixture\n---\n{body}\n".encode() + (directory / "SKILL.md").write_bytes(content) + return content + + +def _package(base: Path, name: str, owner: str, layout: str) -> tuple[PackageInfo, Path]: + """Materialize native, bundle, or non-skill APM package source trees.""" + install_path = base / owner / name + skill_dir = { + "native": install_path, + "bundle": install_path / "skills" / name, + "standalone": install_path / ".apm" / "skills" / name, + }[layout] + _skill_file(skill_dir, name, f"new {owner}/{name}") + package_type = { + "native": PackageType.CLAUDE_SKILL, + "bundle": PackageType.SKILL_BUNDLE, + "standalone": PackageType.APM_PACKAGE, + }[layout] + package = APMPackage( + name=name, + version="1.0.0", + type=PackageContentType.INSTRUCTIONS + if layout == "standalone" + else PackageContentType.SKILL, + ) + return ( + PackageInfo( + package=package, + install_path=install_path, + package_type=package_type, + dependency_ref=DependencyReference(repo_url=f"{owner}/{name}"), + ), + skill_dir, + ) + + +def _write_owners(root: Path, owners: dict[str, str]) -> bytes: + """Persist genuine lock records, including a non-skill path per owner.""" + root.mkdir(parents=True, exist_ok=True) + lock = LockFile() + for name, owner in owners.items(): + lock.add_dependency( + LockedDependency( + repo_url=owner, + resolved_commit="a" * 40, + deployed_files=[ + f".agents/skills/{name}/", + f".github/prompts/{name}-prompt", + ], + ) + ) + content = lock.to_yaml().encode() + get_lockfile_path(root).write_bytes(content) + return content + + +def _deploy( + integrator: SkillIntegrator, + package: PackageInfo, + root: Path, + diagnostics: DiagnosticCollector, + scope: InstallScope = InstallScope.PROJECT, + *, + force: bool = False, +) -> SkillIntegrationResult: + """Use public routing and actual target deployment, not a forwarding stub.""" + return integrator.integrate_package_skill( + package, + root, + targets=[KNOWN_TARGETS["copilot"]], + managed_files=None, + force=force, + diagnostics=diagnostics, + scope=scope, + ) + + +@pytest.mark.parametrize("layout", ["native", "bundle", "standalone"]) +@pytest.mark.parametrize("scope", [InstallScope.PROJECT, InstallScope.USER]) +def test_consumers_build_once_with_linear_record_visits( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ownership_work: OwnershipWork, + layout: str, + scope: InstallScope, +) -> None: + """N-to-10N real deployments retain exact ownership and update old bytes.""" + visits = [] + for count in (5, 50): + root = tmp_path / str(count) + root.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls, root=root: root)) + monkeypatch.setenv("HOME", str(root)) + monkeypatch.setenv("APM_HOME", str(root / ".apm")) + metadata = root / ".apm" if scope is InstallScope.USER else root + owners = {f"skill-{index}": f"owner-{index}/skill-{index}" for index in range(count)} + before = _write_owners(metadata, owners) + packages = [ + _package(root / "sources", name, f"owner-{index}", layout) + for index, name in enumerate(owners) + ] + for name in owners: + _skill_file(root / ".agents" / "skills" / name, name, "previous deployment") + integrator = SkillIntegrator() + diagnostics = DiagnosticCollector() + initial_visits = ownership_work.dependencies + ownership_work.paths + + with integrator.ownership_snapshot(): + for package, source in packages: + result = _deploy(integrator, package, root, diagnostics, scope) + target = root / ".agents" / "skills" / source.name + assert result.target_paths == [target] + assert (target / "SKILL.md").read_bytes() == (source / "SKILL.md").read_bytes() + owned, native = integrator._ownership_maps(metadata) + assert native == {f".agents/skills/{name}": owner for name, owner in owners.items()} + assert owned == owners | {f"{name}-prompt": owner for name, owner in owners.items()} + assert isinstance(owned, MappingProxyType) + assert isinstance(native, MappingProxyType) + with pytest.raises(TypeError): + owned["injected"] = "foreign/owner" + with pytest.raises(TypeError): + native["injected"] = "foreign/owner" + assert get_lockfile_path(metadata).read_bytes() == before + + assert not diagnostics.has_diagnostics + assert ownership_work.builds[metadata.resolve()] == 1 + assert ownership_work.reads[get_lockfile_path(metadata).resolve()] == 1 + visits.append(ownership_work.dependencies + ownership_work.paths - initial_visits) + assert visits[-1] == count * 3 + + assert visits[1] <= 15 * visits[0] + + +@pytest.mark.parametrize("lifetime", ["same-integrator", "fresh-integrator", "uncached"]) +def test_replace_delete_create_and_reinstall_start_fresh_epochs( + tmp_path: Path, ownership_work: OwnershipWork, lifetime: str +) -> None: + """Each valid boundary observes changed durable owners and deployed content.""" + from contextlib import nullcontext + + package, source = _package(tmp_path / "sources", "shared", "incoming", "native") + target = tmp_path / ".agents" / "skills" / "shared" + _skill_file(target, "shared", "initial") + integrator = SkillIntegrator() + owners = ("old/shared", "replacement/shared", None, "created/shared", "created/shared") + for epoch, owner in enumerate(owners): + if owner is None: + get_lockfile_path(tmp_path).unlink() + else: + _write_owners(tmp_path, {"shared": owner}) + if lifetime == "fresh-integrator": + integrator = SkillIntegrator() + assert integrator._native_skill_session_owners == {} + content = _skill_file(source, "shared", f"epoch {epoch}") + diagnostics = DiagnosticCollector() + context = nullcontext() if lifetime == "uncached" else integrator.ownership_snapshot() + with context: + result = _deploy(integrator, package, tmp_path, diagnostics, force=owner is None) + assert result.skill_updated + assert (target / "SKILL.md").read_bytes() == content + if lifetime != "uncached": + _, native = integrator._ownership_maps(tmp_path) + assert native == ({".agents/skills/shared": owner} if owner else {}) + collisions = diagnostics.by_category().get(CATEGORY_OVERWRITE, []) + assert [entry.package for entry in collisions] == (["incoming/shared"] if owner else []) + if owner: + assert collisions[0].detail == ( + f"Skill 'shared' from 'incoming/shared' replaced " + f"'{owner}' -- remove one package to avoid this" + ) + assert integrator._ownership_snapshots is None + + assert ownership_work.builds[tmp_path.resolve()] == len(owners) + assert ownership_work.reads[get_lockfile_path(tmp_path).resolve()] == len(owners) + + +def test_roots_are_separate_but_resolved_aliases_share_maps( + tmp_path: Path, ownership_work: OwnershipWork +) -> None: + """Only physical metadata-root identity determines snapshot reuse.""" + project = tmp_path / "project" + user = tmp_path / "home" / ".apm" + _write_owners(project, {"shared": "project/shared"}) + _write_owners(user, {"shared": "user/shared"}) + (project / "nested").mkdir() + alias = project / "nested" / ".." + integrator = SkillIntegrator() + with integrator.ownership_snapshot(): + project_maps = integrator._ownership_maps(project) + user_maps = integrator._ownership_maps(user) + assert integrator._ownership_maps(alias) is project_maps + assert project_maps[1] == {".agents/skills/shared": "project/shared"} + assert user_maps[1] == {".agents/skills/shared": "user/shared"} + assert user_maps is not project_maps + assert ownership_work.builds == {project.resolve(): 1, user.resolve(): 1} + + +def test_symlink_metadata_alias_shares_only_its_physical_root( + tmp_path: Path, ownership_work: OwnershipWork +) -> None: + """A filesystem alias shares one build, never another root's same-named owner.""" + physical = tmp_path / "physical" + separate = tmp_path / "separate" + _write_owners(physical, {"shared": "first/shared"}) + _write_owners(separate, {"shared": "second/shared"}) + alias = tmp_path / "metadata-alias" + alias.symlink_to(physical, target_is_directory=True) + integrator = SkillIntegrator() + with integrator.ownership_snapshot(): + aliased = integrator._ownership_maps(alias) + assert integrator._ownership_maps(physical) is aliased + assert aliased[1] == {".agents/skills/shared": "first/shared"} + distinct = integrator._ownership_maps(separate) + assert distinct is not aliased + assert distinct[1] == {".agents/skills/shared": "second/shared"} + assert integrator._ownership_maps(alias) is aliased + assert ownership_work.builds == {physical.resolve(): 1, separate.resolve(): 1} + assert ownership_work.reads == { + get_lockfile_path(physical).resolve(): 1, + get_lockfile_path(separate).resolve(): 1, + } + + +def test_unused_snapshot_and_package_without_skills_do_not_read( + tmp_path: Path, ownership_work: OwnershipWork +) -> None: + """Entering an epoch is lazy even when a real non-skill package is routed.""" + _write_owners(tmp_path, {"unrelated": "owner/unrelated"}) + package = PackageInfo( + package=APMPackage(name="empty", version="1.0.0", type=PackageContentType.INSTRUCTIONS), + install_path=tmp_path / "empty", + package_type=PackageType.APM_PACKAGE, + ) + package.install_path.mkdir() + integrator = SkillIntegrator() + with integrator.ownership_snapshot(): + pass + with integrator.ownership_snapshot(): + result = _deploy(integrator, package, tmp_path, DiagnosticCollector()) + assert result.skill_skipped + assert result.target_paths == [] + assert ownership_work.builds == {} + assert ownership_work.reads == {} + assert not (tmp_path / ".agents").exists() + + +def test_nested_refusal_and_caught_package_failure_keep_outer_epoch( + tmp_path: Path, ownership_work: OwnershipWork +) -> None: + """A real filesystem deployment failure may be caught without losing the batch.""" + before = _write_owners(tmp_path, {"shared": "incoming/shared"}) + package, source = _package(tmp_path / "sources", "shared", "incoming", "native") + target = tmp_path / ".agents" / "skills" / "shared" + target.parent.mkdir(parents=True) + target.write_bytes(b"not a directory") + integrator = SkillIntegrator() + with integrator.ownership_snapshot(): + with pytest.raises(OSError): + _deploy(integrator, package, tmp_path, DiagnosticCollector()) + original = integrator._ownership_maps(tmp_path) + with pytest.raises(RuntimeError, match="already active"): + with integrator.ownership_snapshot(): + pytest.fail("Nested ownership epoch was accepted") + assert integrator._ownership_maps(tmp_path) is original + target.unlink() + result = _deploy(integrator, package, tmp_path, DiagnosticCollector()) + assert result.skill_created + assert (target / "SKILL.md").read_bytes() == (source / "SKILL.md").read_bytes() + assert get_lockfile_path(tmp_path).read_bytes() == before + assert ownership_work.builds[tmp_path.resolve()] == 1 + assert integrator._ownership_snapshots is None + + +def test_exception_drops_maps_before_retry(tmp_path: Path, ownership_work: OwnershipWork) -> None: + """Exceptional exit discards a populated snapshot before a repaired retry.""" + _write_owners(tmp_path, {"shared": "first/shared"}) + package, source = _package(tmp_path / "sources", "shared", "incoming", "native") + target = tmp_path / ".agents" / "skills" / "shared" + target.parent.mkdir(parents=True) + target.write_bytes(b"invalid destination") + integrator = SkillIntegrator() + with pytest.raises(OSError): + with integrator.ownership_snapshot(): + _deploy(integrator, package, tmp_path, DiagnosticCollector()) + assert integrator._ownership_snapshots is None + _write_owners(tmp_path, {"shared": "second/shared"}) + target.unlink() + _skill_file(target, "shared", "repaired destination") + diagnostics = DiagnosticCollector() + with integrator.ownership_snapshot(): + _deploy(integrator, package, tmp_path, diagnostics) + assert (target / "SKILL.md").read_bytes() == (source / "SKILL.md").read_bytes() + assert diagnostics.by_category()[CATEGORY_OVERWRITE][0].detail == ( + "Skill 'shared' from 'incoming/shared' replaced " + "'second/shared' -- remove one package to avoid this" + ) + assert ownership_work.builds[tmp_path.resolve()] == 2 + + +@pytest.mark.parametrize("durable_owner", [None, "durable/shared"]) +def test_session_overlay_stays_live_with_durable_owner_precedence( + tmp_path: Path, ownership_work: OwnershipWork, durable_owner: str | None +) -> None: + """Unchanged durable maps must not freeze current-run collision ownership.""" + before = _write_owners(tmp_path, {"shared": durable_owner}) if durable_owner else None + first, _ = _package(tmp_path / "sources", "shared", "first", "native") + second, source = _package(tmp_path / "sources", "shared", "second", "native") + integrator = SkillIntegrator() + diagnostics = DiagnosticCollector() + destination = (tmp_path / ".agents" / "skills" / "shared").resolve() + with integrator.ownership_snapshot(): + _deploy(integrator, first, tmp_path, DiagnosticCollector()) + assert integrator._native_skill_session_owners == {destination: "first/shared"} + _deploy(integrator, second, tmp_path, diagnostics) + assert integrator._native_skill_session_owners == {destination: "second/shared"} + _, native = integrator._ownership_maps(tmp_path) + assert native == ({".agents/skills/shared": durable_owner} if durable_owner else {}) + previous = durable_owner or "first/shared" + assert diagnostics.by_category()[CATEGORY_OVERWRITE][0].detail == ( + f"Skill 'shared' from 'second/shared' replaced " + f"'{previous}' -- remove one package to avoid this" + ) + assert (tmp_path / ".agents" / "skills" / "shared" / "SKILL.md").read_bytes() == ( + source / "SKILL.md" + ).read_bytes() + lock_path = get_lockfile_path(tmp_path) + assert (lock_path.read_bytes() if lock_path.exists() else None) == before + assert ownership_work.builds[tmp_path.resolve()] == 1 + + +@pytest.mark.parametrize("managed", [None, set()]) +def test_snapshot_preserves_destination_ownership_and_explicit_managed_set( + tmp_path: Path, ownership_work: OwnershipWork, managed: set[str] | None +) -> None: + """A durable first-target owner cannot authorize the second target's same name.""" + lock_bytes = _write_owners(tmp_path, {"shared": "incoming/shared"}) + package, source = _package(tmp_path / "sources", "shared", "incoming", "native") + owned_target = tmp_path / ".agents" / "skills" / "shared" + unowned_target = tmp_path / ".claude" / "skills" / "shared" + owned_before = _skill_file(owned_target, "shared", "previous managed bytes") + unowned_before = _skill_file(unowned_target, "shared", "user-owned bytes") + sentinel = unowned_target / "user.bin" + sentinel.write_bytes(b"\xff\x00preserve") + targets = [ + KNOWN_TARGETS["copilot"], + apply_legacy_skill_paths([KNOWN_TARGETS["claude"]])[0], + ] + integrator = SkillIntegrator() + with integrator.ownership_snapshot(): + result = integrator.integrate_package_skill( + package, + tmp_path, + targets=targets, + managed_files=managed, + diagnostics=DiagnosticCollector(), + ) + assert integrator._ownership_maps(tmp_path)[1] == { + ".agents/skills/shared": "incoming/shared" + } + assert result.target_paths == ([owned_target] if managed is None else []) + assert (owned_target / "SKILL.md").read_bytes() == ( + (source / "SKILL.md").read_bytes() if managed is None else owned_before + ) + assert (unowned_target / "SKILL.md").read_bytes() == unowned_before + assert sentinel.read_bytes() == b"\xff\x00preserve" + assert integrator._native_skill_session_owners == ( + {owned_target.resolve(): "incoming/shared"} if managed is None else {} + ) + assert get_lockfile_path(tmp_path).read_bytes() == lock_bytes + assert ownership_work.builds == {tmp_path.resolve(): 1} + + +def test_standalone_drift_replay_uses_fresh_uncached_integrators( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ownership_work: OwnershipWork +) -> None: + """Consecutive real scratch replays see replaced lock owners, not install epochs.""" + from apm_cli.install import drift + + project = tmp_path / "project" + project.mkdir() + (project / "apm.yml").write_bytes(b"name: replay-project\nversion: 1.0.0\ntarget: copilot\n") + live_skill = project / ".agents" / "skills" / "shared" + live_content = _skill_file(live_skill, "shared", "untouched live deployment") + instruction = project / ".github" / "instructions" / "local.instructions.md" + instruction.parent.mkdir(parents=True) + instruction.write_bytes(b"---\napplyTo: '**'\n---\nUntouched local instruction.\n") + instruction_before = instruction.read_bytes() + make_integrators = drift._make_integrators + observed_integrators: list[SkillIntegrator] = [] + maps = SkillIntegrator._ownership_maps + accesses: list[tuple[SkillIntegrator, Path]] = [] + + def observe_integrators() -> dict[str, object]: + integrators = make_integrators() + skill = integrators["skill"] + assert skill._ownership_snapshots is None + assert skill._native_skill_session_owners == {} + observed_integrators.append(skill) + return integrators + + def observe_maps(self: SkillIntegrator, root: Path) -> object: + assert self._ownership_snapshots is None + accesses.append((self, root.resolve())) + return maps(self, root) + + monkeypatch.setattr(drift, "_make_integrators", observe_integrators) + monkeypatch.setattr(SkillIntegrator, "_ownership_maps", observe_maps) + outputs: list[bytes] = [] + for epoch, owner in enumerate(("first", "replacement")): + package, source = _package(tmp_path / "sources", "shared", owner, "native") + dep = LockedDependency( + repo_url=f"{owner}/shared", + source="local", + local_path=str(package.install_path), + package_type=PackageType.CLAUDE_SKILL.value, + deployed_files=[".agents/skills/shared"], + ) + lock = LockFile() + lock.add_dependency(dep) + lock_path = get_lockfile_path(project) + lock_path.write_bytes(lock.to_yaml().encode()) + lock_before = lock_path.read_bytes() + scratch = tmp_path / f"replay-{epoch}" + result = drift.run_replay( + drift.ReplayConfig( + project_root=project, + lockfile_path=lock_path, + scratch_root=scratch, + cache_only=True, + ), + drift.CheckLogger(verbose=False), + ) + assert result == scratch.resolve() + replayed = scratch / ".agents" / "skills" / "shared" / "SKILL.md" + outputs.append(replayed.read_bytes()) + assert outputs[-1] == (source / "SKILL.md").read_bytes() + assert lock_path.read_bytes() == lock_before + assert (live_skill / "SKILL.md").read_bytes() == live_content + assert instruction.read_bytes() == instruction_before + assert not get_lockfile_path(scratch).exists() + assert observed_integrators[-1]._native_skill_session_owners == { + replayed.parent.resolve(): dep.get_unique_key() + } + assert ownership_work.builds[scratch.resolve()] == 1 + assert ownership_work.reads[get_lockfile_path(scratch).resolve()] == 1 + + assert len(observed_integrators) == 2 + assert observed_integrators[0] is not observed_integrators[1] + assert accesses == [ + (observed_integrators[0], (tmp_path / "replay-0").resolve()), + (observed_integrators[1], (tmp_path / "replay-1").resolve()), + ] + assert outputs[0] != outputs[1] + assert ownership_work.dependencies == 0 + assert ownership_work.paths == 0 diff --git a/tests/unit/scripts/test_architecture_runner.py b/tests/unit/scripts/test_architecture_runner.py index 82b33ad3fc..335aeb6057 100644 --- a/tests/unit/scripts/test_architecture_runner.py +++ b/tests/unit/scripts/test_architecture_runner.py @@ -612,6 +612,7 @@ def exiting_import( contracts-tooling-lockfile-timestamp contracts-tooling-lockfile-timestamp-constructor contracts-tooling-lockfile-timestamp-fallback +contracts-tooling-native-lifecycle-evidence contracts-tooling-policy-content-hash contracts-tooling-policy-identity contracts-tooling-project-yaml-write-delegation diff --git a/tests/unit/scripts/test_lifecycle_evidence.py b/tests/unit/scripts/test_lifecycle_evidence.py new file mode 100644 index 0000000000..d6200b44df --- /dev/null +++ b/tests/unit/scripts/test_lifecycle_evidence.py @@ -0,0 +1,901 @@ +"""Fail-closed native-provider regressions; no full lifecycle campaign runs here. + +Observer test mechanics adapted from be73ed139f3a73c19437a895dfd6d68e41df8bfc. +""" + +from __future__ import annotations + +import argparse +import copy +import hashlib +import json +import os +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +from scripts import check_lifecycle_evidence as gate +from scripts.lifecycle_contracts import ( + CONTRACT_ID, + LEDGER, + REQUIRED_WITNESSES, + EvidenceError, + candidate_contract, + command_inventory, + command_path, + load_ledger, + validate_contracts, + validate_execution, +) +from tests.utils.isolated_apm_environment import DURABLE_ENVIRONMENT_ROOTS, IsolatedApmEnvironment +from tests.utils.lifecycle_evidence import LifecycleEvidencePlugin, _physical_roots, snapshot + +pytestmark = pytest.mark.component +pytest_plugins = ["pytester"] +RATCHET_TEST_SCOPE = "fixture" + + +def _ledger() -> dict[str, Any]: + return load_ledger((gate.ROOT / LEDGER).read_text(encoding="utf-8")) + + +def _sample() -> tuple[dict[str, Any], dict[str, Any]]: + witness = { + "nodeid": "tests/sample.py::test_generated[project]", + "kind": "generated", + "dimensions": {"variant": "project"}, + "transitions": [ + {"command": "install", "argv_contains": [], "returncode": 0, "state": state} + for state in ("changed", "unchanged") + ], + } + evidence = { + "collected": True, + "dimensions": {"variant": "project"}, + "phases": {"setup": "passed", "call": "passed", "teardown": "passed"}, + "models": 1, + "events": [ + { + "command": "install", + "args": ["install"], + "returncode": 0, + "cwd": "/fixture", + "roots": {"workspace": "/fixture", "HOME": "/fixture/home"}, + "environment": {"HOME": "/fixture/home"}, + "context": "initial", + "model": 1, + "before": before, + "after": after, + } + for before, after in (("a", "b"), ("b", "b")) + ], + } + return witness, evidence + + +def test_immutable_witnesses_and_current_inventory() -> None: + """Pin obligations independently of both implementation constants and ledger rows.""" + required = "tests/integration/test_required_lifecycle_state_machine.py::" + generated = "tests/integration/test_generated_lifecycle_state_machine.py::" + expected = { + required + "test_required_global_audit_rule_matrix_for_external_roots[False]", + required + "test_required_global_audit_rule_matrix_for_external_roots[True]", + required + "test_required_frozen_semver_transport_refusal_recovers", + *( + generated + function + f"[{variant}]" + for function in ( + "test_generated_lifecycle_mandatory_replay", + "test_generated_lifecycle_sequences_preserve_reference_model", + ) + for variant in ("project", "global-canonical", "global-aliased") + ), + } + contract = validate_contracts(_ledger(), command_inventory())[CONTRACT_ID] + assert set(REQUIRED_WITNESSES) == expected + assert {w["nodeid"] for w in contract["witnesses"]} == expected + schema = json.loads((gate.ROOT / "tests/fixtures/lifecycle_completion.schema.json").read_text()) + assert set(schema["properties"]["witnesses"]["items"]["enum"]) == expected + inventory = command_inventory() + assert set(contract["commands"]) == set(inventory) + assert len(inventory) == 85 + assert sum(e["disposition"] == "applicable" for e in contract["commands"].values()) == 22 + assert ( + command_path(["lock", "--global", "export", "--format", "spdx"], inventory) == "lock export" + ) + + +@pytest.mark.parametrize( + "mutation", + [ + "none", + "delete", + "duplicate", + "kind", + "dimension", + "dimension-type", + "command", + "reason", + "refs", + "trajectory", + ], +) +def test_contract_edits_cannot_vacuously_remove_proof(mutation: str) -> None: + ledger = _ledger() + contract = ledger["lifecycle_contracts"][0] + if mutation == "none": + ledger["lifecycle_contracts"] = [] + elif mutation == "delete": + contract["witnesses"].pop() + elif mutation == "duplicate": + contract["witnesses"][-1] = copy.deepcopy(contract["witnesses"][0]) + elif mutation == "kind": + contract["witnesses"][3]["kind"] = "deterministic" + elif mutation == "dimension": + contract["witnesses"][0]["dimensions"] = {} + elif mutation == "dimension-type": + contract["witnesses"][-2]["dimensions"] = {"aliased_home": 1} + elif mutation == "command": + del contract["commands"]["discover"] + elif mutation == "reason": + contract["commands"]["discover"]["reason"] = "" + elif mutation == "refs": + contract["commands"]["install"]["generated"] = [] + else: + contract["witnesses"][0]["transitions"] = [] + with pytest.raises(EvidenceError): + validate_contracts(ledger, command_inventory()) + + +@pytest.mark.parametrize("field", ["property_catalog", "bugs", "known_gaps"]) +def test_legacy_records_cannot_disappear( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, field: str +) -> None: + original = json.loads((gate.ROOT / LEDGER).read_text()) + current = copy.deepcopy(original) + current[field].pop() + path = tmp_path / LEDGER + path.parent.mkdir(parents=True) + path.write_text(json.dumps(current)) + monkeypatch.setattr("scripts.lifecycle_contracts.git", lambda *_: json.dumps(original)) + with pytest.raises(EvidenceError, match="legacy"): + candidate_contract(tmp_path, "a" * 40, command_inventory()) + + +@pytest.mark.parametrize( + "mutation", + [ + "collection", + "skip", + "xfail", + "setup", + "call", + "teardown", + "dimension", + "generation", + "missing-transition", + "status", + "command", + "args", + "workspace", + "state", + "state-missing", + "outside-model", + "roots", + "continuity", + "model-splice", + "environment", + "context", + ], +) +def test_execution_obligations_fail_closed(mutation: str) -> None: + witness, evidence = _sample() + validate_execution(witness, evidence) + event = evidence["events"][-1] + if mutation == "collection": + evidence["collected"] = False + elif mutation in {"skip", "xfail"}: + evidence["phases"]["call"] = mutation + elif mutation in {"setup", "call", "teardown"}: + evidence["phases"][mutation] = "failed" + elif mutation == "dimension": + evidence["dimensions"] = {} + elif mutation == "generation": + evidence["models"] = 0 + elif mutation == "missing-transition": + evidence["events"].pop() + elif mutation == "status": + event["returncode"] = 1 + elif mutation == "command": + event["command"] = "update" + elif mutation == "args": + witness["transitions"][-1]["argv_contains"] = ["--global"] + elif mutation == "workspace": + event["cwd"] = "/elsewhere" + elif mutation == "state": + event["after"] = "c" + elif mutation == "state-missing": + del event["before"] + elif mutation == "outside-model": + event["model"] = None + elif mutation == "roots": + event["roots"] = {} + elif mutation == "continuity": + event.update(before="c", after="c") + elif mutation == "model-splice": + evidence["models"] = 2 + event["model"] = 2 + elif mutation == "environment": + event["environment"]["HOME"] = "/different/home" + else: + event["context"] = "other" + with pytest.raises(EvidenceError): + validate_execution(witness, evidence) + + +def test_preparation_and_reviewed_context_preserve_connected_domain() -> None: + witness, evidence = _sample() + for event in evidence["events"]: + event["roots"]["APM_HOME"] = "/fixture/home/.apm" + event["environment"]["APM_HOME"] = "/fixture/home/.apm" + event = evidence["events"][-1] + event.update(cwd="/fixture/home/.apm", context="APM_HOME", before="prepared", after="prepared") + transition = witness["transitions"][-1] + transition["context"] = "APM_HOME" + with pytest.raises(EvidenceError, match="trajectory"): + validate_execution(witness, evidence) + transition["preparation"] = "A reviewed fixture mutation changes the declaration." + validate_execution(witness, evidence) + + +def test_snapshot_and_isolation_share_the_existing_durable_root_owner(tmp_path: Path) -> None: + isolated = IsolatedApmEnvironment.create(tmp_path / "domain", base_env={}) + env = isolated.subprocess_env() + assert all(Path(env[key]).is_dir() for key in DURABLE_ENVIRONMENT_ROOTS) + domain = {key: env[key] for key in DURABLE_ENVIRONMENT_ROOTS} + before = snapshot(domain) + user = isolated.home / "unowned" + user.write_text("user bytes") + assert snapshot(domain) != before + assert "APM_TEMP_DIR" not in DURABLE_ENVIRONMENT_ROOTS + + +def test_domains_do_not_splice_roots_models_or_lexical_aliases(tmp_path: Path) -> None: + isolated = IsolatedApmEnvironment.create(tmp_path / "domain", base_env={}) + env = isolated.subprocess_env() + plugin = LifecycleEvidencePlugin(["node"], None, {"node": {"APM_HOME"}}) + plugin.active = "node" + roots, lexical, context = plugin._domain(isolated.work_root, env) + assert context == "initial" + assert lexical["HOME"] == str(isolated.home) + assert plugin._domain(isolated.config_root, env) == (roots, lexical, "APM_HOME") + changed = {**env, "HOME": str(tmp_path / "other-home")} + with pytest.raises(EvidenceError, match="durable roots changed"): + plugin._domain(isolated.work_root, changed) + with pytest.raises(EvidenceError, match="Unreviewed"): + plugin._domain(isolated.repository_root, env) + plugin.model_run = 2 + other, _, _ = plugin._domain(isolated.work_root, changed) + assert other != roots + + +def test_child_root_and_source_probes_use_actual_child_environment(tmp_path: Path) -> None: + isolated = IsolatedApmEnvironment.create(tmp_path / "domain", base_env=os.environ) + env = isolated.subprocess_env() + env["CLAUDE_CONFIG_DIR"] = "~/custom" + roots = _physical_roots(isolated.work_root, env) + assert roots["CLAUDE_CONFIG_DIR"] == str(isolated.home / "custom") + plugin = LifecycleEvidencePlugin([], None) + assert plugin._source_identity(isolated.work_root, env, 30)["package"] == str( + gate.ROOT / "src/apm_cli" + ) + foreign = tmp_path / "foreign/apm_cli" + foreign.mkdir(parents=True) + (foreign / "__init__.py").write_text("") + (foreign / "cli.py").write_text("print('success is not source identity')") + env["PYTHONPATH"] = str(foreign.parent) + with pytest.raises(EvidenceError, match="candidate checkout"): + plugin._source_identity(isolated.work_root, env, 30) + + +@pytest.mark.parametrize("mutation", ["command", "interpreter", "launcher"]) +def test_runner_observer_refuses_unverified_execution(tmp_path: Path, mutation: str) -> None: + from tests.utils.apm_lifecycle_runner import ApmLifecycleRunner + + launcher = tmp_path / "apm" + launcher.write_text("fixture launcher") + plugin = LifecycleEvidencePlugin(["node"], launcher) + plugin.active, plugin.phase = "node", "call" + plugin.pytest_sessionstart(SimpleNamespace()) + try: + command = (sys.executable, "-m", "apm_cli.cli") + if mutation == "command": + command = (sys.executable, "-c", "print('not APM')") + elif mutation == "interpreter": + plugin.python_hash = "changed" + else: + command = (str(launcher),) + launcher.write_text("changed launcher") + with pytest.raises(EvidenceError, match=r"Unverified|Executable changed"): + ApmLifecycleRunner(command).run(["--version"], cwd=tmp_path, env={}) + finally: + plugin.patch.undo() + + +@pytest.mark.parametrize("response", ["[]", '{"CLAUDE_CONFIG_DIR":0}', '{"other":"/path"}']) +def test_root_expansion_rejects_malformed_probe( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, response: str +) -> None: + monkeypatch.setattr( + subprocess, "run", lambda *a, **k: SimpleNamespace(returncode=0, stdout=response) + ) + with pytest.raises(EvidenceError, match="invalid paths"): + _physical_roots(tmp_path, {"CLAUDE_CONFIG_DIR": "~/custom"}) + + +@pytest.mark.parametrize("mutation", ["head", "tree", "dirty", "untracked", "replace", "abbrev"]) +def test_candidate_identity_refuses_stale_or_dirty_inputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mutation: str +) -> None: + base, head = "a" * 40, "b" * 40 + answers = { + ("for-each-ref", "--format=%(refname)", "refs/replace"): "", + ("rev-parse", "--verify", "--end-of-options", f"{base}^{{commit}}"): base, + ("rev-parse", "--verify", "--end-of-options", f"{head}^{{commit}}"): head, + ("rev-parse", "HEAD"): head, + ("merge-base", "--is-ancestor", base, head): "", + ("status", "--porcelain", "--untracked-files=all"): "", + ("rev-parse", "HEAD^{tree}"): "c" * 40, + } + monkeypatch.setattr(gate, "git", lambda root, *args: answers[args]) + initial = gate.candidate(tmp_path, base, head) + if mutation == "head": + answers[("rev-parse", "HEAD")] = "d" * 40 + elif mutation == "tree": + answers[("rev-parse", "HEAD^{tree}")] = "d" * 40 + assert gate.candidate(tmp_path, base, head) != initial + return + elif mutation in {"dirty", "untracked"}: + answers[("status", "--porcelain", "--untracked-files=all")] = ( + " M tracked.py" if mutation == "dirty" else "?? new.py" + ) + elif mutation == "replace": + answers[("for-each-ref", "--format=%(refname)", "refs/replace")] = "refs/replace/hash" + else: + answers[("rev-parse", "--verify", "--end-of-options", "main^{commit}")] = base + base = "main" + with pytest.raises(EvidenceError): + gate.candidate(tmp_path, base, head) + + +def test_source_profile_pins_checkout_launcher_and_interpreter( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import apm_cli + + source = tmp_path / "src/apm_cli" + source.mkdir(parents=True) + (source / "cli.py").write_text("fixture") + monkeypatch.setattr(apm_cli, "__file__", str(source / "__init__.py")) + python = tmp_path / "python" + python.write_text("fixture interpreter") + monkeypatch.setattr(sys, "executable", str(python)) + assert gate.source_profile(tmp_path)[0] is None + with pytest.raises(EvidenceError, match="candidate checkout"): + gate.source_profile(tmp_path / "foreign") + if os.name != "nt": + launcher = tmp_path / "apm" + launcher.write_text("#!/foreign/python\nfrom apm_cli.cli import cli\n") + with pytest.raises(EvidenceError, match="this Python environment"): + gate.source_profile(tmp_path) + launcher.write_text(f"#!{python}\nfrom apm_cli.cli import cli\n") + initial = gate.source_profile(tmp_path)[1] + launcher.write_text(f"#!{python}\nfrom apm_cli.cli import cli\n# changed\n") + assert gate.source_profile(tmp_path)[1] != initial + + +def test_launcher_cannot_borrow_another_environment_interpreter( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + if os.name == "nt": + assert gate.source_profile(gate.ROOT)[0] is None + return + current, foreign = tmp_path / "current", tmp_path / "foreign" + current.mkdir() + foreign.mkdir() + original_python = Path(sys.executable).resolve() + (current / "python").symlink_to(original_python) + (foreign / "python").symlink_to(original_python) + launcher = current / "apm" + launcher.write_text(f"#!{foreign / 'python'}\nfrom apm_cli.cli import cli\n") + monkeypatch.setattr(sys, "executable", str(current / "python")) + assert (current / "python").samefile(foreign / "python") + with pytest.raises(EvidenceError, match="this Python environment"): + gate.source_profile(gate.ROOT) + + +def _completion(tmp_path: Path) -> tuple[Path, dict[str, Any], dict[str, Any]]: + native = { + "version": 1, + "contract_id": CONTRACT_ID, + "base": "a" * 40, + "head": "b" * 40, + "tested_tree": "c" * 40, + "lane": "full", + "status": "passed", + "witnesses": {nodeid: {} for nodeid in REQUIRED_WITNESSES}, + "profile": {"kind": "source-python", "cli_sha256": "f" * 64, "source_root": "/driver"}, + } + driver = tmp_path / "driver.json" + driver.write_text(json.dumps(native)) + summary = { + "version": 1, + "contract_id": CONTRACT_ID, + "base_sha": native["base"], + "head_sha": native["head"], + "tested_tree": native["tested_tree"], + "lane": "full", + "status": "passed", + "report_path": str(driver), + "report_sha256": hashlib.sha256(driver.read_bytes()).hexdigest(), + "witnesses": list(REQUIRED_WITNESSES), + } + path = tmp_path / "completion.json" + path.write_text(json.dumps(summary)) + native["profile"]["source_root"] = "/independent-checkout" + return path, summary, native + + +@pytest.fixture +def external_checkout(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Keep component artifacts in the project while modelling an external checkout.""" + checkout = tmp_path / "checkout" + schema = checkout / "tests/fixtures/lifecycle_completion.schema.json" + schema.parent.mkdir(parents=True) + schema.write_bytes((gate.ROOT / "tests/fixtures/lifecycle_completion.schema.json").read_bytes()) + monkeypatch.setattr(gate, "ROOT", checkout) + return checkout + + +@pytest.mark.parametrize( + "mutation", + [ + "base_sha", + "head_sha", + "tested_tree", + "lane", + "status", + "version", + "contract_id", + "report_sha256", + "report_path", + "witnesses", + "extra-field", + "driver-head", + "driver-witness", + "source", + "profile", + "schema", + ], +) +def test_completion_rejects_stale_and_malformed_claims( + tmp_path: Path, external_checkout: Path, mutation: str +) -> None: + path, summary, native = _completion(tmp_path) + output = tmp_path / "independent.json" + gate.validate_completion(path, native, output) + if mutation in {"base_sha", "head_sha", "tested_tree", "report_sha256"}: + summary[mutation] = "d" * len(summary[mutation]) + elif mutation == "version": + summary["version"] = True + elif mutation == "witnesses": + summary["witnesses"].pop() + elif mutation == "extra-field": + summary["lifecycle_evidence"] = {} + elif mutation.startswith("driver") or mutation in {"source", "profile"}: + driver = Path(summary["report_path"]) + data = json.loads(driver.read_text()) + if mutation == "driver-head": + data["head"] = "d" * 40 + elif mutation == "driver-witness": + data["witnesses"].pop(REQUIRED_WITNESSES[0]) + elif mutation == "source": + data["profile"]["cli_sha256"] = "e" * 64 + else: + data["profile"]["kind"] = "packaged-binary" + driver.write_text(json.dumps(data)) + summary["report_sha256"] = hashlib.sha256(driver.read_bytes()).hexdigest() + elif mutation == "schema": + summary = [] + else: + summary[mutation] = "" + path.write_text(json.dumps(summary)) + with pytest.raises(EvidenceError): + gate.validate_completion(path, native, output) + + +def test_completion_protects_aliases_and_accepts_independent_source_path( + tmp_path: Path, external_checkout: Path +) -> None: + path, summary, native = _completion(tmp_path) + summary["report_path"] = "driver.json" + path.write_text(json.dumps(summary)) + gate.validate_completion(path, native, tmp_path / "independent.json") + driver = tmp_path / "driver.json" + for kind in ("hardlink", "symlink"): + alias = tmp_path / kind + if kind == "hardlink": + alias.hardlink_to(driver) + else: + alias.symlink_to(driver) + with pytest.raises(EvidenceError, match="overwrite"): + gate.validate_completion(path, native, alias) + with pytest.raises(EvidenceError, match="overwrite"): + gate.validate_completion(path, native, path) + + +def test_completion_runs_fresh_and_never_overwrites_previous_reports( + tmp_path: Path, external_checkout: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path, _, native = _completion(tmp_path) + output = tmp_path / "independent.json" + calls = [] + + def fresh(args: argparse.Namespace) -> dict[str, Any]: + calls.append((args.base, args.head, args.lane)) + return native + + monkeypatch.setattr(gate, "execute", fresh) + monkeypatch.setattr( + sys, + "argv", + [ + "gate", + "--base", + native["base"], + "--head", + native["head"], + "--lane", + "full", + "--report", + str(output), + "--completion", + str(path), + ], + ) + assert gate.main() == 0 + assert calls == [(native["base"], native["head"], "full")] + before = output.read_bytes() + assert gate.main() == 1 + assert output.read_bytes() == before + assert len(calls) == 1 + + +def test_emitted_sidecar_round_trips_through_fresh_independent_verification( + tmp_path: Path, external_checkout: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _, _, native = _completion(tmp_path) + report = tmp_path / "fresh-driver.json" + sidecar = tmp_path / "fresh-completion.json" + independent = tmp_path / "independent.json" + calls = [] + + def fresh(args: argparse.Namespace) -> dict[str, Any]: + calls.append(args.report) + result = copy.deepcopy(native) + result["profile"]["source_root"] = f"/checkout-{len(calls)}" + return result + + monkeypatch.setattr(gate, "execute", fresh) + arguments = ["gate", "--base", native["base"], "--head", native["head"], "--lane", "full"] + monkeypatch.setattr( + sys, + "argv", + [*arguments, "--report", str(report), "--completion-output", str(sidecar)], + ) + assert gate.main() == 0 + summary = json.loads(sidecar.read_bytes()) + assert summary["report_path"] == str(report.resolve()) + assert summary["report_sha256"] == hashlib.sha256(report.read_bytes()).hexdigest() + assert summary["witnesses"] == sorted(REQUIRED_WITNESSES) + assert sidecar.read_bytes() == gate._json_bytes(summary) + assert b"\r" not in sidecar.read_bytes() + assert summary == gate.completion_summary( + json.loads(report.read_bytes()), report, report.read_bytes() + ) + monkeypatch.setattr( + sys, + "argv", + [*arguments, "--report", str(independent), "--completion", str(sidecar)], + ) + assert gate.main() == 0 + assert calls == [report, independent] + assert json.loads(independent.read_bytes())["profile"]["source_root"] == "/checkout-2" + assert summary["report_sha256"] == hashlib.sha256(report.read_bytes()).hexdigest() + + +@pytest.mark.parametrize("status", ["blocked", "pending", "not_applicable"]) +def test_unsuccessful_execution_never_emits_a_completion( + tmp_path: Path, external_checkout: Path, monkeypatch: pytest.MonkeyPatch, status: str +) -> None: + _, _, native = _completion(tmp_path) + native.update(status=status, error="negative control") + report, sidecar = tmp_path / "failed-report.json", tmp_path / "must-not-exist.json" + monkeypatch.setattr(gate, "execute", lambda args: native) + monkeypatch.setattr( + sys, + "argv", + [ + "gate", + "--base", + native["base"], + "--head", + native["head"], + "--lane", + "full", + "--report", + str(report), + "--completion-output", + str(sidecar), + ], + ) + assert gate.main() == 1 + assert json.loads(report.read_bytes())["status"] == status + assert not sidecar.exists() + + +@pytest.mark.parametrize( + "mutation", ["existing", "same-report", "symlink", "hardlink", "in-checkout", "both-modes"] +) +def test_completion_output_refuses_unsafe_paths_before_execution( + tmp_path: Path, external_checkout: Path, monkeypatch: pytest.MonkeyPatch, mutation: str +) -> None: + report, sidecar = tmp_path / "new-report.json", tmp_path / "new-completion.json" + sentinel = tmp_path / "preserved.json" + sentinel.write_bytes(b"preserve these bytes") + if mutation == "existing": + sidecar = sentinel + elif mutation == "same-report": + sidecar = report + elif mutation == "symlink": + sidecar.symlink_to(sentinel) + elif mutation == "hardlink": + sidecar.hardlink_to(sentinel) + elif mutation == "in-checkout": + sidecar = external_checkout / "forbidden.json" + arguments = [ + "gate", + "--base", + "a" * 40, + "--head", + "b" * 40, + "--lane", + "full", + "--report", + str(report), + "--completion-output", + str(sidecar), + ] + if mutation == "both-modes": + arguments.extend(["--completion", str(sentinel)]) + + def unexpected(args: argparse.Namespace) -> dict[str, Any]: + raise AssertionError("Unsafe output must be rejected before native execution") + + monkeypatch.setattr(gate, "execute", unexpected) + monkeypatch.setattr(sys, "argv", arguments) + if mutation in {"in-checkout", "both-modes"}: + with pytest.raises(SystemExit) as error: + gate.main() + assert error.value.code == 2 + else: + assert gate.main() == 1 + assert sentinel.read_bytes() == b"preserve these bytes" + assert not report.exists() + + +@pytest.mark.parametrize("mutation", ["witnesses", "profile", "bytes", "schema"]) +def test_completion_emission_refuses_inconsistent_success_report( + tmp_path: Path, external_checkout: Path, mutation: str +) -> None: + _, _, native = _completion(tmp_path) + if mutation == "witnesses": + native["witnesses"].pop(REQUIRED_WITNESSES[0]) + elif mutation == "profile": + native["profile"]["kind"] = "packaged-binary" + elif mutation == "schema": + native["head"] = "not-a-sha" + raw = gate._json_bytes(native) + if mutation == "bytes": + raw = b"{}" + with pytest.raises(EvidenceError): + gate.completion_summary(native, tmp_path / "report.json", raw) + + +@pytest.mark.parametrize( + "mutation", + ["pass", "pytest-failed", "missing", "dirty-after", "head-after", "tree-after", "source-after"], +) +def test_provider_execution_checks_identity_after_its_own_run( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mutation: str +) -> None: + """Unit-only fabricated events exercise orchestration, never native acceptance.""" + contract = _ledger()["lifecycle_contracts"][0] + records = {} + for witness in contract["witnesses"]: + events, state = [], 0 + for transition in witness["transitions"]: + before = str(state) + if transition["state"] != "unchanged": + state += 1 + context = transition.get("context", "initial") + events.append( + { + **transition, + "args": transition["argv_contains"], + "before": before, + "after": str(state), + "context": context, + "cwd": "/fixture" if context == "initial" else "/home/.apm", + "roots": {"workspace": "/fixture", "HOME": "/home", "APM_HOME": "/home/.apm"}, + "environment": {"HOME": "/home", "APM_HOME": "/home/.apm"}, + "model": 1, + } + ) + records[witness["nodeid"]] = { + "collected": True, + "dimensions": witness["dimensions"], + "models": 1, + "events": events, + "phases": {"setup": "passed", "call": "passed", "teardown": "passed"}, + } + identity = {"base": "a" * 40, "head": "b" * 40, "tested_tree": "c" * 40} + profile = {"kind": "source-python", "source_root": str(gate.ROOT / "src/apm_cli")} + calls: list[str] = [] + + def candidate(*args: Any) -> dict[str, str]: + if calls: + if mutation == "dirty-after": + raise EvidenceError("Candidate dirty after execution") + if mutation in {"head-after", "tree-after"}: + field = "head" if mutation == "head-after" else "tested_tree" + return {**identity, field: "d" * 40} + return identity + + def source(*args: Any) -> tuple[None, dict[str, str]]: + if calls and mutation == "source-after": + return None, {**profile, "python": "changed"} + return None, profile + + def fresh_pytest(args: list[str], plugins: list[Any]) -> int: + assert args[-9:] == sorted(REQUIRED_WITNESSES) + assert plugins[0].records is records + calls.append("fresh pytest") + if mutation == "missing": + records.pop(REQUIRED_WITNESSES[0]) + return 1 if mutation == "pytest-failed" else 0 + + plugin = SimpleNamespace(records=records, patch=SimpleNamespace(undo=lambda: None)) + monkeypatch.setattr(gate, "candidate", candidate) + monkeypatch.setattr(gate, "source_profile", source) + monkeypatch.setattr(gate, "candidate_contract", lambda *args: contract) + monkeypatch.setattr("tests.utils.lifecycle_evidence.LifecycleEvidencePlugin", lambda *a: plugin) + monkeypatch.setattr(pytest, "main", fresh_pytest) + result = gate.execute( + argparse.Namespace( + base=identity["base"], + head=identity["head"], + lane="full", + report=tmp_path / "report.json", + ) + ) + assert calls == ["fresh pytest"] + assert result["status"] == ("passed" if mutation == "pass" else "blocked") + if mutation != "pass": + assert result["error"] + + +@pytest.mark.parametrize("mode", ["skip", "xfail", "setup", "teardown", "call", "pass"]) +def test_observer_records_real_pytest_phases(pytester: pytest.Pytester, mode: str) -> None: + pytester.makeini("[pytest]") + module = pytester.makepyfile(f""" + import pytest + @pytest.fixture + def fixture(): + if {mode!r} == "setup": + raise RuntimeError("setup failed") + yield + if {mode!r} == "teardown": + raise RuntimeError("teardown failed") + def test_observed(fixture): + if {mode!r} == "skip": + pytest.skip("negative control") + if {mode!r} == "xfail": + pytest.xfail("negative control") + if {mode!r} == "call": + raise RuntimeError("call failed") + assert 1 == 1 + """) + nodeid = f"{module.name}::test_observed" + plugin = LifecycleEvidencePlugin([nodeid], None) + result = pytester.runpytest_inprocess("-q", "-o", "addopts=", nodeid, plugins=[plugin]) + record = plugin.records[nodeid] + phase = mode if mode in {"setup", "teardown"} else "call" + expected = {"pass": "passed", "skip": "skipped", "xfail": "xfail"}.get(mode, "failed") + assert record["phases"][phase] == expected + if mode in {"setup", "teardown", "call"}: + assert result.ret != 0 + + +def test_repeated_phase_cannot_erase_an_earlier_failure() -> None: + plugin = LifecycleEvidencePlugin(["node"], None) + plugin.records["node"] = {"phases": {}} + for outcome in ("failed", "passed", "passed"): + plugin.pytest_runtest_logreport( + SimpleNamespace(nodeid="node", when="call", outcome=outcome) + ) + assert plugin.records["node"]["phases"] == {"call": "repeated"} + + +@pytest.mark.parametrize("selection", ["missing", "extra", "deselected", "wrong-param"]) +def test_observer_requires_exact_collection(pytester: pytest.Pytester, selection: str) -> None: + pytester.makeini("[pytest]") + module = pytester.makepyfile(""" + import pytest + @pytest.mark.parametrize("variant", ["actual"]) + def test_observed(variant): + assert variant == "actual" + def test_extra(): + assert 1 == 1 + """) + nodeid = f"{module.name}::test_observed[actual]" + desired = nodeid if selection != "wrong-param" else nodeid.replace("[actual]", "[missing]") + plugin = LifecycleEvidencePlugin([desired], None) + args = [nodeid] + if selection == "missing": + args = [f"{module.name}::missing"] + elif selection == "extra": + args = [module.name] + elif selection == "deselected": + args += ["-k", "not observed"] + result = pytester.runpytest_inprocess("-q", "-o", "addopts=", *args, plugins=[plugin]) + assert result.ret != 0 + + +def test_observer_requires_real_runner_and_hypothesis_execution(pytester: pytest.Pytester) -> None: + """A tiny genuine generated run exercises instrumentation without native scenarios.""" + pytester.makeini("[pytest]") + module = pytester.makepyfile(""" + import os + import sys + from hypothesis import settings + from hypothesis.stateful import RuleBasedStateMachine, initialize, rule, run_state_machine_as_test + from tests.utils.apm_lifecycle_runner import ApmLifecycleRunner + from tests.utils.isolated_apm_environment import IsolatedApmEnvironment + def test_observed(tmp_path): + environment = IsolatedApmEnvironment.create(tmp_path / "domain", base_env=os.environ) + runner = ApmLifecycleRunner((sys.executable, "-m", "apm_cli.cli")) + class Model(RuleBasedStateMachine): + @initialize() + def start(self): + result = runner.run(["--version"], cwd=environment.work_root, + env=environment.subprocess_env()) + assert result.returncode == 0 + @rule() + def step(self): + assert environment.home.is_dir() + run_state_machine_as_test(Model, settings=settings( + max_examples=1, stateful_step_count=1, deadline=None, database=None)) + """) + nodeid = f"{module.name}::test_observed" + plugin = LifecycleEvidencePlugin([nodeid], None) + result = pytester.runpytest_inprocess("-q", "-o", "addopts=", nodeid, plugins=[plugin]) + assert result.ret == 0 + record = plugin.records[nodeid] + assert record["models"] == 1 + assert record["events"] + assert all(event["model"] == 1 and event["returncode"] == 0 for event in record["events"]) + assert record["phases"] == {"setup": "passed", "call": "passed", "teardown": "passed"} diff --git a/tests/unit/test_drift_detection.py b/tests/unit/test_drift_detection.py index b096c594b9..8b581410f7 100644 --- a/tests/unit/test_drift_detection.py +++ b/tests/unit/test_drift_detection.py @@ -7,10 +7,11 @@ from __future__ import annotations +import os import unittest from dataclasses import dataclass, field from typing import Dict, List, Optional # noqa: F401, UP035 -from unittest.mock import MagicMock # noqa: F401 +from unittest.mock import MagicMock, call, patch # noqa: F401 from apm_cli.drift import ( build_download_ref, @@ -32,7 +33,9 @@ class _LockedDep: repo_url: str = "owner/repo" resolved_ref: str | None = None resolved_commit: str | None = None + constraint: str | None = None host: str | None = None + host_type: str | None = None registry_prefix: str | None = None virtual_path: str | None = None source: str | None = None @@ -107,6 +110,20 @@ def test_both_none_returns_false(self): dep = _dep(reference=None) self.assertFalse(detect_ref_change(dep, locked)) + def test_git_semver_transport_change_precedes_constraint_replay(self) -> None: + for locked_insecure in (False, True): + with self.subTest(locked_insecure=locked_insecure): + locked = _LockedDep( + resolved_ref="v1.2.3", constraint="^1.2.0", is_insecure=locked_insecure + ) + dep = DependencyReference( + repo_url="owner/repo", reference="^1.2.0", is_insecure=not locked_insecure + ) + self.assertTrue(detect_ref_change(dep, locked)) + self.assertFalse(detect_ref_change(dep, locked, update_refs=True)) + dep.is_insecure = locked_insecure + self.assertFalse(detect_ref_change(dep, locked)) + # --- ref changed --- def test_ref_added_returns_true(self): @@ -138,6 +155,41 @@ def test_hash_ref_added_returns_true(self): dep = _dep(reference="abc1234") self.assertTrue(detect_ref_change(dep, locked)) + def test_custom_host_backend_change_returns_true(self): + locked = _LockedDep(host="code.example.com", resolved_ref="main") + dep = DependencyReference( + repo_url="owner/repo", host="code.example.com", host_type="gitlab", reference="main" + ) + self.assertTrue(detect_ref_change(dep, locked)) + + def test_inferred_and_explicit_equivalent_provider_returns_false(self): + locked = _LockedDep(host="gitlab.com", resolved_ref="main") + dep = DependencyReference( + repo_url="owner/repo", host="gitlab.com", host_type="gitlab", reference="main" + ) + self.assertFalse(detect_ref_change(dep, locked)) + + def test_provider_drift_routes_through_canonical_host_identity_owner(self): + locked = _LockedDep(host="code.example.com", resolved_ref="main") + dep = DependencyReference(repo_url="owner/repo", host="code.example.com", reference="main") + with patch( + "apm_cli.drift.effective_host_provider_identity", + side_effect=[("gitlab", "generic_modules"), ("generic", "generic_modules")], + ) as identity: + self.assertTrue(detect_ref_change(dep, locked)) + self.assertEqual( + identity.call_args_list, + [call("code.example.com", host_type=None), call("code.example.com", host_type=None)], + ) + + def test_unclassifiable_locked_provider_fails_closed_as_drift(self): + locked = _LockedDep(host="code.example.com", host_type="gitlab", resolved_ref="main") + dep = DependencyReference( + repo_url="owner/repo", host="code.example.com", host_type="gitlab", reference="main" + ) + with patch.dict(os.environ, {"GITHUB_HOST": "code.example.com"}, clear=True): + self.assertTrue(detect_ref_change(dep, locked)) + # --- local-path deps (issue: false source-flip) --- def test_local_path_dep_parses_with_local_source(self): diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py index 66d83477c3..c198d20adf 100644 --- a/tests/unit/test_install_command.py +++ b/tests/unit/test_install_command.py @@ -1572,14 +1572,16 @@ def test_http_dep_rejected_without_allow_insecure_flag(self): assert "allow_insecure: true" in result.output assert "--allow-insecure" in result.output - def test_install_help_mentions_allow_insecure_for_http_deps(self): - """Install help should mention the HTTP allow-insecure flow.""" + def test_install_help_describes_transport_and_frozen_identity(self): + """Install help describes opt-in HTTP and the frozen identity boundary.""" result = self.runner.invoke(cli, ["install", "--help"]) assert result.exit_code == 0 normalized = " ".join(result.output.split()) assert "use --allow-insecure for http:// packages" in normalized assert "--allow-insecure-host HOSTNAME" in result.output + assert "Git source, ref, host provider, transport, and MCP config state" in normalized + assert "Use 'apm audit' for on-disk integrity." in normalized def test_allow_insecure_host_rejects_non_hostname(self): """The explicit transitive host option only accepts bare hostnames.""" diff --git a/tests/utils/isolated_apm_environment.py b/tests/utils/isolated_apm_environment.py index 58c28b9f3f..e91f1089fc 100644 --- a/tests/utils/isolated_apm_environment.py +++ b/tests/utils/isolated_apm_environment.py @@ -262,7 +262,7 @@ def _guarded_import_module(name, package=None): | _APM_REMOTE_CONTROL_ENV_NAMES ) _STRIPPED_ENV_PREFIXES = _APM_AUTH_ENV_PREFIXES + _GIT_CONFIG_INJECTION_PREFIXES -_PINNED_ENVIRONMENT_NAMES = ( +DURABLE_ENVIRONMENT_ROOTS = ( "HOME", "USERPROFILE", "XDG_CONFIG_HOME", @@ -271,9 +271,12 @@ def _guarded_import_module(name, package=None): "LOCALAPPDATA", "APM_HOME", "APM_CACHE_DIR", - "APM_TEMP_DIR", "GH_CONFIG_DIR", "AZURE_CONFIG_DIR", +) +_PINNED_ENVIRONMENT_NAMES = ( + *DURABLE_ENVIRONMENT_ROOTS, + "APM_TEMP_DIR", "TMPDIR", "TMP", "TEMP", @@ -426,9 +429,9 @@ def create( "LOCALAPPDATA": str(local_app_data), "APM_HOME": str(config_root), "APM_CACHE_DIR": str(cache_root), - "APM_TEMP_DIR": str(temp_root), "GH_CONFIG_DIR": str(gh_config_root), "AZURE_CONFIG_DIR": str(azure_config_root), + "APM_TEMP_DIR": str(temp_root), "TMPDIR": str(temp_root), "TMP": str(temp_root), "TEMP": str(temp_root), diff --git a/tests/utils/lifecycle_evidence.py b/tests/utils/lifecycle_evidence.py new file mode 100644 index 0000000000..a3423ffb97 --- /dev/null +++ b/tests/utils/lifecycle_evidence.py @@ -0,0 +1,303 @@ +"""Scoped fresh pytest observations, adapted from be73ed139. + +No smoke deferral, synthetic evidence ingestion, or global pytest registration. +""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +import sys +from dataclasses import asdict +from pathlib import Path +from typing import Any + +import pytest + +from scripts.lifecycle_contracts import EvidenceError, command_inventory, command_path +from tests.utils.apm_lifecycle_runner import ApmLifecycleRunner +from tests.utils.artifact_snapshot import ArtifactSnapshotSet +from tests.utils.isolated_apm_environment import DURABLE_ENVIRONMENT_ROOTS + + +def fingerprint(path: Path) -> str: + """Hash the actual source or executable bytes.""" + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def snapshot(domain: dict[str, str]) -> str: + """Observe all durable roots with the existing open-world snapshot authority.""" + roots: list[Path] = [] + candidates = {Path(value).resolve() for value in domain.values()} + for candidate in sorted(candidates, key=lambda p: (len(p.parts), str(p))): + if not any(candidate.is_relative_to(root) for root in roots): + roots.append(candidate) + captured = ArtifactSnapshotSet.capture({str(path): path for path in roots}) + payload = [ + (name, value.root_existed, [asdict(entry) for entry in value.entries]) + for name, value in captured.snapshots + ] + return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest() + + +def _root_values(env: dict[str, str]) -> dict[str, str]: + """Include target-specific overrides alongside the isolation owner's roots.""" + return { + key: env[key] + for key in (*DURABLE_ENVIRONMENT_ROOTS, "CLAUDE_CONFIG_DIR", "HERMES_HOME") + if env.get(key) + } + + +def _physical_roots(cwd: Path, env: dict[str, str]) -> dict[str, str]: + """Resolve overrides exactly in the child HOME, not the observer's HOME.""" + expanded_keys = {"CLAUDE_CONFIG_DIR", "HERMES_HOME", "APM_CACHE_DIR", "XDG_CACHE_HOME"} + values = { + key: value.strip() if key in expanded_keys else value + for key, value in _root_values(env).items() + } + tilde = {k: v for k, v in values.items() if k in expanded_keys and v.startswith("~")} + if tilde: + probe = subprocess.run( + [ + sys.executable, + "-c", + "import json, sys; from pathlib import Path; " + "print(json.dumps({k: str(Path(v).expanduser()) " + "for k, v in json.load(sys.stdin).items()}))", + ], + input=json.dumps(tilde), + cwd=cwd, + env=dict(env), + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if probe.returncode: + raise EvidenceError("Child durable root expansion failed") + try: + expanded = json.loads(probe.stdout) + except json.JSONDecodeError as exc: + raise EvidenceError("Child durable root expansion produced invalid output") from exc + if ( + not isinstance(expanded, dict) + or expanded.keys() != tilde.keys() + or any(not isinstance(value, str) or not value for value in expanded.values()) + ): + raise EvidenceError("Child durable root expansion produced invalid paths") + values.update(expanded) + return {key: str((cwd / value).resolve()) for key, value in values.items()} + + +class LifecycleEvidencePlugin: + """Observe exact collected nodes, genuine Hypothesis calls and runner subprocesses.""" + + def __init__( + self, + nodeids: list[str], + executable: Path | None, + contexts: dict[str, set[str]] | None = None, + candidate_source: Path | None = None, + ) -> None: + import apm_cli + + self.nodeids = set(nodeids) + self.contexts = contexts or {} + self.candidate_source = (candidate_source or Path(apm_cli.__file__).parent).resolve() + self.source_hash = fingerprint(self.candidate_source / "cli.py") + self.executable = executable.absolute() if executable else None + self.executable_hash = fingerprint(self.executable) if self.executable else None + self.python_hash = fingerprint(Path(sys.executable).resolve()) + self.inventory = command_inventory() + self.records: dict[str, dict[str, Any]] = {} + self.active: str | None = None + self.phase: str | None = None + self.model_run: int | None = None + self.domains: dict[tuple[str, int | None, str], dict[str, str]] = {} + self.patch = pytest.MonkeyPatch() + + def _source_identity( + self, + cwd: Path, + env: dict[str, str], + timeout: float, + entrypoint: tuple[str, ...] | None = None, + ) -> dict[str, str]: + """Probe actual child import resolution, accounting for script versus -m.""" + path_head = ( + self.executable.parent + if self.executable and entrypoint == (str(self.executable),) + else cwd.resolve() + ) + probe = subprocess.run( + [ + sys.executable, + "-c", + "import sys; " + "sys.path[:] = sys.path if getattr(sys.flags, 'safe_path', False) " + "else [sys.argv[1], *sys.path[1:]]; " + "import hashlib, importlib, importlib.util, json; from pathlib import Path; " + "package = importlib.import_module('apm_cli'); " + "spec = importlib.util.find_spec('apm_cli.cli'); path = Path(spec.origin).resolve(); " + "print(json.dumps({'package': str(Path(package.__file__).resolve().parent), " + "'cli': str(path), 'sha256': hashlib.sha256(path.read_bytes()).hexdigest(), " + "'python': str(Path(sys.executable).resolve()), " + "'python_sha256': hashlib.sha256(Path(sys.executable).read_bytes()).hexdigest()}))", + str(path_head), + ], + cwd=cwd, + env=dict(env), + capture_output=True, + text=True, + timeout=min(timeout, 30), + check=False, + ) + if probe.returncode: + raise EvidenceError(f"Child source identity probe failed: exit {probe.returncode}") + try: + source = json.loads(probe.stdout) + except json.JSONDecodeError as exc: + raise EvidenceError("Child source identity probe produced invalid output") from exc + expected = { + "package": str(self.candidate_source), + "cli": str(self.candidate_source / "cli.py"), + "sha256": self.source_hash, + "python": str(Path(sys.executable).resolve()), + "python_sha256": self.python_hash, + } + if source != expected: + raise EvidenceError("Child source identity does not match the candidate checkout") + return source + + def _domain(self, cwd: Path, env: dict[str, str]) -> tuple[dict[str, str], dict[str, str], str]: + """Pin lexical and physical roots across explicitly reviewed cwd changes.""" + if self.active is None: + raise EvidenceError("Lifecycle context requires an active test node") + environment = _root_values(env) + if not {"HOME", "APM_HOME"} <= environment.keys(): + raise EvidenceError("Lifecycle requires isolated HOME and APM_HOME") + physical = _physical_roots(cwd, env) + identity = json.dumps([environment, physical], sort_keys=True) + key = (self.active, self.model_run, identity) + location = str(cwd.resolve()) + if key not in self.domains: + if any( + node == self.active + and model == self.model_run + and location in {roots["workspace"], roots.get("APM_HOME")} + for (node, model, _identity), roots in self.domains.items() + ): + raise EvidenceError("Lifecycle durable roots changed for the same caller workspace") + self.domains[key] = {"workspace": location, **physical} + roots = self.domains[key] + context = "initial" + if location != roots["workspace"]: + if location != roots.get("APM_HOME"): + raise EvidenceError(f"Unreviewed lifecycle command cwd: {cwd}") + context = "APM_HOME" + if context not in self.contexts.get(self.active, set()) | {"initial"}: + raise EvidenceError(f"Undeclared lifecycle command context: {context}") + return roots, environment, context + + def pytest_sessionstart(self, session: pytest.Session) -> None: + """Install observers before test-module imports bind runner/model functions.""" + from hypothesis import stateful + + original = ApmLifecycleRunner._run_with_timeout + model = stateful.run_state_machine_as_test + + def observe(runner: ApmLifecycleRunner, args: Any, **kwargs: Any) -> Any: + if self.active not in self.nodeids or self.phase != "call": + return original(runner, args, **kwargs) + command = runner._command + if command != (sys.executable, "-m", "apm_cli.cli") and not ( + self.executable and command == (str(self.executable),) + ): + raise EvidenceError(f"Unverified source executable: {command}") + if ( + self.executable and fingerprint(self.executable) != self.executable_hash + ) or fingerprint(Path(sys.executable).resolve()) != self.python_hash: + raise EvidenceError("Executable changed during evidence execution") + cwd, env = kwargs["cwd"], kwargs["env"] + roots, environment, context = self._domain(cwd, env) + source = self._source_identity(cwd, env, kwargs["timeout_seconds"], command) + before = snapshot(roots) + result = original(runner, args, **kwargs) + if _physical_roots(cwd, env) != {key: roots[key] for key in environment}: + raise EvidenceError("Lifecycle durable root identity changed during command") + self.records[self.active]["events"].append( + { + "command": command_path(list(args), self.inventory), + "args": list(args), + "returncode": result.returncode, + "cwd": str(cwd.resolve()), + "roots": roots, + "environment": environment, + "context": context, + "source": source, + "before": before, + "after": snapshot(roots), + "scenario_id": kwargs["scenario_id"], + "entrypoint": list(command), + "model": self.model_run, + } + ) + return result + + def observe_model(*args: Any, **kwargs: Any) -> Any: + if self.active not in self.nodeids or self.phase != "call": + return model(*args, **kwargs) + previous = self.model_run + self.model_run = self.records[self.active]["models"] + 1 + try: + result = model(*args, **kwargs) + self.records[self.active]["models"] += 1 + return result + finally: + self.model_run = previous + + self.patch.setattr(ApmLifecycleRunner, "_run_with_timeout", observe) + self.patch.setattr(stateful, "run_state_machine_as_test", observe_model) + + def pytest_collection_finish(self, session: pytest.Session) -> None: + """Fail on extra, duplicate, missing or deselected final collected nodes.""" + actual = [item.nodeid for item in session.items] + if len(actual) != len(self.nodeids) or set(actual) != self.nodeids: + raise pytest.UsageError("Lifecycle exact collection differs from required witnesses") + for item in session.items: + callspec = getattr(item, "callspec", None) + self.records[item.nodeid] = { + "collected": True, + "dimensions": dict(callspec.params) if callspec else {}, + "phases": {}, + "events": [], + "models": 0, + } + + @pytest.hookimpl(hookwrapper=True) + def pytest_runtest_protocol(self, item: pytest.Item, nextitem: pytest.Item | None) -> Any: + """Associate fixture execution with its exact collected node.""" + self.active = item.nodeid + yield + self.active, self.phase = None, None + + def pytest_runtest_setup(self, item: pytest.Item) -> None: + self.phase = "setup" + + def pytest_runtest_call(self, item: pytest.Item) -> None: + self.phase = "call" + + def pytest_runtest_teardown(self, item: pytest.Item, nextitem: pytest.Item | None) -> None: + self.phase = "teardown" + + def pytest_runtest_logreport(self, report: pytest.TestReport) -> None: + if report.nodeid in self.records: + outcome = "xfail" if hasattr(report, "wasxfail") else report.outcome + phases = self.records[report.nodeid]["phases"] + phases[report.when] = "repeated" if report.when in phases else outcome + + def pytest_sessionfinish(self, session: pytest.Session, exitstatus: int) -> None: + """Restore instrumentation after both success and failure.""" + self.patch.undo() From ace6ee385acce2df015322a973a767442a083790 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 30 Sep 2026 13:09:56 +0200 Subject: [PATCH 10/15] test: close native proof mutation gaps and preserve launcher identity Exercise same-cardinality witness substitution, skipped calls behind successful pytest exits, realistic foreign launchers, and manifest-only alias escape. Admit only canonical Python exec trampolines while preserving exact environment and source checks, including foreign virtualenvs sharing a base interpreter. Remove unrelated prototype ratchet metadata; ordinary required unit coverage remains unchanged. Align inherited scoped-lock fixture with actual bare-ref serialization without relaxing frozen admission. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- scripts/check_lifecycle_evidence.py | 13 ++ .../unit/commands/test_deps_alias_readers.py | 25 +++- tests/unit/install/test_frozen_roots.py | 1 - tests/unit/scripts/test_lifecycle_evidence.py | 115 +++++++++++++++++- 4 files changed, 143 insertions(+), 11 deletions(-) diff --git a/scripts/check_lifecycle_evidence.py b/scripts/check_lifecycle_evidence.py index 6dec59055f..c3d43bbed2 100644 --- a/scripts/check_lifecycle_evidence.py +++ b/scripts/check_lifecycle_evidence.py @@ -11,6 +11,7 @@ import hashlib import json import os +import re import subprocess import sys from pathlib import Path @@ -56,6 +57,18 @@ def source_profile(root: Path) -> tuple[Path | None, dict[str, Any]]: if executable: script = executable.read_text(encoding="utf-8") interpreter = Path(script.splitlines()[0][2:]) if script.startswith("#!") else None + if script.startswith("#!/bin/sh\n"): + trampoline = re.match( + r"\A#!/bin/sh\n'''exec' " + r"""(?:'([^'\r\n]+)'|"([^"$`\\\r\n]+)"|([A-Za-z0-9_./:+-]+))""" + r""" "\$0" "\$@"\n' '''\n""", + script, + ) + interpreter = ( + Path(next(part for part in trampoline.groups() if part is not None)) + if trampoline + else None + ) if ( "apm_cli.cli" not in script or interpreter is None diff --git a/tests/unit/commands/test_deps_alias_readers.py b/tests/unit/commands/test_deps_alias_readers.py index af23ef203d..d492c1dfc7 100644 --- a/tests/unit/commands/test_deps_alias_readers.py +++ b/tests/unit/commands/test_deps_alias_readers.py @@ -83,7 +83,10 @@ def test_alias_reader_uses_only_selected_store(tmp_path: Path) -> None: ) -def test_alias_reader_refuses_escape_without_inspecting_external_package(tmp_path: Path) -> None: +@pytest.mark.parametrize("declarations", ["manifest", "lock", "both"]) +def test_alias_reader_refuses_escape_without_inspecting_external_package( + tmp_path: Path, declarations: str +) -> None: store = tmp_path / "store" external = tmp_path / "external" external.mkdir() @@ -91,11 +94,21 @@ def test_alias_reader_refuses_escape_without_inspecting_external_package(tmp_pat modules = store / "apm_modules" modules.mkdir(parents=True) (modules / "escaped").symlink_to(external, target_is_directory=True) - lock = LockFile() - lock.add_dependency( - LockedDependency(repo_url="owner/bundle", alias="escaped", resolved_commit="a" * 40) - ) - lock.save(store / "apm.lock.yaml") + if declarations in {"manifest", "both"}: + dump_yaml( + { + "name": "consumer", + "version": "1.0.0", + "dependencies": {"apm": [{"git": "owner/bundle", "alias": "escaped"}]}, + }, + store / "apm.yml", + ) + if declarations in {"lock", "both"}: + lock = LockFile() + lock.add_dependency( + LockedDependency(repo_url="owner/bundle", alias="escaped", resolved_commit="a" * 40) + ) + lock.save(store / "apm.lock.yaml") before = ArtifactSnapshotSet.capture({"store": store, "external": external}) with pytest.raises(PathTraversalError): _resolve_scope_deps(store, MagicMock()) diff --git a/tests/unit/install/test_frozen_roots.py b/tests/unit/install/test_frozen_roots.py index 771f04d528..5b7f5aa998 100644 --- a/tests/unit/install/test_frozen_roots.py +++ b/tests/unit/install/test_frozen_roots.py @@ -55,7 +55,6 @@ def test_frozen_scoped_lockfile_selection_is_read_only( lock.add_dependency( LockedDependency( repo_url="owner/package", - resolved_ref="main", resolved_commit="a" * 40, depth=1, ) diff --git a/tests/unit/scripts/test_lifecycle_evidence.py b/tests/unit/scripts/test_lifecycle_evidence.py index d6200b44df..a82158a468 100644 --- a/tests/unit/scripts/test_lifecycle_evidence.py +++ b/tests/unit/scripts/test_lifecycle_evidence.py @@ -36,7 +36,6 @@ pytestmark = pytest.mark.component pytest_plugins = ["pytester"] -RATCHET_TEST_SCOPE = "fixture" def _ledger() -> dict[str, Any]: @@ -114,6 +113,7 @@ def test_immutable_witnesses_and_current_inventory() -> None: "none", "delete", "duplicate", + "nodeid", "kind", "dimension", "dimension-type", @@ -132,6 +132,10 @@ def test_contract_edits_cannot_vacuously_remove_proof(mutation: str) -> None: contract["witnesses"].pop() elif mutation == "duplicate": contract["witnesses"][-1] = copy.deepcopy(contract["witnesses"][0]) + elif mutation == "nodeid": + contract["witnesses"][-1]["nodeid"] = ( + "tests/integration/test_required_lifecycle_state_machine.py::test_unreviewed_semver_witness" + ) elif mutation == "kind": contract["witnesses"][3]["kind"] = "deterministic" elif mutation == "dimension": @@ -146,7 +150,8 @@ def test_contract_edits_cannot_vacuously_remove_proof(mutation: str) -> None: contract["commands"]["install"]["generated"] = [] else: contract["witnesses"][0]["transitions"] = [] - with pytest.raises(EvidenceError): + message = "Exact immutable witness identities required" if mutation == "nodeid" else None + with pytest.raises(EvidenceError, match=message): validate_contracts(ledger, command_inventory()) @@ -378,12 +383,18 @@ def test_source_profile_pins_checkout_launcher_and_interpreter( source = tmp_path / "src/apm_cli" source.mkdir(parents=True) - (source / "cli.py").write_text("fixture") + (source / "cli.py").write_text("def main():\n return 0\n") monkeypatch.setattr(apm_cli, "__file__", str(source / "__init__.py")) python = tmp_path / "python" python.write_text("fixture interpreter") monkeypatch.setattr(sys, "executable", str(python)) assert gate.source_profile(tmp_path)[0] is None + foreign = tmp_path / "foreign/src/apm_cli" + foreign.mkdir(parents=True) + (foreign / "__init__.py").write_text("") + (foreign / "cli.py").write_bytes((source / "cli.py").read_bytes()) + if os.name != "nt": + (tmp_path / "apm").write_text(f"#!{python}\nfrom apm_cli.cli import main\nmain()\n") with pytest.raises(EvidenceError, match="candidate checkout"): gate.source_profile(tmp_path / "foreign") if os.name != "nt": @@ -417,6 +428,88 @@ def test_launcher_cannot_borrow_another_environment_interpreter( gate.source_profile(gate.ROOT) +@pytest.mark.parametrize( + "variant", + [ + "single-quoted", + "double-quoted", + "unquoted", + "foreign-environment", + "different-interpreter", + "relative-interpreter", + "empty-interpreter", + "unterminated-path", + "missing-terminator", + "extra-shell-line", + "extra-shell-command", + "extra-python-argument", + "command-substitution", + "variable-expansion", + "wrong-forwarding", + "missing-cli", + ], +) +def test_source_profile_accepts_only_canonical_long_path_launchers( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, variant: str +) -> None: + from pip._vendor.distlib.scripts import ScriptMaker + + if os.name == "nt": + assert gate.source_profile(gate.ROOT)[0] is None + return + directory = tmp_path / ("x" * 170) / ("y" * 170) / ("z" * 170) + directory.mkdir(parents=True) + python = directory / "python" + python.symlink_to(Path(sys.executable).resolve()) + assert len(str(python)) > 512 + quoted = { + "double-quoted": f'"{python}"', + "unquoted": str(python), + }.get(variant, f"'{python}'") + maker = ScriptMaker(None, str(directory)) + maker.executable = quoted + maker.variants = {""} + launcher = directory / "apm" + assert maker.make("apm = apm_cli.cli:main") == [str(launcher)] + script = launcher.read_text(encoding="utf-8") + assert script.startswith("#!/bin/sh\n'''exec' ") + monkeypatch.setattr(sys, "executable", str(python)) + executable, profile = gate.source_profile(gate.ROOT) + assert executable == launcher + assert profile["python_environment"] == str(directory.resolve()) + assert profile["executable_sha256"] == hashlib.sha256(launcher.read_bytes()).hexdigest() + if variant in {"single-quoted", "double-quoted", "unquoted"}: + return + + foreign = tmp_path / "foreign" + foreign.mkdir() + foreign_python = foreign / "python" + foreign_python.symlink_to(python.resolve()) + different_python = directory / "different-python" + different_python.write_text("not this interpreter") + substitutions = { + "foreign-environment": (quoted, f"'{foreign_python}'"), + "different-interpreter": (quoted, f"'{different_python}'"), + "relative-interpreter": (quoted, "'python'"), + "empty-interpreter": (quoted, "''"), + "unterminated-path": (quoted, f"'{python}"), + "missing-terminator": ("\n' '''\n", "\n"), + "extra-shell-line": ("#!/bin/sh\n", "#!/bin/sh\ntrue\n"), + "extra-shell-command": (' "$0" "$@"\n', ' "$0" "$@"; true\n'), + "extra-python-argument": (' "$0" "$@"\n', ' -I "$0" "$@"\n'), + "command-substitution": (quoted, f'"{python}$(true)"'), + "variable-expansion": (quoted, f'"{python}$UNSET"'), + "wrong-forwarding": (' "$0" "$@"\n', ' "$@" "$0"\n'), + "missing-cli": ("apm_cli.cli", "foreign.cli"), + } + old, new = substitutions[variant] + changed = script.replace(old, new, 1) + assert changed != script + launcher.write_text(changed, encoding="utf-8") + with pytest.raises(EvidenceError, match="this Python environment"): + gate.source_profile(gate.ROOT) + + def _completion(tmp_path: Path) -> tuple[Path, dict[str, Any], dict[str, Any]]: native = { "version": 1, @@ -717,7 +810,16 @@ def test_completion_emission_refuses_inconsistent_success_report( @pytest.mark.parametrize( "mutation", - ["pass", "pytest-failed", "missing", "dirty-after", "head-after", "tree-after", "source-after"], + [ + "pass", + "pytest-failed", + "missing", + "skipped-call", + "dirty-after", + "head-after", + "tree-after", + "source-after", + ], ) def test_provider_execution_checks_identity_after_its_own_run( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mutation: str @@ -776,6 +878,8 @@ def fresh_pytest(args: list[str], plugins: list[Any]) -> int: calls.append("fresh pytest") if mutation == "missing": records.pop(REQUIRED_WITNESSES[0]) + elif mutation == "skipped-call": + records[REQUIRED_WITNESSES[0]]["phases"]["call"] = "skipped" return 1 if mutation == "pytest-failed" else 0 plugin = SimpleNamespace(records=records, patch=SimpleNamespace(undo=lambda: None)) @@ -796,6 +900,9 @@ def fresh_pytest(args: list[str], plugins: list[Any]) -> int: assert result["status"] == ("passed" if mutation == "pass" else "blocked") if mutation != "pass": assert result["error"] + if mutation == "skipped-call": + assert set(result["witnesses"]) == set(REQUIRED_WITNESSES) + assert "setup/call/teardown must pass" in result["error"] @pytest.mark.parametrize("mode", ["skip", "xfail", "setup", "teardown", "call", "pass"]) From 25299d0166f97fc3bd2149b10651da90de4958f9 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 30 Sep 2026 13:32:19 +0200 Subject: [PATCH 11/15] test: strengthen real execution and alias ownership witnesses Require actual APM Popen argv and isolated cwd/environment rather than counting identity probes. Reject executable-prefix lookalikes alongside foreign virtualenvs. Correct the external-global ownership test to independently expect its explicit alias path, lock alias and absence of the non-alias cache while retaining deployed bytes and compile assertions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../test_ownership_invariant_lifecycle.py | 9 ++++---- tests/unit/scripts/test_lifecycle_evidence.py | 23 ++++++++++++++++--- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/tests/integration/test_ownership_invariant_lifecycle.py b/tests/integration/test_ownership_invariant_lifecycle.py index 2a91a542aa..cb60561841 100644 --- a/tests/integration/test_ownership_invariant_lifecycle.py +++ b/tests/integration/test_ownership_invariant_lifecycle.py @@ -867,16 +867,17 @@ def test_global_update_preserves_owned_external_skill_targets( assert claude_skill.read_bytes() == expected_skill assert hermes_skill.read_bytes() == expected_skill assert "file skipped (local files exist)" not in (update.stdout + update.stderr) - canonical_cache = scenario.isolated.config_root / "apm_modules" / _OWNER / "global-audit-kit" - alias_cache = scenario.isolated.config_root / "apm_modules" / "global-audit-kit" - assert (canonical_cache / "skills" / "global-audit" / "SKILL.md").read_bytes() == ( + installed_cache = scenario.isolated.config_root / "apm_modules" / "global-audit-kit" + non_alias_cache = scenario.isolated.config_root / "apm_modules" / _OWNER / "global-audit-kit" + assert (installed_cache / "skills" / "global-audit" / "SKILL.md").read_bytes() == ( expected_skill ) - assert not alias_cache.exists() + assert not non_alias_cache.exists() lock = LockFile.read(scenario.isolated.config_root / "apm.lock.yaml") assert lock is not None dependency_lock = lock.get_package_dependencies()[0] + assert dependency_lock.alias == "global-audit-kit" assert dependency_lock.resolved_commit == commit_b.sha deployed = set(dependency_lock.deployed_files) assert str(claude_skill) in deployed diff --git a/tests/unit/scripts/test_lifecycle_evidence.py b/tests/unit/scripts/test_lifecycle_evidence.py index a82158a468..885e7ee143 100644 --- a/tests/unit/scripts/test_lifecycle_evidence.py +++ b/tests/unit/scripts/test_lifecycle_evidence.py @@ -485,8 +485,11 @@ def test_source_profile_accepts_only_canonical_long_path_launchers( foreign.mkdir() foreign_python = foreign / "python" foreign_python.symlink_to(python.resolve()) - different_python = directory / "different-python" + different_python = directory / "python-untrusted" different_python.write_text("not this interpreter") + assert different_python.parent == python.parent + assert str(different_python).startswith(str(python)) + assert not different_python.samefile(python) substitutions = { "foreign-environment": (quoted, f"'{foreign_python}'"), "different-interpreter": (quoted, f"'{different_python}'"), @@ -977,13 +980,18 @@ def test_observer_requires_real_runner_and_hypothesis_execution(pytester: pytest pytester.makeini("[pytest]") module = pytester.makepyfile(""" import os + import subprocess import sys + from unittest.mock import patch from hypothesis import settings from hypothesis.stateful import RuleBasedStateMachine, initialize, rule, run_state_machine_as_test from tests.utils.apm_lifecycle_runner import ApmLifecycleRunner from tests.utils.isolated_apm_environment import IsolatedApmEnvironment def test_observed(tmp_path): environment = IsolatedApmEnvironment.create(tmp_path / "domain", base_env=os.environ) + expected_command = (sys.executable, "-m", "apm_cli.cli", "--version") + expected_cwd = environment.work_root + expected_env = environment.subprocess_env() runner = ApmLifecycleRunner((sys.executable, "-m", "apm_cli.cli")) class Model(RuleBasedStateMachine): @initialize() @@ -994,8 +1002,17 @@ def start(self): @rule() def step(self): assert environment.home.is_dir() - run_state_machine_as_test(Model, settings=settings( - max_examples=1, stateful_step_count=1, deadline=None, database=None)) + with patch("subprocess.Popen", wraps=subprocess.Popen) as popen: + run_state_machine_as_test(Model, settings=settings( + max_examples=1, stateful_step_count=1, deadline=None, database=None)) + apm_calls = [ + call for call in popen.call_args_list + if call.args and tuple(call.args[0]) == expected_command + ] + assert apm_calls + for call in apm_calls: + assert call.kwargs["cwd"] == expected_cwd + assert call.kwargs["env"] == expected_env """) nodeid = f"{module.name}::test_observed" plugin = LifecycleEvidencePlugin([nodeid], None) From 260d5dcef9c035ef928cf69af988b0aea01f0d95 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 30 Sep 2026 15:21:01 +0200 Subject: [PATCH 12/15] test: cover marketplace remote owner in mutation matrix Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 0aad2c16c07146a165451c868f699a0244d19d0b) --- .../integration/test_architecture_owner_rule_mutations.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/integration/test_architecture_owner_rule_mutations.py b/tests/integration/test_architecture_owner_rule_mutations.py index 68f795d04f..43a4095316 100644 --- a/tests/integration/test_architecture_owner_rule_mutations.py +++ b/tests/integration/test_architecture_owner_rule_mutations.py @@ -1029,6 +1029,14 @@ class MutationCase: new="def parse_host_qualified_reference_disabled(", intent="Host-qualified reference parsing loses its canonical coordinate owner.", ), + MutationCase( + guard_id="transport-platform-marketplace-package-remote", + rule_id="transport-platform-marketplace-package-remote", + path="src/apm_cli/marketplace/resolver.py", + old=" dep_ref = lookup", + new=" dep_ref = None", + intent="Marketplace version resolution drops the canonical package-remote handoff.", + ), MutationCase( guard_id="transport-platform-network-host-parsing", rule_id="transport-platform-network-host-parsing", From ed32e028922ca075cb34044e204de0a881a3de8e Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 30 Sep 2026 15:56:42 +0200 Subject: [PATCH 13/15] fix: fold standalone lifecycle evidence and seed review Validate driver witness contents and execution identity, preserve failure reports and first causes, reject unclassifiable historical provider seeds, and exercise real frozen full-pin refusal/recovery in both global trajectories. Preserve initialized integrator contracts in pipeline unit fixtures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../docs/contributing/integration-testing.md | 21 +- .../src/content/docs/reference/cli/install.md | 18 +- .../checks/contracts_test_taxonomy.py | 76 ++++- .../checks/transport_network_and_runtime.py | 22 ++ scripts/check_lifecycle_evidence.py | 66 ++++- src/apm_cli/deps/tiered_ref_resolver.py | 14 +- tests/fixtures/lifecycle_bug_ledger.json | 6 +- ..._architecture_native_lifecycle_evidence.py | 55 +++- .../test_architecture_ref_freshness.py | 16 + .../test_required_lifecycle_state_machine.py | 64 ++++ .../deps/test_github_downloader_phase3.py | 24 +- tests/unit/deps/test_tiered_ref_resolver.py | 39 +++ .../phases/test_resolve_seed_from_lockfile.py | 68 ++++- tests/unit/install/test_direct_dep_failure.py | 5 +- .../install/test_gitignore_scope_guard.py | 4 + tests/unit/scripts/test_lifecycle_evidence.py | 279 +++++++++++++++--- 16 files changed, 698 insertions(+), 79 deletions(-) diff --git a/docs/src/content/docs/contributing/integration-testing.md b/docs/src/content/docs/contributing/integration-testing.md index 8c056f4da3..92fb5b5176 100644 --- a/docs/src/content/docs/contributing/integration-testing.md +++ b/docs/src/content/docs/contributing/integration-testing.md @@ -72,7 +72,9 @@ also enforce one lockfile read per used root and integration phase, zero unused reads, and at most 15x record visits when fixture size grows from N to 10N. These counts do not claim a wall-clock speedup. -Use a clean committed checkout and put reports outside it: +Use a clean committed checkout. Set `BASE_SHA` to the full 40-hex commit SHA +of a locally available, distinct ancestor of the checked-out `HEAD`. +Set `REPORT_PATH` and `COMPLETION_PATH` to distinct, new paths outside the checkout: ```bash uv run --frozen --extra dev python scripts/check_lifecycle_evidence.py \ @@ -80,20 +82,19 @@ uv run --frozen --extra dev python scripts/check_lifecycle_evidence.py \ --report "$REPORT_PATH" --completion-output "$COMPLETION_PATH" ``` -Both output paths must be new and outside the checkout. The command emits the -schema-validated native completion sidecar only after fresh success. +The command emits the schema-validated native completion sidecar only after fresh success. It verifies source/interpreter identity and freshly executes the authored contract; a skipped witness or disconnected sequence is not acceptance. It is source-Python coverage, not packaged-binary parity. Ordinary hosted platform checks remain separate. -For independent acceptance, use another clean checkout of the same head and -its own interpreter and disposable environment. Pass the driver's native -completion sidecar with `--completion "$DRIVER_COMPLETION_PATH"` and a different -`--report` path. Completion mode verifies the driver's report digest and -reruns the contract; it does not reuse that report as execution. Keep this -native sidecar separate from the merge-worker completion JSON, whose schema -is unchanged. +For independent acceptance, use another clean checkout with the same base/head, +its own interpreter and disposable environment. Replace `--completion-output` +with `--completion "$DRIVER_COMPLETION_PATH"`, pointing to the driver's native +sidecar, and use a new external `--report` path. Keep the driver's report available +at the path recorded in that sidecar. Completion mode verifies its digest and +reruns the contract; it does not reuse the report as execution. Keep this native +sidecar separate from the unchanged merge-worker completion JSON. ### Selecting integration tests diff --git a/docs/src/content/docs/reference/cli/install.md b/docs/src/content/docs/reference/cli/install.md index 6bae348b13..4e6e9f4025 100644 --- a/docs/src/content/docs/reference/cli/install.md +++ b/docs/src/content/docs/reference/cli/install.md @@ -122,18 +122,6 @@ in `apm.yml`, then run `apm install` again. ## Behavior -- **Lock replay and freshness.** A matching dependency, declared ref and host - provider reuse the locked commit. A mutable ref without a matching lock entry - resolves upstream, even if another package or sibling path is locked. - `--frozen` rejects changed refs, full commit pins, host providers and HTTP/HTTPS - transport before the pipeline, including transport changes under an unchanged - semver range. Review the change and run `apm install --update` to refresh the - lock. Unchanged frozen installs do not check whether an upstream branch moved. -- **Home aliases.** Global skill deployment works when `HOME` or `APM_HOME` - names a directory symlink. This does not permit escaping package descendants - or symlinked skill destinations. Explicit dependency `alias:` placement is a - separate feature; unrelated user files remain protected. - - **Immutable dependency conflicts.** Install fails when two paths require different immutable commits for one package identity, showing both paths and requested refs as ordered `owner/repo@ref -> owner/repo@ref` chains. @@ -177,12 +165,12 @@ in `apm.yml`, then run `apm install` again. installation. See [Author primitives](../../../producer/author-primitives/) for fence and UTF-8 BOM syntax. - **MCP-only lock state.** A normal project install creates or updates `apm.lock.yaml` when `apm.yml` declares only MCP dependencies, records the resolved MCP configs and targets, and migrates a legacy `apm.lock` first. Repeating the same install leaves the lockfile and target configs byte-identical. If initial lock creation fails, install exits nonzero and warns with writable-directory and rerun guidance. -- **Lockfile replay and Git ref freshness.** Plain and `--frozen` installs may trust `apm.lock.yaml` and the local Git cache, reusing the locked commit for unchanged Git dependencies across the full resolved graph. When no lockfile exists, a mutable branch install resolves current upstream on first materialization instead of reusing a stale bare-cache ref. In contrast, `apm install --update`, `apm install --refresh`, [`apm update`](../update/) with or without `--force`, [`apm lock --update`](../lock/), and [`apm outdated`](../outdated/) establish mutable Git refs from upstream instead of accepting stale refs from a local bare Git cache. APM picks up upstream changes to a transitive package's `apm.yml` only when you regenerate the graph -- run `apm update` or `apm lock --update`. See the [lockfile specification](../../lockfile-spec/) for the replay contract. +- **Lockfile replay and Git ref freshness.** Plain and `--frozen` installs reuse the locked commit for matching Git dependencies, declared refs and host providers across the resolved graph, without checking whether upstream branches moved. A mutable ref without a matching lock entry resolves upstream instead of reusing a stale bare-cache ref, even if another package or sibling path is locked. In contrast, `apm install --update`, `apm install --refresh`, [`apm update`](../update/) with or without `--force`, [`apm lock --update`](../lock/), and [`apm outdated`](../outdated/) establish mutable Git refs from upstream. APM picks up upstream changes to a transitive package's `apm.yml` only when you regenerate the graph -- run `apm update` or `apm lock --update`. See the [lockfile specification](../../lockfile-spec/) for the replay contract. - **Semver ranges on git deps.** `ref:` accepts semver ranges (`^1.2.0`, `~1.4`, `>=2.0 <3`, `1.5.x`) for git-source deps, including positional virtual-subdirectory references. APM runs `git ls-remote` against the dep, picks the highest tag matching the range, and pins the resolved tag plus commit SHA, version, and original constraint in `apm.lock.yaml`. Subsequent installs replay the lockfile without network; use `--update` (or change the manifest constraint) to re-resolve. See [manage dependencies](../../../consumer/manage-dependencies/#pin-a-semver-range) for the supported syntax. - **No-op nudge.** When the lockfile is already satisfied and nothing needs deploying, install prints `[i] Run 'apm update' to check for newer versions.` so you know the silent success was not a missed refresh. -- **Frozen mode.** With `--frozen`, install resolves only what is in `apm.lock.yaml`. A missing lockfile, a direct dependency missing from it, or MCP config state that differs from `apm.yml` exits `1` before lockfile, target config, deployment, or cache mutation. Cold-cache installs (empty `apm_modules/`) with git `apm_package` deps are tolerated: MCP checks are skipped for absent package directories (the packages will be hydrated by the pipeline), and their MCP server configs are restored from the lockfile so no false drift is reported. Remote `claude_skill` dependencies declared at a repository root or subdirectory are also accepted from their locked type before materialization; once present, the lock type and detected skill shape must agree. Missing local paths still fail. See [`config-consistency`](../../baseline-checks/#config-consistency) for the full manifest rule. Run normal `apm install` to create or repair MCP-only lock state, then retry frozen mode. Add-style invocations (`apm install PACKAGE` and `apm install --mcp NAME`) are rejected because they mutate `apm.yml`. Orphan package lock entries are tolerated; local-path deps are skipped. This is a structural check, not a content check -- run `apm audit --ci` for hash verification. +- **Frozen mode.** With `--frozen`, install resolves only what is in `apm.lock.yaml`. A missing lockfile, a missing direct dependency, or changed refs, full commit pins, host providers, HTTP/HTTPS transport or MCP config state exits `1` before lockfile, target config, deployment, or cache mutation. This includes transport changes under an unchanged semver range. Review the change and run `apm install --update` to refresh the lock. Cold-cache installs (empty `apm_modules/`) with git `apm_package` deps are tolerated: MCP checks are skipped for absent package directories (the packages will be hydrated by the pipeline), and their MCP server configs are restored from the lockfile so no false drift is reported. Remote `claude_skill` dependencies declared at a repository root or subdirectory are also accepted from their locked type before materialization; once present, the lock type and detected skill shape must agree. Missing local paths still fail. See [`config-consistency`](../../baseline-checks/#config-consistency) for the full manifest rule. Run normal `apm install` to create or repair MCP-only lock state, then retry frozen mode. Add-style invocations (`apm install PACKAGE` and `apm install --mcp NAME`) are rejected because they mutate `apm.yml`. Orphan package lock entries are tolerated; local-path deps are skipped. This is a structural check, not a content or network freshness check -- run `apm audit --ci` for hash verification. - **Local `.apm/` deployment.** After dependencies are integrated, primitives in the project's own `.apm/` directory are deployed to the same targets. Local files win on collision. Skipped at `--global` and with `--only mcp`. -- **User-scope skill paths.** Global skill installation resolves the user-scope lockfile from the canonical `~/.apm/` root, even when `HOME` or `APM_HOME` points at a symlink alias. Root aliases work without extra flags or environment changes; symlinks inside the package remain rejected. +- **User-scope skill paths.** Global skill installation reads the lockfile from the canonical user metadata root (`~/.apm/`), separate from the skill deployment root. `HOME` or `APM_HOME` directory symlink aliases work without extra flags; package-descendant symlinks and symlinked skill destinations remain rejected. Explicit dependency `alias:` placement is separate; unrelated user files remain protected. - **User-scope root context hint.** Compilation stays explicit. After `apm install -g`, targets with native user-scope instruction files pick up global instructions during install. Targets whose user-scope instruction surface is a root context file require [`apm compile --global`](../compile/#global-compilation); install prints a one-line `[i]` hint and writes no root context file. - **OpenCode user scope.** `apm install -g --target opencode` deploys skills to `~/.config/opencode/skills/`. Run `apm compile -g` to refresh diff --git a/scripts/architecture_linter/checks/contracts_test_taxonomy.py b/scripts/architecture_linter/checks/contracts_test_taxonomy.py index 3a34bb5030..b14f2e6e21 100644 --- a/scripts/architecture_linter/checks/contracts_test_taxonomy.py +++ b/scripts/architecture_linter/checks/contracts_test_taxonomy.py @@ -1458,7 +1458,11 @@ def check_native_lifecycle_evidence(provider: FactsProvider) -> tuple[Violation, "sorted(REQUIRED_WITNESSES)", "validate_execution(witness, plugin.records[witness['nodeid']])", "candidate(ROOT, args.base, args.head) != identity", - "source_profile(ROOT)[1] != report['profile']", + "profile is not None", + "source_profile(ROOT)[1] != profile", + "executable, profile = source_profile(ROOT)", + "report.setdefault('error', str(exc))", + "report.setdefault('postflight_errors', []).append(str(exc))", "set(plugin.records) != set(REQUIRED_WITNESSES)", ), ), @@ -1467,6 +1471,8 @@ def check_native_lifecycle_evidence(provider: FactsProvider) -> tuple[Violation, "main", ( "report = execute(args)", + "report['status'] != 'passed'", + "_write_new(args.report, _json_bytes(report))", "validate_completion(args.completion, report, args.report)", "completion_summary(report, args.report, raw)", ), @@ -1485,11 +1491,37 @@ def check_native_lifecycle_evidence(provider: FactsProvider) -> tuple[Violation, "_completion_input", ("_validate_completion_summary(summary)", "_same_file(output, driver)"), ), + ( + _NATIVE_PROVIDER, + "_validate_source_profile", + ( + "('source_root', 'python', 'python_environment', 'executable')", + "('cli_sha256', 'python_sha256', 'executable_sha256')", + "isinstance(profile, dict)", + "profile.get('kind') != 'source-python'", + "Path(value).is_absolute()", + "PureWindowsPath(value).is_absolute()", + "re.fullmatch('[0-9a-f]{64}', value)", + "valid = field in profile and value is None", + ), + ), + ( + _NATIVE_PROVIDER, + "_validate_report_execution", + ( + "_validate_source_profile(report.get('profile'))", + "set(records) != set(REQUIRED_WITNESSES)", + "candidate_contract(ROOT, report['base'], command_inventory())", + "record = records[witness['nodeid']]", + "validate_execution(witness, record)", + ), + ), ( _NATIVE_PROVIDER, "completion_summary", ( "_validate_completion_summary(summary)", + "_validate_report_execution(report)", "report.get('status') != 'passed'", "json.loads(raw) != report", "hashlib.sha256(raw).hexdigest()", @@ -1502,7 +1534,7 @@ def check_native_lifecycle_evidence(provider: FactsProvider) -> tuple[Violation, "_completion_input(path, output)", "summary[claim] != native.get(field)", "summary['report_sha256'] != hashlib.sha256(raw).hexdigest()", - "set(report.get('witnesses', {})) != set(REQUIRED_WITNESSES)", + "_validate_report_execution(report)", "fields = {'version': 'version', 'contract_id': 'contract_id', " "'base_sha': 'base', 'head_sha': 'head', 'tested_tree': 'tested_tree', " "'lane': 'lane', 'status': 'status'}", @@ -1581,7 +1613,7 @@ def check_native_lifecycle_evidence(provider: FactsProvider) -> tuple[Violation, observed = { ast.unparse(node) for node in nodes - if isinstance(node, (ast.Call, ast.Compare, ast.Assign)) + if isinstance(node, (ast.Call, ast.Compare, ast.Assign, ast.Tuple)) } if not set(expressions) <= observed: findings.append( @@ -1610,11 +1642,21 @@ def check_native_lifecycle_evidence(provider: FactsProvider) -> tuple[Violation, for statement in node.finalbody for child in index.walk(statement) } + postflight_handlers = [node for node in final_nodes if isinstance(node, ast.ExceptHandler)] wired = ( len(candidates) == len(profiles) == 2 and len(pytest_calls) == len(validations) == len(plugins) == 1 and candidates[-1] in final_nodes and profiles[-1] in final_nodes + and len(postflight_handlers) == 2 + and all( + { + "report.setdefault('error', str(exc))", + "report.setdefault('postflight_errors', []).append(str(exc))", + } + <= {ast.unparse(node) for node in index.walk(handler)} + for handler in postflight_handlers + ) ) if wired: run = pytest_calls[0] @@ -1633,6 +1675,34 @@ def check_native_lifecycle_evidence(provider: FactsProvider) -> tuple[Violation, ) ) + main = index.function("main") + main_nodes = index.own_scope(main) if main else () + fresh_calls = _named_calls(main_nodes, "execute") + completion_calls = _named_calls(main_nodes, "validate_completion") + blocked = [ + node + for node in main_nodes + if isinstance(node, ast.If) and ast.unparse(node.test) == "report['status'] != 'passed'" + ] + if not ( + len(fresh_calls) == len(completion_calls) == len(blocked) == 1 + and fresh_calls[0].lineno < blocked[0].lineno < completion_calls[0].lineno + and any( + isinstance(node, ast.Call) + and ast.unparse(node) == "_write_new(args.report, _json_bytes(report))" + for statement in blocked[0].body + for node in index.walk(statement) + ) + and isinstance(blocked[0].body[-1], ast.Raise) + ): + findings.append( + _summary( + rule_id, + _NATIVE_PROVIDER, + "Failed fresh execution must persist and stop before completion comparison", + ) + ) + facts, failures = _facts_for(provider, _NATIVE_OBSERVER, rule_id) if not failures and (index := facts.tree_index) is not None: observe = index.function("LifecycleEvidencePlugin.pytest_sessionstart.observe") diff --git a/scripts/architecture_linter/checks/transport_network_and_runtime.py b/scripts/architecture_linter/checks/transport_network_and_runtime.py index 84722a26f3..afbf6902bf 100644 --- a/scripts/architecture_linter/checks/transport_network_and_runtime.py +++ b/scripts/architecture_linter/checks/transport_network_and_runtime.py @@ -149,6 +149,28 @@ def _check_ref_freshness(provider: FactsProvider) -> tuple[Violation, ...]: "tiered_ref_resolver must own RefFreshnessPolicy and its install policy", ) ) + findings.extend( + _require_subs( + provider, + inv, + _RID_FRESHNESS, + _TIERED, + ( + " try:\n" + " key = self._lock_seed_key(dep_ref, ref)\n" + " except (ValueError, RuntimeError) as exc:\n" + " _log.debug(\n" + ' "Skipping lock seed: historical provider identity is ' + 'unclassifiable (%s)",\n' + " type(exc).__name__,\n" + " )\n" + " return False\n" + " with self._coalesce_lock:\n" + " self._lock_seeds[key] = sha.lower()", + ), + "unclassifiable historical seeds must be rejected before storage", + ) + ) findings.extend( _require_subs( provider, diff --git a/scripts/check_lifecycle_evidence.py b/scripts/check_lifecycle_evidence.py index c3d43bbed2..2bb93cc7b5 100644 --- a/scripts/check_lifecycle_evidence.py +++ b/scripts/check_lifecycle_evidence.py @@ -14,7 +14,7 @@ import re import subprocess import sys -from pathlib import Path +from pathlib import Path, PureWindowsPath from typing import Any ROOT = Path(__file__).resolve().parents[1] @@ -144,16 +144,53 @@ def _completion_input(path: Path, output: Path) -> tuple[dict[str, Any], Path]: return summary, driver +def _validate_source_profile(profile: Any) -> None: + """Require recorded identities without resolving another checkout's paths.""" + if not isinstance(profile, dict) or profile.get("kind") != "source-python": + raise EvidenceError("Native report requires a source-python profile") + for field in ("source_root", "python", "python_environment", "executable"): + if field == "executable" and field in profile and profile[field] is None: + continue + value = profile.get(field) + if ( + not isinstance(value, str) + or not value.strip() + or not (Path(value).is_absolute() or PureWindowsPath(value).is_absolute()) + ): + raise EvidenceError(f"Native source profile requires an absolute {field}") + for field in ("cli_sha256", "python_sha256", "executable_sha256"): + value = profile.get(field) + if field == "executable_sha256" and profile["executable"] is None: + valid = field in profile and value is None + else: + valid = isinstance(value, str) and re.fullmatch(r"[0-9a-f]{64}", value) is not None + if not valid: + raise EvidenceError(f"Native source profile has invalid {field}") + + +def _validate_report_execution(report: dict[str, Any]) -> None: + """Delegate report records to the current candidate's sole trajectory owner.""" + _validate_source_profile(report.get("profile")) + records = report.get("witnesses") + if not isinstance(records, dict) or set(records) != set(REQUIRED_WITNESSES): + raise EvidenceError("Native report requires exact immutable witnesses") + contract = candidate_contract(ROOT, report["base"], command_inventory()) + for witness in contract["witnesses"]: + record = records[witness["nodeid"]] + if not isinstance(record, dict): + raise EvidenceError(f"{witness['nodeid']}: invalid execution record") + validate_execution(witness, record) + + def completion_summary(report: dict[str, Any], report_path: Path, raw: bytes) -> dict[str, Any]: """Derive success claims solely from the freshly executed report and its bytes.""" if ( report.get("status") != "passed" or report.get("lane") != "full" - or report.get("profile", {}).get("kind") != "source-python" - or set(report.get("witnesses", {})) != set(REQUIRED_WITNESSES) or json.loads(raw) != report ): raise EvidenceError("Completion emission requires the exact successful native report") + _validate_report_execution(report) summary = { "version": report["version"], "contract_id": report["contract_id"], @@ -206,10 +243,9 @@ def validate_completion(path: Path, native: dict[str, Any], output: Path) -> Non not isinstance(report, dict) or type(report.get("version")) is not int or any(report.get(field) != native.get(field) for field in fields.values()) - or set(report.get("witnesses", {})) != set(REQUIRED_WITNESSES) - or report.get("profile", {}).get("kind") != "source-python" ): raise EvidenceError("Driver report header/witnesses disagree with fresh execution") + _validate_report_execution(report) if driver["profile"].get("cli_sha256") != native["profile"].get("cli_sha256"): raise EvidenceError("Driver source digest disagrees with fresh execution") @@ -230,6 +266,7 @@ def execute(args: argparse.Namespace) -> dict[str, Any]: if args.lane != "full": raise EvidenceError("Only the full lifecycle lane is supported") plugin = None + profile = None try: import pytest @@ -238,7 +275,8 @@ def execute(args: argparse.Namespace) -> dict[str, Any]: inventory = command_inventory() contract = candidate_contract(ROOT, identity["base"], inventory) report["command_inventory"] = sorted(inventory) - executable, report["profile"] = source_profile(ROOT) + executable, profile = source_profile(ROOT) + report["profile"] = profile witnesses = contract["witnesses"] nodeids = sorted(REQUIRED_WITNESSES) contexts = { @@ -288,11 +326,17 @@ def execute(args: argparse.Namespace) -> dict[str, Any]: try: if candidate(ROOT, args.base, args.head) != identity: raise EvidenceError("Candidate identity changed during execution") - if source_profile(ROOT)[1] != report["profile"]: + except (EvidenceError, OSError, subprocess.CalledProcessError) as exc: + report["status"] = "blocked" + report.setdefault("error", str(exc)) + report.setdefault("postflight_errors", []).append(str(exc)) + try: + if profile is not None and source_profile(ROOT)[1] != profile: raise EvidenceError("Source executable identity changed during execution") except (EvidenceError, OSError, subprocess.CalledProcessError) as exc: report["status"] = "blocked" - report["error"] = str(exc) + report.setdefault("error", str(exc)) + report.setdefault("postflight_errors", []).append(str(exc)) return report @@ -330,6 +374,10 @@ def main() -> int: if args.completion: _completion_input(args.completion, args.report) report = execute(args) + if report["status"] != "passed": + _write_new(args.report, _json_bytes(report)) + print(f"Lifecycle evidence: {report['status']} ({args.report})") + raise EvidenceError(report.get("error", "Lifecycle execution did not pass")) if args.completion: validate_completion(args.completion, report, args.report) raw = _json_bytes(report) @@ -340,8 +388,6 @@ def main() -> int: ) _write_new(args.report, raw) print(f"Lifecycle evidence: {report['status']} ({args.report})") - if report["status"] != "passed": - raise EvidenceError(report.get("error", "Lifecycle execution did not pass")) if summary is not None: _write_new(args.completion_output, _json_bytes(summary)) print(f"Lifecycle completion: {args.completion_output}") diff --git a/src/apm_cli/deps/tiered_ref_resolver.py b/src/apm_cli/deps/tiered_ref_resolver.py index ad478b1c13..99286c25e3 100644 --- a/src/apm_cli/deps/tiered_ref_resolver.py +++ b/src/apm_cli/deps/tiered_ref_resolver.py @@ -451,7 +451,8 @@ def seed(self, repo_ref: str | DependencyReference, ref: str, sha: str) -> bool: Lock seeds must not enter the repository-wide fresh-result cache: a sibling virtual package without a matching lock entry must still - establish the current upstream ref. + establish the current upstream ref. Historical provider metadata + that can no longer be classified is rejected, not replayed. """ if ( not self.freshness_policy.allows_lock_seed @@ -461,7 +462,16 @@ def seed(self, repo_ref: str | DependencyReference, ref: str, sha: str) -> bool: ): return False dep_ref = self._normalize(repo_ref) - key = self._lock_seed_key(dep_ref, ref) + # Only historical admission is recoverable; current declarations + # still use strict classification during parsing and resolution. + try: + key = self._lock_seed_key(dep_ref, ref) + except (ValueError, RuntimeError) as exc: + _log.debug( + "Skipping lock seed: historical provider identity is unclassifiable (%s)", + type(exc).__name__, + ) + return False with self._coalesce_lock: self._lock_seeds[key] = sha.lower() return True diff --git a/tests/fixtures/lifecycle_bug_ledger.json b/tests/fixtures/lifecycle_bug_ledger.json index 54e267b080..a5be2a49ca 100644 --- a/tests/fixtures/lifecycle_bug_ledger.json +++ b/tests/fixtures/lifecycle_bug_ledger.json @@ -473,7 +473,11 @@ {"command":"lock","argv_contains":["--global","--update"],"returncode":0,"state":"observed"}, {"command":"install","argv_contains":["--global","--frozen"],"returncode":0,"state":"observed"}, {"command":"install","argv_contains":["--global","--frozen"],"returncode":1,"state":"unchanged","preparation":"Declare A while lock records B."}, - {"command":"install","argv_contains":["--global"],"returncode":0,"state":"observed","preparation":"Restore original main declaration."} + {"command":"install","argv_contains":["--global"],"returncode":0,"state":"observed","preparation":"Restore original main declaration."}, + {"command":"install","argv_contains":["--global"],"returncode":0,"state":"observed","preparation":"Declare full commit A and install its original bytes."}, + {"command":"install","argv_contains":["--global","--frozen"],"returncode":1,"state":"unchanged","preparation":"Keep manifest ref and locked resolved_ref at A; corrupt only locked resolved_commit to real B."}, + {"command":"install","argv_contains":["--global","--frozen"],"returncode":0,"state":"unchanged","preparation":"Restore exact matching A lock bytes; replay must preserve pinned deployment."}, + {"command":"install","argv_contains":["--global"],"returncode":0,"state":"observed","preparation":"Restore original main declaration and install B."} ], "global-clean-audit": [ {"command":"audit","argv_contains":["--ci","--no-policy"],"returncode":0,"state":"unchanged","context":"APM_HOME"} diff --git a/tests/integration/test_architecture_native_lifecycle_evidence.py b/tests/integration/test_architecture_native_lifecycle_evidence.py index cfe01e7f69..e95ce6cdf4 100644 --- a/tests/integration/test_architecture_native_lifecycle_evidence.py +++ b/tests/integration/test_architecture_native_lifecycle_evidence.py @@ -61,7 +61,17 @@ def test_native_evidence_has_one_registered_owner_and_bounded_guard() -> None: "identity = cached_candidate()", ), (PROVIDER, "if candidate(ROOT, args.base, args.head) != identity:", "if False:"), - (PROVIDER, 'if source_profile(ROOT)[1] != report["profile"]:', "if False:"), + ( + PROVIDER, + "if profile is not None and source_profile(ROOT)[1] != profile:", + "if False:", + ), + (PROVIDER, "profile is not None and source_profile", "source_profile"), + ( + PROVIDER, + 'report.setdefault("error", str(exc))', + 'report["error"] = str(exc)', + ), ( PROVIDER, "finally:\n if plugin is not None:", @@ -91,6 +101,49 @@ def test_native_evidence_has_one_registered_owner_and_bounded_guard() -> None: (PROVIDER, 'summary["report_sha256"] != hashlib.sha256(raw).hexdigest()', "False"), (PROVIDER, '"tested_tree": "tested_tree"', '"tested_tree": "head"'), (PROVIDER, "summary[claim] != native.get(field)", "False"), + ( + PROVIDER, + 'candidate_contract(ROOT, report["base"], command_inventory())', + "cached_contract()", + ), + (PROVIDER, "validate_execution(witness, record)", "accept_record(witness, record)"), + ( + PROVIDER, + '_validate_source_profile(report.get("profile"))', + "accept_profile(report)", + ), + ( + PROVIDER, + '("source_root", "python", "python_environment", "executable")', + '("source_root",)', + ), + ( + PROVIDER, + '("cli_sha256", "python_sha256", "executable_sha256")', + '("cli_sha256",)', + ), + (PROVIDER, 're.fullmatch(r"[0-9a-f]{64}", value)', "True"), + (PROVIDER, "valid = field in profile and value is None", "valid = True"), + ( + PROVIDER, + "_validate_report_execution(report)\n summary", + "accept_report(report)\n summary", + ), + ( + PROVIDER, + " _validate_report_execution(report)\n if driver", + " accept_report(report)\n if driver", + ), + ( + PROVIDER, + 'if report["status"] != "passed":', + 'if report["status"] == "unused":', + ), + ( + PROVIDER, + "_write_new(args.report, _json_bytes(report))", + "discard_failed_report(report)", + ), (CONTRACTS, "REQUIRED_WITNESSES = (", "REMOVED_REQUIRED_WITNESSES = ("), (CONTRACTS, "== set(REQUIRED_WITNESSES)", "== set()"), (CONTRACTS, "validate_contracts(current, inventory)", "unvalidated_contracts(current)"), diff --git a/tests/integration/test_architecture_ref_freshness.py b/tests/integration/test_architecture_ref_freshness.py index e73af72b83..8a4c2c14e0 100644 --- a/tests/integration/test_architecture_ref_freshness.py +++ b/tests/integration/test_architecture_ref_freshness.py @@ -18,6 +18,22 @@ [ ("return cls.LOCKED_OR_CURRENT", "return cls.REPRODUCIBLE"), ("dep_ref.get_unique_key(),", '"",'), + ( + " except (ValueError, RuntimeError) as exc:\n", + " except TypeError as exc:\n", + ), + ( + " return False\n with self._coalesce_lock:\n", + " raise\n with self._coalesce_lock:\n", + ), + ( + " return False\n with self._coalesce_lock:\n", + " return True\n with self._coalesce_lock:\n", + ), + ( + " except (ValueError, RuntimeError) as exc:\n", + " except Exception as exc:\n", + ), ], ) def test_install_freshness_guard_rejects_blanket_bare_cache_replay(old: str, new: str) -> None: diff --git a/tests/integration/test_required_lifecycle_state_machine.py b/tests/integration/test_required_lifecycle_state_machine.py index 9db2fdcead..d81330df1a 100644 --- a/tests/integration/test_required_lifecycle_state_machine.py +++ b/tests/integration/test_required_lifecycle_state_machine.py @@ -2579,6 +2579,7 @@ def _exercise_global_revision_commands( compiled_path: Path, install_args: tuple[str, ...], manifest_path: Path, + lock_path: Path, ) -> GitCommit: """Advance one installed workspace, retaining byte and ownership oracles.""" installed = capture() @@ -2700,9 +2701,71 @@ def _exercise_global_revision_commands( run(install_args, "global-reinstall-b") _assert_same_state(installed_b, capture()) assert_snapshot_set_unchanged(before, ArtifactSnapshotSet.capture(artifact_roots)) + _exercise_frozen_full_pin_commands( + run, + capture, + assert_revision, + manifest_path, + lock_path, + install_args, + artifact_roots, + commit_a, + commit_b, + ) return commit_b +def _exercise_frozen_full_pin_commands( + run: Callable[..., CommandResult], + capture: Callable[[], LifecycleStateSnapshot], + assert_revision: Callable[[GitCommit, str], None], + manifest_path: Path, + lock_path: Path, + install_args: tuple[str, ...], + artifact_roots: Mapping[str, Path], + commit_a: GitCommit, + commit_b: GitCommit, +) -> None: + """Refuse a corrupt locked commit even when its declared full pin still matches.""" + original_manifest = manifest_path.read_bytes() + manifest = load_yaml(manifest_path) + manifest["dependencies"]["apm"][0]["ref"] = commit_a.sha + dump_yaml(manifest, manifest_path) + run(install_args, "global-full-pin-install-a") + assert_revision(commit_a, "a") + accepted = capture() + pinned_lock = lock_path.read_bytes() + lock = load_yaml(lock_path) + assert len(lock["dependencies"]) == 1 + locked = lock["dependencies"][0] + assert (locked["resolved_ref"], locked["resolved_commit"]) == (commit_a.sha, commit_a.sha) + locked["resolved_commit"] = commit_b.sha + dump_yaml(lock, lock_path) + + corrupted = capture() + before = ArtifactSnapshotSet.capture(artifact_roots) + refused = run( + (*install_args, "--frozen"), + "global-full-pin-commit-refusal", + expected_returncode=1, + ) + diagnostic = " ".join((refused.stdout + refused.stderr).split()) + assert f"manifest commit '{commit_a.sha}'" in diagnostic, _result_evidence(refused) + assert f"lockfile resolved_commit '{commit_b.sha}'" in diagnostic, _result_evidence(refused) + _assert_same_state(corrupted, capture()) + assert_snapshot_set_unchanged(before, ArtifactSnapshotSet.capture(artifact_roots)) + + lock_path.write_bytes(pinned_lock) + before = ArtifactSnapshotSet.capture(artifact_roots) + run((*install_args, "--frozen"), "global-full-pin-recovery-a") + _assert_same_state(accepted, capture()) + assert_snapshot_set_unchanged(before, ArtifactSnapshotSet.capture(artifact_roots)) + assert_revision(commit_a, "a") + manifest_path.write_bytes(original_manifest) + run(install_args, "global-full-pin-restore-branch-b") + assert_revision(commit_b, "b") + + def _semver_transport_environment(scenario: _Scenario, source: _PublishedPackage) -> dict[str, str]: """Map real Git operations locally without changing selected transport.""" shim = GitCredentialShimFactory(scenario.isolated.root / "semver-git-shim").create( @@ -2985,6 +3048,7 @@ def assert_revision(commit: GitCommit, revision: str) -> None: external_roots["claude"] / "CLAUDE.md", install_args, manifest_path, + lock_path, ) scenario.repositories.tag(source.repository, "v1.0.0", commit_b) _exercise_frozen_semver_transport_commands( diff --git a/tests/unit/deps/test_github_downloader_phase3.py b/tests/unit/deps/test_github_downloader_phase3.py index 35f8db5c68..3a20050cb5 100644 --- a/tests/unit/deps/test_github_downloader_phase3.py +++ b/tests/unit/deps/test_github_downloader_phase3.py @@ -124,6 +124,8 @@ def downloader() -> GitHubPackageDownloader: [ "locked-match", "locked-sibling", + "locked-stale-provider", + "locked-unrelated-stale-provider", "current-api", "current-legacy", "sha-passthrough", @@ -145,6 +147,10 @@ def test_scoped_lock_seeds_and_current_remote_receipts_remain_distinct( monkeypatch.setenv("APM_TIERED_RESOLVER", "1") old_sha, prior_sha, current_sha = "a" * 40, "b" * 40, "c" * 40 dependency = _make_dep() + stale_provider = case in {"locked-stale-provider", "locked-unrelated-stale-provider"} + if stale_provider: + monkeypatch.setenv("GITHUB_HOST", "code.example.com") + dependency = _make_dep(host="code.example.com") cache = GitCache(tmp_path / "cache") original_url = dependency.to_github_url() cache.remember_resolved_ref(original_url, "main", prior_sha) @@ -171,7 +177,18 @@ def test_scoped_lock_seeds_and_current_remote_receipts_remain_distinct( ) assert resolver is not None downloader._tiered_resolver = resolver - if case.startswith("locked-"): + if stale_provider: + seeded = DependencyReference( + repo_url=( + dependency.repo_url if case == "locked-stale-provider" else "removed/package" + ), + host="code.example.com", + host_type="gitlab", + reference="main", + ) + assert resolver.seed(seeded, "main", old_sha) is False + assert resolver._lock_seeds == {} + elif case.startswith("locked-"): seeded = ( dependency if case == "locked-match" @@ -186,7 +203,7 @@ def test_scoped_lock_seeds_and_current_remote_receipts_remain_distinct( assert resolver.remotely_resolved(dependency, current_sha) is False assert cache.read_resolved_ref(original_url, "main") == (True, prior_sha) return - resolved = resolver.resolve(dependency) + resolved = downloader.resolve_git_reference(dependency) expected_sha = old_sha if case in {"locked-match", "sha-passthrough"} else current_sha assert resolved.resolved_commit == expected_sha if case == "locked-match": @@ -196,6 +213,9 @@ def test_scoped_lock_seeds_and_current_remote_receipts_remain_distinct( downloader._refs.resolve_commit_sha_for_ref.assert_called_once_with(dependency, "main") assert resolver.resolve(seeded).resolved_commit == old_sha assert resolver.remotely_resolved(seeded, old_sha) is False + if stale_provider: + downloader._refs.resolve_commit_sha_for_ref.assert_called_once_with(dependency, "main") + downloader._refs.resolve.assert_not_called() if case == "mismatched-repository": dependency = _make_dep(repo_url="other/repository") elif case == "mismatched-ref": diff --git a/tests/unit/deps/test_tiered_ref_resolver.py b/tests/unit/deps/test_tiered_ref_resolver.py index 3597992d50..b7225e82c3 100644 --- a/tests/unit/deps/test_tiered_ref_resolver.py +++ b/tests/unit/deps/test_tiered_ref_resolver.py @@ -470,6 +470,45 @@ def test_current_policy_rejects_lock_seed() -> None: assert resolver.resolve(_dep()).resolved_commit == SHA_B +@pytest.mark.parametrize("error_type", [ValueError, RuntimeError]) +def test_seed_rejects_unclassifiable_history_without_recording( + caplog: pytest.LogCaptureFixture, error_type: type[Exception] +) -> None: + """Historical classification failures reject admission, not current resolution.""" + cache = PerRunRefCache() + legacy = _make_legacy_with(SHA_B) + resolver = TieredRefResolver( + tiers=[L0PerRunCache(cache=cache), legacy], cache=cache, legacy=legacy + ) + with ( + patch( + "apm_cli.core.host_providers.effective_host_provider_identity", + side_effect=error_type("untrusted historical metadata"), + ), + caplog.at_level("DEBUG", logger="apm_cli.deps.tiered_ref_resolver"), + ): + assert resolver.seed(_dep(), "main", SHA_A) is False + assert resolver._lock_seeds == {} + assert cache.size() == 0 + assert resolver.remotely_resolved(_dep(), SHA_A) is False + assert caplog.messages == [ + f"Skipping lock seed: historical provider identity is unclassifiable ({error_type.__name__})" + ] + assert resolver.resolve(_dep()).resolved_commit == SHA_B + + +def test_seed_rejection_does_not_swallow_unexpected_errors() -> None: + """Programming errors are not historical-provider rejection or successful replay.""" + resolver = TieredRefResolver([], PerRunRefCache(), _make_legacy_with(SHA_B)) + with patch( + "apm_cli.core.host_providers.effective_host_provider_identity", + side_effect=TypeError("fixture programming error"), + ): + with pytest.raises(TypeError, match="fixture programming error"): + resolver.seed(_dep(), "main", SHA_A) + assert resolver._lock_seeds == {} + + def test_orchestrator_collapses_concurrent_resolves(): cache = PerRunRefCache() legacy = _make_legacy_with(SHA_A) diff --git a/tests/unit/install/phases/test_resolve_seed_from_lockfile.py b/tests/unit/install/phases/test_resolve_seed_from_lockfile.py index fee010c62d..e9f8a82cad 100644 --- a/tests/unit/install/phases/test_resolve_seed_from_lockfile.py +++ b/tests/unit/install/phases/test_resolve_seed_from_lockfile.py @@ -12,10 +12,18 @@ import re import sys import types +from unittest.mock import MagicMock + +import pytest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "src")) -from apm_cli.deps.tiered_ref_resolver import RefFreshnessPolicy +from apm_cli.deps.lockfile import LockedDependency, LockFile +from apm_cli.deps.tiered_ref_resolver import ( + RefFreshnessPolicy, + build_tiered_ref_resolver, +) +from apm_cli.drift import detect_ref_change from apm_cli.install.helpers.ref_seed import seed_ref_resolver_from_lockfile from apm_cli.models.dependency.reference import DependencyReference @@ -70,6 +78,64 @@ def _ctx(*, resolver, lockfile, update_refs=False, refresh=False): SHA = "a" * 40 +@pytest.mark.parametrize("stale_repo", ["owner/repo", "removed/package"]) +def test_unclassifiable_historical_seed_allows_current_resolution( + monkeypatch: pytest.MonkeyPatch, stale_repo: str +) -> None: + """Both matching and removed provider hints leave valid current work resolvable.""" + monkeypatch.setenv("APM_TIERED_RESOLVER", "1") + monkeypatch.delenv("GITHUB_HOST", raising=False) + historical = LockedDependency( + repo_url=stale_repo, + host="code.example.com", + host_type="gitlab", + resolved_ref="main", + resolved_commit=SHA, + ) + historical_ref = historical.to_dependency_ref() + monkeypatch.setenv("GITHUB_HOST", "code.example.com") + declaration = {"git": "https://code.example.com/owner/repo", "ref": "main"} + current = DependencyReference.parse_from_dict(declaration) + assert detect_ref_change(current, historical) is True + valid_locked = LockedDependency( + repo_url="still/locked", host="code.example.com", resolved_ref="main", resolved_commit=SHA + ) + downloader = MagicMock() + downloader._refs.resolve_commit_sha_for_ref.return_value = "b" * 40 + resolver = build_tiered_ref_resolver( + downloader=downloader, freshness_policy=RefFreshnessPolicy.LOCKED_OR_CURRENT + ) + assert resolver is not None + context = _ctx( + resolver=resolver, + lockfile=LockFile( + dependencies={ + historical_ref.get_unique_key(): historical, + valid_locked.get_unique_key(): valid_locked, + } + ), + ) + context.logger = MagicMock() + seed_ref_resolver_from_lockfile(context) + context.logger.verbose_detail.assert_called_once_with( + "[i] Seeded ref resolver from lockfile: 1 ref(s) (0 round-trips)" + ) + assert len(resolver._lock_seeds) == 1 + assert resolver._cache.size() == 0 + assert resolver.resolve(current).resolved_commit == "b" * 40 + downloader._refs.resolve_commit_sha_for_ref.assert_called_once_with(current, "main") + assert resolver.resolve(valid_locked.to_dependency_ref()).resolved_commit == SHA + downloader._refs.resolve.assert_not_called() + assert resolver.remotely_resolved(current, "b" * 40) is False + + # Historical rejection must not turn invalid current declarations into success, + # even after a fresh repository answer is cached. + invalid_current = DependencyReference.parse_from_dict({**declaration, "type": "gitlab"}) + with pytest.raises(ValueError, match="conflicts with recognized 'ghes'"): + resolver.resolve(invalid_current) + downloader._refs.resolve_commit_sha_for_ref.assert_called_once_with(current, "main") + + def test_seeds_branch_and_tag_refs(): resolver = _FakeResolver() lockfile = _FakeLockfile( diff --git a/tests/unit/install/test_direct_dep_failure.py b/tests/unit/install/test_direct_dep_failure.py index 31d697f637..ed3adec691 100644 --- a/tests/unit/install/test_direct_dep_failure.py +++ b/tests/unit/install/test_direct_dep_failure.py @@ -94,10 +94,13 @@ def _fake_integrate_run(ctx): """Simulate the integrate phase setting the failure flag.""" ctx.direct_dep_failed = True + def _fake_targets_run(ctx: InstallContext) -> None: + ctx.integrators["skill"] = MagicMock() + with ( patch("apm_cli.install.phases.integrate.run", side_effect=_fake_integrate_run), patch("apm_cli.install.phases.resolve.run"), - patch("apm_cli.install.phases.targets.run"), + patch("apm_cli.install.phases.targets.run", side_effect=_fake_targets_run), patch("apm_cli.install.phases.download.run"), pytest.raises(DirectDependencyError), ): diff --git a/tests/unit/install/test_gitignore_scope_guard.py b/tests/unit/install/test_gitignore_scope_guard.py index ca78b28389..e21880d73e 100644 --- a/tests/unit/install/test_gitignore_scope_guard.py +++ b/tests/unit/install/test_gitignore_scope_guard.py @@ -71,6 +71,8 @@ def test_global_scope_does_not_call_update_gitignore(self) -> None: def _resolve_sets_deps(name, phase, ctx): if name == "resolve": ctx.deps_to_install = [MagicMock()] + elif name == "targets": + ctx.integrators["skill"] = MagicMock() mock_run_phase.side_effect = _resolve_sets_deps @@ -102,6 +104,8 @@ def test_project_scope_calls_update_gitignore(self) -> None: def _resolve_sets_deps(name, phase, ctx): if name == "resolve": ctx.deps_to_install = [MagicMock()] + elif name == "targets": + ctx.integrators["skill"] = MagicMock() mock_run_phase.side_effect = _resolve_sets_deps diff --git a/tests/unit/scripts/test_lifecycle_evidence.py b/tests/unit/scripts/test_lifecycle_evidence.py index 885e7ee143..008cd1919f 100644 --- a/tests/unit/scripts/test_lifecycle_evidence.py +++ b/tests/unit/scripts/test_lifecycle_evidence.py @@ -513,6 +513,39 @@ def test_source_profile_accepts_only_canonical_long_path_launchers( gate.source_profile(gate.ROOT) +def _records(contract: dict[str, Any]) -> dict[str, Any]: + """Fabricate connected observations for orchestration tests, not native proof.""" + records = {} + for witness in contract["witnesses"]: + events, state = [], 0 + for transition in witness["transitions"]: + before = str(state) + if transition["state"] != "unchanged": + state += 1 + context = transition.get("context", "initial") + events.append( + { + **transition, + "args": transition["argv_contains"], + "before": before, + "after": str(state), + "context": context, + "cwd": "/fixture" if context == "initial" else "/home/.apm", + "roots": {"workspace": "/fixture", "HOME": "/home", "APM_HOME": "/home/.apm"}, + "environment": {"HOME": "/home", "APM_HOME": "/home/.apm"}, + "model": 1, + } + ) + records[witness["nodeid"]] = { + "collected": True, + "dimensions": witness["dimensions"], + "models": 1, + "events": events, + "phases": {"setup": "passed", "call": "passed", "teardown": "passed"}, + } + return records + + def _completion(tmp_path: Path) -> tuple[Path, dict[str, Any], dict[str, Any]]: native = { "version": 1, @@ -522,8 +555,17 @@ def _completion(tmp_path: Path) -> tuple[Path, dict[str, Any], dict[str, Any]]: "tested_tree": "c" * 40, "lane": "full", "status": "passed", - "witnesses": {nodeid: {} for nodeid in REQUIRED_WITNESSES}, - "profile": {"kind": "source-python", "cli_sha256": "f" * 64, "source_root": "/driver"}, + "witnesses": _records(_ledger()["lifecycle_contracts"][0]), + "profile": { + "kind": "source-python", + "cli_sha256": "f" * 64, + "source_root": "/driver/src/apm_cli", + "python": "/driver/python", + "python_environment": "/driver/venv/bin", + "python_sha256": "e" * 64, + "executable": "/driver/venv/bin/apm", + "executable_sha256": "d" * 64, + }, } driver = tmp_path / "driver.json" driver.write_text(json.dumps(native)) @@ -542,6 +584,13 @@ def _completion(tmp_path: Path) -> tuple[Path, dict[str, Any], dict[str, Any]]: path = tmp_path / "completion.json" path.write_text(json.dumps(summary)) native["profile"]["source_root"] = "/independent-checkout" + native["profile"].update( + python="/independent/python", + python_environment="/independent/venv/bin", + python_sha256="c" * 64, + executable=None, + executable_sha256=None, + ) return path, summary, native @@ -552,6 +601,9 @@ def external_checkout(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: schema = checkout / "tests/fixtures/lifecycle_completion.schema.json" schema.parent.mkdir(parents=True) schema.write_bytes((gate.ROOT / "tests/fixtures/lifecycle_completion.schema.json").read_bytes()) + ledger = (gate.ROOT / LEDGER).read_text(encoding="utf-8") + (checkout / LEDGER).write_text(ledger, encoding="utf-8") + monkeypatch.setattr("scripts.lifecycle_contracts.git", lambda *_: ledger) monkeypatch.setattr(gate, "ROOT", checkout) return checkout @@ -613,6 +665,99 @@ def test_completion_rejects_stale_and_malformed_claims( gate.validate_completion(path, native, output) +@pytest.mark.parametrize("mode", ["verify", "emit"]) +@pytest.mark.parametrize( + "mutation", + ["uncollected", "skipped", "empty-record", "invalid-record", "empty-events", "no-models"], +) +def test_completion_rejects_invalid_execution_even_with_recomputed_digest( + tmp_path: Path, external_checkout: Path, mode: str, mutation: str +) -> None: + path, summary, native = _completion(tmp_path) + driver = Path(summary["report_path"]) + report = json.loads(driver.read_bytes()) + nodeid = next(node for node in REQUIRED_WITNESSES if "sequences_preserve" in node) + record = report["witnesses"][nodeid] + if mutation == "uncollected": + record["collected"] = False + elif mutation == "skipped": + record["phases"]["setup"] = "skipped" + elif mutation in {"empty-record", "invalid-record"}: + report["witnesses"][nodeid] = {} if mutation == "empty-record" else None + elif mutation == "empty-events": + record["events"] = [] + else: + record["models"] = 0 + raw = gate._json_bytes(report) + driver.write_bytes(raw) + summary["report_sha256"] = hashlib.sha256(raw).hexdigest() + path.write_bytes(gate._json_bytes(summary)) + with pytest.raises( + EvidenceError, match=r"not collected|must pass|execution record|trajectory|model" + ): + if mode == "verify": + gate.validate_completion(path, native, tmp_path / "independent.json") + else: + gate.completion_summary(report, driver, raw) + + +@pytest.mark.parametrize("mode", ["verify", "emit"]) +@pytest.mark.parametrize( + "mutation", + [ + "missing-profile", + "null-profile", + "list-profile", + "missing-source_root", + "missing-python", + "missing-python_environment", + "missing-python_sha256", + "missing-cli_sha256", + "missing-executable", + "missing-executable_sha256", + "relative-source_root", + "relative-python", + "relative-python_environment", + "relative-executable", + "invalid-python_sha256", + "invalid-cli_sha256", + "invalid-executable_sha256", + "missing-launcher-digest", + "orphan-launcher-digest", + ], +) +def test_completion_rejects_malformed_identity_even_with_recomputed_digest( + tmp_path: Path, external_checkout: Path, mode: str, mutation: str +) -> None: + path, summary, native = _completion(tmp_path) + driver = Path(summary["report_path"]) + report = json.loads(driver.read_bytes()) + profile = report["profile"] + if mutation == "missing-profile": + del report["profile"] + elif mutation in {"null-profile", "list-profile"}: + report["profile"] = None if mutation == "null-profile" else [] + elif mutation == "missing-launcher-digest": + profile["executable_sha256"] = None + elif mutation == "orphan-launcher-digest": + profile["executable"] = None + else: + operation, field = mutation.split("-", 1) + if operation == "missing": + del profile[field] + else: + profile[field] = "relative/path" if operation == "relative" else 64 + raw = gate._json_bytes(report) + driver.write_bytes(raw) + summary["report_sha256"] = hashlib.sha256(raw).hexdigest() + path.write_bytes(gate._json_bytes(summary)) + with pytest.raises(EvidenceError, match=r"profile"): + if mode == "verify": + gate.validate_completion(path, native, tmp_path / "independent.json") + else: + gate.completion_summary(report, driver, raw) + + def test_completion_protects_aliases_and_accepts_independent_source_path( tmp_path: Path, external_checkout: Path ) -> None: @@ -670,6 +815,45 @@ def fresh(args: argparse.Namespace) -> dict[str, Any]: assert len(calls) == 1 +def test_failed_independent_execution_persists_original_failure_before_completion_comparison( + tmp_path: Path, + external_checkout: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + path, summary, native = _completion(tmp_path) + driver = Path(summary["report_path"]) + preserved = (driver.read_bytes(), path.read_bytes()) + native.update(status="blocked", error="Fresh witness setup failed") + output = tmp_path / "failed-independent.json" + monkeypatch.setattr(gate, "execute", lambda args: native) + monkeypatch.setattr( + sys, + "argv", + [ + "gate", + "--base", + native["base"], + "--head", + native["head"], + "--lane", + "full", + "--report", + str(output), + "--completion", + str(path), + ], + ) + assert gate.main() == 1 + captured = capsys.readouterr() + assert json.loads(output.read_bytes()) == native + assert str(output) in captured.out + assert "Fresh witness setup failed" in captured.err + assert "Completion disagrees" not in captured.err + assert "Lifecycle completion:" not in captured.out + assert (driver.read_bytes(), path.read_bytes()) == preserved + + def test_emitted_sidecar_round_trips_through_fresh_independent_verification( tmp_path: Path, external_checkout: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -822,6 +1006,9 @@ def test_completion_emission_refuses_inconsistent_success_report( "head-after", "tree-after", "source-after", + "pytest-failed-dirty-after", + "pytest-failed-source-after", + "pytest-failed-both-after", ], ) def test_provider_execution_checks_identity_after_its_own_run( @@ -829,41 +1016,14 @@ def test_provider_execution_checks_identity_after_its_own_run( ) -> None: """Unit-only fabricated events exercise orchestration, never native acceptance.""" contract = _ledger()["lifecycle_contracts"][0] - records = {} - for witness in contract["witnesses"]: - events, state = [], 0 - for transition in witness["transitions"]: - before = str(state) - if transition["state"] != "unchanged": - state += 1 - context = transition.get("context", "initial") - events.append( - { - **transition, - "args": transition["argv_contains"], - "before": before, - "after": str(state), - "context": context, - "cwd": "/fixture" if context == "initial" else "/home/.apm", - "roots": {"workspace": "/fixture", "HOME": "/home", "APM_HOME": "/home/.apm"}, - "environment": {"HOME": "/home", "APM_HOME": "/home/.apm"}, - "model": 1, - } - ) - records[witness["nodeid"]] = { - "collected": True, - "dimensions": witness["dimensions"], - "models": 1, - "events": events, - "phases": {"setup": "passed", "call": "passed", "teardown": "passed"}, - } + records = _records(contract) identity = {"base": "a" * 40, "head": "b" * 40, "tested_tree": "c" * 40} profile = {"kind": "source-python", "source_root": str(gate.ROOT / "src/apm_cli")} calls: list[str] = [] def candidate(*args: Any) -> dict[str, str]: if calls: - if mutation == "dirty-after": + if mutation in {"dirty-after", "pytest-failed-dirty-after", "pytest-failed-both-after"}: raise EvidenceError("Candidate dirty after execution") if mutation in {"head-after", "tree-after"}: field = "head" if mutation == "head-after" else "tested_tree" @@ -871,7 +1031,11 @@ def candidate(*args: Any) -> dict[str, str]: return identity def source(*args: Any) -> tuple[None, dict[str, str]]: - if calls and mutation == "source-after": + if calls and mutation in { + "source-after", + "pytest-failed-source-after", + "pytest-failed-both-after", + }: return None, {**profile, "python": "changed"} return None, profile @@ -883,7 +1047,7 @@ def fresh_pytest(args: list[str], plugins: list[Any]) -> int: records.pop(REQUIRED_WITNESSES[0]) elif mutation == "skipped-call": records[REQUIRED_WITNESSES[0]]["phases"]["call"] = "skipped" - return 1 if mutation == "pytest-failed" else 0 + return 1 if mutation.startswith("pytest-failed") else 0 plugin = SimpleNamespace(records=records, patch=SimpleNamespace(undo=lambda: None)) monkeypatch.setattr(gate, "candidate", candidate) @@ -906,6 +1070,55 @@ def fresh_pytest(args: list[str], plugins: list[Any]) -> int: if mutation == "skipped-call": assert set(result["witnesses"]) == set(REQUIRED_WITNESSES) assert "setup/call/teardown must pass" in result["error"] + if mutation.startswith("pytest-failed"): + assert result["error"] == "Lifecycle pytest execution failed with exit code 1" + if mutation.startswith("pytest-failed-"): + expected = [] + if mutation in {"pytest-failed-dirty-after", "pytest-failed-both-after"}: + expected.append("Candidate dirty after execution") + if mutation in {"pytest-failed-source-after", "pytest-failed-both-after"}: + expected.append("Source executable identity changed during execution") + assert result["postflight_errors"] == expected + + +@pytest.mark.parametrize("postflight_failure", [False, True]) +def test_early_contract_error_preserves_first_cause_without_a_profile_baseline( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, postflight_failure: bool +) -> None: + identity = {"base": "a" * 40, "head": "b" * 40, "tested_tree": "c" * 40} + calls = [] + + def candidate(*args: Any) -> dict[str, str]: + calls.append("candidate") + if len(calls) > 1 and postflight_failure: + raise EvidenceError("Candidate genuinely changed after contract failure") + return identity + + def contract(*args: Any) -> dict[str, Any]: + raise EvidenceError("Missing reviewed assessment") + + def unexpected_profile(*args: Any) -> Any: + raise AssertionError("No source baseline was captured") + + monkeypatch.setattr(gate, "candidate", candidate) + monkeypatch.setattr(gate, "candidate_contract", contract) + monkeypatch.setattr(gate, "source_profile", unexpected_profile) + result = gate.execute( + argparse.Namespace( + base=identity["base"], + head=identity["head"], + lane="full", + report=tmp_path / "report.json", + ) + ) + assert result["status"] == "blocked" + assert result["error"] == "Missing reviewed assessment" + assert result["witnesses"] == {} + assert calls == ["candidate", "candidate"] + assert result.get("postflight_errors", []) == ( + ["Candidate genuinely changed after contract failure"] if postflight_failure else [] + ) + assert not (tmp_path / ".report.json.pytest").exists() @pytest.mark.parametrize("mode", ["skip", "xfail", "setup", "teardown", "call", "pass"]) From 541e5832f2cffce9c4b97de9a8e8a60e473ac999 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 30 Sep 2026 16:33:30 +0200 Subject: [PATCH 14/15] test: tolerate wrapped legacy plugin diagnostics Deep isolated paths can make Rich wrap the existing schema warning between words. Preserve the exact warning phrase, successful install, and deployed-byte assertions while comparing whitespace-normalized output. The unchanged b6 test fails on real wrapped output; the corrected ownership suite passes in the same deep environment. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/integration/test_ownership_invariant_lifecycle.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_ownership_invariant_lifecycle.py b/tests/integration/test_ownership_invariant_lifecycle.py index cb60561841..9fca288714 100644 --- a/tests/integration/test_ownership_invariant_lifecycle.py +++ b/tests/integration/test_ownership_invariant_lifecycle.py @@ -557,7 +557,9 @@ def test_unrecognized_claude_plugin_schema_falls_back_to_legacy_structure( assert skill_path.is_file(), _result_evidence(result) assert skill_path.read_bytes() == skill_bytes assert result.returncode == 0, _result_evidence(result) - assert "Unrecognized plugin manifest $schema" in result.stdout + result.stderr + assert "Unrecognized plugin manifest $schema" in " ".join( + (result.stdout + result.stderr).split() + ) @pytest.mark.parametrize( From 2e819da58835436be1c936fbfe6e5c38856f7baf Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 30 Sep 2026 17:39:54 +0200 Subject: [PATCH 15/15] test: close provider recovery and lifecycle smoke budget gaps Exercise matching and unrelated stale provider hints through ordinary CLI installs. Use four bounded smoke workers without changing selection, grouping or the six-minute cap. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 7 ++- .../docs/contributing/integration-testing.md | 2 +- .../test_ref_freshness_lifecycle.py | 57 +++++++++++++++---- tests/quality/test_ci_topology.py | 15 ++--- 4 files changed, 60 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5959305948..b73bbeb5f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -581,7 +581,8 @@ jobs: # # Why timeout-minutes: 6 # ------------------------ - # Two xdist workers share the required selection without widening the cap. + # Four xdist workers use the public Ubuntu runner's four CPUs without + # widening the cap or changing the required selection. # loadgroup preserves xdist_group serialization for HOME-mutating tests, # matching the merge-group integration scheduler. The 6-minute cap includes # checkout/setup/sync/cleanup; hosted job duration is the release authority. @@ -628,6 +629,8 @@ jobs: env: APM_E2E_TESTS: "1" run: | + printf 'Runner logical CPUs: ' + nproc uv run --extra dev pytest -p no:cacheprovider -q --strict-markers \ - -n 2 --dist loadgroup \ + -n 4 --dist loadgroup \ -m 'lifecycle_smoke and not lifecycle_merge_group' tests/integration diff --git a/docs/src/content/docs/contributing/integration-testing.md b/docs/src/content/docs/contributing/integration-testing.md index 92fb5b5176..4f125426a0 100644 --- a/docs/src/content/docs/contributing/integration-testing.md +++ b/docs/src/content/docs/contributing/integration-testing.md @@ -456,7 +456,7 @@ skill. The local fixture covers both an independent skill and a separately installed skill from the plugin; the network-backed case uses the same assertions. Cleanup wording is not the lifecycle contract. -Linux Lifecycle Smoke runs the required marker subset with `-n 2 --dist loadgroup`. Grouped tests stay on one worker, and the six-minute job limit remains unchanged. +Linux Lifecycle Smoke runs the required marker subset with four bounded workers (`-n 4 --dist loadgroup`) on the public Ubuntu runner. Grouped tests stay on one worker, and the six-minute job limit remains unchanged. Its `lifecycle_smoke and not lifecycle_merge_group` selection is not all lifecycle coverage: also run affected generated state machines, deployment ledger, and failure/retry contracts when changing those behaviors. diff --git a/tests/integration/test_ref_freshness_lifecycle.py b/tests/integration/test_ref_freshness_lifecycle.py index f6c4518609..2994f16967 100644 --- a/tests/integration/test_ref_freshness_lifecycle.py +++ b/tests/integration/test_ref_freshness_lifecycle.py @@ -63,6 +63,7 @@ class _Scenario: runner: ApmLifecycleRunner skill_path: Path commit_a: GitCommit + remote_url: str def _skill_document(marker: str) -> str: @@ -76,7 +77,11 @@ def _skill_document(marker: str) -> str: def _new_scenario( - root: Path, apm_binary_path: Path, *, reference: str | None = "main" + root: Path, + apm_binary_path: Path, + *, + reference: str | None = "main", + host: str = "github.com", ) -> _Scenario: isolated = IsolatedApmEnvironment.create(root, base_env=dict(os.environ)) environment = isolated.subprocess_env(overrides={"APM_TIERED_RESOLVER": "1"}) @@ -89,13 +94,13 @@ def _new_scenario( ) repository = repositories.create("ref-freshness-source", source_tree=source.root) commit_a = repositories.commit(repository, message="publish commit A") - remote_url = "https://github.com/apm-fixture-org/ref-freshness-source" + remote_url = f"https://{host}/apm-fixture-org/ref-freshness-source" environment = repositories.url_rewrite_subprocess_env(repository, remote_url) rewrite_index = int(environment["GIT_CONFIG_COUNT"]) environment["GIT_CONFIG_COUNT"] = str(rewrite_index + 1) environment[f"GIT_CONFIG_KEY_{rewrite_index}"] = f"url.{repository.file_url}/.insteadOf" environment[f"GIT_CONFIG_VALUE_{rewrite_index}"] = ( - "git@github.com:apm-fixture-org/ref-freshness-source.git" + f"git@{host}:apm-fixture-org/ref-freshness-source.git" ) consumer = LocalPackageFactory(isolated.work_root).create( "ref-freshness-consumer", @@ -121,6 +126,7 @@ def _new_scenario( ), skill_path=repository.worktree / source_skill.relative_to(source.root), commit_a=commit_a, + remote_url=remote_url, ) @@ -217,12 +223,24 @@ def test_frozen_default_ref_rehydrates_cold_cache_without_ref_drift( _run(scenario, _AUDIT_ARGS, scenario_id="default-ref-audit-a") -@pytest.mark.parametrize("unrelated_lock_entry", [False, True]) +@pytest.mark.parametrize( + ("unrelated_lock_entry", "historical_provider"), + [ + pytest.param(False, False, id="False"), + pytest.param(True, False, id="True"), + pytest.param(False, True, id="historical-matching"), + pytest.param(True, True, id="historical-unrelated"), + ], +) def test_reinstall_uses_current_ref_without_a_matching_lock_entry( - tmp_path: Path, apm_binary_path: Path, unrelated_lock_entry: bool + tmp_path: Path, + apm_binary_path: Path, + unrelated_lock_entry: bool, + historical_provider: bool, ) -> None: - """An empty or unrelated lock cannot authorize the removed package's stale cache.""" - scenario = _new_scenario(tmp_path / "unlocked-ref", apm_binary_path) + """Missing or unusable historical identity cannot authorize stale package bytes.""" + host = "fixture.ghe.com" if historical_provider else "github.com" + scenario = _new_scenario(tmp_path / "unlocked-ref", apm_binary_path, host=host) manifest = scenario.consumer.root / "apm.yml" original_manifest = manifest.read_bytes() sentinel = scenario.consumer.root / "user-owned.txt" @@ -231,19 +249,36 @@ def test_reinstall_uses_current_ref_without_a_matching_lock_entry( commit_b = _advance(scenario, "commit-b") _run( scenario, - ("uninstall", "https://github.com/apm-fixture-org/ref-freshness-source"), + ("uninstall", scenario.remote_url), scenario_id="unlocked-remove-a", ) manifest.write_bytes(original_manifest) lock = LockFile() - if unrelated_lock_entry: - lock.dependencies["unrelated/package"] = LockedDependency( - repo_url="unrelated/package", resolved_ref="main", resolved_commit=scenario.commit_a.sha + if unrelated_lock_entry or historical_provider: + repo_url = ( + "unrelated/package" if unrelated_lock_entry else "apm-fixture-org/ref-freshness-source" + ) + locked = LockedDependency( + repo_url=repo_url, + host=host, + host_type="gitlab" if historical_provider else None, + resolved_ref="main", + resolved_commit=scenario.commit_a.sha, ) + lock.dependencies[locked.get_unique_key()] = locked lock.save(scenario.consumer.root / "apm.lock.yaml") _run(scenario, _INSTALL_ARGS, scenario_id="unlocked-reinstall-b") assert _locked_commit(scenario) == commit_b.sha assert _deployed_bytes(scenario) == _skill_document("commit-b").encode() + assert _module_skill_bytes(scenario) == _skill_document("commit-b").encode() + assert manifest.read_bytes() == original_manifest + recovered_lock = LockFile.read(scenario.consumer.root / "apm.lock.yaml") + assert recovered_lock is not None + recovered = recovered_lock.get_package_dependencies()[0] + assert recovered.repo_url == "apm-fixture-org/ref-freshness-source" + assert recovered.host == host + assert recovered.host_type is None + assert recovered.resolved_ref == "main" assert sentinel.read_text(encoding="ascii") == "preserve\n" _run(scenario, _AUDIT_ARGS, scenario_id="unlocked-audit-b") converged = _capture(scenario) diff --git a/tests/quality/test_ci_topology.py b/tests/quality/test_ci_topology.py index 123fb3d2a9..1c7e53dae4 100644 --- a/tests/quality/test_ci_topology.py +++ b/tests/quality/test_ci_topology.py @@ -491,10 +491,10 @@ def _assert_lifecycle_smoke_command(job: WorkflowNode) -> None: "selecting zero tests" ) worker_flags = [token for token in tokens if token.startswith(("-n", "--numprocesses"))] - assert worker_flags == ["-n"], "lifecycle-smoke must use two bounded xdist workers" + assert worker_flags == ["-n"], "lifecycle-smoke must use four bounded xdist workers" worker_index = tokens.index("-n") - assert tokens[worker_index : worker_index + 2] == ["-n", "2"], ( - "lifecycle-smoke must use two bounded xdist workers" + assert tokens[worker_index : worker_index + 2] == ["-n", "4"], ( + "lifecycle-smoke must use four bounded xdist workers" ) assert [token for token in tokens if token.startswith("--dist")] == ["--dist"], ( "lifecycle-smoke must use loadgroup to preserve xdist_group serialization" @@ -789,10 +789,11 @@ def test_lifecycle_smoke_e2e_flag_dropped_fails( "", "-n 0 --dist loadgroup", "-n auto --dist loadgroup", + "-n 2 --dist loadgroup", "-n 3 --dist loadgroup", - "-n 2 --dist worksteal", - "-n 2 --dist loadgroup --numprocesses=auto", - "-n 2 --dist loadgroup --dist=load", + "-n 4 --dist worksteal", + "-n 4 --dist loadgroup --numprocesses=auto", + "-n 4 --dist loadgroup --dist=load", ], ) def test_lifecycle_smoke_parallelism_drift_fails( @@ -800,7 +801,7 @@ def test_lifecycle_smoke_parallelism_drift_fails( ) -> None: """Keep the runtime budget without dropping grouping or allowing unbounded workers.""" step = workflow_step(provisional_lifecycle_job, LIFECYCLE_SMOKE_RUN_STEP) - step["run"] = step["run"].replace("-n 2 --dist loadgroup", parallel_args) + step["run"] = step["run"].replace("-n 4 --dist loadgroup", parallel_args) with pytest.raises(AssertionError, match=r"workers|loadgroup"): _assert_lifecycle_smoke_command(provisional_lifecycle_job)