diff --git a/.agents/skills/python-architecture/SKILL.md b/.agents/skills/python-architecture/SKILL.md index c658c24eb..5f1ed86bc 100644 --- a/.agents/skills/python-architecture/SKILL.md +++ b/.agents/skills/python-architecture/SKILL.md @@ -23,3 +23,5 @@ description: > - Follow existing patterns (BaseIntegrator, CommandLogger, AuthResolver) before inventing new ones - Prefer composition over deep inheritance - Push shared logic into base classes, not duplicated across siblings +- One canonical owner per decision: route through the existing authority (target_catalog, AuthResolver/host_providers, runtime registry, CommandLogger, CompiledOutputWriter, deployment_ledger, install-outcome, neutral hook IR, BaseIntegrator); extend it, never fork it. Full rule: `.apm/instructions/architecture.instructions.md` +- Lock every centralization with a dual guardrail: a regression test plus a static check in `scripts/lint-architecture-boundaries.sh` diff --git a/.apm/agents/apm-primitives-architect.agent.md b/.apm/agents/apm-primitives-architect.agent.md index 0874a6a83..5a0924be6 100644 --- a/.apm/agents/apm-primitives-architect.agent.md +++ b/.apm/agents/apm-primitives-architect.agent.md @@ -98,6 +98,11 @@ Cite the principle by name in every recommendation. Never appeal to trigger -- too noisy. - New YAML manifests, new tools, or new dispatcher sub-agents when wording changes would suffice. +- One capability split across two primitives (two skills, or a skill + and an agent, owning the same decision) -- duplication equals drift. + Give each capability one owning primitive; the same single-canonical + -owner rule that governs the Python codebase (see + architecture.instructions.md) governs primitive design. ## Scope boundaries diff --git a/.apm/agents/python-architect.agent.md b/.apm/agents/python-architect.agent.md index f447dbfce..0b9f09c84 100644 --- a/.apm/agents/python-architect.agent.md +++ b/.apm/agents/python-architect.agent.md @@ -24,6 +24,29 @@ You are an expert Python architect specializing in CLI tool design. You guide ar - **Collect-then-render**: `DiagnosticCollector` — push diagnostics during operation, render summary at end - **BaseIntegrator**: All file integrators share one base for collision detection, manifest sync, path security +## Canonical authority discipline (single-owner rule) + +Most APM reliability bugs reduce to one root: the same decision +(accepted targets, install outcome, hook shape, integrity hash, +resolved credential) was computed in more than one place, so a fix on +one path missed a sibling. Hold the line structurally: + +- Every durable decision / vocabulary / outcome / write / contract has + exactly ONE canonical owner; every call site routes through it. + Extend the owner, never fork it. The enumerated owners and the full + rule live in `.apm/instructions/architecture.instructions.md` + (deployed to `.github/instructions/`) -- treat that file as the + canonical statement; do not restate it here. +- When you centralize a decision or fix a split-authority bug, ship a + DUAL guardrail: a behavioral regression test AND a static boundary + check (`scripts/lint-architecture-boundaries.sh` + + `tests/integration/test_architecture_*.py`). A fix without the guard + will silently regress. + +At PR-review time, add one check to your findings: does the diff +compute or enforce a decision the codebase already owns elsewhere? A +new parallel authority is a `required` finding, not a nit. + ## When to Abstract vs Inline - **Abstract** when 3+ call sites share the same logic pattern diff --git a/.apm/instructions/architecture.instructions.md b/.apm/instructions/architecture.instructions.md new file mode 100644 index 000000000..c30293ffc --- /dev/null +++ b/.apm/instructions/architecture.instructions.md @@ -0,0 +1,66 @@ +--- +applyTo: "src/apm_cli/**" +description: "Single canonical owner discipline: one authority per durable decision, guarded by a regression test + a static boundary check" +--- + +# Architecture discipline: one canonical owner per decision + +APM is a pipeline of durable facts (targets, lock state, install +outcomes, compiled output, hook shapes, credentials, deployment +provenance). Most reliability bugs in this codebase have one shape: +the SAME decision was computed or enforced in more than one place, so +a fix on one path silently missed a sibling path. The cure is +structural, not case-by-case. + +## The rule + +Every durable decision, vocabulary, outcome, write, or contract has +exactly ONE canonical owner. Every call site routes THROUGH that owner +instead of re-deriving the answer locally. + +- A "decision" is anything a reader must be able to trust is computed + identically everywhere: the accepted target set, whether an install + succeeded, the on-disk shape of a hook, the integrity hash of a + deployed file, the resolved credential for a host. +- Adding a second place that computes or enforces the same decision is + a "split authority" and is a defect even if it currently agrees -- + it WILL drift the next time one side is patched. + +## Existing canonical owners -- route through these, do not re-derive + +| Decision / fact | Canonical owner | +|---|---| +| Accepted target vocabulary | core/target_catalog.py | +| Host + credential resolution | core/auth.py (AuthResolver), core/host_providers.py | +| Runtime descriptors | runtime/registry.py | +| User-facing output / diagnostics | CommandLogger / console owner | +| Compiled-output writes (atomic) | CompiledOutputWriter | +| Deployment provenance / state | deployment_ledger.py | +| Install success / failure outcome | the canonical install-outcome path | +| Neutral hook shape -> per-target native | the neutral hook IR + per-target integrators | +| File-level deploy / sync / cleanup | BaseIntegrator (see integrators.instructions.md) | + +If you are about to compute one of these locally, stop and call the +owner. If the owner is missing a case you need, EXTEND the owner -- +never fork it. + +## When you centralize or fix a split-authority bug: dual guardrail + +A fix is not done until the split cannot silently return. Add BOTH: + +1. A behavioral **regression test** (hermetic, under tests/) that + encodes the exact symptom and fails before / passes after. +2. A **static boundary guard** so a future contributor cannot re-add a + second owner: extend scripts/lint-architecture-boundaries.sh and the + matching tests/integration/test_architecture_*.py suite. + +The scripts/lint-architecture-boundaries.sh check is wired into CI (the +Lint job) alongside the auth-signal guard. Treat a new authority the +same way: give it a guard line. + +## Review lens + +When reviewing or authoring a change, ask: "Does this compute or +enforce a decision the codebase already owns elsewhere?" If yes, the +change must route through the owner, and a new parallel path is a +blocking finding, not a nit. diff --git a/.apm/instructions/integrators.instructions.md b/.apm/instructions/integrators.instructions.md index 2b00687f4..0f6a93c14 100644 --- a/.apm/instructions/integrators.instructions.md +++ b/.apm/instructions/integrators.instructions.md @@ -12,7 +12,7 @@ APM runs inside repositories of any size — from single-package repos to monore 1. **One base, many file types.** All file-level integrators share a single `BaseIntegrator` infrastructure for collision detection, manifest-based sync, path security, link resolution, and file discovery. New integrators add *what* to deploy, never *how* to deploy. When logic belongs to more than one integrator, push it into `BaseIntegrator`. 2. **Pay only for what you touch.** Operations must be proportional to the files a single package deploys, not the size of the workspace or the total managed-files set. Pre-normalize once, partition once, look up in O(1). Avoid full-tree walks, per-file parent cleanup, or repeated set scans. -When evolving integration logic — new file types, richer transforms, cross-package awareness — preserve these properties. If a change would violate either principle, refactor the base class first. +When evolving integration logic -- new file types, richer transforms, cross-package awareness -- preserve these properties. If a change would violate either principle, refactor the base class first. This subsystem is the integration-specific instance of the repo-wide single-canonical-owner rule; see architecture.instructions.md. ## Required structure diff --git a/.apm/instructions/python.instructions.md b/.apm/instructions/python.instructions.md index fa5a10a18..36e4c8ca5 100644 --- a/.apm/instructions/python.instructions.md +++ b/.apm/instructions/python.instructions.md @@ -6,3 +6,5 @@ applyTo: '**/*.py' Use type hints for all function parameters and return values. Follow PEP 8 style guidelines. Write comprehensive docstrings. + +Before computing a shared decision (accepted targets, install outcome, hook shape, integrity hash, resolved credential) locally, route through its canonical owner instead of duplicating it; see architecture.instructions.md. diff --git a/.apm/skills/python-architecture/SKILL.md b/.apm/skills/python-architecture/SKILL.md index 244c5abec..7ecaa644e 100644 --- a/.apm/skills/python-architecture/SKILL.md +++ b/.apm/skills/python-architecture/SKILL.md @@ -23,3 +23,5 @@ description: > - Follow existing patterns (BaseIntegrator, CommandLogger, AuthResolver) before inventing new ones - Prefer composition over deep inheritance - Push shared logic into base classes, not duplicated across siblings +- One canonical owner per decision: route through the existing authority (target_catalog, AuthResolver/host_providers, runtime registry, CommandLogger, CompiledOutputWriter, deployment_ledger, install-outcome, neutral hook IR, BaseIntegrator); extend it, never fork it. Full rule: `.apm/instructions/architecture.instructions.md` +- Lock every centralization with a dual guardrail: a regression test plus a static check in `scripts/lint-architecture-boundaries.sh` diff --git a/.github/agents/apm-primitives-architect.agent.md b/.github/agents/apm-primitives-architect.agent.md index 0874a6a83..5a0924be6 100644 --- a/.github/agents/apm-primitives-architect.agent.md +++ b/.github/agents/apm-primitives-architect.agent.md @@ -98,6 +98,11 @@ Cite the principle by name in every recommendation. Never appeal to trigger -- too noisy. - New YAML manifests, new tools, or new dispatcher sub-agents when wording changes would suffice. +- One capability split across two primitives (two skills, or a skill + and an agent, owning the same decision) -- duplication equals drift. + Give each capability one owning primitive; the same single-canonical + -owner rule that governs the Python codebase (see + architecture.instructions.md) governs primitive design. ## Scope boundaries diff --git a/.github/agents/python-architect.agent.md b/.github/agents/python-architect.agent.md index f447dbfce..0b9f09c84 100644 --- a/.github/agents/python-architect.agent.md +++ b/.github/agents/python-architect.agent.md @@ -24,6 +24,29 @@ You are an expert Python architect specializing in CLI tool design. You guide ar - **Collect-then-render**: `DiagnosticCollector` — push diagnostics during operation, render summary at end - **BaseIntegrator**: All file integrators share one base for collision detection, manifest sync, path security +## Canonical authority discipline (single-owner rule) + +Most APM reliability bugs reduce to one root: the same decision +(accepted targets, install outcome, hook shape, integrity hash, +resolved credential) was computed in more than one place, so a fix on +one path missed a sibling. Hold the line structurally: + +- Every durable decision / vocabulary / outcome / write / contract has + exactly ONE canonical owner; every call site routes through it. + Extend the owner, never fork it. The enumerated owners and the full + rule live in `.apm/instructions/architecture.instructions.md` + (deployed to `.github/instructions/`) -- treat that file as the + canonical statement; do not restate it here. +- When you centralize a decision or fix a split-authority bug, ship a + DUAL guardrail: a behavioral regression test AND a static boundary + check (`scripts/lint-architecture-boundaries.sh` + + `tests/integration/test_architecture_*.py`). A fix without the guard + will silently regress. + +At PR-review time, add one check to your findings: does the diff +compute or enforce a decision the codebase already owns elsewhere? A +new parallel authority is a `required` finding, not a nit. + ## When to Abstract vs Inline - **Abstract** when 3+ call sites share the same logic pattern diff --git a/.github/instructions/architecture.instructions.md b/.github/instructions/architecture.instructions.md new file mode 100644 index 000000000..c30293ffc --- /dev/null +++ b/.github/instructions/architecture.instructions.md @@ -0,0 +1,66 @@ +--- +applyTo: "src/apm_cli/**" +description: "Single canonical owner discipline: one authority per durable decision, guarded by a regression test + a static boundary check" +--- + +# Architecture discipline: one canonical owner per decision + +APM is a pipeline of durable facts (targets, lock state, install +outcomes, compiled output, hook shapes, credentials, deployment +provenance). Most reliability bugs in this codebase have one shape: +the SAME decision was computed or enforced in more than one place, so +a fix on one path silently missed a sibling path. The cure is +structural, not case-by-case. + +## The rule + +Every durable decision, vocabulary, outcome, write, or contract has +exactly ONE canonical owner. Every call site routes THROUGH that owner +instead of re-deriving the answer locally. + +- A "decision" is anything a reader must be able to trust is computed + identically everywhere: the accepted target set, whether an install + succeeded, the on-disk shape of a hook, the integrity hash of a + deployed file, the resolved credential for a host. +- Adding a second place that computes or enforces the same decision is + a "split authority" and is a defect even if it currently agrees -- + it WILL drift the next time one side is patched. + +## Existing canonical owners -- route through these, do not re-derive + +| Decision / fact | Canonical owner | +|---|---| +| Accepted target vocabulary | core/target_catalog.py | +| Host + credential resolution | core/auth.py (AuthResolver), core/host_providers.py | +| Runtime descriptors | runtime/registry.py | +| User-facing output / diagnostics | CommandLogger / console owner | +| Compiled-output writes (atomic) | CompiledOutputWriter | +| Deployment provenance / state | deployment_ledger.py | +| Install success / failure outcome | the canonical install-outcome path | +| Neutral hook shape -> per-target native | the neutral hook IR + per-target integrators | +| File-level deploy / sync / cleanup | BaseIntegrator (see integrators.instructions.md) | + +If you are about to compute one of these locally, stop and call the +owner. If the owner is missing a case you need, EXTEND the owner -- +never fork it. + +## When you centralize or fix a split-authority bug: dual guardrail + +A fix is not done until the split cannot silently return. Add BOTH: + +1. A behavioral **regression test** (hermetic, under tests/) that + encodes the exact symptom and fails before / passes after. +2. A **static boundary guard** so a future contributor cannot re-add a + second owner: extend scripts/lint-architecture-boundaries.sh and the + matching tests/integration/test_architecture_*.py suite. + +The scripts/lint-architecture-boundaries.sh check is wired into CI (the +Lint job) alongside the auth-signal guard. Treat a new authority the +same way: give it a guard line. + +## Review lens + +When reviewing or authoring a change, ask: "Does this compute or +enforce a decision the codebase already owns elsewhere?" If yes, the +change must route through the owner, and a new parallel path is a +blocking finding, not a nit. diff --git a/.github/instructions/integrators.instructions.md b/.github/instructions/integrators.instructions.md index 2b00687f4..0f6a93c14 100644 --- a/.github/instructions/integrators.instructions.md +++ b/.github/instructions/integrators.instructions.md @@ -12,7 +12,7 @@ APM runs inside repositories of any size — from single-package repos to monore 1. **One base, many file types.** All file-level integrators share a single `BaseIntegrator` infrastructure for collision detection, manifest-based sync, path security, link resolution, and file discovery. New integrators add *what* to deploy, never *how* to deploy. When logic belongs to more than one integrator, push it into `BaseIntegrator`. 2. **Pay only for what you touch.** Operations must be proportional to the files a single package deploys, not the size of the workspace or the total managed-files set. Pre-normalize once, partition once, look up in O(1). Avoid full-tree walks, per-file parent cleanup, or repeated set scans. -When evolving integration logic — new file types, richer transforms, cross-package awareness — preserve these properties. If a change would violate either principle, refactor the base class first. +When evolving integration logic -- new file types, richer transforms, cross-package awareness -- preserve these properties. If a change would violate either principle, refactor the base class first. This subsystem is the integration-specific instance of the repo-wide single-canonical-owner rule; see architecture.instructions.md. ## Required structure diff --git a/.github/instructions/python.instructions.md b/.github/instructions/python.instructions.md index fa5a10a18..36e4c8ca5 100644 --- a/.github/instructions/python.instructions.md +++ b/.github/instructions/python.instructions.md @@ -6,3 +6,5 @@ applyTo: '**/*.py' Use type hints for all function parameters and return values. Follow PEP 8 style guidelines. Write comprehensive docstrings. + +Before computing a shared decision (accepted targets, install outcome, hook shape, integrity hash, resolved credential) locally, route through its canonical owner instead of duplicating it; see architecture.instructions.md. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5d57cd4f..003c38417 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,9 @@ jobs: - name: Lint - auth-protocol boundary (#1212 anti-regression) run: bash scripts/lint-auth-signals.sh + - name: Lint - architecture authority boundaries + run: bash scripts/lint-architecture-boundaries.sh + # Linux-only for PR feedback. Full platform matrix (incl. macOS + Windows) runs post-merge in build-release.yml. # # Two-way sharded with pytest-split, mirroring the pattern proven in @@ -308,4 +311,3 @@ jobs: # .agents/skills/ content could silently diverge from source (#1716). - name: apm audit --ci run: uv run apm audit --ci - diff --git a/CHANGELOG.md b/CHANGELOG.md index 674411a76..5e4cdf763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,54 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] - + +### Changed + +- Hardened command behavior: invalid lockfiles and incomplete policy chains + fail closed before mutation; failed installs skip post-install hooks; + registry routes do not fall back to Git; machine output keeps notices on + stderr; unauthenticated Git retries scrub inherited credentials; `$schema` + selects the manifest contract; and timed-out runtimes are reaped. (#2155) +- MCP audit, install, and uninstall now share one lockfile-bounded current-source + view, so `config-consistency` detects changed or removed transitive declarations + and fails on unreadable package manifests. (#2155) +- `apm compile --target ` now accepts every project target from the + canonical catalog, emits its native context artifact when applicable, and + treats targets without compile output as successful no-ops. (#2155) +- Merged hook configs now contain only each target's native upstream fields; + APM keeps reconciliation ownership in an external `apm-hooks.json` sidecar. + (#2155) + ### Fixed +- Regenerating the lockfile now records each transitive local dependency's + identity as a project-root-relative POSIX path instead of an absolute machine + path, so a committed `apm.lock.yaml` stays portable and deterministic across + machines and CI. (#2155) +- Positional `apm install ` now exits non-zero when all packages fail + validation, matching manifest installs for reliable CI scripting. (#2131) +- A failed first `apm install` no longer leaves behind an `apm.yml` it created, + and the final completion summary now reflects the committed outcome instead of + rendering before commit or rollback. Successful dry-run bootstrap keeps the + generated manifest and explicit targets for the next run. (#2155) +- Manifest and policy parsers now reject wrong-typed native schema values and + report unknown policy keys. Migration: quote numeric `apm.yml` versions, use + non-empty string identities, and use mappings/lists for policy blocks. (closes #2137) (#2143) +- `apm uninstall` now transfers shared deployed-file ownership to a surviving + package, preserving lockfile content-integrity coverage. (#2148) +- Semver resolution now preserves each dependency's authentication scheme, so + Azure DevOps bearer credentials are used consistently for tag listing, and a + rejected `ADO_APM_PAT` retries ref resolution with the Azure CLI bearer. (#2155) +- Contracting the active target set now removes APM-managed MCP server + entries from dropped targets while preserving user-authored servers and + unrelated native config, including safe adoption of self-defined servers from + legacy lockfiles when the native entry exactly matches its stored baseline. + (#2155) +- `apm uninstall` now reconciles dependency removal, survivor ownership, hashes, + MCP state, and the deployment ledger before one atomic lockfile write. (#2155) +- `apm install` exception and rollback diagnostics now render through the + `CommandLogger` owner, keeping recovery hints and warning severity consistent. + (#2155) - Fresh checkouts with declared consumer targets no longer remain `apm audit --ci`-red for files those targets cannot restore: `apm install` now removes stale `deployed_files` entries outside the legitimate target @@ -18,12 +63,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `apm install --target intellij` now configures JetBrains Copilot MCP support while routing package file primitives through the Copilot profile. (by @sergio-sisternes-epam; closes #1957) (#2041) - -### Fixed - +- Global Claude installs now support an absolute `CLAUDE_CONFIG_DIR` outside + `HOME` without leaving a partial deployment. (closes #2129) (#2135) - Azure DevOps marketplace checks now preserve suffix-free `/_git/` URLs and pass Azure CLI bearer authentication through to `git ls-remote`. (closes #2119) +### Performance + +- Deployment manifest reconciliation and uninstall ownership handoff now use + precomputed path indexes, avoiding quadratic scans on large deployment + ledgers. (#2155) + ## [0.24.1] - 2026-07-10 ### Fixed diff --git a/apm.lock.yaml b/apm.lock.yaml index d077bc39e..2e3844c89 100644 --- a/apm.lock.yaml +++ b/apm.lock.yaml @@ -1,6 +1,6 @@ lockfile_version: '1' -generated_at: '2026-06-25T18:39:34.738228+00:00' -apm_version: 0.21.0 +generated_at: '2026-07-11T22:04:42.215028+00:00' +apm_version: 0.24.1 dependencies: - repo_url: _local/apm-issue-autopilot name: apm-issue-autopilot @@ -139,6 +139,8 @@ dependencies: .agents/skills/apm-triage-panel/assets/triage-template.md: sha256:69a027959eaec5335668e087c86babd814ba20cf21662ed524cf285b178e21d1 source: local local_path: ../apm-triage-panel + declaring_parent: ./packages/apm-issue-autopilot + anchored_local_path: packages/apm-triage-panel declared_license: MIT - repo_url: _local/pr-description-skill name: pr-description-skill @@ -193,6 +195,8 @@ dependencies: .agents/skills/pr-description-skill/scripts/run_evals.py: sha256:a1792f8cb95b4fb37a4133af079908e2a3b5643c1a81d97e4dcda399bac069a2 source: local local_path: ../pr-description-skill + declaring_parent: ./packages/apm-issue-autopilot + anchored_local_path: packages/pr-description-skill declared_license: MIT - repo_url: _local/shepherd-driver name: shepherd-driver @@ -225,6 +229,8 @@ dependencies: .agents/skills/shepherd-driver/references/mergeability-gate.md: sha256:90bf5cd838d1025dd0fa223a0843be926cd509c85c91ef683cc9bfc98b5e8785 source: local local_path: ../shepherd-driver + declaring_parent: ./packages/apm-issue-autopilot + anchored_local_path: packages/shepherd-driver declared_license: MIT - repo_url: _local/apm-review-panel name: apm-review-panel @@ -261,7 +267,2575 @@ dependencies: .agents/skills/apm-review-panel/evals/trigger-evals.json: sha256:c715ba52cd2131f294c1cf685a10995a08e4ee6971f97cf5d36e09df28d3af91 source: local local_path: ../apm-review-panel + declaring_parent: local:packages/shepherd-driver + anchored_local_path: packages/apm-review-panel declared_license: MIT +deployments: +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/SKILL.md + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:428fd804932b75977ee72ec9b820e0d300190cda095f4f38343f27dedbde6ef2 +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/assets/acceptance-observer.md + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:e4ddab439f62bb372f5ad608775294dd44653ef475eeca8ef5517af7f5e42699 +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/assets/autopilot-triage-schema.json + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:35d672ce4584fb576b3c16ce4dffbe29de6b3f6711725855b3c6eb0f338d464d +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/assets/confidence-gate-rubric.md + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:2d6eff739cd4ac1eb4533868cbdcf0c44d469ae247447f29595d05858b2d8a97 +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/assets/final-report-template.md + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:8088fe8f5bb9fd1ec93fbe806971465bb7f6850019af5be1a329e252d4818df2 +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/assets/ground-truth-table.md + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:00dd5a35ef0b39ba299e5fcd7f9ca682fc322792ec20440f7861edd39997d40e +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/assets/ideate-prompt.md + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:abab1a5e2a765229d1cdd215b90c57036cdb4b2764866b68399eb81a51362ddc +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/assets/implement-bug.md + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:4c74c8dfa0f98987ab5beede16d3e8c0e8113fac60955826d2ed61949624bcc5 +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/assets/implement-docs.md + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:81f2f9b617587df4a6f422838b05c63a5e05e0c8afd6d80952a9aa5cdad88b5a +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/assets/implement-feature.md + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:394b25a0b0b41b0f99b250590af782968c0f25673c156d0b87d4f1f9b205de75 +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/assets/implement-refactor.md + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:5175ea7edefdaaf787d1701ba6f7fce8433d04134718d9a6445ca524ececa3bc +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/assets/model-routing.md + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:4d1ffaf63fab75a7a7de265ff77d457c0636939d36a373dc9ce485ba76275209 +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/assets/plan-panel-prompt.md + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:422daafc8fcdf26f1096787075b2e8c15722df6b51e1d1784ea3b1435809b09f +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/assets/plan-schema.json + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:f766382fbac2a2b99c0dad1d0327659103c4482cc155854ea7ef2e3c82fce8f2 +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/assets/solution-pipeline-prompt.md + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:812487116b89e66414562312992204dfb3132cc5e9265392b718d78e33cd9973 +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/assets/task-implement-prompt.md + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:397ab60c270f0312a3ddaac0fbc03e853fa16552a962f6b2ebca5ed27bab2818 +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/assets/triage-digest-template.md + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:78c2e7be93543122e485eb494a2b50ecac6e18d7e077a9e8edde12b478242ca9 +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/assets/triage-prompt.md + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:5136ab42be575b58730a8d8a1614394813014ca74e2d6df3e56948e94c419470 +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/assets/wave-gate-rubric.md + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:a8bf5270c8d63c1d9ffcf0b3f18ba22d591b062e7b969982320150986ab211cd +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/evals/.gitignore + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:f370ad043ec0c98a166fd98646c3b94280ed78bc996223b6cca2ba936f7c6781 +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/evals/README.md + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:79bb6636a89edce40fc24bde9576e351de57a0f828181def37c423a284cd1ef8 +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/evals/content/gate-escalate-doubtful.json + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:20bcabed2b58be8552421f68e9522cea3a1206a1796c26d2f77e65a3982d438f +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/evals/content/gate-proceed-clean-accept.json + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:f304138fe9aafe48989710bd2c68238342b32e643281f9747c0678b7cb1ae244 +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/evals/content/mixed-types-one-review.json + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:df2b1b66e1ec64743cc8d66e465e5cca931de6fb51974cc51c81908efad58eb5 +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/evals/evals.json + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:a94ed339f171e7ca557df3e7ed10c8e55f9a14befcba940b7263c9d7fe4d8ca1 +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/evals/real-task-refinement.md + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:183d6d7270dc5f865a02d42643ba0afbae9046509f0e3b2481559fc9c1dcb64a +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/evals/triggers.json + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:59bbb6e937d6de8db69aee4478989fccff561c77e86cc6894e7994eb841defd0 +- kind: project-relative + target: agents + value: .agents/skills/apm-issue-autopilot/scripts/run_evals.py + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: sha256:8f4580cb2bdb88e7e570e4feb60cbf244d7fbe3e8217a8eadfd9dff2c763b615 +- kind: project-relative + target: agents + value: .agents/skills/apm-review-panel + runtime: null + scope: project + owners: + - local:packages/apm-review-panel + active_owner: local:packages/apm-review-panel + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/apm-review-panel/SKILL.md + runtime: null + scope: project + owners: + - local:packages/apm-review-panel + active_owner: local:packages/apm-review-panel + content_hash: sha256:fc63e25479c5c2066f317b5b0e430738bd8a67054a2ae6934fbe5b3d281fa10a +- kind: project-relative + target: agents + value: .agents/skills/apm-review-panel/apm.yml + runtime: null + scope: project + owners: + - local:packages/apm-review-panel + active_owner: local:packages/apm-review-panel + content_hash: sha256:80c403c4d3c59a91bb27b85650e21a466e9cd0cbc28e92bf0f3e53c6ea71cb2c +- kind: project-relative + target: agents + value: .agents/skills/apm-review-panel/assets/ceo-return-schema.json + runtime: null + scope: project + owners: + - local:packages/apm-review-panel + active_owner: local:packages/apm-review-panel + content_hash: sha256:d8707211968efb0471d083f880d5353d66a0eda84635e803a930f60c91837468 +- kind: project-relative + target: agents + value: .agents/skills/apm-review-panel/assets/panelist-return-schema.json + runtime: null + scope: project + owners: + - local:packages/apm-review-panel + active_owner: local:packages/apm-review-panel + content_hash: sha256:e3cf2ae17e93dd934f2659ed77d2640ea9601fbe8698f63ba800edf046691f99 +- kind: project-relative + target: agents + value: .agents/skills/apm-review-panel/assets/recommendation-template.md + runtime: null + scope: project + owners: + - local:packages/apm-review-panel + active_owner: local:packages/apm-review-panel + content_hash: sha256:ed973263897671ea04c980cb54c76b33aaa9bb7d1b5b24082df89b388d4329b8 +- kind: project-relative + target: agents + value: .agents/skills/apm-review-panel/evals/README.md + runtime: null + scope: project + owners: + - local:packages/apm-review-panel + active_owner: local:packages/apm-review-panel + content_hash: sha256:40e7abb1fa15403a71505797e23dde8433f31ca70546657886c3f9760974bfb8 +- kind: project-relative + target: agents + value: .agents/skills/apm-review-panel/evals/fixtures/01-ship-now-pr1084-shape.json + runtime: null + scope: project + owners: + - local:packages/apm-review-panel + active_owner: local:packages/apm-review-panel + content_hash: sha256:108fc261680f17bdade3e2bdf8e64fc908d16a37ec9c29c53169b394530bdd67 +- kind: project-relative + target: agents + value: .agents/skills/apm-review-panel/evals/fixtures/01-ship-now-pr1084-shape.rendered.md + runtime: null + scope: project + owners: + - local:packages/apm-review-panel + active_owner: local:packages/apm-review-panel + content_hash: sha256:ca1fdd3cd24e0fd8ec15b37690c597921ebf1c872270166edb2727d54ee155d8 +- kind: project-relative + target: agents + value: .agents/skills/apm-review-panel/evals/fixtures/02-needs-rework-shape.json + runtime: null + scope: project + owners: + - local:packages/apm-review-panel + active_owner: local:packages/apm-review-panel + content_hash: sha256:faa41c6c83c4a7955447bcb8fc0ad5a3b8afb4235db257462f649ba0a901913e +- kind: project-relative + target: agents + value: .agents/skills/apm-review-panel/evals/fixtures/02-needs-rework-shape.rendered.md + runtime: null + scope: project + owners: + - local:packages/apm-review-panel + active_owner: local:packages/apm-review-panel + content_hash: sha256:b69cd113a428b61d8bbb9381a85261b3ee3acd3f101c08d06419e26aab03bba8 +- kind: project-relative + target: agents + value: .agents/skills/apm-review-panel/evals/render_eval.py + runtime: null + scope: project + owners: + - local:packages/apm-review-panel + active_owner: local:packages/apm-review-panel + content_hash: sha256:690baf6267775077ad011b98839eda1001e207bbae78a7a8454c4d39bb8a2864 +- kind: project-relative + target: agents + value: .agents/skills/apm-review-panel/evals/trigger-evals.json + runtime: null + scope: project + owners: + - local:packages/apm-review-panel + active_owner: local:packages/apm-review-panel + content_hash: sha256:c715ba52cd2131f294c1cf685a10995a08e4ee6971f97cf5d36e09df28d3af91 +- kind: project-relative + target: agents + value: .agents/skills/apm-spec-guardian + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/apm-spec-guardian/SKILL.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:5e2b98792b458ca946d3bfc06a1e0c661f9a0dc05a4606602eb71d7d8afae4dd +- kind: project-relative + target: agents + value: .agents/skills/apm-spec-guardian/assets/comment-template.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:8f5703aa98d1036d2418ffa45eac9d0270504dac33f3e9b1892884a9d48da1a6 +- kind: project-relative + target: agents + value: .agents/skills/apm-spec-guardian/assets/linter-checklist.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:4b71113da805c5504b4d74029f3113402044bc7e5c48abd010c0b8df3f18938b +- kind: project-relative + target: agents + value: .agents/skills/apm-spec-guardian/assets/panelist-return-schema.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:bb66b270394c386665c6b93d83befe05ce9f8206abf8d656079c29d65729fefd +- kind: project-relative + target: agents + value: .agents/skills/apm-spec-guardian/assets/synthesizer-return-schema.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:e6e2771f2fe8578e72d12e51edcc3817717f8a0bbef66d8a8a5c556f0dce06d7 +- kind: project-relative + target: agents + value: .agents/skills/apm-strategy + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/apm-strategy/SKILL.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:6c7fbc821fef930ab76764c7b33d55bd181f31cdd406cff5ce7cf14d1071c2ef +- kind: project-relative + target: agents + value: .agents/skills/apm-triage-panel + runtime: null + scope: project + owners: + - local:packages/apm-triage-panel + active_owner: local:packages/apm-triage-panel + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/apm-triage-panel/SKILL.md + runtime: null + scope: project + owners: + - local:packages/apm-triage-panel + active_owner: local:packages/apm-triage-panel + content_hash: sha256:2ef70329aefd0cbec1302aad3975f6caa0a27654d58005bba622ef1021267d1e +- kind: project-relative + target: agents + value: .agents/skills/apm-triage-panel/apm.yml + runtime: null + scope: project + owners: + - local:packages/apm-triage-panel + active_owner: local:packages/apm-triage-panel + content_hash: sha256:e8b7946f433abdc8342241483963bac2895182916ea66f5aa39497d00c7930a4 +- kind: project-relative + target: agents + value: .agents/skills/apm-triage-panel/assets/triage-template.md + runtime: null + scope: project + owners: + - local:packages/apm-triage-panel + active_owner: local:packages/apm-triage-panel + content_hash: sha256:69a027959eaec5335668e087c86babd814ba20cf21662ed524cf285b178e21d1 +- kind: project-relative + target: agents + value: .agents/skills/auth + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/auth/SKILL.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:bf1039cb63dbb968d50dbf4a9e4ceec9a4c30e40e8dd666d404f1c39eda55bc0 +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/SKILL.md + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:a9d4c3b924e7a62eaf8f968cd78df2676a49fef5b47b892d149f4590733f6816 +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/assets/final-report-template.md + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:d843d23d44c91eb488746b92b055ec0f507a646670bd63246127e9ae241037cd +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/assets/fix-prompt.md + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:02c4e450930efc7c306db7d9b6f7734af4976d72ac782ca432d2a20150a5366a +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/assets/ground-truth-table.md + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:981ebeee98596beb6a00db6edb35a8e871acdd52a938faa8d8915edfd01ba15b +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/assets/progress-diagram.md + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:72c4606899f50b0761a7683f6ab17878472bc45b18fe03869aae65e3bd6a7bb2 +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/assets/strategic-alignment-prompt.md + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:67db0bb51d6cbdafad4ea8224ab2012f02e6341b2fd4eb7a09b2a24cc9cb4d7e +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/assets/triage-prompt.md + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:51bab548ac85044400b77858c6a1554f59bda197c388d9a8546076d87380b060 +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/assets/verdict-schema.json + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:d82b7ed0791b89617be965097fce1ace9ec6f6b5a33d9a1567cdf4b3651cd83a +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/evals/.gitignore + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:f370ad043ec0c98a166fd98646c3b94280ed78bc996223b6cca2ba936f7c6781 +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/evals/README.md + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:7d0a5c983612953289a8e34c0b95995ccad7273653c4ab145c3f10b9adafa905 +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/evals/content/sweep-bug-queue.json + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:8213fc770f8f373281d310082c0e44939f7f43d6c96b9d9a45b3175b490f2a59 +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/evals/content/three-issues-mixed.json + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:40af3256645f7020019f897b4eea60d785e63d484ddad32c3238dfa37ea4007f +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/evals/evals.json + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:0b47a8ea4c49c375a575d0f44d488b65c0d797bff5022ebb79746808495dab26 +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/evals/fixtures/sweep-bug-queue.with_skill.md + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:1dfdd8942667640929715b2de55fbfb8e22abb9d0c75819a55ff96c6b4659fe1 +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/evals/fixtures/sweep-bug-queue.without_skill.md + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:83a2a11ac6dc2a188bb95a92f24dc7f94568f8af30ec4979ae85f52bd3102ee6 +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/evals/fixtures/three-issues-mixed.with_skill.md + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:5dba73e00c9e9bbdcc915b40a91738d0bef9e52b89ec8ff988dcda0f637bc5b0 +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/evals/fixtures/three-issues-mixed.without_skill.md + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:8bf17d4cdc3c10beb54bcb1348efb4a111846d49d9548ca714e4354b4eccff22 +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/evals/triggers.json + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:f195dc4b80b66f23a5fd9cfc6c60cf3fa1bba5b09b97f044d31f8dae27445470 +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/references/invariants.md + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:e2eec0a2e648819bc79c08384335e15e13000d67012641b90dfb1a70e967439a +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/references/strategic-alignment-gate.md + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:66613a8344411a2f9d5147b4acea798887bed20132ffd27bac9b3f5ba6bb65ed +- kind: project-relative + target: agents + value: .agents/skills/batch-bug-shepherd/scripts/run_evals.py + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: sha256:ecc003164e65cb002f301a7cdff9c0a2e39eddc35a81ede63da8934519e483c9 +- kind: project-relative + target: agents + value: .agents/skills/cli-logging-ux + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/cli-logging-ux/SKILL.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:b04d5282a9f21b33c75ac98c41c7eab7e562eafea8c89bbb5732d9956958fe7c +- kind: project-relative + target: agents + value: .agents/skills/cut-release + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/cut-release/SKILL.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:07c15054e54679c07c5798ccad9b4348399beb8de3aac11bc1002ad1c07b6b9a +- kind: project-relative + target: agents + value: .agents/skills/cut-release/assets/entry-sanitizer.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:b78d605f1bb17b042681b678e07a6431209ae11934ea320e0b573da313649743 +- kind: project-relative + target: agents + value: .agents/skills/cut-release/assets/pr-body-template.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:8034ad7d64a1f2a9e8677d332ffa1ebbf19071f3b30ba6ecdda660c82bb11917 +- kind: project-relative + target: agents + value: .agents/skills/cut-release/assets/semver-rubric.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:61b9a2001cbbb41360de509c4b56eb3859333cbf9ea74a896fb9e03a1d3a5244 +- kind: project-relative + target: agents + value: .agents/skills/cut-release/evals/evals.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:96f07f2e46e47930c9cf5e565f6470f4ca26aa88caa8ad1f63f0a3b80adfdb35 +- kind: project-relative + target: agents + value: .agents/skills/cut-release/scripts/bump-version.sh + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:bc382b5024f415998a3d66689a36596f0458e0a3c79370e96705477a5672a1d4 +- kind: project-relative + target: agents + value: .agents/skills/cut-release/scripts/list-changes-since-tag.sh + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:4aaacc7f61ca85be9776e921d02a67112569b370d31b581d045e0c3dc941a071 +- kind: project-relative + target: agents + value: .agents/skills/cut-release/scripts/verify-lint-mirror.sh + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:ab3f4a9ebc8f2b8c357c4cf81e0ada3be093cfc94c06703805d56c87f6fd7ecc +- kind: project-relative + target: agents + value: .agents/skills/devx-ux + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/devx-ux/SKILL.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:e66de2f844eebbfccf641b1a6fdf0ff9bb8c476364bda87266ce47f7ebfa7e5a +- kind: project-relative + target: agents + value: .agents/skills/docs-corpus-audit + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/docs-corpus-audit/SKILL.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:d20498ee8c1a38553303e23fc2f184c59fab624d1f701839f00dc7b291db3afb +- kind: project-relative + target: agents + value: .agents/skills/docs-corpus-audit/assets/panelist-return-schema.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:720e2d7a7a51b23bda19feff90072c64dc5322916218f1030e4fd9e388b34f4b +- kind: project-relative + target: agents + value: .agents/skills/docs-corpus-audit/assets/subagent-prompt-template.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:19799c05fae7873c02d6183294ebf6e5f29372bc393a19f1d400f5ed36251760 +- kind: project-relative + target: agents + value: .agents/skills/docs-corpus-audit/evals/README.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:6cd4924f82e8192f3b0c76aaa086ee6da5055f9f807dda9252842b0b59fb4c8f +- kind: project-relative + target: agents + value: .agents/skills/docs-corpus-audit/evals/content-evals.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:f498fd7fe5c53021be2738d0961bcf50a2a054589f95c43818e74b242e3c0a5b +- kind: project-relative + target: agents + value: .agents/skills/docs-corpus-audit/evals/trigger-evals.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:ebecf51a2a2b84adf7b3a5799f73f30a6b87f21a3e7970a9086aadb2c17da085 +- kind: project-relative + target: agents + value: .agents/skills/docs-corpus-audit/scripts/scan-cross-corpus-drift.sh + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:5793571642a4ab008987da66c793d878e91071dc6f0df1d3e39e4d6b30b77705 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/SKILL.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:4d136a7ccdff7b686585864b770f6ec6efd95746b21d4a7ef92fb5ceb6fec58d +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/assets/judge-prompt.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:0a767c6166c4b1bb3ea2525d652f6b7ee23ffcfc6e83bd800666e1a35fa029ea +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/content-evals.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:ff8070b95ee54eeb664ae7bd3e7fa43ed65ac0bbead3ae5752f23763f04ad979 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/run-evals.sh + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:bbcda2bf81c651235d154fbb716747c125923f4bd7302e911126ba833390da0e +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/20260527-194228/seeded-corpus/drift-install-flag/page.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:7c49ae61d391a0a8e5581b11473a3cc5d0c4151a73579c506ea334bcbacc783f +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/20260527-194228/seeded-corpus/drift-install-flag/scenario.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:2eb59c0c7efc804a6bda738fb5930a408e1edffc144f211efb9d9d7aff7d06b3 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/20260527-194228/seeded-corpus/drift-policy-reject/page.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:c96f432f2320087c3b77ceb8ec8d6e9291d1836cdf61522189ef69853bcb8dae +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/20260527-194228/seeded-corpus/drift-policy-reject/scenario.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:056c820e98a4776d2340c779d6dd6ddcd0d94ea251b7f2b8c5e8087de16939cf +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/20260527-194228/seeded-corpus/drift-registry-resolver/page.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:21d6c3fd4e031c5f4ac1f6d2e9f697dc944e3b4ba64d735d467ff3fb1e6308a8 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/20260527-194228/seeded-corpus/drift-registry-resolver/scenario.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:df77802b6769e5a284f936867f3253ff7521f9e232d99719c1899ce8ce38da65 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/20260527-194228/trigger-prompts.txt + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:a168e4843165e53388597959ab7ad5bac511fc48ceb1ff5beb5c5da377a8358d +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/claims/copilot.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:7900a8157d70297557d055ec85fce7f77800883f697467aaf5f17ba6fd4632c3 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/claims/install.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:4858c69c3c474655630f0a6ff91dbc92b12c460fcc994a14086953726109afd0 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/claims/pkgtypes.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:1075130429de6d56c4a6af7874be9b1d4ff96e51e7968044efae73608ccbcaec +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/claims/policy.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:4d3e1a19b6404bc268f775c819b1620997c347ef48247d97f655a8dd80a5db37 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/claims/registries.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:7e98df45fc8863bc608e957afee1a4a557972ad26210dfcce528261de902397f +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/copilot/evidence/docs_src_content_docs_integrations_copilot-app_md_c1.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:11a4590ed3b358df495ffa71a787f6689545dbaf1c82f750890a2a8aa7167d0d +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/copilot/evidence/docs_src_content_docs_integrations_copilot-app_md_c10.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:2d893a5cc9ff8f72e357b11645b4942fa919c498aec8bd69a514149b23e6e5b0 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/copilot/evidence/docs_src_content_docs_integrations_copilot-app_md_c11.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:5014f4c44f9ee48f8e551b6d156941973dc3e98ec2db8dac30ba40e31d6f2d09 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/copilot/evidence/docs_src_content_docs_integrations_copilot-app_md_c12.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:9aab3c4ed34e68c331cdea360c0e51ea09780c0e1ecfd025fb232d62e76b08a2 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/copilot/evidence/docs_src_content_docs_integrations_copilot-app_md_c13.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:7bd568d557621fcad5a195320ec2c0868f74816ff567fe4877d4e7d17740726b +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/copilot/evidence/docs_src_content_docs_integrations_copilot-app_md_c14.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:dfc4986521f54cbe08cd57cdc7e43136991bb4f38c87bccc98aa9c390706256d +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/copilot/evidence/docs_src_content_docs_integrations_copilot-app_md_c15.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:3a0803126c8809fdd5a552c6b986fff79c2e51a98f4152ce6f5e706aba905fc3 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/copilot/evidence/docs_src_content_docs_integrations_copilot-app_md_c2.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:1a66e59ce2d9ff29714c3b29dd16020822057808ce779093b869133345bd1038 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/copilot/evidence/docs_src_content_docs_integrations_copilot-app_md_c3.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:259f4eed7bf78bd63193f6ecf4e3cf9af3acdde3049c13c9491972cc19cf16b4 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/copilot/evidence/docs_src_content_docs_integrations_copilot-app_md_c4.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:0c13b2af4c86ba7fe57a9b9f27c521c1c0e566cde876e4d3ffba12af03a8b200 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/copilot/evidence/docs_src_content_docs_integrations_copilot-app_md_c5.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:ac81afbc6f1775ab14f80bbe9865b82035b97354c8ab42daf85dbf7fb0cea9e7 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/copilot/evidence/docs_src_content_docs_integrations_copilot-app_md_c6.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:853c8e85cf2b2bcbcfc2d2c7ac5d494e2eac02203dde2af55d833e2fc61c556d +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/copilot/evidence/docs_src_content_docs_integrations_copilot-app_md_c7.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:212341fcf309a244fa3da72e76510ef3cda443da9bedaaae114ff97aa8a8e358 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/copilot/evidence/docs_src_content_docs_integrations_copilot-app_md_c8.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:08f68cb8ceffcf73d2db9074b2739e27c96b2297a5c9592c2b829dc9395c18f8 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/copilot/evidence/docs_src_content_docs_integrations_copilot-app_md_c9.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:82dab7bb099c6b5fccf24825cf9becba0f443ae33b57415375e25eb9a4908c4f +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/copilot/judge-batch-docs_src_content_docs_integrations_copilot-app_md.txt + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:a19634cdebf04aab002382cc8d71d1ec3fcbd2867f957e43b5001e655e928618 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/install/evidence/docs_src_content_docs_reference_cli_install_md_c1.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:08c01483523348c208e3f6fe2188b967002edd59adcf98f130efb4394774403a +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/install/evidence/docs_src_content_docs_reference_cli_install_md_c10.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:dd7a632f6c4a4a1aa7f97235ae8e5e6598d0411fffd96ce99e40137b12be307f +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/install/evidence/docs_src_content_docs_reference_cli_install_md_c11.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:e3770a85c08d8047e9ea9c6d4f54b00c9c003fe4e5c6c8d7f0a113bff21358eb +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/install/evidence/docs_src_content_docs_reference_cli_install_md_c12.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:03d93269acc4fdcf85673c06bbd956298159230dbcbe06ffeedec880d518f9b1 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/install/evidence/docs_src_content_docs_reference_cli_install_md_c13.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:767c131a2eca0698a572140e0984542d5cf43d284702fcc0d07420d030c5f59a +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/install/evidence/docs_src_content_docs_reference_cli_install_md_c14.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:63bf64427c7c4621a06aff93ad411f71b38ac7e0d876467d7fe2e3101d46138f +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/install/evidence/docs_src_content_docs_reference_cli_install_md_c15.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:2f594c1afe45bdb2d0b184daa9c33f4e684a979d61127fc4d7f282f6a2daa915 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/install/evidence/docs_src_content_docs_reference_cli_install_md_c2.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:a155cc6087fbb73fa2a419f74a9ef84a1e6f5fb145fe31b43ab3b3e9f70c65c4 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/install/evidence/docs_src_content_docs_reference_cli_install_md_c3.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:9011bbd6311bee94500ac255c75739a01143c895fcb8f4dd0e831bbcd163cb10 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/install/evidence/docs_src_content_docs_reference_cli_install_md_c4.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:f52b83178fbf2e8e6dcb5815b74c9050be62273a40cc891a62321d5032b7cc71 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/install/evidence/docs_src_content_docs_reference_cli_install_md_c5.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:8ac41732ab40d31fcce05550c2b53c26baf14605a6cb7f28ba0aace494f6d85d +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/install/evidence/docs_src_content_docs_reference_cli_install_md_c6.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:23431dd26eace6040553dbec405198411064428269d2b2a10e535e2c0fd8a017 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/install/evidence/docs_src_content_docs_reference_cli_install_md_c7.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:bbcd251365a7a4f16c3f71ded2dc52902859bed50ef05b5fe3dc12daa1ffb0bf +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/install/evidence/docs_src_content_docs_reference_cli_install_md_c8.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:74706c3bfd419cb29e848930415903f45684ceb85089f1c96e307a8b431b097a +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/install/evidence/docs_src_content_docs_reference_cli_install_md_c9.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:8658f138f866c2bc9e6e396cf70bf5b5efcca010ad6681a5554b9bec219909bf +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/install/judge-batch-docs_src_content_docs_reference_cli_install_md.txt + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:61d821a41e4ef7f8d2b4bda479e7d0371e0199a4100f986ab805fe8df97388ab +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/pkgtypes/evidence/docs_src_content_docs_reference_package-types_md_c1.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:aa03612dd2db02699a80d72e0fa9cf30dc83fc0186da3bda195b9ef039744c63 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/pkgtypes/evidence/docs_src_content_docs_reference_package-types_md_c10.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:28c753a4fc182342eb70144bf6f7e4cf46f9ff296959bc2160302c724deb03a6 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/pkgtypes/evidence/docs_src_content_docs_reference_package-types_md_c11.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:eccc398bbc2f0afd4d42b8222628a36eddff02984f72cc40bc1553af559b0cf0 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/pkgtypes/evidence/docs_src_content_docs_reference_package-types_md_c12.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:8afda93ba1f2b19b87709ae4da6925b5d1f96eb5d925ee2715b88f9bee67eaa7 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/pkgtypes/evidence/docs_src_content_docs_reference_package-types_md_c13.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:fe0f6759224109c21c5721e2f681d277e06dcfe17ced2bc807495b922ca77064 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/pkgtypes/evidence/docs_src_content_docs_reference_package-types_md_c14.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:a15aa164faddfc0d7eb75b6e8393971d55ed2af1551d08d7b1f924aa146186eb +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/pkgtypes/evidence/docs_src_content_docs_reference_package-types_md_c15.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:bb1d42aa51d46a2c0274f502f250af57ffd49b030e0f742519853164e8d1184e +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/pkgtypes/evidence/docs_src_content_docs_reference_package-types_md_c2.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:562f69f0d6d40ff2ba4e7af16df7d9bee11fee0c5af5d9e03b67bfadd4bedb3a +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/pkgtypes/evidence/docs_src_content_docs_reference_package-types_md_c3.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:447a6ceb4964aad579b1a9a240d42692088330bc56de024b1818349ae5d441f5 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/pkgtypes/evidence/docs_src_content_docs_reference_package-types_md_c4.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:8698d41bb991c118140d85b176e1405c42f143885eb9e8e0852a08a350809151 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/pkgtypes/evidence/docs_src_content_docs_reference_package-types_md_c5.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:316351e399963440ec8a48366b2466eba472a01ce5b4ecfb9e75387338b862c8 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/pkgtypes/evidence/docs_src_content_docs_reference_package-types_md_c6.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:9a755529eb958a7416cd383f5c1a0579a56caa5e920e867b930966e66be28ba3 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/pkgtypes/evidence/docs_src_content_docs_reference_package-types_md_c7.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:c99428c2320ce13c2323c95fc60f642e68eedd13829653072796f71d6e7d00c5 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/pkgtypes/evidence/docs_src_content_docs_reference_package-types_md_c8.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:63b0b76dd92db3d2962d4576cd79f64b1994385bf2ea79edde59d6fac8800800 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/pkgtypes/evidence/docs_src_content_docs_reference_package-types_md_c9.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:8038f4bb1b609b55736e4244e0c0360d127ebcca561e1293a865469354a6c8fe +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/pkgtypes/judge-batch-docs_src_content_docs_reference_package-types_md.txt + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:99f87397610abe9bb7881d959bc41740563322d13af9cfa498f4a8d3854dc22b +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/policy/evidence/docs_src_content_docs_enterprise_apm-policy_md_c1.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:41cc929fa549dae9cd1113ec8269c111eeb66a93ff363f828e322e165698cd2e +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/policy/evidence/docs_src_content_docs_enterprise_apm-policy_md_c10.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:6032e38c860412389acd7a6d4a630c892189f04e4dd2153c4efb0cd868c1bad7 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/policy/evidence/docs_src_content_docs_enterprise_apm-policy_md_c11.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:b97abe20f0ce7c916848e0df01ffbfea4aaafead96d588e57035e26cd1530f34 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/policy/evidence/docs_src_content_docs_enterprise_apm-policy_md_c12.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:64dae53182bb569bcbb232ef57886f48015bd9bf1dc3ebc1d12de8a8feec3e53 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/policy/evidence/docs_src_content_docs_enterprise_apm-policy_md_c13.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:1c6f59d6bd36ae8b91689adc73bd65e215582785cf3cae8a643e116bc002083e +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/policy/evidence/docs_src_content_docs_enterprise_apm-policy_md_c14.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:ec7d388fd9b3da5719f1829917dbc5c462a2154b8c6bc9ab5893fe06bd8a1979 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/policy/evidence/docs_src_content_docs_enterprise_apm-policy_md_c15.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:5e06f4250786f406be51b42bf99d6a0f53a2ef51352b3fe2b6ea05bd5e399fcf +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/policy/evidence/docs_src_content_docs_enterprise_apm-policy_md_c2.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:7fc0acd68b9612fcf634d685ed267bd4959b6ed2993b34d4842b911d04afb05a +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/policy/evidence/docs_src_content_docs_enterprise_apm-policy_md_c3.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:d828a0859911b50f84f1ea24dd8c77ff73195af82ce3f8f2ce23f0259339fb1b +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/policy/evidence/docs_src_content_docs_enterprise_apm-policy_md_c4.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:fff64cab449a06473dfbcae9f7d63e9f138823868146d8df61552d38c1f0b1df +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/policy/evidence/docs_src_content_docs_enterprise_apm-policy_md_c5.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:7ca428b39d76a08287ffaa16c19a89c93a77c1930f4e391bda03d16fa9343461 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/policy/evidence/docs_src_content_docs_enterprise_apm-policy_md_c6.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:c8f0f1b0b29ea3eb5c5471e0a795e508aede78e1f983fee003771a95057bc07c +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/policy/evidence/docs_src_content_docs_enterprise_apm-policy_md_c7.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:fb3e636541002e8f0f569b002803f8f58feff34d9872df4a0ef3a95d81800362 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/policy/evidence/docs_src_content_docs_enterprise_apm-policy_md_c8.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:63bd17c2ca2cd6b05c475cb1dd72fb86a483606860b7f3e082d5f5e587f2d99c +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/policy/evidence/docs_src_content_docs_enterprise_apm-policy_md_c9.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:0e70ce442c1753cfde74d6b43b2dfa69f5679e693ab95efb93612ab314ea7d02 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/policy/judge-batch-docs_src_content_docs_enterprise_apm-policy_md.txt + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:03fd0106dc9ff6993c5cf98ec84ac165848442b47023d3e7bd0e2e18653275bb +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/registries/evidence/docs_src_content_docs_guides_registries_md_c1.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:0e17b582b9e1befcb48e82f1075e96b8b0a504b3b1ae3c7437217c9705d6d6e8 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/registries/evidence/docs_src_content_docs_guides_registries_md_c10.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:a5d295a4e9e65ef49bb0d955dd730d1692eabe941c53182e4b8317af92619ec7 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/registries/evidence/docs_src_content_docs_guides_registries_md_c11.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:614d5555be43e18e933561f1e2594be5fe03a9d496c707928b60e3fafe073379 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/registries/evidence/docs_src_content_docs_guides_registries_md_c12.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:ee997677db367770c27e2339ca368339026f4316323369dab675be1afc84817b +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/registries/evidence/docs_src_content_docs_guides_registries_md_c13.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:33d58c49665767f92f87c65ddc892a614bb02a3ec0dab51eee31f3e57639b954 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/registries/evidence/docs_src_content_docs_guides_registries_md_c14.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:693dbb84172320c7160432a156c1c3730ded4fb06ec5ee39d8150e82118f9ddb +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/registries/evidence/docs_src_content_docs_guides_registries_md_c15.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:4d52c6b564eea4516852a8c15076d4840d9ace06bdf70be5db7efe1efecc928c +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/registries/evidence/docs_src_content_docs_guides_registries_md_c2.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:2922bc71c4d03708e9d119b5f161eefdb3a5ed1b0dd674846fed9882661afcbc +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/registries/evidence/docs_src_content_docs_guides_registries_md_c3.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:a74b49ce7eb3ae607cd9bc6e242c19e305d48d84c0b6b388d62eb846410b5b0d +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/registries/evidence/docs_src_content_docs_guides_registries_md_c4.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:363a5c9ee672381075f3ce0be50ad73d9a9c86e4eacd4bf0ee1df681b900829f +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/registries/evidence/docs_src_content_docs_guides_registries_md_c5.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:0a9d8a3d19fefd810522e90cbe9ffbb14ca38fa83310a022a9c64fc186d327fb +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/registries/evidence/docs_src_content_docs_guides_registries_md_c6.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:3ded4bef426433b26c8f53a0b48f0cbfcb2735f4b6787815458da7e590a6f36c +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/registries/evidence/docs_src_content_docs_guides_registries_md_c7.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:7fc768249ead8d05af7b0e43c26a91db37889d56dbf9d0a6c0fdc2530628d1f6 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/registries/evidence/docs_src_content_docs_guides_registries_md_c8.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:ffb12fc71daff5a7a40351f8cbaf8136d0213dfaff4a0130796cc8749672d335 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/registries/evidence/docs_src_content_docs_guides_registries_md_c9.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:32df71cf9748cb36eca8f135ffa2de3ae078bf5df94fd9d5ea6999270c6a49d9 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/registries/judge-batch-docs_src_content_docs_guides_registries_md.txt + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:05b1f00ad9fd75e10da501a5357237bfd0d869e95cc5a051433ce92432e3156f +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/runs/proof/trigger-responses.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:393748120d700b3ac9af42f060f0681607e53177759c20eeb41f7f1b2b12078f +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/evals/trigger-evals.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:5f5f8f3404c6bd95597c7b567186c14711cc91c9f66fe590bda57cf69a84c92d +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/scripts/extract-claims.py + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:baabcb5bcd0ba4bdd7f125ac8f30f8d422089b7deb0825a473a868390e1b8441 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/scripts/retrieve-evidence.sh + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:c045f28399705c129145bf3eb808c1d0fc15fcd19f3fb6441d8d6a57950bb6e9 +- kind: project-relative + target: agents + value: .agents/skills/docs-grounding-verifier/scripts/verify-page.sh + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:36258e6ce8ff9763699f1db28927358370ea369a07e6e20a76e31819398b8ede +- kind: project-relative + target: agents + value: .agents/skills/docs-impact-architect + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/docs-impact-architect/SKILL.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:3bc13d5ac9e59b094573565ef5fd07cdbf355b6ba64235e399e2f72b2ba587c6 +- kind: project-relative + target: agents + value: .agents/skills/docs-impact-classifier + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/docs-impact-classifier/SKILL.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:3540bb7062835ba254d971be636a60763448aa6b7e0dd1b7624d959c9495e6c2 +- kind: project-relative + target: agents + value: .agents/skills/docs-impact-localizer + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/docs-impact-localizer/SKILL.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:1a46fb1da757ace60ed4bd80a308136a4c0e7ecd62447f1581a51664993dbe0e +- kind: project-relative + target: agents + value: .agents/skills/docs-sync + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/docs-sync/SKILL.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:2214d7ce6770f3e8cfe53a3b7de6efccceb5810db3e78da79de7a970a4be23bd +- kind: project-relative + target: agents + value: .agents/skills/docs-sync/assets/advisory-comment-template.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:9abbd82239925a85a2b2c08d84130259044660c8114245b7e939d4c761c0053e +- kind: project-relative + target: agents + value: .agents/skills/docs-sync/assets/classifier-return-schema.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:9e9fda0c39bfe684f367f4c89e69814d43a23e68c18a3603e9d212659c1a8689 +- kind: project-relative + target: agents + value: .agents/skills/docs-sync/assets/panelist-return-schema.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:6e661b1042e8475e15fcc943a290891f041509a903dfa770a6e0ef4bc793326d +- kind: project-relative + target: agents + value: .agents/skills/docs-sync/evals/README.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:7dbf5dfa89e78f7d5a3214b01bb5fabfee59c2979827c2a0c72f008fa9c69de5 +- kind: project-relative + target: agents + value: .agents/skills/docs-sync/evals/content-evals.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:31a8b26677f84337775b81971145e12d327c6b65dfc21b2c6f5b886462410dc2 +- kind: project-relative + target: agents + value: .agents/skills/docs-sync/evals/trigger-evals.json + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:b0da21a8a6e588676247a9a15d792ef6596389f16ae100f83fa423ee47992501 +- kind: project-relative + target: agents + value: .agents/skills/oss-growth + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/oss-growth/SKILL.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:90b5d0369032de850f2255910f4cf5caafb7d60f07bda8a2146d394bfa459918 +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/SKILL.md + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:d07254e9d0b95ea52d1a81d3262ca42da8c4c51d801eac4f11b6bc454cfb01a0 +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/apm.yml + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:ee9bb48581e62279708a9a12dc57174c9f5f7d347a384e4ca6b99c050c63879d +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/assets/mermaid-conventions.md + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:3d9c7adf95d40db00833ec087d699a7d04b3d799e9b9d07271cf2ae7d51524ca +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/assets/pr-body-template.md + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:aaeb885fbfa87364724f575eb150c9c39dc3cb60ab0459972e76185df9c98f97 +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/assets/scenario-evidence-rubric.md + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:bbb386b6f48974bbb78a380fd4674ed7a3a81810a6c2136e30c80158153596ae +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/assets/section-rubric.md + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:f07fbbc5c741c4fd30f32edec985d8301d20e3deebb1761dd92b04da51c5e42d +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/evals/.gitignore + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:dc810cf3561d22d2d47b89fd0697de46aff0068819b3514577842cb2a2a1c332 +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/evals/README.md + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:92b4529515917d25cbf5e597d1d46751e602dd4eb80be20a97ac81597f410cd0 +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/evals/content/auth-refactor.json + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:86791ada2f4983fde709237eaae9ef9c5a0f87a28f14e8b0f26b4b7e5b0e73c9 +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/evals/content/dep-bump.json + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:5136278ecf83a6c3a618176810ef62634cc988527dcdb8bd343ae83278f9e1d7 +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/evals/content/docs-only.json + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:78a6f98f939b1b8e0efa1b67a57a4b9008ddffd3b4f02fbde4f9843c4f29b39a +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/evals/evals.json + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:1d137370f60955469a089a7ef085371aa14bbb74a80675957f4d6e0a694252cc +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/evals/fixtures/auth-refactor__with_skill.md + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:b5ebacb04c4ace19e0d634e9b9104ef70b72330afb9f29ac0de07a03f1e78628 +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/evals/fixtures/auth-refactor__without_skill.md + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:81c42403d4f1406ba2689106bb6e1cdf0a74f1921681f6e096cefac0c46efd46 +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/evals/fixtures/dep-bump__with_skill.md + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:60df233958541ed42b94c1751a3d2d99324ec83f263fa0becc3abcb4a682ee91 +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/evals/fixtures/dep-bump__without_skill.md + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:1410a89d81b6745d14242181197d2d05ec5aba5078f1ad3ae702e3341a3fc798 +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/evals/fixtures/docs-only__with_skill.md + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:e4ca53c7dc8c8a65f4945e2e4e4cc878b433c5662d13c5b613d957d6d505029c +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/evals/fixtures/docs-only__without_skill.md + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:7e790d6711b8a33702c6bf0f7486e1928c58c60b4dad0a998c570a07d6a05fec +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/evals/results/.gitkeep + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/evals/triggers.json + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:6762c7f071195491700cc59ce5329ff185b0493682c93efdcf01db8fb522bbe4 +- kind: project-relative + target: agents + value: .agents/skills/pr-description-skill/scripts/run_evals.py + runtime: null + scope: project + owners: + - local:packages/pr-description-skill + active_owner: local:packages/pr-description-skill + content_hash: sha256:a1792f8cb95b4fb37a4133af079908e2a3b5643c1a81d97e4dcda399bac069a2 +- kind: project-relative + target: agents + value: .agents/skills/python-architecture + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/python-architecture/SKILL.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:f06e50b8c40d7e5a732a5405a603efd80da58de2b6f6b293f0f6b58f28eefe7b +- kind: project-relative + target: agents + value: .agents/skills/shepherd-driver + runtime: null + scope: project + owners: + - local:packages/shepherd-driver + active_owner: local:packages/shepherd-driver + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/shepherd-driver/SKILL.md + runtime: null + scope: project + owners: + - local:packages/shepherd-driver + active_owner: local:packages/shepherd-driver + content_hash: sha256:654e08a073133ba09937e4244c7ad37cdc9b586e1b081ad3e180444122c00d73 +- kind: project-relative + target: agents + value: .agents/skills/shepherd-driver/apm.yml + runtime: null + scope: project + owners: + - local:packages/shepherd-driver + active_owner: local:packages/shepherd-driver + content_hash: sha256:d53355a3862fee76c099f99eeb49ed6fd9ce1c24d648451ce9a2efdced9fda4b +- kind: project-relative + target: agents + value: .agents/skills/shepherd-driver/assets/ci-recovery-checklist.md + runtime: null + scope: project + owners: + - local:packages/shepherd-driver + active_owner: local:packages/shepherd-driver + content_hash: sha256:21801867a8459d9713e45efe68e612c4d22a2cb577c23eceab55cd855bbd23ef +- kind: project-relative + target: agents + value: .agents/skills/shepherd-driver/assets/completion-schema.json + runtime: null + scope: project + owners: + - local:packages/shepherd-driver + active_owner: local:packages/shepherd-driver + content_hash: sha256:9ede05a8c42de124688a899d599e8faacb83b4ed23fe91e317cf70c66e474eb4 +- kind: project-relative + target: agents + value: .agents/skills/shepherd-driver/assets/conflict-resolution-prompt.md + runtime: null + scope: project + owners: + - local:packages/shepherd-driver + active_owner: local:packages/shepherd-driver + content_hash: sha256:453237e56043e4008c37c5a89d9da5e77a1fa46b028ea172321dcc39a737c679 +- kind: project-relative + target: agents + value: .agents/skills/shepherd-driver/assets/copilot-classification-prompt.md + runtime: null + scope: project + owners: + - local:packages/shepherd-driver + active_owner: local:packages/shepherd-driver + content_hash: sha256:71c0ad6c5d2c422dcc7fc0c44e13d8b3b049b37c04418ade7daf9722fe115c1f +- kind: project-relative + target: agents + value: .agents/skills/shepherd-driver/assets/fold-vs-defer-rubric.md + runtime: null + scope: project + owners: + - local:packages/shepherd-driver + active_owner: local:packages/shepherd-driver + content_hash: sha256:50ece018b83c70508ca3fc4b3d267e39df2de4497938ce4aa56d3b49c594f75f +- kind: project-relative + target: agents + value: .agents/skills/shepherd-driver/assets/pr-comment-templates.md + runtime: null + scope: project + owners: + - local:packages/shepherd-driver + active_owner: local:packages/shepherd-driver + content_hash: sha256:24fe0ec64286c7b39037a5e93acfa607a43dbb4763d653f3955a1c4d65d68882 +- kind: project-relative + target: agents + value: .agents/skills/shepherd-driver/assets/shepherd-driver-prompt.md + runtime: null + scope: project + owners: + - local:packages/shepherd-driver + active_owner: local:packages/shepherd-driver + content_hash: sha256:e9cfd182845efa6c33346b504b656ad2dd2dcc271c5535d8f3de9861bb01ebff +- kind: project-relative + target: agents + value: .agents/skills/shepherd-driver/references/mergeability-gate.md + runtime: null + scope: project + owners: + - local:packages/shepherd-driver + active_owner: local:packages/shepherd-driver + content_hash: sha256:90bf5cd838d1025dd0fa223a0843be926cd509c85c91ef683cc9bfc98b5e8785 +- kind: project-relative + target: agents + value: .agents/skills/supply-chain-security + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: null +- kind: project-relative + target: agents + value: .agents/skills/supply-chain-security/SKILL.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:55ef10cd0f6b68e0db5a435a79851c055eab74d93fd552c6626df631deb51df4 +- kind: uri + target: copilot-app + value: copilot-app-db://workflows/apm--_local--apm-issue-autopilot--apm-issue-autopilot + runtime: null + scope: project + owners: + - ./packages/apm-issue-autopilot + active_owner: ./packages/apm-issue-autopilot + content_hash: null +- kind: uri + target: copilot-app + value: copilot-app-db://workflows/apm--_local--batch-bug-shepherd--batch-bug-shepherd + runtime: null + scope: project + owners: + - ./packages/batch-bug-shepherd + active_owner: ./packages/batch-bug-shepherd + content_hash: null +- kind: project-relative + target: copilot + value: .github/agents/agentic-workflows.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:d1ea2d038e2af8be11d6c95b3213b03b9777fae46f0438efa95d5a803e6c3765 +- kind: project-relative + target: copilot + value: .github/agents/apm-ceo.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:484da64428ea46a6183dffd3f30c9fc5fc5c747639c0c79e55be69dba0899323 +- kind: project-relative + target: copilot + value: .github/agents/apm-primitives-architect.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:0c8f04297b14e144f84211b5b881b099233a9847c0fca257b9aa69c4d43d1eef +- kind: project-relative + target: copilot + value: .github/agents/auth-expert.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:18264a933cba432b77d133e6ae11eee294c92ed245629af8c9b7a5bb7a9a300c +- kind: project-relative + target: copilot + value: .github/agents/cdo.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:71e7684942679f86199b6720fc69d52ef796a0ec28981250b9ad275a1ed41d31 +- kind: project-relative + target: copilot + value: .github/agents/cli-logging-expert.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:3ed7fe1a2e28e03a40311d4999ef54330908920d6515205708dd3f037abfcf0f +- kind: project-relative + target: copilot + value: .github/agents/devx-ux-expert.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:8310d130cca5bc548baf4a2a84e3c9680c9dc5d83a2718150636896ab2aa1f30 +- kind: project-relative + target: copilot + value: .github/agents/doc-analyser.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:47b1d0204904b786c19d4fe84343e86cdab6f92f862f676ba741ffe6e1385679 +- kind: project-relative + target: copilot + value: .github/agents/doc-writer.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:328a5b9ea079869b8ccd914a6e2135c204225a5eedb42f59a1ec73058f7f0b47 +- kind: project-relative + target: copilot + value: .github/agents/editorial-owner.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:9dd101a9476dd93b67da1b823cc3b649f1227168fd809b108c74f9304262d860 +- kind: project-relative + target: copilot + value: .github/agents/oss-growth-hacker.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:1cd56bb78ab37d52c50e45ab69d759f775cd49cdf35981b3dc6c4004315c6b83 +- kind: project-relative + target: copilot + value: .github/agents/performance-expert.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:5262b505660c3e8ec2064cbd2f9e2732e6cae2d65b0292abe99b90b077c26ef4 +- kind: project-relative + target: copilot + value: .github/agents/python-architect.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:9b18ed8f318755055e304edaf283435c446c1404a2199e53d2558acfb076fca2 +- kind: project-relative + target: copilot + value: .github/agents/spec-editor-synthesizer.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:dad51758c91753528f3a80e1102f5a71460b816897d650c0f0ad4eeb7b1c8928 +- kind: project-relative + target: copilot + value: .github/agents/spec-oci-editor.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:d96482cb733b0540b14ba0812fde48c07d8937ef11507b81c28a239b35b4f94a +- kind: project-relative + target: copilot + value: .github/agents/spec-pkgmgr-editor.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:42a1eb939892bdaca2f3bf139911956893d5da03f60b4cbc0642ae88c2084deb +- kind: project-relative + target: copilot + value: .github/agents/spec-swagger-editor.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:51019f52b2a2aed2a269187b58e6a867802636a79d1410153dc9b5b28d5d1b25 +- kind: project-relative + target: copilot + value: .github/agents/spec-tag-architect.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:82907265c5e7cf1ac61ad96866fa7c5683b69c8f09b7a4c5f3cc241acc9568ca +- kind: project-relative + target: copilot + value: .github/agents/supply-chain-security-expert.agent.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:8fb8cc426d6af17ba084a28b3f026c2b475b62e3ca63ed2f88b83bd823f877af +- kind: project-relative + target: copilot + value: .github/instructions/architecture.instructions.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:a24c5c8ce4f50855a10e4b6c0814ab8e6faa4bdb0aaa715f6cf7a5fe6f253121 +- kind: project-relative + target: copilot + value: .github/instructions/changelog.instructions.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:1e51ec4c74e847967962bd279dc4c6e582c5d3578490b3c28d5f3acd3e05f73e +- kind: project-relative + target: copilot + value: .github/instructions/cicd.instructions.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:08d87b7d635761cb41deb8fc71d5d83f54678de463db484afb16d2d4f8713ecb +- kind: project-relative + target: copilot + value: .github/instructions/cli.instructions.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:8e39e8d5047ce88575cb02f87c2bcede584dfef258bd86f7466c7badf136541a +- kind: project-relative + target: copilot + value: .github/instructions/doc-sync.instructions.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:bb3816254f8df6bffc6faacd556871f36903e9d7f348982f1e2de0339384c696 +- kind: project-relative + target: copilot + value: .github/instructions/encoding.instructions.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:93db7377dc896f6efecf2c5d8c5d89255a555562f468d034d64c42edd5cf46d5 +- kind: project-relative + target: copilot + value: .github/instructions/integrators.instructions.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:19919cbd04031a8a09be8aa0860aa941640b8313df74439e7bfac31310aa1b13 +- kind: project-relative + target: copilot + value: .github/instructions/linting.instructions.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:8d31137f18842570bef6c93f8396587ac691e5fc973988bd865b008b0983f90d +- kind: project-relative + target: copilot + value: .github/instructions/python.instructions.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:8acd2bb824eb8d70a1977dfef309a5a122b49320d22222dd8d3544ca08712bca +- kind: project-relative + target: copilot + value: .github/instructions/tests.instructions.md + runtime: null + scope: project + owners: + - . + active_owner: . + content_hash: sha256:b527ccaecf0e92f74d300fc9027f1bc49bb43d8ddcdd36338c1556fcde0d8b2d local_deployed_files: - .agents/skills/apm-spec-guardian - .agents/skills/apm-spec-guardian/SKILL.md @@ -435,6 +3009,7 @@ local_deployed_files: - .github/agents/spec-swagger-editor.agent.md - .github/agents/spec-tag-architect.agent.md - .github/agents/supply-chain-security-expert.agent.md +- .github/instructions/architecture.instructions.md - .github/instructions/changelog.instructions.md - .github/instructions/cicd.instructions.md - .github/instructions/cli.instructions.md @@ -661,11 +3236,11 @@ local_deployed_file_hashes: .agents/skills/docs-sync/evals/content-evals.json: sha256:31a8b26677f84337775b81971145e12d327c6b65dfc21b2c6f5b886462410dc2 .agents/skills/docs-sync/evals/trigger-evals.json: sha256:b0da21a8a6e588676247a9a15d792ef6596389f16ae100f83fa423ee47992501 .agents/skills/oss-growth/SKILL.md: sha256:90b5d0369032de850f2255910f4cf5caafb7d60f07bda8a2146d394bfa459918 - .agents/skills/python-architecture/SKILL.md: sha256:e4a209765072f75cee6dc5fbe95d9924055e2c5d59c804c8075f887795c83fa9 + .agents/skills/python-architecture/SKILL.md: sha256:f06e50b8c40d7e5a732a5405a603efd80da58de2b6f6b293f0f6b58f28eefe7b .agents/skills/supply-chain-security/SKILL.md: sha256:55ef10cd0f6b68e0db5a435a79851c055eab74d93fd552c6626df631deb51df4 .github/agents/agentic-workflows.agent.md: sha256:d1ea2d038e2af8be11d6c95b3213b03b9777fae46f0438efa95d5a803e6c3765 .github/agents/apm-ceo.agent.md: sha256:484da64428ea46a6183dffd3f30c9fc5fc5c747639c0c79e55be69dba0899323 - .github/agents/apm-primitives-architect.agent.md: sha256:6c01eab74ba18d70f21d45010d636cc6535d63cee81da12e61898d8036e0b028 + .github/agents/apm-primitives-architect.agent.md: sha256:0c8f04297b14e144f84211b5b881b099233a9847c0fca257b9aa69c4d43d1eef .github/agents/auth-expert.agent.md: sha256:18264a933cba432b77d133e6ae11eee294c92ed245629af8c9b7a5bb7a9a300c .github/agents/cdo.agent.md: sha256:71e7684942679f86199b6720fc69d52ef796a0ec28981250b9ad275a1ed41d31 .github/agents/cli-logging-expert.agent.md: sha256:3ed7fe1a2e28e03a40311d4999ef54330908920d6515205708dd3f037abfcf0f @@ -675,19 +3250,20 @@ local_deployed_file_hashes: .github/agents/editorial-owner.agent.md: sha256:9dd101a9476dd93b67da1b823cc3b649f1227168fd809b108c74f9304262d860 .github/agents/oss-growth-hacker.agent.md: sha256:1cd56bb78ab37d52c50e45ab69d759f775cd49cdf35981b3dc6c4004315c6b83 .github/agents/performance-expert.agent.md: sha256:5262b505660c3e8ec2064cbd2f9e2732e6cae2d65b0292abe99b90b077c26ef4 - .github/agents/python-architect.agent.md: sha256:7587ee7c684c61046a83dfa1b7e39d1345f2f119c3395478e3ca2dbbaaaff0e9 + .github/agents/python-architect.agent.md: sha256:9b18ed8f318755055e304edaf283435c446c1404a2199e53d2558acfb076fca2 .github/agents/spec-editor-synthesizer.agent.md: sha256:dad51758c91753528f3a80e1102f5a71460b816897d650c0f0ad4eeb7b1c8928 .github/agents/spec-oci-editor.agent.md: sha256:d96482cb733b0540b14ba0812fde48c07d8937ef11507b81c28a239b35b4f94a .github/agents/spec-pkgmgr-editor.agent.md: sha256:42a1eb939892bdaca2f3bf139911956893d5da03f60b4cbc0642ae88c2084deb .github/agents/spec-swagger-editor.agent.md: sha256:51019f52b2a2aed2a269187b58e6a867802636a79d1410153dc9b5b28d5d1b25 .github/agents/spec-tag-architect.agent.md: sha256:82907265c5e7cf1ac61ad96866fa7c5683b69c8f09b7a4c5f3cc241acc9568ca .github/agents/supply-chain-security-expert.agent.md: sha256:8fb8cc426d6af17ba084a28b3f026c2b475b62e3ca63ed2f88b83bd823f877af + .github/instructions/architecture.instructions.md: sha256:a24c5c8ce4f50855a10e4b6c0814ab8e6faa4bdb0aaa715f6cf7a5fe6f253121 .github/instructions/changelog.instructions.md: sha256:1e51ec4c74e847967962bd279dc4c6e582c5d3578490b3c28d5f3acd3e05f73e .github/instructions/cicd.instructions.md: sha256:08d87b7d635761cb41deb8fc71d5d83f54678de463db484afb16d2d4f8713ecb .github/instructions/cli.instructions.md: sha256:8e39e8d5047ce88575cb02f87c2bcede584dfef258bd86f7466c7badf136541a .github/instructions/doc-sync.instructions.md: sha256:bb3816254f8df6bffc6faacd556871f36903e9d7f348982f1e2de0339384c696 .github/instructions/encoding.instructions.md: sha256:93db7377dc896f6efecf2c5d8c5d89255a555562f468d034d64c42edd5cf46d5 - .github/instructions/integrators.instructions.md: sha256:b151e0438088d2c0b636dfc28532ecf43c3b51e5f1070a354b8d5b57c345e335 + .github/instructions/integrators.instructions.md: sha256:19919cbd04031a8a09be8aa0860aa941640b8313df74439e7bfac31310aa1b13 .github/instructions/linting.instructions.md: sha256:8d31137f18842570bef6c93f8396587ac691e5fc973988bd865b008b0983f90d - .github/instructions/python.instructions.md: sha256:45173f778eddc126c37c7ace96acd0e17adb1895031eec134ec0754638d3ba37 + .github/instructions/python.instructions.md: sha256:8acd2bb824eb8d70a1977dfef309a5a122b49320d22222dd8d3544ca08712bca .github/instructions/tests.instructions.md: sha256:b527ccaecf0e92f74d300fc9027f1bc49bb43d8ddcdd36338c1556fcde0d8b2d diff --git a/docs/src/content/docs/concepts/lifecycle.md b/docs/src/content/docs/concepts/lifecycle.md index e5c903aac..5044392c7 100644 --- a/docs/src/content/docs/concepts/lifecycle.md +++ b/docs/src/content/docs/concepts/lifecycle.md @@ -55,7 +55,7 @@ Order of operations is deterministic and worth memorizing: 4. **Integrate** -- write primitives into each target harness's native directory (`.github/`, `.claude/`, etc.) and merge MCP server configs into the harness-specific config files. 5. **Lockfile** -- write `apm.lock.yaml` with pinned versions, content hashes, and the resolved MCP server set. -`apm install` with no arguments installs from the existing manifest. `apm install ` adds a new dependency, re-runs the full pipeline, and updates both `apm.yml` and `apm.lock.yaml`. `--dry-run` runs steps 1 and 2 only and prints the plan. +`apm install` with no arguments installs from the existing manifest. `apm install ` adds a new dependency, re-runs the full pipeline, and updates both `apm.yml` and `apm.lock.yaml`. `--dry-run` runs steps 1 and 2 only and prints the plan. If that command bootstraps a new project, it keeps the generated `apm.yml` and explicit target selection while rolling back package and deployment writes. :::note[Coming from npm?] `apm install` mirrors `npm install` deliberately. The big difference: APM also runs a security scan and, if present, an org policy gate before writing deployed files. @@ -76,7 +76,11 @@ apm compile [--target ] Transforms the primitives in `.apm/` (and dependencies under `apm_modules/`) into harness-native files: `AGENTS.md` for Codex, `GEMINI.md` for Gemini, populated `.cursor/`, `.opencode/`, `.windsurf/`, `.kiro/` directories, and so on. -Most users never call `apm compile` directly. `apm install` runs it as part of the integrate phase, and `apm run` auto-compiles any `.prompt.md` files referenced by a script just before execution. Reach for `apm compile` when you want to inspect what will be deployed without changing dependencies, when you are iterating on local primitives between installs, or when you only need output for one harness. +`apm install` deploys individual primitives but does not run aggregate +compilation. Run `apm compile` explicitly to generate root or distributed +context files such as `AGENTS.md`, `CLAUDE.md`, and `GEMINI.md`. `apm run` +still compiles any `.prompt.md` files referenced by a script immediately +before execution. The `--target` flag accepts a comma-separated list (`copilot,claude,cursor,opencode,codex,gemini,windsurf,kiro,agent-skills`) or `all`. `--dry-run` prints placement decisions without writing files. `--validate` checks primitive frontmatter and structure without producing output. `--watch` re-runs compilation on every change. diff --git a/docs/src/content/docs/concepts/package-anatomy.md b/docs/src/content/docs/concepts/package-anatomy.md index e116a3ea7..7bcc9ae8d 100644 --- a/docs/src/content/docs/concepts/package-anatomy.md +++ b/docs/src/content/docs/concepts/package-anatomy.md @@ -29,7 +29,8 @@ name: my-pkg version: 1.0.0 ``` -`name` and `version` are the only required fields. +`name` and `version` are the only required fields. Both must be non-empty +strings; quote a numeric version so YAML does not parse it as a number. `apm install` will validate the manifest, generate `apm.lock.yaml`, and deploy `hello` to whatever harnesses you target. diff --git a/docs/src/content/docs/consumer/install-mcp-servers.md b/docs/src/content/docs/consumer/install-mcp-servers.md index 4ff10ea15..7ae7d257d 100644 --- a/docs/src/content/docs/consumer/install-mcp-servers.md +++ b/docs/src/content/docs/consumer/install-mcp-servers.md @@ -128,6 +128,10 @@ the gate took effect: [i] Skipped MCP config for claude, codex (active targets: copilot) ``` +On reinstall, removing a previously configured target also removes the +APM-managed server entries from that target's native config. User-authored +servers and unrelated JSON or TOML settings remain unchanged. + This single rule replaces two older ones that used to coexist: - A "directory opt-in" carve-out for Cursor / Gemini / OpenCode -- now diff --git a/docs/src/content/docs/consumer/installing-from-marketplaces.md b/docs/src/content/docs/consumer/installing-from-marketplaces.md index 36721dbf5..276937723 100644 --- a/docs/src/content/docs/consumer/installing-from-marketplaces.md +++ b/docs/src/content/docs/consumer/installing-from-marketplaces.md @@ -97,6 +97,13 @@ browse / install / update workflow works against: `git:` and `path:` entry to `apm.yml`. HTTPS registrations likewise remain HTTPS. +Marketplace publishers can route an entry through a configured APM package +registry with the entry's `registry` and semver `version` fields. Enable the +feature first with `apm experimental enable registries`; see +[Registries](../../guides/registries/). APM honors that route through the +package-registry resolver. Missing or malformed registry intent fails closed +instead of silently falling back to the plugin's Git source. + :::note[Hosted JSON is public HTTPS] Hosted `marketplace.json` URLs are public HTTPS sources: APM does not send custom auth headers. Use git-backed marketplaces for private @@ -109,7 +116,7 @@ not forward `GITHUB_APM_PAT` or `GITLAB_APM_PAT` to non-GitHub / non-GitLab hosts. Authentication falls through to the host's matching `*_APM_PAT` variable (such as `ADO_APM_PAT`) or local git credential helper when one is configured. See -[Authentication](../authentication/). +[Authentication](../../getting-started/authentication/). **Lockfile note.** Installs from a local marketplace record a local-path source in `apm.lock.yaml`. Lockfiles produced this way diff --git a/docs/src/content/docs/enterprise/lifecycle-scripts.md b/docs/src/content/docs/enterprise/lifecycle-scripts.md index e08637be9..8a2cd736d 100644 --- a/docs/src/content/docs/enterprise/lifecycle-scripts.md +++ b/docs/src/content/docs/enterprise/lifecycle-scripts.md @@ -21,7 +21,7 @@ lifecycle: Run `apm lifecycle init` to scaffold this block in the current project. Project scripts require explicit trust before they run: `apm lifecycle trust`. -See the [CLI reference](../reference/cli/lifecycle/) for all subcommands. +See the [CLI reference](../../reference/cli/lifecycle/) for all subcommands. A failing script never aborts the CLI operation. HTTP scripts dispatch in a background thread (fire-and-forget), while command scripts run synchronously @@ -38,7 +38,7 @@ and fleet-managed deployment. | Event | Fires when | |------------------|--------------------------------------| | `pre-install` | Before the install pipeline runs | -| `post-install` | After a successful install completes | +| `post-install` | After success or partial success; not after failure or dry run | | `pre-update` | Before the update pipeline runs | | `post-update` | After a successful update completes | | `pre-uninstall` | Before uninstall begins | diff --git a/docs/src/content/docs/enterprise/policy-reference.md b/docs/src/content/docs/enterprise/policy-reference.md index 5ddbd5fc1..36050eaf4 100644 --- a/docs/src/content/docs/enterprise/policy-reference.md +++ b/docs/src/content/docs/enterprise/policy-reference.md @@ -614,7 +614,7 @@ The `--policy ` flag is **audit-only today** — it works on `apm audi Policy resolves through the chain documented in [Inheritance](#inheritance) above: enterprise hub -> org -> repo override. The merge is **tighten-only**: a child can narrow allow lists, add deny entries, and escalate enforcement, but never relax a parent constraint. The full merge rule table is in [Tighten-only merge rules](#tighten-only-merge-rules). -Install-time enforcement and `apm audit --ci` both resolve the **full multi-level `extends:` chain** (enterprise hub -> org -> repo, or any depth up to `MAX_CHAIN_DEPTH = 5`). The walker fetches each parent via the same single-policy fetcher used for direct discovery, so caching, retries, and source-prefix handling are consistent across levels. Cycles (`A extends B`, `B extends A`) are detected by tracking visited refs and abort the walk with a clear error. If a parent fetch fails midway, APM merges the policies it already resolved and emits a `[!] Policy chain incomplete: unreachable, using of policies` warning so the operator learns that an upstream policy was unreachable. +Install-time enforcement and `apm audit --ci` both resolve the **full multi-level `extends:` chain** (enterprise hub -> org -> repo, or any depth up to `MAX_CHAIN_DEPTH = 5`). The walker fetches each parent via the same single-policy fetcher used for direct discovery, so caching, retries, and source-prefix handling are consistent across levels. Cycles (`A extends B`, `B extends A`) abort the walk. If any parent is unreachable, APM rejects the incomplete chain instead of enforcing a weaker subset. ### 4. What gets enforced diff --git a/docs/src/content/docs/enterprise/security.md b/docs/src/content/docs/enterprise/security.md index 7f739bcf2..e513434ba 100644 --- a/docs/src/content/docs/enterprise/security.md +++ b/docs/src/content/docs/enterprise/security.md @@ -234,7 +234,8 @@ APM records the license the package manifest *declares* (`license:` in `apm.yml` ## Path security -APM deploys files only to controlled subdirectories within the project root. +APM deploys files only to controlled subdirectories within the project root +at project scope or within a configured, managed target root at global scope. ### Path traversal prevention @@ -242,7 +243,9 @@ All deploy paths are validated before any file operation: 1. **No `..` segments.** Any path containing `..` is rejected outright. 2. **Allowed prefixes only.** Paths must start with an allowed target-integrator prefix (`.github/`, `.claude/`, `.cursor/`, `.opencode/`, `.codex/`, `.gemini/`, `.windsurf/`, `.kiro/`, `.agents/`). In addition, the local-bundle install path stages instructions for compile-only targets under `apm_modules//.apm/instructions/` with its own containment check (the resolved path must remain within `apm_modules/`) and `` validation rejecting traversal sequences and characters outside `[A-Za-z0-9._-]`. -3. **Resolution containment.** The fully resolved path must remain within the project root directory. +3. **Resolution containment.** The fully resolved path must remain within the + project root or the configured managed target root. Symlink escapes from + either root are rejected. A path must pass all three checks. Failure on any check prevents the file from being written. diff --git a/docs/src/content/docs/getting-started/authentication.md b/docs/src/content/docs/getting-started/authentication.md index 6aa08b60d..4e5826571 100644 --- a/docs/src/content/docs/getting-started/authentication.md +++ b/docs/src/content/docs/getting-started/authentication.md @@ -15,7 +15,7 @@ APM resolves tokens per `(host, port, org)` pair. For each dependency, it walks 3. **Generic hosts** (other FQDNs such as Bitbucket): host-specific **git credential helper** or unauthenticated/public access -- **no** GitHub or GitLab platform env vars. Azure DevOps uses its own chain (`ADO_APM_PAT` -> Azure CLI bearer). See [Azure DevOps](#azure-devops). -If the resolved token fails for the target host, APM retries with git credential helpers on paths that support it. If nothing matches, APM attempts unauthenticated access where the host exposes public repos (not *ghe.com* Data Residency). +If the resolved token fails for the target host, APM retries with git credential helpers on paths that support it. If nothing matches, APM attempts unauthenticated access where the host exposes public repos (not *ghe.com* Data Residency). Before an unauthenticated attempt, APM removes inherited Git token and authorization-header environment settings. Results are cached per-process for each `(host, port, org)` key. All token-bearing requests use HTTPS. @@ -234,7 +234,10 @@ header, never embedded in the repository URL. [!] Consider unsetting the stale variable. ``` -This fallback applies to all ADO operations including `apm install --update`. If you have a stale `ADO_APM_PAT` but an active `az login` session, `apm install --update` will succeed transparently via the bearer retry. +This fallback applies to all ADO operations, including semver tag enumeration, +marketplace version resolution, and `apm install --update`. If you have a stale +`ADO_APM_PAT` but an active `az login` session, the ref lookup succeeds +transparently via the bearer retry. If both `ADO_APM_PAT` and the `az` bearer fail, APM emits: @@ -267,7 +270,7 @@ APM must classify a host as GitLab to use **GitLab REST v4** (for example `marke |--------|---------| | `GITLAB_HOST` | Environment variable for one self-managed GitLab FQDN (e.g. `git.company.com`) | | `APM_GITLAB_HOSTS` | Environment variable for several self-managed GitLab FQDNs, comma-separated | -| `type: gitlab` | Manifest object-form hint for one bespoke GitLab host | +| `type: gitlab` | Backend/API routing hint for one dependency; does not authorize global GitLab tokens | `gitlab.com` is detected automatically. For a single dependency on a bespoke hostname, use object form instead of a hostname convention: @@ -277,7 +280,7 @@ hostname, use object form instead of a hostname convention: type: gitlab ``` -For GitLab-class hosts, resolved credentials follow **`GITLAB_APM_PAT` → `GITLAB_TOKEN`** and then **`git credential fill`** (see [GitLab-class hosts](#gitlab-class-hosts-gitlabcom-gitlab_host-apm_gitlab_hosts) under [Token lookup](#token-lookup)). GitHub PAT env vars are not used on GitLab. Use a GitLab personal or project access token with API read access where your policy requires it. +For `gitlab.com` and hosts explicitly trusted through `GITLAB_HOST` or `APM_GITLAB_HOSTS`, credentials follow **`GITLAB_APM_PAT` → `GITLAB_TOKEN`** and then **`git credential fill`** (see [GitLab-class hosts](#gitlab-class-hosts-gitlabcom-gitlab_host-apm_gitlab_hosts) under [Token lookup](#token-lookup)). `type: gitlab` selects backend/API routing only; other hinted hosts use host-scoped `git credential fill` or anonymous access and do not receive global GitLab tokens. GitHub PAT env vars are not used on GitLab. Use a GitLab personal or project access token with API read access where your policy requires it. ### REST headers (GitLab vs GitHub) diff --git a/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md b/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md index 26f76d86f..14dc99a3a 100644 --- a/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md +++ b/docs/src/content/docs/producer/author-primitives/hooks-and-commands.md @@ -138,17 +138,22 @@ Supported targets and where the integrator writes: | kiro | `.kiro/hooks/---.json` | one file per hook action | | opencode | -- not supported -- | silently skipped | +APM parses the source into vendor-neutral hook intent, then each target +integrator renders its native schema. Flat command entries become Claude's +required `{ "matcher": "*", "hooks": [...] }` entries in +`.claude/settings.json`. Kiro receives its current v1 standalone schema: +`{ "version": "v1", "hooks": [{ "name", "trigger", "matcher", "action" }] }`. +Kiro trigger names are PascalCase and command timeouts remain in seconds. + Copilot hook files are namespaced with the source package name to avoid collisions across installed deps; bundled scripts land alongside under `.github/hooks/scripts//`. -Claude's `settings.json` uses `additionalProperties: false` in its JSON -schema, which rejects any unknown keys (including APM's internal -`_apm_source` ownership marker). APM therefore writes a companion sidecar -file `.claude/apm-hooks.json` that stores the ownership metadata separately. -This sidecar is created and cleaned up automatically alongside -`settings.json`; it is an APM implementation detail and should not be edited -by hand. +Merged hook files contain only each target's native upstream fields. APM writes +ownership metadata to a sibling `apm-hooks.json` sidecar for Claude, Cursor, +Gemini, Codex, Windsurf, and Antigravity. The sidecar is created and cleaned up +automatically alongside the native config; it is an APM implementation detail +and should not be edited by hand. Verified against `src/apm_cli/integration/targets.py` and `src/apm_cli/integration/hook_integrator.py`. diff --git a/docs/src/content/docs/producer/compile.md b/docs/src/content/docs/producer/compile.md index 116ffaced..c75d7d85e 100644 --- a/docs/src/content/docs/producer/compile.md +++ b/docs/src/content/docs/producer/compile.md @@ -90,10 +90,12 @@ apm compile --all # every canonical target ``` Accepted values: `copilot`, `claude`, `cursor`, `opencode`, `codex`, -`gemini`, `antigravity`, `windsurf`, `kiro`, `agent-skills`, `all`. The `agent-skills` slug -is a no-op for compile (skills are deployed by `apm install`); it is -accepted in target lists for symmetry only. Unknown slugs are -rejected before any work runs. +`gemini`, `antigravity`, `windsurf`, `kiro`, `intellij`, `agent-skills`, +and `all`. The `agent-skills` slug is a no-op for compile (skills are +deployed by `apm install`); it is accepted in target lists for symmetry +only. `intellij` uses the Copilot profile for file primitives and produces +`AGENTS.md`; IntelliJ-specific integration remains MCP-only. Unknown slugs +are rejected before any work runs. Experimental targets (`hermes`, `openclaw`, `copilot-cowork`, `copilot-app`) are deployment targets for `apm install --target ` @@ -145,11 +147,10 @@ Per target, with the rules shape on disk after compile: | Add a dependency or refresh `apm_modules/` | `apm install` | | Verify deployed bytes match the lockfile | `apm audit` | -`apm install` runs compile internally as part of its integrate phase, -so a normal `apm install` on a clean checkout already produces -correct AGENTS.md / CLAUDE.md / GEMINI.md output. Reach for -`apm compile` directly when you are iterating on instructions and -do not want install's side effects. +`apm install` deploys individual primitives but does not generate aggregate +context files. On a clean checkout, run `apm install && apm compile` when you +need `AGENTS.md`, `CLAUDE.md`, or `GEMINI.md`. Run `apm compile` by itself +when iterating on instructions without install's dependency side effects. :::note[Copilot deduplication] diff --git a/docs/src/content/docs/reference/baseline-checks.md b/docs/src/content/docs/reference/baseline-checks.md index e6fe75b59..c4d0e6632 100644 --- a/docs/src/content/docs/reference/baseline-checks.md +++ b/docs/src/content/docs/reference/baseline-checks.md @@ -93,10 +93,9 @@ the [policy schema](./policy-schema/). ### `config-consistency` -- **What it verifies.** That MCP server configs derived from both `dependencies.mcp` and `devDependencies.mcp` in `apm.yml` match the `mcp_configs` baseline stored in the lockfile. -- **Fails when.** A server's resolved config differs from the lockfile, a server is in the lockfile but not the manifest, or a server is in the manifest but not the lockfile. -- **Exception.** A lockfile-only server is expected when `mcp_config_provenance` identifies the sub-package that contributed it, so those transitive servers are not treated as orphans. -- **Remediation.** Run `apm install` to reconcile the MCP configuration. +- **What it verifies.** That MCP server configs derived from the root `dependencies.mcp` and `devDependencies.mcp`, plus every current local or installed-remote package manifest bounded by the lockfile, match the `mcp_configs` baseline. +- **Fails when.** A server's resolved config differs from the lockfile, a server exists on only one side, or a locked package manifest is missing or unreadable. `mcp_config_provenance` identifies the package in lock-only diagnostics but never exempts a removed declaration. +- **Remediation.** Run `apm install` to reconcile the MCP configuration or restore an unreadable package source. ### `content-integrity` diff --git a/docs/src/content/docs/reference/cli/compile.md b/docs/src/content/docs/reference/cli/compile.md index 6fbeb27cf..b2146af7b 100644 --- a/docs/src/content/docs/reference/cli/compile.md +++ b/docs/src/content/docs/reference/cli/compile.md @@ -30,12 +30,17 @@ and are not touched by `apm compile`. See [Primitives and targets](../../../concepts/primitives-and-targets/) for the full reach map. +After a successful non-dry-run compile, APM also reconciles deployed-file +ownership with the current `targets:` declaration. If the declared target set +contracts, artifacts and lockfile entries owned by the removed target are +cleaned up with the same hash and user-edit safeguards as `apm install`. + **When you actually need it:** compile is **optional for the `copilot` target** -- GitHub Copilot natively reads `.github/instructions/*.instructions.md` (with their `applyTo:` frontmatter) that `apm install` already deploys. Compile is -**recommended for every other compile target** (`claude`, `cursor`, `codex`, -`gemini`, `opencode`, `antigravity`, `windsurf`, `kiro`), which load instructions through a +**recommended for every other context-producing target** (`claude`, `cursor`, `codex`, +`gemini`, `opencode`, `antigravity`, `windsurf`, `kiro`, `hermes`, `intellij`), which load instructions through a root context file or harness-specific rules folder that compile generates. @@ -59,16 +64,19 @@ written. Critical findings cause the command to exit non-zero. See | Flag | Description | |------|-------------| -| `-t, --target VALUE` | Target(s) to compile. Comma-separated. Values: `copilot`, `claude`, `cursor`, `opencode`, `codex`, `gemini`, `antigravity`, `windsurf`, `kiro`, `agent-skills`, `all`. | +| `-t, --target VALUE` | Target(s) to compile. Comma-separated. The help text derives its values from the same catalog used for validation, including `intellij`. | | `--all` | Compile for all canonical targets. Equivalent to `--target all` and mutually exclusive with `--target`. Preferred form. | `vscode` and `agents` are accepted as deprecated aliases for `copilot` and emit a one-line warning. `--target all` also emits a deprecation warning -- prefer `--all`. -`agent-skills` is a no-op for `compile` (skills-only deployment target); -`antigravity` is explicit-only and is not included in `all`. Use `apm install` -or `apm deps update` when you want shared `.agents/skills/` output. +Every accepted project target is also accepted by `compile`. Targets without +root-context output, including `agent-skills`, are successful no-ops. +`antigravity` is explicit-only and `intellij` is MCP-only. Neither is included in `all`. +For `intellij`, file primitives use the Copilot profile and produce `AGENTS.md`; +IntelliJ-specific integration remains MCP-only. Use `apm install` or +`apm deps update` when you want shared `.agents/skills/` output. ### Output control @@ -267,6 +275,8 @@ one-shot `apm compile`; `--output` only applies in single-file mode. | `antigravity` | `AGENTS.md` | | `windsurf` | `AGENTS.md` | | `kiro` | `AGENTS.md` | +| `hermes` | `AGENTS.md` | +| `intellij` | `AGENTS.md` | | `agent-skills` | none | | `all` | `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `.github/copilot-instructions.md` | diff --git a/docs/src/content/docs/reference/cli/init.md b/docs/src/content/docs/reference/cli/init.md index 4f859f00f..210980318 100644 --- a/docs/src/content/docs/reference/cli/init.md +++ b/docs/src/content/docs/reference/cli/init.md @@ -41,6 +41,12 @@ and [`apm marketplace init`](../marketplace/) instead. Target precedence: `--target` flag > interactive prompt > auto-detect at compile time (used with `--yes` or in non-TTY shells). +`init` resolves CLI aliases before writing `targets:`. For example, +`--target agents` and `--target vscode` both persist the canonical +`copilot` identifier, while `--target all` expands to canonical manifest +targets. The generated manifest is therefore accepted unchanged by +`apm install`. + ## Examples Initialize in the current directory with prompts: diff --git a/docs/src/content/docs/reference/cli/install.md b/docs/src/content/docs/reference/cli/install.md index af1bb7b4d..8df678c2f 100644 --- a/docs/src/content/docs/reference/cli/install.md +++ b/docs/src/content/docs/reference/cli/install.md @@ -31,7 +31,7 @@ With no arguments it installs everything from `apm.yml`. With one or more `PACKA |---|---|---| | `--update` | off | Re-resolve dependencies to the latest version or Git ref allowed by `apm.yml` and rewrite `apm.lock.yaml`. Mutually exclusive with `--frozen`. Prefer the dedicated [`apm update`](../update/) command for the consent-gated workflow. | | `--frozen` | off | Lockfile-only install: refuse to resolve anything new and fail if `apm.yml` and `apm.lock.yaml` have drifted. Mirrors `npm ci`. Mutually exclusive with `--update`. | -| `--dry-run` | off | Print the install plan without touching the filesystem. | +| `--dry-run` | off | Print the install plan without deployment writes. If a positional package bootstraps a project, the new `apm.yml` and any explicit `--target` selection are kept for the next run. | | `--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). Does **not** refresh remote refs -- use `apm update` for that. Use only after independent verification. | | `--verbose`, `-v` | off | Show per-file paths and full error context in the diagnostic summary. | | `--dev` | off | Add new packages to `devDependencies`. Dev deps install locally but are excluded from `apm pack` output. | @@ -80,7 +80,7 @@ Transport env vars: `APM_GIT_PROTOCOL` (`ssh` or `https`) sets the default initi | Flag | Default | Description | |---|---|---| -| `--skill NAME` | all | Install only named skill(s) from a skill collection (`SKILL_BUNDLE` or plugin manifest). Repeatable. For plugin manifests, `NAME` may be the skill name or manifest path, such as `skills/productivity/grill-me`. The selection is persisted to `apm.yml` and `apm.lock.yaml`. `--skill` is additive across separate installs: a later `apm install --skill X` adds `X` to the existing pin (union) rather than replacing it -- previously deployed skills are never silently removed. Use `--skill '*'` to reset to the full bundle; to drop a single skill, edit the `skills:` list in `apm.yml` and re-run `apm install`. | +| `--skill NAME` | all | Install only named skill(s) from a skill collection (`SKILL_BUNDLE` or plugin manifest). Repeatable. For plugin manifests, `NAME` may be the skill name or manifest path, such as `skills/productivity/grill-me`. A name that matches no declared skill is an install error; the diagnostic lists the available names. The selection is persisted to `apm.yml` and `apm.lock.yaml` only after a successful match. `--skill` is additive across separate installs: a later `apm install --skill X` adds `X` to the existing pin (union) rather than replacing it -- previously deployed skills are never silently removed. Use `--skill '*'` to reset to the full bundle; to drop a single skill, edit the `skills:` list in `apm.yml` and re-run `apm install`. | | `--as ALIAS` | bundle id | Override the log/display label for a local-bundle install. Only valid with a single local-bundle `PACKAGE_REF`. | ### MCP server entry (use only with `--mcp`) @@ -110,6 +110,7 @@ Transport env vars: `APM_GIT_PROTOCOL` (`ssh` or `https`) sets the default initi - **Enterprise marketplace gate.** When installing from a `*.ghe.com` marketplace, bare cross-repo `repo:` fields (e.g. `repo: owner/repo`) are refused before any network request runs, preventing dependency-confusion attacks. Host-qualify the field to proceed: `repo: corp.ghe.com/owner/repo` for an enterprise dep, or `repo: github.com/owner/repo` for a declared cross-host dep. - **Security scan.** Source files are scanned for hidden Unicode and other tag-character / bidi-override patterns before deployment. Critical findings block the package; the install exits `1`. Use `--force` to deploy anyway, or run `apm audit --strip` first to remediate. - **Diagnostic summary.** Output is grouped at the end (collisions, replacements, warnings, errors) instead of inline. Use `--verbose` to expand individual file paths. +- **Declared plugin components.** Every path explicitly listed under a recognized plugin manifest's `agents`, `skills`, `commands`, or `hooks` field must resolve inside that plugin root. A missing or escaping path fails before deployment and lockfile commit; remove the declaration or add the component, then reinstall. Omitted fields and empty lists remain valid. - **Default registry routing.** When a default registry is configured (project `registries.default` in `apm.yml` or `registry..default true` in `~/.apm/config.json`), unscoped `owner/repo#ref` shorthand deps passed to `apm install` route to the registry instead of GitHub. A `#` selector is required; omitting it exits `1`. The selector may be a semver range (`^1.0.0`), an exact version (`1.2.3`), or a non-semver label (`main`, `stable`, `v1.4.2`) -- the registry exact-matches non-semver selectors against its published version list. GitHub probe is skipped for these deps; use the `git:` URL form in `apm.yml` to force the GitHub path (e.g., `- git: https://github.com/owner/repo.git`). ## Examples diff --git a/docs/src/content/docs/reference/cli/lock.md b/docs/src/content/docs/reference/cli/lock.md index 03ff02a7d..304de426d 100644 --- a/docs/src/content/docs/reference/cli/lock.md +++ b/docs/src/content/docs/reference/cli/lock.md @@ -85,7 +85,7 @@ apm lock export [OPTIONS] | `--global`, `-g` | off | Read the user-scope (`~/.apm/`) lockfile instead of the current project. | | `--timestamp TS` | auto | Pin the SBOM timestamp (ISO 8601, e.g. `2024-06-01T00:00:00+00:00`) for reproducible output. Defaults to `SOURCE_DATE_EPOCH`, then the lockfile's `generated_at`. | -Component identity is a Package URL (`pkg:github//@` for git deps, `pkg:oci/@` for registry deps, `pkg:generic/@` for local primitives), and the declared license is passed through verbatim (or `NOASSERTION` when undeclared). Output is deterministic -- components sorted by purl with a pinned timestamp -- so two runs are byte-identical. Credentials in recorded URLs are scrubbed. The diagnostic line routes to stderr, so `apm lock export | jq` stays clean. See [Inventory export (SBOM)](../../../enterprise/security/#inventory-export-sbom) for the full model. +Component identity is a Package URL (`pkg:github//@` for git deps, `pkg:oci/@` for registry deps, `pkg:generic/@` for local primitives), and the declared license is passed through verbatim (or `NOASSERTION` when undeclared). Output is deterministic -- components sorted by purl with a pinned timestamp -- so two runs are byte-identical. Credentials in recorded URLs are scrubbed. Diagnostics and update notifications route to stderr from process startup, so `apm lock export | jq` stays clean. See [Inventory export (SBOM)](../../../enterprise/security/#inventory-export-sbom) for the full model. Export a CycloneDX SBOM to a file: diff --git a/docs/src/content/docs/reference/cli/runtime.md b/docs/src/content/docs/reference/cli/runtime.md index 138382169..b8cc99ff8 100644 --- a/docs/src/content/docs/reference/cli/runtime.md +++ b/docs/src/content/docs/reference/cli/runtime.md @@ -23,6 +23,11 @@ It does not install APM packages and does not deploy primitives. For that, see [ A runtime here is the AI CLI itself (`copilot`, `codex`, `llm`, `gemini`) -- the program that consumes the prompts and skills APM deploys. For the broader concept of harness targets that receive primitives, see [Primitives and targets](../../../concepts/primitives-and-targets/). +Workflow runtime adapters enforce wall-clock deadlines while output streams: +600 seconds for Copilot and 300 seconds for Codex. On expiry, APM terminates +and reaps the process instead of waiting for stdout to close first. The LLM +adapter has no fixed deadline. + ## Subcommands | Command | Purpose | diff --git a/docs/src/content/docs/reference/cli/update.md b/docs/src/content/docs/reference/cli/update.md index 7db2a237d..95681d802 100644 --- a/docs/src/content/docs/reference/cli/update.md +++ b/docs/src/content/docs/reference/cli/update.md @@ -98,6 +98,7 @@ apm update - **Consent gate.** The prompt defaults to **No**. Without `--yes`, declining (or running in a non-interactive context) aborts with a clean exit; the manifest, lockfile, and workspace are untouched. - **No partial consent.** A single prompt covers both revision-pin manifest rewrites and the normal update plan; declining leaves everything unchanged. - **`--dry-run` skips the prompt.** It computes and prints the plan, including revision-pin SHA/tag rewrites, but never writes and never asks. +- **Target contraction is reconciled.** A successful update removes unchanged dependencies' deployed files and lockfile ownership for targets no longer declared in `apm.yml`, even when no dependency ref changes. ## Back-compat: `apm update` used to be the self-updater diff --git a/docs/src/content/docs/reference/lockfile-spec.md b/docs/src/content/docs/reference/lockfile-spec.md index 0abcbe5f1..2a7057491 100644 --- a/docs/src/content/docs/reference/lockfile-spec.md +++ b/docs/src/content/docs/reference/lockfile-spec.md @@ -99,6 +99,12 @@ mcp_configs: transitive-server: type: stdio command: local-server +mcp_target_servers: + codex: + - github + copilot: + - github + - transitive-server mcp_config_provenance: transitive-server: local-package lsp_servers: @@ -124,7 +130,8 @@ local_deployed_file_hashes: | `dependencies` | list | yes | Resolved APM packages. See [per-entry fields](#per-entry-fields). | | `mcp_servers` | list of strings | no | Names of MCP servers managed as of the last install or update, including transitively contributed servers. | | `mcp_configs` | map | no | `server_name -> resolved config dict` baseline used to detect MCP drift. | -| `mcp_config_provenance` | map | no | `server_name -> declaring package` for transitively contributed MCP servers. Used by `config-consistency` to distinguish transitive entries from orphans. | +| `mcp_target_servers` | map of string lists | no | `target -> server names` for MCP entries APM successfully wrote. Reinstall uses this ownership record to remove only APM-managed entries when a target is dropped. Older lockfiles without this field adopt an existing self-defined native entry only when it exactly matches the stored `mcp_configs` baseline; registry-resolved and user-edited entries remain unowned. | +| `mcp_config_provenance` | map | no | `server_name -> declaring package` for transitively contributed MCP servers. Used to identify the former owner in `config-consistency` diagnostics; it never exempts a lock-only entry. | | `lsp_servers` | list of strings | no | Names of LSP servers declared in the manifest as of the last install or update. | | `lsp_configs` | map | no | `server_name -> resolved config dict` baseline used to detect LSP drift. | | `local_deployed_files` | list | no | Files this project itself contributes (sources its own primitives). Reinstall reconciles these paths with the same target rules as per-dependency `deployed_files`. See [self entry](#self-entry). | @@ -152,7 +159,7 @@ Each item in `dependencies` describes one resolved package. | `package_type` | string | no | Kind of package: `apm_package`, `skill_bundle`, `claude_skill`, `hook_package`, `hybrid`, `marketplace_plugin`. Drives target placement. | | `skill_subset` | list of strings | no | For `skill_bundle` packages: the sorted subset of skill names the manifest selected. Empty means "all". | | `target_subset` | list of strings | no | Sorted target names selected by a dependency's `targets:` subset. Empty means "all active install targets". | -| `deployed_files` | list of strings | no | Project-relative paths APM wrote for this dep. Sorted. Powers `prune` and `audit`'s file-presence check. When the consumer manifest declares targets, reinstall preserves entries for other declared, gated, or dynamic targets and removes entries outside that target universe. Without a declared target set, reinstall preserves prior other-target entries. | +| `deployed_files` | list of strings | no | Project-relative paths APM wrote for this dep. Sorted. Powers `prune` and `audit`'s file-presence check. A shared path has one canonical package owner; uninstall transfers ownership to a surviving provider. When the consumer manifest declares targets, reinstall preserves entries for other declared, gated, or dynamic targets and removes entries outside that target universe. Without a declared target set, reinstall preserves prior other-target entries. | | `deployed_file_hashes` | map | no | `path -> sha256` for the files in `deployed_files`. Powers `audit`'s content-integrity check. Hashed over canonical content -- UTF-8 text is normalized CRLF -> LF (bare CR preserved) so the hash is the same whether git checks the file out with Windows or POSIX line endings; binary is hashed raw. Directory entries (trailing `/`) have no hash. | | `exec_status` | string | no | Executable-trust state of this dep's executable primitives, set by the install-time gate via the shared deny-wins resolver. One of `deployed` (trusted and materialized), `gated_pending_approval` (present but parked until approved), `denied` (blocked by an org/user deny), or `absent` (declares no executables). Consumed by `audit`'s `required-executable-untrusted` signal; see [Executable approval](./cli/approve/). | | `source` | string | no | `"local"` for path dependencies, `"registry"` for dedicated-registry resolutions. Absent for Git deps. | @@ -310,8 +317,11 @@ Orphan detection works in two directions: `lockfile_version`. APM refuses to operate on a lockfile whose version it does not recognize, and will instruct the user to upgrade or regenerate. -A lockfile that fails to parse is treated as absent - APM logs the error and, -for non-frozen installs, proceeds to regenerate from `apm.yml`. +Invalid YAML, an unsupported explicit version, an empty or non-mapping root, +malformed container fields, and invalid deployment rows fail closed before APM +constructs lock state. Pre-versioned legacy files migrate as v1 inputs. Fix or +remove other invalid files explicitly; APM does not silently replace them with +an empty lockfile. ## Example diff --git a/docs/src/content/docs/reference/manifest-schema.md b/docs/src/content/docs/reference/manifest-schema.md index 051a79cd6..881847e25 100644 --- a/docs/src/content/docs/reference/manifest-schema.md +++ b/docs/src/content/docs/reference/manifest-schema.md @@ -43,6 +43,7 @@ A conforming manifest MUST be a YAML mapping at the top level with the following ```yaml # apm.yml +$schema: # OPTIONAL contract selector name: # REQUIRED version: # REQUIRED description: @@ -68,6 +69,14 @@ marketplace: # OPTIONAL; marketplace authoring Two fields are REQUIRED at parse time: `name` and `version`. All other fields are OPTIONAL. Unknown top-level keys MUST be preserved by writers but MAY be ignored by resolvers. +The standard `$schema` key negotiates the manifest contract. Omit it for the +current APM working draft. Set it to +`https://microsoft.github.io/apm/specs/schemas/manifest-v0.1.schema.json` for +the normative OpenAPM v0.1 shape. Unknown schema identities fail closed; APM +does not interpret a working-draft manifest as v0.1. Under explicit v0.1, +`registries` follows the normative string-or-object registry map rather than +the working draft's named map plus `default` selector. + The `marketplace:` block is the source for `apm pack`'s marketplace output. Repositories that do not publish a marketplace omit it entirely. See [Section 7](#7-marketplace-authoring-block). Newly initialised projects (`apm init`) are scaffolded by the CLI; see [`apm init`](./cli/init/) for the templates. @@ -457,7 +466,7 @@ Marketplace dependency (resolved at install time): | `marketplace` | `string` | REQUIRED | `^[a-zA-Z0-9._-]+$` | Registered marketplace name. | | `version` | `string` | OPTIONAL | Semver range or exact version (e.g. `~2.1.0`, `^2.0`, `>=1.4`, `2.1.0`) | Version constraint resolved against git tags on the marketplace repository. When omitted the marketplace entry's default ref is used. | -The `marketplace` key is mutually exclusive with `git`, `path`, `registry`, and `id`; combining them raises a parse error. Unknown keys in a marketplace entry are rejected. During dependency resolution the resolver calls `resolve_marketplace_plugin()` to look up the plugin in the marketplace's `marketplace.json` and replace the entry with a concrete git reference (owner/repo, ref, and optional virtual path). +The `marketplace` key is mutually exclusive with `git`, `path`, `registry`, and `id`; combining them raises a parse error. Unknown keys in a marketplace entry are rejected. During dependency resolution the resolver calls `resolve_marketplace_plugin()`. A plugin entry that declares `registry` plus a semver `version` becomes a registry-sourced dependency using its declared owner/repo repository identity. Other entries become concrete Git coordinates (owner/repo, ref, and optional virtual path). When `version` is specified and is a semver range or bare version number (e.g. `~2.1.0`, `^2.0`, `2.1.0`), the resolver lists git tags on the marketplace repository matching the `{name}--v{version}` convention, filters to those satisfying the constraint, and resolves to the highest matching tag. If no tag satisfies an explicit semver range, resolution fails with a `NoMatchingVersionError`. A bare version with no matching tag falls back to using the value as a raw git ref. Pre-release versions (e.g. `2.0.0-beta.1`) are excluded from semver-range resolution; target them explicitly as raw git refs. When `version` is a raw git ref (e.g. `v2.0.0`, `main`, or a commit SHA), it is used as a direct ref override without tag resolution. diff --git a/docs/src/content/docs/reference/package-types.md b/docs/src/content/docs/reference/package-types.md index 11612a671..f49f15435 100644 --- a/docs/src/content/docs/reference/package-types.md +++ b/docs/src/content/docs/reference/package-types.md @@ -211,6 +211,13 @@ the appropriate runtime directory via `_map_plugin_artifacts`. Use `--skill` to cherry-pick plugin skills by leaf name or manifest path, such as `skills/productivity/grill-me`. +Declared component paths are requirements, not hints. If an `agents`, +`skills`, `commands`, or `hooks` entry is missing or escapes the plugin +root, install exits non-zero before deployment or lockfile commit. Likewise, +`--skill` exits non-zero when none of the manifest-declared skills match. +Omit an optional field or use an empty list when the plugin has no component +of that type. + **When to choose:** you already have a Claude plugin and want APM to consume it without restructuring. diff --git a/docs/src/content/docs/reference/policy-schema.md b/docs/src/content/docs/reference/policy-schema.md index 2b2780c29..051efee1a 100644 --- a/docs/src/content/docs/reference/policy-schema.md +++ b/docs/src/content/docs/reference/policy-schema.md @@ -67,7 +67,10 @@ The `` accepts: | `executables` | object | see section | no | Org ceiling for executable-primitive trust (hooks, bin, self-defined MCP, canvas). See [executables](#executables). | | `bin_deploy` | object | see section | no | DEPRECATED alias folded into `executables.deny` (bin-scoped). See [bin_deploy](#bin_deploy). | -Unknown top-level keys produce a warning, never an error -- so newer policy files load on older clients. +Unknown top-level keys produce a warning, never an error -- so newer policy +files load on older clients. `apm policy status --json` returns these in the +`warnings` array. Known fields with the wrong native YAML type are rejected +instead of being silently replaced by defaults. ## Enforcement modes @@ -265,6 +268,8 @@ turned them on. - `https://...` -- a direct URL. For supply-chain safety, `extends:` references are pinned to the **leaf policy's host** -- a policy fetched from `github.com` cannot extend one on `evil.example.com`. +If any parent is unreachable, the chain is incomplete and enforcement fails +closed. APM never applies the weaker subset that happened to resolve. ### Merge rules @@ -280,6 +285,7 @@ inherited list (see the tri-state table below). | `*.allow` lists | Set intersection. `null` is transparent (no opinion). | | `*.deny` / `require` lists | Union, deduplicated, parent order preserved. Omitting the field (or setting it to `null`) is transparent -- the parent value passes through unchanged. `[]` is an explicit empty override. | | `dependencies.max_depth` | `min(parent, child)`. | +| `manifest.require_explicit_includes` | Logical OR; once enabled, descendants cannot relax it. | | `dependencies.require_resolution` | Stricter wins (`block` > `policy-wins` > `project-wins`). | | `dependencies.require_pinned_constraint` | Logical OR -- once a parent enables it, child cannot relax. | | `mcp.self_defined` | Stricter wins (`deny` > `warn` > `allow`). | diff --git a/docs/src/content/docs/troubleshooting/install-failures.md b/docs/src/content/docs/troubleshooting/install-failures.md index c14f9aeaa..7ea9e2b2c 100644 --- a/docs/src/content/docs/troubleshooting/install-failures.md +++ b/docs/src/content/docs/troubleshooting/install-failures.md @@ -220,6 +220,8 @@ apm install The cache short-circuits already-downloaded packages and the integrate phase overwrites partially-deployed files. +If resolution rejects a cyclic dependency graph, fix the package manifests and run `apm install` again. APM rolls back only the package snapshots staged by the rejected resolution, so no manual `apm_modules/` deletion is required. + If files in `apm_modules/` or under target harness directories look corrupt, force a fresh deploy by combining cache bypass with overwrite: ```bash diff --git a/packages/apm-guide/.apm/skills/apm-usage/authentication.md b/packages/apm-guide/.apm/skills/apm-usage/authentication.md index 9a8124165..e74705b64 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/authentication.md +++ b/packages/apm-guide/.apm/skills/apm-usage/authentication.md @@ -16,6 +16,9 @@ APM checks these sources in order, using the first valid token found: APM checks the active `gh` CLI account before invoking OS credential helpers. This reduces ambiguous multi-account prompts on hosts like github.com. If the `gh` CLI is not installed or no account is active, APM skips this step silently and continues to `git credential fill`. +Unauthenticated public-repository retries use a fresh Git environment with +inherited token and authorization-header settings removed. + For multi-account Git Credential Manager setups, see the [Multi-account Git Credential Manager](https://microsoft.github.io/apm/getting-started/authentication/#multi-account-git-credential-manager) section in the main authentication guide. ## Marketplace transport @@ -36,9 +39,12 @@ object-form dependency with `type: gitlab`: type: gitlab ``` -GitLab credentials use `GITLAB_APM_PAT`, then `GITLAB_TOKEN`, then host -credentials. GitHub PAT variables are not used for GitLab-class hosts. See the -main [authentication guide](https://microsoft.github.io/apm/getting-started/authentication/) +`GITLAB_APM_PAT` and `GITLAB_TOKEN` apply only to `gitlab.com` and hosts trusted +through `GITLAB_HOST` or `APM_GITLAB_HOSTS`. `type: gitlab` selects backend/API +routing only; other hinted hosts use host-scoped `git credential fill` or +public access and do not receive global GitLab tokens. GitHub PAT variables are +not used for GitLab-class hosts. +See the main [authentication guide](https://microsoft.github.io/apm/getting-started/authentication/) for the full host-class precedence rules. ## Per-org setup @@ -95,7 +101,8 @@ ADO marketplace URL authoring. **Finding your tenant ID:** visit `https://dev.azure.com/{org}/_settings/organizationAad`, or run `az login` and inspect `az account show --query tenantId -o tsv`. -If `ADO_APM_PAT` is set but ADO returns 401, APM silently retries with the `az` bearer and warns: +If `ADO_APM_PAT` is set but ADO returns 401, APM silently retries with the `az` +bearer for clone, preflight, semver tag, and marketplace ref resolution, then warns: `[!] ADO_APM_PAT was rejected for {host} (HTTP 401); fell back to az cli bearer.` When auth fails entirely, APM prints a targeted diagnostic (not a generic "not accessible" diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 152404620..f85d77698 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -4,13 +4,13 @@ | Command | Purpose | Key flags | |---------|---------|-----------| -| `apm init [NAME]` | Initialize a new APM project | `-y` skip prompts, `--plugin` plugin authoring mode, `--marketplace` seed apm.yml with a `marketplace:` block. After init, Next Steps contextually suggests `agentrc init` (if agentrc is in PATH) or prints a tip link when no agent instruction files exist. | +| `apm init [NAME]` | Initialize a new APM project | `-y` skip prompts, `--target` comma-separated targets (CLI aliases such as `agents` and `vscode` are persisted as canonical `copilot`, so the generated manifest is immediately installable), `--plugin` plugin authoring mode, `--marketplace` seed apm.yml with a `marketplace:` block. After init, Next Steps contextually suggests `agentrc init` (if agentrc is in PATH) or prints a tip link when no agent instruction files exist. | ## Dependency management | Command | Purpose | Key flags | |---------|---------|-----------| -| `apm install [PKGS...]` | Install APM and MCP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)) | `--update` (deprecated; prefer `apm update`) refresh refs, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite (does NOT refresh refs; use `apm update` for that), `--frozen` CI-safe install that fails fast when `apm.lock.yaml` is missing or out of sync with `apm.yml` (mutually exclusive with `--update`; structural presence check only -- use `apm audit` for SHA integrity), `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; highest-priority entry in the resolution chain `--target` > apm.yml `targets:` > auto-detect; `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:` so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; 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`, `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skill(s) from a skill collection (SKILL_BUNDLE or plugin manifest; repeatable; plugin manifests accept a leaf name or manifest path; persisted in apm.yml; 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 (NAME goes through the same `--target` > `targets:` > auto-detect resolver as APM packages, 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) | +| `apm install [PKGS...]` | Install APM and MCP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)) | `--update` (deprecated; prefer `apm update`) refresh refs, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite (does NOT refresh refs; use `apm update` for that), `--frozen` CI-safe install that fails fast when `apm.lock.yaml` is missing or out of sync with `apm.yml` (mutually exclusive with `--update`; structural presence check only -- use `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; a newly bootstrapped `apm.yml` and explicit targets are kept), `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; highest-priority entry in the resolution chain `--target` > apm.yml `targets:` > auto-detect; `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:` so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; 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`, `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skill(s) from a skill collection (SKILL_BUNDLE or plugin manifest; repeatable; plugin manifests accept a leaf name or manifest path; zero matches fail with available names; persisted in apm.yml only on success; 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 (NAME goes through the same `--target` > `targets:` > auto-detect resolver as APM packages, 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 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 (accepts `owner/repo` or `name@marketplace`) | `--dry-run`, `-g` global | | `apm prune` | Remove orphaned packages | `--dry-run` | @@ -63,7 +63,13 @@ If no `--target`, no `targets:` in `apm.yml`, and no harness signal is present, | Command | Purpose | Key flags | |---------|---------|-----------| -| `apm compile` | Compile agent context | `-o` output, `-t` target (comma-separated; resolution chain `--target` > apm.yml `targets:` > auto-detect), `--all` compile for every canonical target (preferred over deprecated `--target all`), `-g`/`--global` (read global instructions from `~/.apm/apm_modules/`, write user-scope root files; cannot combine with project-output flags such as `--target`, `--all`, `--watch`, `--root`, or `--output`; critical hidden-character findings stop the write and exit 1), `--chatmode`, `--dry-run`, `--no-links`, `--watch`, `--validate`, `--single-agents`, `-v` verbose, `--local-only`, `--clean`, `--with-constitution/--no-constitution`, `--force-instructions` / `--no-dedup` (opt out of Claude/Copilot deduplication), `--root DIR` redirect generated artifacts under DIR while sources resolve from `$PWD` (mirrors `pip install --target`; not valid with `--watch`) | +| `apm compile` | Compile agent context | `-o` output, `-t` target (comma-separated; resolution chain `--target` > apm.yml `targets:` > auto-detect; `intellij` is accepted and uses the Copilot profile for file primitives), `--all` compile for every canonical target (preferred over deprecated `--target all`), `-g`/`--global` (read global instructions from `~/.apm/apm_modules/`, write user-scope root files; cannot combine with project-output flags such as `--target`, `--all`, `--watch`, `--root`, or `--output`; critical hidden-character findings stop the write and exit 1), `--chatmode`, `--dry-run`, `--no-links`, `--watch`, `--validate`, `--single-agents`, `-v` verbose, `--local-only`, `--clean`, `--with-constitution/--no-constitution`, `--force-instructions` / `--no-dedup` (opt out of Claude/Copilot deduplication), `--root DIR` redirect generated artifacts under DIR while sources resolve from `$PWD` (mirrors `pip install --target`; not valid with `--watch`) | +| `apm compile` | Compile agent context; after a successful write, reconcile deployed artifacts and lockfile ownership when the declared target set contracts | `-o` output, `-t` target (comma-separated; resolution chain `--target` > apm.yml `targets:` > auto-detect), `--all` compile for every canonical target (preferred over deprecated `--target all`), `-g`/`--global` (read global instructions from `~/.apm/apm_modules/`, write user-scope root files; cannot combine with project-output flags such as `--target`, `--all`, `--watch`, `--root`, or `--output`; critical hidden-character findings stop the write and exit 1), `--chatmode`, `--dry-run`, `--no-links`, `--watch`, `--validate`, `--single-agents`, `-v` verbose, `--local-only`, `--clean`, `--with-constitution/--no-constitution`, `--force-instructions` / `--no-dedup` (opt out of Claude/Copilot deduplication), `--root DIR` redirect generated artifacts under DIR while sources resolve from `$PWD` (mirrors `pip install --target`; not valid with `--watch`) | + +`apm install` deploys individual primitives but does not generate aggregate +root context files. Run `apm compile` explicitly for `AGENTS.md`, `CLAUDE.md`, +or `GEMINI.md`; `apm run` separately compiles referenced prompt files at +execution time. `apm compile --watch` live-reloads `apm.yml`: editing `target:` / `targets:` mid-session takes effect on the next file event without restarting the watcher. The CLI `--target` flag, when passed to `apm compile --watch`, still outranks `apm.yml`. Re-resolution is gated on the changed file's basename being `apm.yml`, so `.instructions.md` edits do not pay an extra resolver round-trip and a stray `backup_apm.yml` cannot trigger a reload. `--clean` is ignored in watch mode and the watcher prints an explicit `[!]` warning at startup (`--clean is ignored in watch mode; run 'apm compile --clean' separately to remove orphaned outputs.`); run `apm compile --clean` separately between watch sessions to remove orphans. @@ -106,7 +112,7 @@ When `apm install --target copilot` has already deployed instructions to `.githu | `apm lifecycle trust` | Trust `apm.yml` `lifecycle:` at its current contents so project scripts run on install | -- | | `apm lifecycle untrust` | Revoke trust for `apm.yml` `lifecycle:`; project scripts will stop running | -- | -Lifecycle scripts fire on six events: `pre-install`, `post-install`, `pre-update`, `post-update`, `pre-uninstall`, `post-uninstall`. Script files are discovered from three sources (additive): policy (`/etc/apm/policy.d/*.json`, JSON), user (`~/.apm/apm.yml`, YAML), project (`apm.yml` `lifecycle:` at repo root, YAML). Two script types: `command` (shell via subprocess, event JSON on stdin) and `http` (HTTPS POST). Script output is appended to `~/.apm/logs/scripts.log`. See the [Lifecycle scripts](/apm/enterprise/lifecycle-scripts/) guide for full documentation. +Lifecycle scripts fire on six events: `pre-install`, `post-install`, `pre-update`, `post-update`, `pre-uninstall`, `post-uninstall`. `post-install` fires only after success or partial success; failed and dry-run installs skip it. Script files are discovered from three sources (additive): policy (`/etc/apm/policy.d/*.json`, JSON), user (`~/.apm/apm.yml`, YAML), project (`apm.yml` `lifecycle:` at repo root, YAML). Two script types: `command` (shell via subprocess, event JSON on stdin) and `http` (HTTPS POST). Script output is appended to `~/.apm/logs/scripts.log`. See the [Lifecycle scripts](/apm/enterprise/lifecycle-scripts/) guide for full documentation. ## Distribution @@ -195,11 +201,14 @@ Set `MCP_REGISTRY_URL` (default `https://api.mcp.github.com`) to point all `apm | Command | Purpose | Key flags | |---------|---------|-----------| -| `apm runtime setup {copilot\|codex\|llm\|gemini\|windsurf}` | Install a runtime. Codex verifies the GitHub Releases SHA-256 digest before extracting and fails on missing or mismatched digests. | `--version`, `--vanilla` | +| `apm runtime setup {copilot\|codex\|gemini\|llm}` | Install a runtime. Codex verifies the GitHub Releases SHA-256 digest before extracting and fails on missing or mismatched digests. | `--version`, `--vanilla` | | `apm runtime list` | Show installed runtimes | -- | -| `apm runtime remove {copilot\|codex\|llm\|gemini\|windsurf}` | Remove a runtime | `-y`, `--yes` | +| `apm runtime remove {copilot\|codex\|gemini\|llm}` | Remove a runtime | `-y`, `--yes` | | `apm runtime status` | Show active runtime | -- | +Workflow adapters enforce streaming wall-clock deadlines for Copilot (600s) +and Codex (300s), terminating and reaping the child process on expiry. + ## Experimental features | Command | Purpose | Key flags | @@ -238,8 +247,8 @@ Experimental flags MUST NOT gate security-critical behaviour (content scanning, | `apm config set KEY VALUE` | Set a config value (`auto-integrate`, `target`, `self-update.channel`, `self-update.install-dir`, `temp-dir`, `allow-protocol-fallback`, `prefer-ssh`, `mcp-registry-url`; `copilot-cowork-skills-dir` requires `apm experimental enable copilot-cowork`) | -- | | `apm config unset KEY` | Remove a stored config value (`target`, `self-update.channel`, `self-update.install-dir`, `temp-dir`, `allow-protocol-fallback`, `prefer-ssh`, `copilot-cowork-skills-dir`, `mcp-registry-url`) | -- | | `apm lock` | Resolve all dependencies in `apm.yml` and write `apm.lock.yaml` **without** deploying any files to agent targets. Mirrors `cargo generate-lockfile` / `pnpm lock`. Use to bootstrap or refresh the lockfile before reviewing and applying changes. | `--update` re-resolve to latest SHAs, `--verbose`, `-g/--global`, `--no-policy`, `--target` (comma-separated), `--parallel-downloads N` | -| `apm lock export` | Export an SBOM/inventory from the **existing** `apm.lock.yaml` -- reads the lockfile only (no re-resolve, no re-hash, no network). Emits component identity (purl), recorded hashes, and the declared license. Output is deterministic (components sorted by purl, pinned timestamp) for byte-identical reproducibility. This is an inventory export, not a security attestation. | `-f/--format [cyclonedx\|spdx]` (default `cyclonedx`), `-o/--output FILE` (default stdout), `-g/--global` read user-scope lockfile, `--timestamp ISO8601` pin the document timestamp (falls back to `SOURCE_DATE_EPOCH`, then the lockfile's `generated_at`) | -| `apm update [PKGS...]` | Refresh APM dependencies: resolves `apm.yml` against the latest refs, prints a structured plan (added/updated/removed/unchanged), and prompts before mutating anything (default `[y/N]`). Full-SHA pins are resolved against the latest annotated semver tag, rewritten to that tag's SHA, and annotated as `# ` in `apm.yml`. Pass `[PKGS...]` to refresh only those deps, or `-g` for user scope (`~/.apm/`). Strict superset of the deprecated `apm deps update`. Skips the prompt with `--yes`; previews with `--dry-run`. | `--yes`, `--dry-run`, `--verbose`, `-g/--global`, `--force`, `--parallel-downloads N`, `--target` (comma-separated) | +| `apm lock export` | Export an SBOM/inventory from the **existing** `apm.lock.yaml` -- reads the lockfile only (no re-resolve, no re-hash, no network). Emits component identity (purl), recorded hashes, and the declared license. Output is deterministic (components sorted by purl, pinned timestamp) for byte-identical reproducibility. Diagnostics and startup notices use stderr so stdout stays machine-readable. This is an inventory export, not a security attestation. | `-f/--format [cyclonedx\|spdx]` (default `cyclonedx`), `-o/--output FILE` (default stdout), `-g/--global` read user-scope lockfile, `--timestamp ISO8601` pin the document timestamp (falls back to `SOURCE_DATE_EPOCH`, then the lockfile's `generated_at`) | +| `apm update [PKGS...]` | Refresh APM dependencies: resolves `apm.yml` against the latest refs, prints a structured plan (added/updated/removed/unchanged), and prompts before mutating anything (default `[y/N]`). Full-SHA pins are resolved against the latest annotated semver tag, rewritten to that tag's SHA, and annotated as `# ` in `apm.yml`. Pass `[PKGS...]` to refresh only those deps, or `-g` for user scope (`~/.apm/`). Successful no-op updates still reconcile deployed artifacts and lockfile ownership when the declared target set contracts. Strict superset of the deprecated `apm deps update`. Skips the prompt with `--yes`; previews with `--dry-run`. | `--yes`, `--dry-run`, `--verbose`, `-g/--global`, `--force`, `--parallel-downloads N`, `--target` (comma-separated) | | `apm self-update` | Update the APM CLI itself (or show distributor guidance when self-update is disabled at build time). | `--check` only check | `apm config set prefer-ssh true` and `apm config set allow-protocol-fallback true` persist transport preferences to `~/.apm/config.json` so SSH-only and corporate GHES users no longer need to re-pass `--ssh` / `--allow-protocol-fallback` on every `apm install`. Resolution order: CLI flag > `APM_GIT_PROTOCOL` / `APM_ALLOW_PROTOCOL_FALLBACK` env var > `apm config` value > built-in default (`false`). `apm config unset prefer-ssh` and `apm config unset allow-protocol-fallback` remove the persisted value. In `apm config` / `apm config list` / `apm config get` (no key), the two transport rows surface only when they have been enabled (the `false`-default rows are suppressed to keep the output noise-free); `apm config get ` always returns the effective value. Setting `allow-protocol-fallback=true` while `CI=1` emits a warning because the persisted value affects every subsequent `apm install` on a shared `$HOME`; prefer the env var in CI. diff --git a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md index 075898761..e8b9feffc 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md +++ b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md @@ -209,6 +209,12 @@ resolution and override the source ref directly. The lockfile records the resolved ref, not the marketplace placeholder. Unknown keys in a marketplace entry are rejected. +If the marketplace plugin entry declares `registry`, APM creates a +registry-sourced dependency instead of Git coordinates. Enable registry support +with `apm experimental enable registries` and configure the named registry. +The entry must also declare a valid semver `version`; malformed or unresolvable +registry intent fails closed and never falls back to Git. + ```yaml - name: sec-check marketplace: acme-plugins diff --git a/packages/apm-guide/.apm/skills/apm-usage/governance.md b/packages/apm-guide/.apm/skills/apm-usage/governance.md index d3d895770..9f488c731 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/governance.md +++ b/packages/apm-guide/.apm/skills/apm-usage/governance.md @@ -22,6 +22,10 @@ environment-only so redirecting binary downloads remains invocation-scoped. ## Policy schema overview +Unknown top-level keys are reported as warnings. Known fields with the wrong +native YAML type are rejected; for example, `cache` must be a mapping and +`dependencies.allow` must be a list. + ```yaml name: "Contoso Engineering Policy" version: "1.0.0" @@ -399,8 +403,10 @@ The merge follows "Inheritance rules" above (most fields tighten; deny/require l **Multi-level extends:** install-time enforcement and `apm audit --ci` both resolve the full `extends:` chain up to `MAX_CHAIN_DEPTH = 5`. Cycles are -detected and abort with an error. If a parent fetch fails midway, APM -merges what it resolved and emits a `Policy chain incomplete` warning. +detected and abort with an error. If a parent fetch fails midway, APM marks +the chain incomplete and fails closed rather than enforcing a weaker subset. +`manifest.require_explicit_includes` is OR-merged, so a descendant cannot +relax an ancestor that requires an explicit `includes:` list. ### 4. What gets enforced diff --git a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md index 4272dc7b2..171266528 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md +++ b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md @@ -31,6 +31,13 @@ backfills one from the other: is no apm.yml-side equivalent. - `name`, `version`, `license`, `dependencies`, `scripts` live exclusively in `apm.yml`. +- `name` and `version` must be non-empty strings. Quote numeric versions so + YAML does not parse them as numbers. + +Use the standard `$schema` key when authoring against normative OpenAPM v0.1: +`https://microsoft.github.io/apm/specs/schemas/manifest-v0.1.schema.json`. +Omitting `$schema` selects APM's current working draft. Unknown schema +identities fail closed rather than being interpreted as the working draft. Populate both descriptions when you ship a HYBRID package. `apm pack` warns when `apm.yml.description` is missing so listings do not @@ -144,10 +151,11 @@ to `commonjs`); shell-only bundles do not get a sidecar. `apm install` (project-scope, no `-g`) keeps hook `command` paths **repo-relative** in checked-in configs (`/.claude/settings.json`, -`/.codex/hooks.json`, the `/.claude/apm-hooks.json` -sidecar, and equivalents for Cursor / Gemini / Antigravity / Windsurf / Kiro) so clones, -contributors, and CI runners do not see the installer's machine-local -absolute prefix. `apm install -g` (user-scope, e.g. +`/.codex/hooks.json`, and equivalents for Cursor / Gemini / Antigravity / +Windsurf / Kiro). Native hook files contain only upstream schema fields; each +merged target keeps APM reconciliation ownership in a sibling `apm-hooks.json` +sidecar, so clones, contributors, and CI runners do not see the installer's +machine-local absolute prefix. `apm install -g` (user-scope, e.g. `~/.claude/settings.json`) rewrites `${PLUGIN_ROOT}` and relative `./` references to absolute paths because the user-scope config is read without a fixed cwd. If a manifest in `hooks/` or `.apm/hooks/` uses diff --git a/scripts/lint-architecture-boundaries.sh b/scripts/lint-architecture-boundaries.sh new file mode 100755 index 000000000..5a0c9e99a --- /dev/null +++ b/scripts/lint-architecture-boundaries.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# Static architecture anti-regression guard. +# +# Legitimate exceptions must carry: +# # architecture-authority-exempt: + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +violations=0 + +check_pattern() { + local label="$1" + local pattern="$2" + shift 2 + local hits + hits=$(grep -En "$pattern" "$@" 2>/dev/null \ + | grep -v 'architecture-authority-exempt:' || true) + if [ -n "$hits" ]; then + echo "[x] $label" + echo "$hits" + violations=$((violations + 1)) + fi +} + +echo "[*] AC1: canonical capability authorities" +check_pattern \ + "Runtime names must come from runtime/registry.py" \ + 'click\.Choice\(\[.*(copilot|codex|gemini|llm)|runtime_commands = \[|return \["copilot", "codex"' \ + src/apm_cli/commands/runtime.py \ + src/apm_cli/core/script_runner.py \ + src/apm_cli/runtime/manager.py \ + src/apm_cli/workflow/runner.py +check_pattern \ + "Host backend dispatch must come from core/host_providers.py" \ + '_BACKEND_BY_KIND|only supports .gitlab.|Supported values: gitlab' \ + src/apm_cli/core/auth.py \ + src/apm_cli/deps/host_backends.py \ + src/apm_cli/models/dependency/reference.py +check_pattern \ + "Manifest target consumers must use canonical_targets" \ + '(package|apm_package)\.(target|targets)\b' \ + src/apm_cli/bundle/packer.py \ + src/apm_cli/install/mcp/integration.py \ + src/apm_cli/commands/uninstall/engine.py +check_pattern \ + "Install orchestration must not branch on native locator target names" \ + 'name == "copilot-(app|cowork)"|name in \{.*copilot-(app|cowork)' \ + src/apm_cli/install/deployed_paths.py \ + src/apm_cli/install/manifest_reconcile.py + +echo "[*] AC2: validate-before-mutate boundaries" +compiled_write_hits=$( + grep -rEn \ + 'write_text_lf|atomic_write_text|\.write_text\(|open\([^)]*["'\'']w' \ + src/apm_cli/compilation/ --include='*.py' \ + | grep -v 'src/apm_cli/compilation/output_writer.py' \ + | grep -v 'architecture-authority-exempt:' \ + || true +) +if [ -n "$compiled_write_hits" ]; then + echo "[x] Compiled output writes must use CompiledOutputWriter" + echo "$compiled_write_hits" + violations=$((violations + 1)) +fi +hook_file="src/apm_cli/integration/hook_integrator.py" +validation_line=$(grep -n 'if not validation\.valid:' "$hook_file" | tail -1 | cut -d: -f1) +continue_line=$(awk -v start="$validation_line" 'NR > start && /continue/ {print NR; exit}' "$hook_file") +write_line=$(grep -n 'with open(target_path, "w"' "$hook_file" | tail -1 | cut -d: -f1) +if [ -z "$validation_line" ] || [ -z "$continue_line" ] || [ -z "$write_line" ] \ + || [ "$continue_line" -gt "$write_line" ]; then + echo "[x] Hook payload validation must continue before the native payload write" + violations=$((violations + 1)) +fi +check_pattern \ + "Lockfile supported-version authority belongs in deps/lockfile.py" \ + 'SUPPORTED_LOCKFILE_VERSIONS|lockfile_version[[:space:]]+(==|!=|in)' \ + $(find src/apm_cli -name '*.py' ! -path 'src/apm_cli/deps/lockfile.py') + +echo "[*] AC3: outcome and policy enforcement authorities" +check_pattern \ + "Install adapters must not classify diagnostics" \ + 'classify_post_install_result' \ + src/apm_cli/commands/install.py +check_pattern \ + "Audit policy sources must use chain-aware discovery" \ + 'discover_policy\(' \ + src/apm_cli/commands/audit.py +if ! grep -A20 'def _merge_manifest' src/apm_cli/policy/inheritance.py \ + | grep -q 'require_explicit_includes'; then + echo "[x] Manifest inheritance must merge require_explicit_includes" + violations=$((violations + 1)) +fi +if ! grep -q 'incomplete_chain' src/apm_cli/policy/discovery.py \ + || ! grep -q 'incomplete_chain' src/apm_cli/policy/outcome_routing.py; then + echo "[x] Incomplete policy chains must route through fail-closed outcome handling" + violations=$((violations + 1)) +fi + +echo "[*] AC4: declared-intent preservation" +check_pattern \ + "Deployment claim handoff belongs to DeploymentReconciler" \ + 'def reconcile_cross_package_deployed_files|all_current_deployed|other_current' \ + src/apm_cli/install/phases/lockfile.py +if ! grep -q 'DeploymentReconciler.reconcile_package_claims' \ + src/apm_cli/install/phases/lockfile.py; then + echo "[x] LockfileBuilder must consume DeploymentReconciler package claims" + violations=$((violations + 1)) +fi +check_pattern \ + "Dependency ref winner selection must use one helper" \ + 'download_winners|level_winners|seen_keys|nodes_at_depth\.sort' \ + src/apm_cli/deps/apm_resolver.py +winner_selector_calls=$(grep -c '_select_dependency_winners(' src/apm_cli/deps/apm_resolver.py) +if [ "$winner_selector_calls" -ne 3 ]; then + echo "[x] Dependency dispatch and flattening must share _select_dependency_winners" + violations=$((violations + 1)) +fi +check_pattern \ + "Resolver queue dedup must preserve ref constraints" \ + 'queued_keys.*get_unique_key|get_unique_key.*queued_keys' \ + src/apm_cli/deps/apm_resolver.py +if ! grep -A12 'if source == "local"' src/apm_cli/models/dependency/identity.py \ + | grep -q 'anchored_local_path' \ + || ! grep -q 'declaring_parent' src/apm_cli/deps/lockfile.py; then + echo "[x] Local identity must use its anchor and persist declaring-parent provenance" + violations=$((violations + 1)) +fi +check_pattern \ + "MCP commands must pass the resolved URL into RegistryIntegration" \ + 'RegistryIntegration\(\)' \ + src/apm_cli/commands/mcp.py +if ! grep -A25 'if plugin.registry:' src/apm_cli/marketplace/resolver.py \ + | grep -q 'source="registry"'; then + echo "[x] Marketplace registry intent must create a registry dependency" + violations=$((violations + 1)) +fi + +echo "[*] AC5: process-wide I/O boundaries" +check_pattern \ + "Machine-output routing belongs at the root CLI" \ + 'set_console_stderr' \ + $(find src/apm_cli/commands -name '*.py') +check_pattern \ + "Secret redaction must attach to handlers, not package loggers" \ + 'apm_logger\.addFilter|logging\.getLogger\("apm_cli"\)\.addFilter' \ + src/apm_cli/cli.py +if ! grep -q 'detect_output_mode' src/apm_cli/cli.py \ + || ! grep -q 'handler.addFilter' src/apm_cli/cli.py; then + echo "[x] Root CLI must establish machine mode and handler-level redaction" + violations=$((violations + 1)) +fi +if ! grep -q '_clear_git_auth_env(env)' src/apm_cli/core/auth.py; then + echo "[x] AuthResolver must scrub inherited Git authorization state" + violations=$((violations + 1)) +fi + +echo "[*] AC6: neutral IR and schema contracts" +check_pattern \ + "Neutral hook IR must not contain native harness vocabulary" \ + 'copilot|gemini|antigravity|timeoutSec|powershell|_apm_source|["'\'']hooks["'\'']' \ + src/apm_cli/integration/hook_ir.py +check_pattern \ + "Manifest schema negotiation belongs in manifest_contract.py" \ + 'get\\(["'\'']\\$schema["'\'']\\)' \ + $(find src/apm_cli -name '*.py' ! -path 'src/apm_cli/models/manifest_contract.py') +if ! grep -q 'does not run aggregate' docs/src/content/docs/concepts/lifecycle.md; then + echo "[x] Lifecycle docs must keep aggregate compilation explicit" + violations=$((violations + 1)) +fi + +echo "[*] AC7: concurrency and deadline safety" +check_pattern \ + "Runtime adapters must reuse the deadline-aware base streamer" \ + 'subprocess\.Popen' \ + $(find src/apm_cli/runtime -name '*_runtime.py') +if ! grep -q 'time.monotonic' src/apm_cli/runtime/base.py \ + || ! grep -q '_terminate_and_reap' src/apm_cli/runtime/base.py; then + echo "[x] Runtime streaming must enforce and reap on a wall-clock deadline" + violations=$((violations + 1)) +fi +if ! grep -A8 'def add_marketplace' src/apm_cli/marketplace/registry.py \ + | grep -q '_marketplace_mutation' \ + || ! grep -A12 'def remove_marketplace' src/apm_cli/marketplace/registry.py \ + | grep -q '_marketplace_mutation'; then + echo "[x] Marketplace mutations must lock the full load-modify-save transaction" + violations=$((violations + 1)) +fi + +if [ "$violations" -gt 0 ]; then + echo "[x] $violations architecture boundary rule(s) failed" + exit 1 +fi + +echo "[+] architecture boundary lint clean" diff --git a/src/apm_cli/adapters/client/base.py b/src/apm_cli/adapters/client/base.py index 56a345f0f..f30bf5499 100644 --- a/src/apm_cli/adapters/client/base.py +++ b/src/apm_cli/adapters/client/base.py @@ -210,6 +210,19 @@ def project_root(self) -> Path: return self._project_root return Path(os.getcwd()) + def render_server_config(self, server_info: dict) -> dict: + """Render one native server entry for exact baseline comparisons.""" + rendered = self._format_server_config( + server_info, + env_overrides={}, + runtime_vars={}, + ) + if isinstance(rendered, tuple): + rendered = rendered[0] + if not isinstance(rendered, dict): + raise ValueError("Adapter did not produce a native MCP server mapping") + return rendered + @abstractmethod def get_config_path(self): """Get the path to the MCP configuration file.""" diff --git a/src/apm_cli/bundle/packer.py b/src/apm_cli/bundle/packer.py index 88edacb60..5983796ca 100644 --- a/src/apm_cli/bundle/packer.py +++ b/src/apm_cli/bundle/packer.py @@ -93,7 +93,9 @@ def pack_bundle( package = APMPackage.from_apm_yml(apm_yml_path) pkg_name = package.name pkg_version = package.version or "0.0.0" - config_target = package.target + from apm_cli.models.apm_package import package_target_selection + + config_target = package_target_selection(package) # HYBRID author guard: apm.yml.description and SKILL.md # description serve different consumers (human-facing CLI/search @@ -141,7 +143,7 @@ def pack_bundle( # List from CLI (e.g. --target claude,copilot) passes through directly effective_target = target elif isinstance(config_target, list) and target is None: - # List from apm.yml target: [claude, copilot] + # Canonical list from either apm.yml target spelling. effective_target = config_target else: effective_target, _reason = detect_target( diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index e2553cf51..7152d4fad 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -64,6 +64,14 @@ ) +class _OutputModeGroup(click.Group): + """Capture full argv so root output mode precedes subcommand callbacks.""" + + def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + ctx.meta["apm_raw_args"] = tuple(args) + return super().parse_args(ctx, args) + + def _configure_logging(verbose: bool = False) -> None: """Configure stdlib logging for the ``apm_cli`` package. @@ -102,11 +110,17 @@ def _configure_logging(verbose: bool = False) -> None: # Install secret-redaction filter (idempotent: skip if already present). from apm_cli.core.auth import SecretRedactionFilter - if not any(isinstance(f, SecretRedactionFilter) for f in apm_logger.filters): - apm_logger.addFilter(SecretRedactionFilter()) + handlers = { + *logging.getLogger().handlers, + *apm_logger.handlers, + } + for handler in handlers: + if not any(isinstance(f, SecretRedactionFilter) for f in handler.filters): + handler.addFilter(SecretRedactionFilter()) @click.group( + cls=_OutputModeGroup, help="Agent Package Manager (APM): The package manager for AI-Native Development", epilog=_CLI_EPILOG, ) @@ -130,6 +144,11 @@ def cli(ctx, verbose: bool) -> None: """Main entry point for the APM CLI.""" ctx.ensure_object(dict) ctx.obj["verbose"] = verbose + from apm_cli.core.output_mode import configure_output_mode, detect_output_mode + + output_mode = detect_output_mode(ctx.meta.get("apm_raw_args", sys.argv[1:])) + configure_output_mode(output_mode) + ctx.obj["output_mode"] = output_mode if verbose: # Upgrade to DEBUG when the flag is set; env-var path runs in main(). diff --git a/src/apm_cli/commands/_helpers.py b/src/apm_cli/commands/_helpers.py index 4dda8d095..748c2e4bd 100644 --- a/src/apm_cli/commands/_helpers.py +++ b/src/apm_cli/commands/_helpers.py @@ -474,7 +474,7 @@ def _check_and_notify_updates(): _rich_echo(get_update_hint_message(), color="yellow", bold=True) # Add a blank line for visual separation - click.echo() + _rich_echo("") except Exception: # Silently fail - version checking should never block CLI usage pass @@ -706,12 +706,14 @@ def _create_minimal_apm_yml(config, plugin=False, target_path=None): content = out_file.read_text(encoding="utf-8") if "targets" in apm_yml_data: + from apm_cli.core.target_catalog import manifest_target_names + # Insert comment before the targets: line + accepted_targets = ", ".join(sorted(manifest_target_names())) targets_comment = ( "# Which agent platforms to deploy to.\n" "# Resolution order: --target flag > this field > auto-detect from filesystem.\n" - "# Accepted values: vscode, agents, copilot, claude, cursor, opencode, codex,\n" - "# gemini, antigravity, windsurf, kiro, agent-skills, all\n" + f"# Accepted values: {accepted_targets}\n" ) content = content.replace("targets:", targets_comment + "targets:", 1) else: diff --git a/src/apm_cli/commands/audit.py b/src/apm_cli/commands/audit.py index a831154dd..3e0bf5f2a 100644 --- a/src/apm_cli/commands/audit.py +++ b/src/apm_cli/commands/audit.py @@ -535,7 +535,7 @@ def _audit_ci_gate( # Resolve policy source: explicit --policy wins; otherwise mirror # install's auto-discovery (closes #827) so CI catches sideloaded # files via unmanaged-files checks. --no-policy skips discovery. - from ..policy.discovery import discover_policy, discover_policy_with_chain + from ..policy.discovery import discover_policy_with_chain from ..policy.project_config import ( read_project_fetch_failure_default, ) @@ -543,7 +543,7 @@ def _audit_ci_gate( fetch_result = None auto_discovered = False if policy_source and (not fail_fast or ci_result.passed): - fetch_result = discover_policy( + fetch_result = discover_policy_with_chain( cfg.project_root, policy_override=policy_source, no_cache=no_cache, @@ -576,6 +576,7 @@ def _audit_ci_gate( "malformed", "cache_miss_fetch_fail", "garbage_response", + "incomplete_chain", ) no_policy_outcomes = ("absent", "no_git_remote", "empty") @@ -595,7 +596,7 @@ def _audit_ci_gate( source = fetch_result.source err_text = fetch_result.error or fetch_result.fetch_error or fetch_result.outcome cause = _audit_outcome_cause(fetch_result.outcome, source, err_text) - if project_default == "block": + if fetch_result.outcome == "incomplete_chain" or project_default == "block": click.echo( f"[x] {cause} (policy.fetch_failure_default=block)", err=True, @@ -858,10 +859,16 @@ def _audit_content_scan( else: logger.start("Scanning all installed packages...") - findings_by_file, files_scanned = scan_lockfile_packages( - project_root, - package_filter=package, - ) + from apm_cli.deps.lockfile import LockfileFormatError + + try: + findings_by_file, files_scanned = scan_lockfile_packages( + project_root, + package_filter=package, + ) + except LockfileFormatError as exc: + logger.error(f"Cannot audit invalid apm.lock.yaml: {exc}") + sys.exit(1) if files_scanned == 0 and not external: if package: diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index 4977e88fc..3d48d1c88 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -15,6 +15,12 @@ from ...compilation import AgentsCompiler, CompilationConfig from ...constants import AGENTS_MD_FILENAME, APM_DIR, APM_MODULES_DIR, APM_YML_FILENAME from ...core.command_logger import CommandLogger +from ...core.target_catalog import ( + TARGET_CAPABILITIES, + expand_all, + get_target_capability, + target_help_fragment, +) from ...core.target_detection import TargetParamType from ...primitives.discovery import clear_discovery_cache, discover_primitives from ...utils import perf_stats @@ -169,7 +175,9 @@ def _get_validation_suggestion(error_msg): return "Check primitive structure and frontmatter" -def _resolve_compile_target(target): +def _resolve_compile_target( + target: str | list[str] | None, +) -> CompileTargetType | None: """Map CLI target input to a compiler-understood target. The compiler understands single-string targets (``"vscode"``, @@ -183,10 +191,9 @@ def _resolve_compile_target(target): collapsing to ``"all"`` (which would incorrectly generate files for every family). - Family resolution reads ``TargetProfile.compile_family`` from - ``KNOWN_TARGETS`` so adding a new compile-eligible target only - requires populating that field. The CLI alias ``"vscode"`` is - treated as ``"copilot"`` for this purpose. + Family resolution reads ``TargetCapability.compile_family`` from + ``TARGET_CAPABILITIES`` so adding a new compile-eligible target only + requires populating that field. Args: target: A single target string, a list of target strings, or ``None``. @@ -194,86 +201,104 @@ def _resolve_compile_target(target): Returns: A single string, a ``frozenset`` of compiler families, or ``None``. """ - from ...integration.targets import KNOWN_TARGETS - if target is None: return None # will trigger detect_target() auto-detection - if isinstance(target, list): - target_set = set(target) - # Strip targets with no compile output (compile_family is None); - # they would silently fall through the family resolution otherwise. - # ``vscode`` is a CLI alias for ``copilot`` and shares its profile. - skip = {name for name, profile in KNOWN_TARGETS.items() if profile.compile_family is None} - target_set -= skip - if not target_set: - # Solo agent-skills (or another no-compile target) in a list -- - # pass through as a string so the compiler's no-op path fires. - for sentinel in target: - if sentinel in skip: - return sentinel - return None - - # The "vscode" family handles copilot AND emits AGENTS.md as a - # bonus; the "agents" family emits AGENTS.md only. When both - # appear in a multi-target compile we still need both family - # tokens so the agents compiler routes correctly. - def _family_of(name: str) -> str | None: - if name == "vscode": + requested_targets = [target] if isinstance(target, str) else target + if len(requested_targets) > 1: + deployment_all = {get_target_capability(name).name for name in expand_all("install")} + requested_canonical = { + get_target_capability(name).name for name in requested_targets if name != "all" + } + if "all" in requested_targets or deployment_all <= requested_canonical: + explicit_targets = [ + name + for name in requested_targets + if name != "all" and get_target_capability(name).name not in deployment_all + ] + requested_targets = [*expand_all("compile"), *explicit_targets] + + target_set: set[str] = set() + for requested in requested_targets: + if requested == "all": + target_set.add(requested) + continue + capability = get_target_capability(requested) + if "compile" not in capability.commands: + raise click.UsageError( + f"Target '{requested}' is not a compile target.\n\n" + "Fix with one of:\n\n" + " apm compile --target copilot\n" + " apm compile --dry-run" + ) + target_set.add(capability.name) + + if target_set == {"all"}: + return "all" + + # The "vscode" family handles copilot AND emits AGENTS.md as a + # bonus; the "agents" family emits AGENTS.md only. When both + # appear in a multi-target compile we still need both family + # tokens so the agents compiler routes correctly. + def _family_of(name: str) -> str | None: + return get_target_capability(name).compile_family + + families: set[str] = set() + for name in target_set: + family = _family_of(name) + if family is None: + continue + families.add(family) + if family == "vscode": + # copilot also emits AGENTS.md; mirror legacy behavior. + families.add("agents") + + if len(families) >= 2: + # Collapse {"vscode","agents"} to bare "vscode" ONLY when the + # original target list contains no non-Copilot agents-family + # targets (e.g. codex, opencode, windsurf). When mixed targets + # like [copilot, codex] are requested, keep the frozenset so + # downstream dedup logic knows non-Copilot targets also consume + # AGENTS.md (issue #1678). + if families == {"vscode", "agents"}: + has_non_vscode_agents = any( + name in target_set + for name, capability in TARGET_CAPABILITIES.items() + if capability.compile_family == "agents" and capability.primitive_profile == name + ) + if not has_non_vscode_agents: return "vscode" - profile = KNOWN_TARGETS.get(name) - return profile.compile_family if profile else None - - families: set[str] = set() - for name in target_set: - family = _family_of(name) - if family is None: - continue - families.add(family) - if family == "vscode": - # copilot also emits AGENTS.md; mirror legacy behavior. - families.add("agents") - - if len(families) >= 2: - # Collapse {"vscode","agents"} to bare "vscode" ONLY when the - # original target list contains no non-Copilot agents-family - # targets (e.g. codex, opencode, windsurf). When mixed targets - # like [copilot, codex] are requested, keep the frozenset so - # downstream dedup logic knows non-Copilot targets also consume - # AGENTS.md (issue #1678). - if families == {"vscode", "agents"}: - _vscode_names = {"copilot", "vscode", "agents"} - has_non_vscode_agents = any( - name in target_set - for name, profile in KNOWN_TARGETS.items() - if profile.compile_family == "agents" and name not in _vscode_names - ) - if not has_non_vscode_agents: - return "vscode" - return frozenset(families) - if families == {"agents"} and "antigravity" in target_set and len(target_set) > 1: - # Mixed Antigravity + AGENTS.md-only consumers share AGENTS.md but - # do not all read .agents/rules/. Preserve mixed-target context so - # downstream dedup stays disabled for AGENTS.md-only consumers. - return frozenset({"agents"}) - if "claude" in families: - return "claude" - if "gemini" in families: - return "gemini" - if "vscode" in families: - return "vscode" - # Bare agents-family target: preserve the original target name so - # single-element list routing matches single-string semantics - # (-t cursor and -t [cursor] both end up as "cursor"). Iterate - # KNOWN_TARGETS in insertion order so priority ties (e.g. - # ["opencode","codex"]) resolve deterministically to the - # earliest-registered target. Adding a new agents-family - # target (e.g. zed, cline) costs zero edits here -- it inherits - # whatever priority position it occupies in the registry. - for name, profile in KNOWN_TARGETS.items(): - if profile.compile_family == "agents" and name in target_set: - return name - return "vscode" # defensive fallback (unreachable) - return target # single string pass-through + return frozenset(families) + if families == {"agents"} and "antigravity" in target_set and len(target_set) > 1: + # Mixed Antigravity + AGENTS.md-only consumers share AGENTS.md but + # do not all read .agents/rules/. Preserve mixed-target context so + # downstream dedup stays disabled for AGENTS.md-only consumers. + return frozenset({"agents"}) + if "claude" in families: + return "claude" + if "gemini" in families: + return "gemini" + if "vscode" in families: + return "vscode" + # Bare agents-family target: preserve the original target name so + # single-element list routing matches single-string semantics. Iterate + # TARGET_CAPABILITIES in insertion order so priority ties resolve + # deterministically to the earliest-registered target. + for name, capability in TARGET_CAPABILITIES.items(): + if ( + capability.compile_family == "agents" + and capability.primitive_profile == name + and name in target_set + ): + return name + if families == {"agents"}: + return frozenset(families) + for requested in requested_targets: + if requested == "all": + continue + capability = get_target_capability(requested) + if capability.compile_family is None: + return capability.name + raise click.UsageError("No compile-capable target was selected.") def _resolve_effective_target( @@ -431,12 +456,20 @@ def _display_user_path(path: Path) -> str: return f"~/{rel.as_posix()}" -def _validate_project(logger: CommandLogger, dry_run: bool, source_root: Path) -> None: +def _validate_project( + logger: CommandLogger, + dry_run: bool, + source_root: Path, + *, + allow_empty: bool = False, +) -> None: """Check APM project exists and has content. Calls ``sys.exit(1)`` on fatal errors. In dry-run mode the function emits diagnostic messages but does *not* exit so callers can test the - full compile path even without real content. + full compile path even without real content. ``allow_empty`` lets + ``compile --clean`` reach the compiler's APM-owned orphan cleanup; + callers must keep validation and watch modes on the content-required path. """ from ...compilation.constitution import find_constitution @@ -458,6 +491,9 @@ def _validate_project(logger: CommandLogger, dry_run: bool, source_root: Path) - # If no primitive sources exist, check deeper to provide better feedback if not apm_modules_exists and not local_apm_has_content and not constitution_exists: + if allow_empty: + return + # Check if .apm directories exist but are empty has_empty_apm = ( apm_dir.exists() @@ -744,6 +780,9 @@ def _coerce_provenance_targets(value): "Compilation completed successfully!", symbol="check", ) + elif clean and result.stats.get("claude_empty_due_to_no_primitives"): + # The compiler already reported the expected cleanup outcome. + pass else: # Zero-output compile is the silent-success failure # mode #820 guards against. Don't claim success; @@ -880,6 +919,17 @@ def _coerce_provenance_targets(value): perf_stats.render_summary(logger, project_root=str(_src)) sys.exit(1) + if result.success and not dry_run: + from ...install.manifest_reconcile import reconcile_project_deployed_state + + reconcile_project_deployed_state( + Path(_src).resolve(), + explicit_target=target, + deploy_root=Path(".").resolve(), + lock_root=Path(".").resolve(), + verbose=verbose, + ) + perf_stats.render_summary(logger, project_root=str(_src)) @@ -895,7 +945,10 @@ def _coerce_provenance_targets(value): "-t", type=TargetParamType(), default=None, - help="Target platform (comma-separated). Values: copilot, claude, cursor, opencode, codex, gemini, antigravity, windsurf, kiro, agent-skills, all. 'agent-skills' deploys to .agents/skills/ (cross-client). 'antigravity' (alias 'agy') deploys to .agents/ and is explicit-only -- not part of 'all'. 'all' = copilot+claude+cursor+opencode+codex+gemini+windsurf+kiro (excludes agent-skills and antigravity); combine with 'agent-skills' or 'antigravity' to add them.", + help=f"Target platform (comma-separated). {target_help_fragment('compile')} " + "'antigravity' (alias 'agy') deploys to .agents/ and is explicit-only -- not part of 'all'. " + "'all' excludes antigravity and experimental targets; " + "combine explicit-only targets when needed.", ) @click.option( "--dry-run", @@ -1127,7 +1180,12 @@ def compile( # noqa: PLR0913 -- Click handler # from. Equals $PWD unless --root redirects writes elsewhere. source_root = get_source_root(InstallScope.PROJECT) - _validate_project(logger, dry_run, source_root) + _validate_project( + logger, + dry_run, + source_root, + allow_empty=clean and not validate and not watch, + ) if validate: _run_validation_mode(logger, verbose, source_root) @@ -1167,6 +1225,8 @@ def compile( # noqa: PLR0913 -- Click handler logger.error(f"Compilation module not available: {e}") logger.progress("This might be a development environment issue.") sys.exit(1) + except click.ClickException: + raise except Exception as e: logger.error(f"Error during compilation: {e}") sys.exit(1) diff --git a/src/apm_cli/commands/deps/_utils.py b/src/apm_cli/commands/deps/_utils.py index 450a70db4..fcbe5da18 100644 --- a/src/apm_cli/commands/deps/_utils.py +++ b/src/apm_cli/commands/deps/_utils.py @@ -5,6 +5,7 @@ from ...constants import APM_DIR, APM_YML_FILENAME, SKILL_MD_FILENAME from ...models.apm_package import APMPackage +from ...utils.yaml_io import load_yaml def _scan_installed_packages(apm_modules_dir: Path) -> list: @@ -154,6 +155,35 @@ def _get_detailed_context_counts(package_path: Path) -> dict[str, int]: return counts +def _tolerant_identity(package_path: Path, apm_yml_path: Path) -> dict[str, str] | None: + """Best-effort name/version read for the tolerant ``deps`` display paths. + + ``APMPackage.from_apm_yml`` is the strict identity authority: it rejects + empty/non-string ``name`` and ``version`` so ``audit`` and ``policy`` + surfaces fail schema-invalid manifests. The ``deps list`` / ``deps info`` + display commands are a different surface -- they must stay tolerant so a + manifest that merely omits or empties ``version`` still renders as + ``@unknown`` rather than an alarming ``@error`` that masks the package. + + Returns a ``{"name", "version"}`` dict on a best-effort read, or ``None`` + only when the apm.yml cannot be parsed at all (genuinely malformed YAML), + which the callers surface as ``error``. + """ + try: + data = load_yaml(apm_yml_path) + except Exception: + return None + if not isinstance(data, dict): + return None + raw_name = data.get("name") + name = raw_name.strip() if isinstance(raw_name, str) and raw_name.strip() else package_path.name + raw_version = data.get("version") + version = ( + raw_version.strip() if isinstance(raw_version, str) and raw_version.strip() else "unknown" + ) + return {"name": name, "version": version} + + def _get_package_display_info(package_path: Path) -> dict[str, str]: """Get package display information.""" try: @@ -173,10 +203,17 @@ def _get_package_display_info(package_path: Path) -> dict[str, str]: "version": "unknown", } except Exception: + identity = _tolerant_identity(package_path, package_path / APM_YML_FILENAME) + if identity is None: + return { + "display_name": f"{package_path.name}@error", + "name": package_path.name, + "version": "error", + } return { - "display_name": f"{package_path.name}@error", - "name": package_path.name, - "version": "error", + "display_name": f"{identity['name']}@{identity['version']}", + "name": identity["name"], + "version": identity["version"], } @@ -228,6 +265,22 @@ def _get_detailed_package_info(package_path: Path) -> dict[str, Any]: "hooks": primitives.get("hooks", 0), } except Exception as e: + identity = _tolerant_identity(package_path, package_path / APM_YML_FILENAME) + if identity is not None: + # Tolerant display: a readable manifest with incomplete identity + # (e.g. empty/missing version) renders gracefully -- symmetric with + # `deps list` -- instead of masking the package behind an error. + return { + "name": identity["name"], + "version": identity["version"], + "description": "apm.yml identity incomplete", + "author": "Unknown", + "source": "local", + "install_path": str(package_path.resolve()), + "context_files": _get_detailed_context_counts(package_path), + "workflows": 0, + "hooks": 0, + } return { "name": package_path.name, "version": "error", diff --git a/src/apm_cli/commands/deps/why.py b/src/apm_cli/commands/deps/why.py index e5b2ae84d..13d017a98 100644 --- a/src/apm_cli/commands/deps/why.py +++ b/src/apm_cli/commands/deps/why.py @@ -38,7 +38,6 @@ compute_why, resolve_package_query, ) -from ...utils.console import set_console_stderr # Exit codes _EXIT_OK = 0 @@ -195,13 +194,6 @@ def _load_lockfile(apm_dir, logger: CommandLogger) -> LockFile | None: ) def why(package: str, global_: bool, as_json: bool) -> None: """Entry point for ``apm deps why ``.""" - # Stream discipline: under --json, route ALL human-facing output to - # stderr so that downstream tools (jq, scripts) can consume stdout - # as a clean JSON document. Mirrors the convention established by - # `apm pack --json` (commands/pack.py) and by npm / yarn / cargo. - if as_json: - set_console_stderr(True) - logger = CommandLogger("deps-why") scope = InstallScope.USER if global_ else InstallScope.PROJECT apm_dir = get_apm_dir(scope) diff --git a/src/apm_cli/commands/init.py b/src/apm_cli/commands/init.py index 1db46c296..e209b4258 100644 --- a/src/apm_cli/commands/init.py +++ b/src/apm_cli/commands/init.py @@ -13,6 +13,7 @@ EXPLICIT_ONLY_TARGETS, TargetParamType, detect_signals, + manifest_targets_from_target_option, ) from ..utils.console import ( _create_files_table, @@ -481,7 +482,9 @@ def _resolve_init_targets( """ # Case 1: --target flag provided -- wins unconditionally if target_flag is not None: - targets = [target_flag] if isinstance(target_flag, str) else list(target_flag) + targets = manifest_targets_from_target_option(target_flag) + if not targets: + return None logger.progress(f"Targets set: {', '.join(targets)} (via --target flag)", symbol="info") return targets diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index 503a38a08..68147fdfe 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -8,7 +8,7 @@ import time from collections.abc import Callable from pathlib import Path -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any import click @@ -17,6 +17,7 @@ AuthenticationError, DirectDependencyError, FrozenInstallError, + InstallFailureAlreadyRendered, PolicyViolationError, ) from apm_cli.install.gitlab_resolver import _try_resolve_gitlab_direct_shorthand @@ -77,6 +78,7 @@ _integrate_local_content, # noqa: F401 -- re-exported; test_architecture_invariants checks importability _integrate_package_primitives, # noqa: F401 -- re-exported; tests import/patch from apm_cli.commands.install ) +from apm_cli.install.transaction import InstallTransaction # Re-export validation leaf helpers so that existing test patches like # @patch("apm_cli.commands.install._validate_package_exists") keep working. @@ -89,6 +91,7 @@ _local_path_no_markers_hint, # noqa: F401 -- re-exported; test_architecture_invariants checks importability _validate_package_exists, ) +from apm_cli.models.results import InstallDisposition, InstallResult from apm_cli.utils.diagnostics import DiagnosticCollector # noqa: F401 from ..constants import ( @@ -97,6 +100,7 @@ ) from ..core.auth import AuthResolver from ..core.command_logger import InstallLogger, _ValidationOutcome +from ..core.target_catalog import target_help_fragment from ..core.target_detection import TargetParamType, manifest_targets_from_target_option # MCP --mcp helpers (module-level re-exports for test patches); must stay at @@ -119,58 +123,6 @@ _get_default_config, ) -# --------------------------------------------------------------------------- -# Manifest snapshot + rollback (W2-pkg-rollback, #827) -# --------------------------------------------------------------------------- - - -def _restore_manifest_from_snapshot( - manifest_path: "Path", - snapshot: bytes, -) -> None: - """Atomically restore ``apm.yml`` from a raw-bytes snapshot. - - Uses temp-file + ``os.replace`` to avoid torn writes, mirroring the - W1 cache atomic-write pattern (``discovery.py``). - """ - import os - import tempfile - - fd, tmp_name = tempfile.mkstemp( - prefix="apm-restore-", - dir=str(manifest_path.parent), - ) - try: - with os.fdopen(fd, "wb") as fh: - fh.write(snapshot) - os.replace(tmp_name, str(manifest_path)) - except Exception: - with contextlib.suppress(OSError): - os.unlink(tmp_name) - raise - - -def _maybe_rollback_manifest( - manifest_path: "Path", - snapshot: "bytes | None", - logger: "InstallLogger", -) -> None: - """Restore ``apm.yml`` from *snapshot* if one was captured, then log. - - No-op when *snapshot* is ``None`` (i.e. the command was not - ``apm install `` or the manifest did not exist before mutation). - """ - if snapshot is None: - return - try: - _restore_manifest_from_snapshot(manifest_path, snapshot) - logger.progress("apm.yml restored to its previous state.") - except Exception: - # Best-effort: if the restore itself fails, warn but don't mask - # the original exception that triggered the rollback. - logger.warning("Failed to restore apm.yml to its previous state.") - - # CRITICAL: Shadow Python builtins that share names with Click commands set = builtins.set list = builtins.list @@ -215,16 +167,16 @@ class InstallContext: no_policy: bool install_mode: Any # InstallMode packages: tuple # Original Click packages + transaction: Any = None # InstallTransaction; default preserves direct-call compatibility refresh: bool = False only_packages: builtins.list | None = None - manifest_snapshot: bytes | None = None - snapshot_manifest_path: Optional["Path"] = None legacy_skill_paths: bool = False frozen: bool = False plan_callback: "Callable[[UpdatePlan], bool] | None" = None skill_subset: "builtins.tuple[str, ...] | None" = None skill_subset_from_cli: bool = False audit_override: str | None = None + install_result: InstallResult | None = None # --------------------------------------------------------------------------- @@ -619,10 +571,7 @@ def _merge_packages_into_yml( f"Updated {APM_YML_FILENAME} with {len(validated_packages)} new package(s)" ) except Exception as e: - if logger: - logger.error(f"Failed to write {APM_YML_FILENAME}: {e}") - else: - _rich_error(f"Failed to write {APM_YML_FILENAME}: {e}") + (logger or InstallLogger()).error(f"Failed to write {APM_YML_FILENAME}: {e}") sys.exit(1) @@ -666,10 +615,7 @@ def _validate_and_add_packages_to_apm_yml( data = load_yaml_roundtrip(apm_yml_path) or {} except Exception as e: - if logger: - logger.error(f"Failed to read {APM_YML_FILENAME}: {e}") - else: - _rich_error(f"Failed to read {APM_YML_FILENAME}: {e}") + (logger or InstallLogger()).error(f"Failed to read {APM_YML_FILENAME}: {e}") sys.exit(1) from ..install.registry_wiring import get_effective_default_registry @@ -899,7 +845,7 @@ def _handle_mcp_install( # noqa: PLR0913 @click.option( "--runtime", help=( - "Target specific runtime only (copilot, claude, codex, cursor, gemini, antigravity, intellij, kiro, opencode, windsurf) " + f"Target a specific runtime only. {target_help_fragment('install')} " "(legacy alias for --target, single value only; prefer --target)" ), ) @@ -950,7 +896,14 @@ def _handle_mcp_install( # noqa: PLR0913 "target", type=TargetParamType(), default=None, - help="Target harness(es) to deploy to. Use commas for multiple targets; repeating the flag keeps only the last value (use commas instead). Values: copilot, claude, cursor, opencode, codex, gemini, antigravity (agy), windsurf, kiro, intellij, agent-skills, all. IntelliJ-specific integration is MCP-only; file primitives use the Copilot profile. 'all' = copilot+claude+cursor+opencode+codex+gemini+windsurf+kiro; combine agent-skills, antigravity, or intellij explicitly when needed. Experimental copilot-cowork and copilot-app values require their feature flags. Resolution order: --target > apm.yml targets: > apm config target > auto-detect. With nothing to detect, install exits 2 with a teaching message. For 'apm compile', use '--all'; '--target all' is deprecated.", + help=f"Target harness(es) to deploy to. Use commas for multiple targets; repeating the flag " + f"keeps only the last value (use commas instead). {target_help_fragment('install')} " + "IntelliJ-specific integration is MCP-only; file primitives use the Copilot profile. " + "'all' excludes agent-skills, antigravity, experimental targets, and intellij; combine " + "explicit-only targets when needed. Experimental targets require their feature flags. " + "Resolution order: --target > apm.yml targets: > apm config target > auto-detect. " + "With nothing to detect, install exits 2 with a teaching message. For 'apm compile', " + "use '--all'; '--target all' is deprecated.", ) @click.option( "--allow-insecure", @@ -1204,6 +1157,8 @@ def install( # noqa: PLR0913 install_started_at = time.perf_counter() summary_rendered = False logger = None + command_result: InstallResult | None = None + transaction: InstallTransaction | None = None if frozen and update: raise click.UsageError( "--frozen and --update are mutually exclusive. " @@ -1237,19 +1192,6 @@ def install( # noqa: PLR0913 is_partial = bool(packages) logger = InstallLogger(verbose=verbose, dry_run=dry_run, partial=is_partial) - # W2-pkg-rollback (#827): snapshot bytes captured BEFORE - # _validate_and_add_packages_to_apm_yml mutates apm.yml. Initialised - # to None here -- BEFORE any branch that might raise (e.g. the local - # bundle early-exit path below) -- so the `except` handlers at the - # bottom of this function can always reference both names without - # UnboundLocalError. The bug this prevents: an exception raised in - # the local-bundle branch (e.g. a click.Abort from integrity-verify - # failure on Windows) would otherwise be masked by an - # UnboundLocalError inside the handler that calls - # _maybe_rollback_manifest(_snapshot_manifest_path, ...). - _manifest_snapshot: bytes | None = None - _snapshot_manifest_path: Path | None = None - # Resolve --legacy-skill-paths: CLI flag wins, then env var fallback. if not legacy_skill_paths: from ..integration.targets import should_use_legacy_skill_paths @@ -1353,12 +1295,6 @@ def install( # noqa: PLR0913 if verbose: os.environ["APM_VERBOSE"] = "1" - # W2-pkg-rollback (#827): snapshot bytes captured BEFORE - # _validate_and_add_packages_to_apm_yml mutates apm.yml. - # NOTE: variables are initialised at the top of the try block - # (above the local-bundle early-exit) so exception handlers can - # always reference them without UnboundLocalError. - # ---------------------------------------------------------------- # --mcp branch (W3): when --mcp is set, route to the dedicated # MCP-add path. We compute the post-`--` argv here BEFORE Click's @@ -1450,6 +1386,7 @@ def install( # noqa: PLR0913 ensure_user_dirs, get_apm_dir, get_manifest_path, + get_modules_dir, warn_unsupported_user_scope, ) @@ -1481,8 +1418,14 @@ def install( # noqa: PLR0913 # CommandLogger / DiagnosticCollector instead of stderr/inline writes. auth_resolver.set_logger(logger) - # Check if apm.yml exists + # Capture manifest state before this attempt can auto-create apm.yml. apm_yml_exists = manifest_path.exists() + transaction = InstallTransaction( + manifest_path=manifest_path, + apm_modules_dir=get_modules_dir(scope), + validation=None, + logger=logger, + ) if not apm_yml_exists and packages: project_name = Path.cwd().name if scope is InstallScope.PROJECT else Path.home().name @@ -1507,15 +1450,6 @@ def install( # noqa: PLR0913 outcome = None if packages: - # -- W2-pkg-rollback (#827): snapshot raw bytes BEFORE mutation -- - # _validate_and_add_packages_to_apm_yml does a YAML round-trip - # (load + dump) which may alter whitespace, key ordering, or - # trailing newlines. We snapshot the raw bytes so rollback is - # byte-exact -- no YAML drift. - if manifest_path.exists(): - _manifest_snapshot = manifest_path.read_bytes() - _snapshot_manifest_path = manifest_path - _validated_packages, outcome = _validate_and_add_packages_to_apm_yml( packages, dry_run, @@ -1528,122 +1462,172 @@ def install( # noqa: PLR0913 skill_subset=_skill_subset, skill_subset_from_cli=bool(skill_names), ) - # Short-circuit: all packages failed validation -- nothing to install - if outcome.all_failed: - return + transaction.record_validation(outcome) + command_result = transaction.validation_result() + if command_result is not None: + summary_rendered = True # Note: Empty validated_packages is OK if packages are already in apm.yml; # only_packages is derived from validation outcomes below. - # Build install context - install_ctx = InstallContext( - scope=scope, - manifest_path=manifest_path, - manifest_display=manifest_display, - apm_dir=apm_dir, - project_root=project_root, - logger=logger, - auth_resolver=auth_resolver, - verbose=verbose, - force=force, - dry_run=dry_run, - update=update, - dev=dev, - runtime=runtime, - exclude=exclude, - target=target, - parallel_downloads=parallel_downloads, - allow_insecure=allow_insecure, - allow_insecure_hosts=allow_insecure_hosts, - protocol_pref=protocol_pref, - allow_protocol_fallback=allow_protocol_fallback, - trust_transitive_mcp=trust_transitive_mcp, - no_policy=no_policy, - audit_override=audit_override, - install_mode=InstallMode(only) if only else InstallMode.ALL, - packages=packages, - refresh=refresh, - only_packages=only_packages_from_validation(packages, outcome), - manifest_snapshot=_manifest_snapshot, - snapshot_manifest_path=_snapshot_manifest_path, - legacy_skill_paths=legacy_skill_paths, - frozen=frozen, - plan_callback=None, - skill_subset=_skill_subset, - skill_subset_from_cli=bool(skill_names), - ) + if command_result is None: + install_ctx = InstallContext( + scope=scope, + manifest_path=manifest_path, + manifest_display=manifest_display, + apm_dir=apm_dir, + project_root=project_root, + logger=logger, + auth_resolver=auth_resolver, + verbose=verbose, + force=force, + dry_run=dry_run, + update=update, + dev=dev, + runtime=runtime, + exclude=exclude, + target=target, + parallel_downloads=parallel_downloads, + allow_insecure=allow_insecure, + allow_insecure_hosts=allow_insecure_hosts, + protocol_pref=protocol_pref, + allow_protocol_fallback=allow_protocol_fallback, + trust_transitive_mcp=trust_transitive_mcp, + no_policy=no_policy, + audit_override=audit_override, + install_mode=InstallMode(only) if only else InstallMode.ALL, + packages=packages, + transaction=transaction, + refresh=refresh, + only_packages=only_packages_from_validation(packages, outcome), + legacy_skill_paths=legacy_skill_paths, + frozen=frozen, + plan_callback=None, + skill_subset=_skill_subset, + skill_subset_from_cli=bool(skill_names), + ) - apm_count, mcp_count, lsp_count, apm_diagnostics = _install_apm_packages( - install_ctx, - outcome, - ) + apm_count, mcp_count, lsp_count, apm_diagnostics = _install_apm_packages( + install_ctx, + outcome, + ) - _post_install_summary( - logger=logger, - apm_count=apm_count, - mcp_count=mcp_count, - lsp_count=lsp_count, - apm_diagnostics=apm_diagnostics, - force=force, - elapsed_seconds=time.perf_counter() - install_started_at, - ) - summary_rendered = True - - if frozen and apm_count > 0: - # --frozen verifies LOCKFILE STRUCTURE (every apm.yml dep - # has a lock entry), not on-disk content integrity. Make - # the scope explicit so a CI pipeline that skips - # 'apm audit' on the assumption that --frozen covers SHA - # verification is corrected at the moment of use. - _rich_info( - "Lockfile presence verified. Run 'apm audit' for on-disk content integrity.", - symbol="info", + from apm_cli.install.outcome import finalize_install_result + + command_result = install_ctx.install_result or InstallResult( + installed_count=apm_count, + diagnostics=apm_diagnostics, + ) + command_result.installed_count = apm_count + command_result.diagnostics = apm_diagnostics + if dry_run: + command_result.disposition = InstallDisposition.DRY_RUN + command_result = finalize_install_result( + command_result, + force=force, ) + command_result = transaction.complete(command_result) - except InsecureDependencyPolicyError: - _maybe_rollback_manifest(_snapshot_manifest_path, _manifest_snapshot, logger) - sys.exit(1) + command_result = _post_install_summary( + logger=logger, + apm_count=apm_count, + mcp_count=mcp_count, + lsp_count=lsp_count, + apm_diagnostics=apm_diagnostics, + force=force, + elapsed_seconds=time.perf_counter() - install_started_at, + result=command_result, + ) + summary_rendered = True + + if frozen and apm_count > 0: + # --frozen verifies lockfile structure, not content integrity. + logger.info( + "Lockfile presence verified. Run 'apm audit' for on-disk content integrity.", + symbol="info", + ) + + except InsecureDependencyPolicyError as e: + command_result = ( + transaction.fail(e) + if transaction is not None + else InstallResult(disposition=InstallDisposition.FAILED, exit_code=1, error=e) + ) + except InstallFailureAlreadyRendered as e: + command_result = ( + transaction.fail(e) + if transaction is not None + else InstallResult(disposition=InstallDisposition.FAILED, exit_code=1, error=e) + ) except AuthenticationError as e: - _maybe_rollback_manifest(_snapshot_manifest_path, _manifest_snapshot, logger) - _rich_error(str(e)) + logger.error(str(e)) if e.diagnostic_context: - _rich_echo(e.diagnostic_context) - _rich_info("Tip: run 'apm doctor' to diagnose auth and connectivity.", symbol="info") - sys.exit(1) + logger.error_detail(e.diagnostic_context) + logger.info("Tip: run 'apm doctor' to diagnose auth and connectivity.") + command_result = ( + transaction.fail(e) + if transaction is not None + else InstallResult(disposition=InstallDisposition.FAILED, exit_code=1, error=e) + ) + except FrozenInstallError as e: + logger.error(str(e)) + for reason in e.reasons: + logger.error_detail(reason) + logger.info("Tip: run 'apm outdated' to see what changed, then 'apm update'.") + command_result = ( + transaction.fail(e) + if transaction is not None + else InstallResult(disposition=InstallDisposition.FAILED, exit_code=1, error=e) + ) except DirectDependencyError as e: - _maybe_rollback_manifest(_snapshot_manifest_path, _manifest_snapshot, logger) logger.error(str(e)) - sys.exit(1) + command_result = ( + transaction.fail(e) + if transaction is not None + else InstallResult(disposition=InstallDisposition.FAILED, exit_code=1, error=e) + ) except click.UsageError: # Conflict matrix / argv parser raises UsageError -- let Click # render with exit code 2 and the standard "Usage: ..." prefix. raise except Exception as e: - _maybe_rollback_manifest(_snapshot_manifest_path, _manifest_snapshot, logger) - if logger: - logger.error(f"Error installing dependencies: {e}") - if not verbose: - logger.progress("Run with --verbose for detailed diagnostics") - else: - _rich_error(f"Error installing dependencies: {e}") - sys.exit(1) + (logger or InstallLogger(verbose=verbose, dry_run=dry_run)).exception( + f"Error installing dependencies: {e}" + ) + command_result = ( + transaction.fail(e) + if transaction is not None + else InstallResult(disposition=InstallDisposition.FAILED, exit_code=1, error=e) + ) finally: # --root: restore cwd + clear the source-root override regardless # of how the handler exits (return, sys.exit -> SystemExit, # exception). Done first so cwd is back to $PWD before any # best-effort summary rendering below. _root_redirect.__exit__(None, None, None) + if transaction is not None: + transaction.__exit__(*sys.exc_info()) # F5 (#1116): render minimal elapsed-time line on exit paths that # did not already render the full install summary. Best-effort: # never let a render failure mask the original exception/exit. if not summary_rendered and logger is not None: with contextlib.suppress(Exception): - logger.install_interrupted(elapsed_seconds=time.perf_counter() - install_started_at) + elapsed = time.perf_counter() - install_started_at + if ( + command_result is not None + and command_result.disposition is InstallDisposition.FAILED + ): + logger.install_failed(elapsed_seconds=elapsed) + else: + logger.install_interrupted(elapsed_seconds=elapsed) # HACK(#852) cleanup: restore APM_VERBOSE so it stays scoped to this call. if _apm_verbose_prev is None: os.environ.pop("APM_VERBOSE", None) else: os.environ["APM_VERBOSE"] = _apm_verbose_prev + if command_result is not None: + ctx.exit(command_result.exit_code) + # --------------------------------------------------------------------------- # install() decomposition: APM pipeline + post-install summary @@ -1676,7 +1660,7 @@ def _install_apm_packages(ctx, outcome): apm_package = APMPackage.from_apm_yml(ctx.manifest_path) except Exception as e: logger.error(f"Failed to parse {ctx.manifest_display}: {e}") - sys.exit(1) + raise InstallFailureAlreadyRendered("Failed to parse install manifest") from e apm_deps = apm_package.get_apm_dependencies() dev_apm_deps = apm_package.get_dev_apm_dependencies() @@ -1749,12 +1733,16 @@ def _install_apm_packages(ctx, outcome): old_mcp_servers: builtins.set = builtins.set() old_mcp_configs: builtins.dict = {} old_mcp_provenance: builtins.dict = {} + old_mcp_target_servers: builtins.dict = {} + old_mcp_target_servers_present = True _lock_path = get_lockfile_path(ctx.apm_dir) _existing_lock = LockFile.read(_lock_path) if _existing_lock: old_mcp_servers = builtins.set(_existing_lock.mcp_servers) old_mcp_configs = builtins.dict(_existing_lock.mcp_configs) old_mcp_provenance = builtins.dict(_existing_lock.mcp_config_provenance) + old_mcp_target_servers = builtins.dict(_existing_lock.mcp_target_servers) + old_mcp_target_servers_present = _existing_lock._mcp_target_servers_present # Enter the APM install path when there are deps, local .apm/ primitives # (#714), OR orphan deps in the lockfile to clean up (manifest emptied). @@ -1769,6 +1757,7 @@ def _install_apm_packages(ctx, outcome): and any(k != _LOCK_SELF_KEY for k in _existing_lock.dependencies) ) apm_diagnostics = None + install_result = None if should_install_apm and ( has_any_apm_deps or _project_has_root_primitives(_cli_project_root) @@ -1777,7 +1766,7 @@ def _install_apm_packages(ctx, outcome): if not APM_DEPS_AVAILABLE: logger.error("APM dependency system not available") logger.progress(f"Import error: {_APM_IMPORT_ERROR}") - sys.exit(1) + raise InstallFailureAlreadyRendered("APM dependency system not available") try: # If specific packages were requested, only install those @@ -1810,32 +1799,39 @@ def _install_apm_packages(ctx, outcome): skill_subset=ctx.skill_subset, skill_subset_from_cli=ctx.skill_subset_from_cli, refresh=ctx.refresh, + transaction=ctx.transaction, ) + if not isinstance(install_result, InstallResult): + install_result = InstallResult( + installed_count=int(getattr(install_result, "installed_count", 0)), + diagnostics=getattr(install_result, "diagnostics", None), + ) apm_count = install_result.installed_count apm_diagnostics = install_result.diagnostics + if install_result.disposition not in { + InstallDisposition.SUCCESS, + InstallDisposition.PARTIAL_SUCCESS, + }: + ctx.install_result = install_result + return apm_count, 0, 0, apm_diagnostics except InsecureDependencyPolicyError: - _maybe_rollback_manifest(ctx.snapshot_manifest_path, ctx.manifest_snapshot, logger) - sys.exit(1) + raise except AuthenticationError as e: # #1015: render auth diagnostics on the DEFAULT path (not --verbose). - _maybe_rollback_manifest(ctx.snapshot_manifest_path, ctx.manifest_snapshot, logger) - _rich_error(str(e)) + logger.error(str(e)) if e.diagnostic_context: - _rich_echo(e.diagnostic_context) - _rich_info("Tip: run 'apm doctor' to diagnose auth and connectivity.", symbol="info") - sys.exit(1) + logger.error_detail(e.diagnostic_context) + logger.info("Tip: run 'apm doctor' to diagnose auth and connectivity.") + raise InstallFailureAlreadyRendered(str(e)) from e except FrozenInstallError as e: - _maybe_rollback_manifest(ctx.snapshot_manifest_path, ctx.manifest_snapshot, logger) - _rich_error(str(e)) + logger.error(str(e)) for reason in e.reasons: - _rich_echo(reason) - _rich_info( - "Tip: run 'apm outdated' to see what changed, then 'apm update'.", - symbol="info", - ) - sys.exit(1) + logger.error_detail(reason) + logger.info("Tip: run 'apm outdated' to see what changed, then 'apm update'.") + raise InstallFailureAlreadyRendered(str(e)) from e + except InstallFailureAlreadyRendered: + raise except Exception as e: - _maybe_rollback_manifest(ctx.snapshot_manifest_path, ctx.manifest_snapshot, logger) # #832: surface PolicyViolationError verbatim (no double-nesting). msg = ( str(e) @@ -1845,7 +1841,7 @@ def _install_apm_packages(ctx, outcome): logger.error(msg) if not ctx.verbose: logger.progress("Run with --verbose for detailed diagnostics") - sys.exit(1) + raise InstallFailureAlreadyRendered(msg) from e elif should_install_apm and not has_any_apm_deps: logger.verbose_detail("No APM dependencies found in apm.yml") @@ -1875,6 +1871,8 @@ def _install_apm_packages(ctx, outcome): old_mcp_servers=old_mcp_servers, old_mcp_configs=old_mcp_configs, old_mcp_provenance=old_mcp_provenance, + old_mcp_target_servers=old_mcp_target_servers, + old_mcp_target_servers_present=old_mcp_target_servers_present, project_root=ctx.project_root, user_scope=(ctx.scope is InstallScope.USER), should_install=should_install_mcp, @@ -1894,7 +1892,7 @@ def _install_apm_packages(ctx, outcome): "APM packages remain installed; MCP configs were NOT written." ) logger.render_summary() - sys.exit(1) + raise InstallFailureAlreadyRendered("MCP server(s) blocked by org policy") from None # ------------------------------------------------------------------------- # LSP integration (extracted to install/lsp/integration.py) @@ -1920,11 +1918,25 @@ def _install_apm_packages(ctx, outcome): # initialization, and inline stale-cleanup block that lived here # have been removed. + from apm_cli.install.outcome import finalize_install_result + + command_result = install_result or InstallResult() + command_result.installed_count = apm_count + command_result.diagnostics = apm_diagnostics + ctx.install_result = finalize_install_result(command_result, force=ctx.force) return apm_count, mcp_count, lsp_count, apm_diagnostics def _post_install_summary( - *, logger, apm_count, mcp_count, lsp_count=0, apm_diagnostics, force, elapsed_seconds=None + *, + logger, + apm_count, + mcp_count, + lsp_count=0, + apm_diagnostics, + force, + elapsed_seconds=None, + result=None, ): """Thin shim forwarding to :func:`apm_cli.install.summary.render_post_install_summary`. @@ -1934,7 +1946,7 @@ def _post_install_summary( """ from apm_cli.install.summary import render_post_install_summary - render_post_install_summary( + return render_post_install_summary( logger=logger, apm_count=apm_count, mcp_count=mcp_count, @@ -1942,6 +1954,7 @@ def _post_install_summary( apm_diagnostics=apm_diagnostics, force=force, elapsed_seconds=elapsed_seconds, + result=result, ) @@ -1990,6 +2003,7 @@ def _install_apm_dependencies( # noqa: PLR0913 plan_callback=None, refresh: bool = False, lockfile_only: bool = False, + transaction: "InstallTransaction | None" = None, ): """Thin wrapper -- builds an :class:`InstallRequest` and delegates to :class:`apm_cli.install.service.InstallService`. @@ -2030,5 +2044,6 @@ def _install_apm_dependencies( # noqa: PLR0913 plan_callback=plan_callback, refresh=refresh, lockfile_only=lockfile_only, + transaction=transaction, ) return InstallService().run(request) diff --git a/src/apm_cli/commands/lock.py b/src/apm_cli/commands/lock.py index 3a82267c9..c19abc267 100644 --- a/src/apm_cli/commands/lock.py +++ b/src/apm_cli/commands/lock.py @@ -55,7 +55,6 @@ _rich_error, _rich_info, _rich_success, - set_console_stderr, ) from ._helpers import _find_apm_yml @@ -291,10 +290,6 @@ def lock_export(fmt: str, output: str | None, global_: bool, timestamp: str | No from apm_cli.deps.lockfile import LockFile, get_lockfile_path from apm_cli.export.sbom import export_sbom - # The SBOM streams to stdout (for `apm lock export | jq`); route every - # diagnostic to stderr so it can never corrupt the machine-readable payload. - set_console_stderr(True) - if global_: project_root = get_apm_dir(InstallScope.USER) else: diff --git a/src/apm_cli/commands/mcp.py b/src/apm_cli/commands/mcp.py index f334138ec..1f1fdbf1f 100644 --- a/src/apm_cli/commands/mcp.py +++ b/src/apm_cli/commands/mcp.py @@ -26,23 +26,29 @@ def _build_registry_with_diag(console, logger): """ from ..registry.integration import RegistryIntegration - registry = RegistryIntegration() override = os.environ.get(MCP_REGISTRY_ENV) + config_url = None + if not override: + from ..config import get_mcp_registry_url as _get_mcp_registry_url + + config_url = _get_mcp_registry_url() + registry = ( + RegistryIntegration(config_url) + if config_url + else RegistryIntegration() # architecture-authority-exempt: env/default fallback + ) if override: url = registry.client.registry_url if console: console.print(f"[muted]Registry: {url} (from MCP_REGISTRY_URL)[/muted]") else: logger.progress(f"Registry: {url} (from MCP_REGISTRY_URL)") - else: - from ..config import get_mcp_registry_url as _get_mcp_registry_url - - config_url = _get_mcp_registry_url() - if config_url: - if console: - console.print(f"[muted]Registry: {config_url} (from apm config)[/muted]") - else: - logger.progress(f"Registry: {config_url} (from apm config)") + elif config_url: + url = registry.client.registry_url + if console: + console.print(f"[muted]Registry: {url} (from apm config)[/muted]") + else: + logger.progress(f"Registry: {url} (from apm config)") return registry diff --git a/src/apm_cli/commands/pack.py b/src/apm_cli/commands/pack.py index 51df3ef41..ca9f7203c 100644 --- a/src/apm_cli/commands/pack.py +++ b/src/apm_cli/commands/pack.py @@ -15,7 +15,6 @@ ) from ..core.command_logger import CommandLogger from ..core.target_detection import TargetParamType -from ..utils.console import set_console_stderr MARKETPLACE_DOCS_URL = ( "https://microsoft.github.io/apm/producer/publish-to-a-marketplace/#consume-from-any-assistant" @@ -298,10 +297,6 @@ def pack_cmd( # noqa: PLR0913 -- Click handler, one param per CLI option check_clean, ): """Pack APM artifacts: bundle and/or marketplace.json.""" - # -- Stream discipline: under --json, route ALL output to stderr -- - if json_output: - set_console_stderr(True) - logger = CommandLogger("pack", verbose=verbose, dry_run=dry_run) # Error when --archive-format is explicitly set but --archive is not. diff --git a/src/apm_cli/commands/policy.py b/src/apm_cli/commands/policy.py index a241555b6..06f1cb6b9 100644 --- a/src/apm_cli/commands/policy.py +++ b/src/apm_cli/commands/policy.py @@ -199,6 +199,7 @@ def _build_report( "cached": bool(result.cached), "fetch_error": result.fetch_error, "error": result.error, + "warnings": result.warnings, "extends_chain": chain, "rule_counts": counts, "rule_summary": _summarize_rules(counts), @@ -228,6 +229,10 @@ def _render_table(report: dict[str, Any]) -> None: "Effective rules", "; ".join(report["rule_summary"]) if report["rule_summary"] else "none", ), + ( + "Warnings", + "; ".join(report["warnings"]) if report["warnings"] else "none", + ), ] console = _get_console() diff --git a/src/apm_cli/commands/runtime.py b/src/apm_cli/commands/runtime.py index faadd30be..7edb07b37 100644 --- a/src/apm_cli/commands/runtime.py +++ b/src/apm_cli/commands/runtime.py @@ -6,6 +6,7 @@ import click from ..core.command_logger import CommandLogger +from ..runtime.registry import runtime_names from ..utils.console import ( STATUS_SYMBOLS, _rich_panel, @@ -23,7 +24,7 @@ def runtime(): @runtime.command(help="Set up a runtime") -@click.argument("runtime_name", type=click.Choice(["copilot", "codex", "llm", "gemini"])) +@click.argument("runtime_name", type=click.Choice(runtime_names())) @click.option("--version", help="Specific version to install") @click.option( "--vanilla", @@ -119,7 +120,7 @@ def list(): # noqa: F811 @runtime.command(help="Remove an installed runtime") -@click.argument("runtime_name", type=click.Choice(["copilot", "codex", "llm", "gemini"])) +@click.argument("runtime_name", type=click.Choice(runtime_names())) @click.confirmation_option( "--yes", "-y", diff --git a/src/apm_cli/commands/uninstall/cli.py b/src/apm_cli/commands/uninstall/cli.py index 34a25073b..d133f362e 100644 --- a/src/apm_cli/commands/uninstall/cli.py +++ b/src/apm_cli/commands/uninstall/cli.py @@ -206,9 +206,10 @@ def uninstall(ctx, packages, dry_run, verbose, global_): BaseIntegrator.normalize_managed_files(all_deployed_files) or builtins.set() ) - # Step 8: Update lockfile + # Step 8: Mutate dependency state in memory. Persistence happens once + # after survivor ownership, hashes, ledger, and MCP state agree. + lockfile_updated = False if lockfile: - lockfile_updated = False for pkg in packages_to_remove: try: ref = _parse_dependency_entry(pkg) @@ -222,16 +223,6 @@ def uninstall(ctx, packages, dry_run, verbose, global_): if orphan_key in lockfile.dependencies: del lockfile.dependencies[orphan_key] lockfile_updated = True - if lockfile_updated: - try: - if lockfile.dependencies: - lockfile.write(lockfile_path) - else: - lockfile_path.unlink(missing_ok=True) - except Exception: - logger.warning( - "Failed to update lockfile -- it may be out of sync with uninstalled packages." - ) # Step 9: Sync integrations cleaned = { @@ -242,9 +233,11 @@ def uninstall(ctx, packages, dry_run, verbose, global_): "hooks": 0, "instructions": 0, } + surviving_deployed_files = {} + lockfile_ready = True try: apm_package = APMPackage.from_apm_yml(manifest_path) - cleaned = _sync_integrations_after_uninstall( + cleaned, surviving_deployed_files = _sync_integrations_after_uninstall( apm_package, deploy_root, all_deployed_files, @@ -264,6 +257,27 @@ def uninstall(ctx, packages, dry_run, verbose, global_): "Some integrated files may remain. Run `apm install --force` to resync." ) + if lockfile: + try: + from .lockfile_state import reconcile_uninstall_deployment_state + + lockfile_updated = ( + reconcile_uninstall_deployment_state( + lockfile, + deploy_root=deploy_root, + all_deployed_files=all_deployed_files, + surviving_deployed_files=surviving_deployed_files, + ) + or lockfile_updated + ) + except Exception as state_err: + lockfile_ready = False + logger.warning( + "Lockfile state could not be reconciled. " + "Run 'apm install --force' to resync before retrying." + ) + logger.verbose_detail(f"Lockfile reconciliation error: {state_err}") + for label, count in cleaned.items(): if count > 0: logger.progress(f"Cleaned up {count} integrated {label}", symbol="check") @@ -281,10 +295,24 @@ def uninstall(ctx, packages, dry_run, verbose, global_): project_root=deploy_root, user_scope=scope is InstallScope.USER, scope=scope, + persist=False, ) except Exception: logger.warning("MCP cleanup during uninstall failed") + if lockfile and lockfile_updated and lockfile_ready: + try: + from .lockfile_state import lockfile_has_persisted_state + + if lockfile_has_persisted_state(lockfile): + lockfile.write(lockfile_path) + else: + lockfile_path.unlink(missing_ok=True) + except Exception: + logger.warning( + "Failed to update lockfile -- it may be out of sync with uninstalled packages." + ) + # Final summary summary_lines = [f"Removed {len(packages_to_remove)} package(s) from apm.yml"] if removed_from_modules > 0: diff --git a/src/apm_cli/commands/uninstall/engine.py b/src/apm_cli/commands/uninstall/engine.py index 5ad208ca6..5959cd22b 100644 --- a/src/apm_cli/commands/uninstall/engine.py +++ b/src/apm_cli/commands/uninstall/engine.py @@ -446,7 +446,7 @@ def _cleanup_transitive_orphans( if not orphan_dep: continue try: - orphan_ref = DependencyReference.parse(orphan_key) + orphan_ref = orphan_dep.to_dependency_ref() orphan_path = orphan_ref.get_install_path(apm_modules_dir) except ValueError: parts = orphan_key.split("/") @@ -480,6 +480,7 @@ def _sync_integrations_after_uninstall( When *user_scope* is ``True``, targets are resolved for user-level deployment so cleanup and re-integration use the correct paths. """ + from ...install.services import _deployed_path_entry, _skill_bundle_file_entries from ...integration.base_integrator import BaseIntegrator from ...integration.dispatch import get_dispatch_table from ...integration.targets import resolve_targets @@ -497,7 +498,7 @@ def _sync_integrations_after_uninstall( _integrators = {name: entry.integrator_class() for name, entry in _dispatch.items()} # Resolve targets once -- used for both Phase 1 removal and Phase 2 re-integration. - config_target = apm_package.target + config_target = list(apm_package.canonical_targets) _explicit = config_target or None _resolved_targets = resolve_targets( project_root, user_scope=user_scope, explicit_target=_explicit @@ -524,6 +525,7 @@ def _sync_integrations_after_uninstall( _buckets = None counts = {entry.counter_key: 0 for entry in _dispatch.values()} + package_deployed_files: dict[str, list[str]] = {} # Phase 1: Remove all APM-deployed files # Per-target sync for primitives with sync_for_target @@ -605,7 +607,7 @@ def _sync_integrations_after_uninstall( # Scan sync_managed DIRECTLY for copilot-app-db:// entries. # The copilot-app target is opt-in: resolve_targets() excludes it from the # default user-scope set unless --target copilot-app was passed at install - # time and recorded on apm_package.target. Without this scan, prompts + # time and recorded on the package's canonical target list. Without this scan, prompts # deployed to ~/.copilot/data.db would never be deleted on uninstall # because the per-target loop above does not iterate copilot-app. if sync_managed: @@ -666,6 +668,8 @@ def _sync_integrations_after_uninstall( dependency_ref=dep_ref, package_type=result.package_type if result else None, ) + dep_key = dep_ref.get_unique_key() + deployed_files = package_deployed_files.setdefault(dep_key, []) try: for _target in _targets: @@ -673,21 +677,28 @@ def _sync_integrations_after_uninstall( _entry = _dispatch.get(_prim_name) if not _entry or _entry.multi_target: continue - getattr(_integrators[_prim_name], _entry.integrate_method)( + integration_result = getattr(_integrators[_prim_name], _entry.integrate_method)( _target, pkg_info, project_root, ) - _integrators["skills"].integrate_package_skill( + deployed_files.extend( + _deployed_path_entry(path, project_root, _targets) + for path in integration_result.target_paths + ) + skill_result = _integrators["skills"].integrate_package_skill( pkg_info, project_root, targets=_targets, ) + for path in skill_result.target_paths: + deployed_files.append(_deployed_path_entry(path, project_root, _targets)) + deployed_files.extend(_skill_bundle_file_entries(path, project_root, _targets)) except Exception: pkg_id = dep_ref.get_identity() if hasattr(dep_ref, "get_identity") else str(dep_ref) logger.warning(f"Best-effort re-integration skipped for {pkg_id}") - return counts + return counts, package_deployed_files def _cleanup_stale_mcp( @@ -699,20 +710,21 @@ def _cleanup_stale_mcp( project_root=None, user_scope: bool = False, scope=None, + persist: bool = True, ): """Remove MCP servers that are no longer needed after uninstall.""" if not old_mcp_servers: return + from apm_cli.integration.mcp_config_view import CurrentMcpConfigView + apm_modules_path = modules_dir if modules_dir is not None else Path.cwd() / APM_MODULES_DIR - remaining_mcp = MCPIntegrator.collect_transitive( - apm_modules_path, lockfile_path, trust_private=True + view = CurrentMcpConfigView.derive( + apm_package, + lockfile, + apm_modules_path, + trust_transitive_self_defined=True, ) - try: - remaining_root_mcp = apm_package.get_mcp_dependencies() - except Exception: - remaining_root_mcp = [] - all_remaining_mcp = MCPIntegrator.deduplicate(remaining_root_mcp + remaining_mcp) - new_mcp_servers = MCPIntegrator.get_server_names(all_remaining_mcp) + new_mcp_servers = MCPIntegrator.get_server_names(view.dependencies) stale_servers = old_mcp_servers - new_mcp_servers if stale_servers: MCPIntegrator.remove_stale( @@ -721,4 +733,25 @@ def _cleanup_stale_mcp( user_scope=user_scope, scope=scope, ) - MCPIntegrator.update_lockfile(new_mcp_servers, lockfile_path) + if persist: + MCPIntegrator.update_lockfile( + new_mcp_servers, + lockfile_path, + mcp_configs=dict(view.configs), + mcp_config_provenance=dict(view.provenance), + ) + return + + lockfile.mcp_servers = sorted(new_mcp_servers) + lockfile.mcp_configs = dict(view.configs) + lockfile.mcp_config_provenance = dict(view.provenance) + from apm_cli.core.deployment_ledger import DeploymentLedgerCodec + + DeploymentLedgerCodec.replace_mcp_target_servers( + lockfile, + { + runtime: sorted(set(servers).intersection(new_mcp_servers)) + for runtime, servers in lockfile.mcp_target_servers.items() + if set(servers).intersection(new_mcp_servers) + }, + ) diff --git a/src/apm_cli/commands/uninstall/lockfile_state.py b/src/apm_cli/commands/uninstall/lockfile_state.py new file mode 100644 index 000000000..799b0656b --- /dev/null +++ b/src/apm_cli/commands/uninstall/lockfile_state.py @@ -0,0 +1,78 @@ +"""In-memory uninstall reconciliation for one atomic lockfile write.""" + +from __future__ import annotations + +from pathlib import Path + + +def reconcile_uninstall_deployment_state( + lockfile, + *, + deploy_root: Path, + all_deployed_files: set[str], + surviving_deployed_files: dict[str, set[str]], +) -> bool: + """Reconcile survivor ownership and hashes without persisting.""" + from apm_cli.core.deployment_ledger import DeploymentLedgerCodec + from apm_cli.core.deployment_state import ( + DeploymentIntent, + DeploymentReconciler, + MaterializationResult, + MaterializationStatus, + NativePayloadValidation, + ) + from apm_cli.install.phases.lockfile import compute_deployed_hashes + from apm_cli.integration.targets import KNOWN_TARGETS + from apm_cli.utils.diagnostics import DiagnosticCollector + + previous_ledger = DeploymentLedgerCodec.from_lockfile(lockfile) + records_by_value = {} + for record in previous_ledger.records.values(): + records_by_value.setdefault(record.locator.value, []).append(record) + materializations: list[MaterializationResult] = [] + for dep_key, deployed_files in surviving_deployed_files.items(): + transferred = sorted(all_deployed_files.intersection(deployed_files)) + hashes = compute_deployed_hashes(transferred, deploy_root) + for path in transferred: + for prior in records_by_value.get(path, ()): + materializations.append( + MaterializationResult( + locator=prior.locator, + owners=frozenset({dep_key}), + status=MaterializationStatus.UNCHANGED, + content_hash=hashes.get(path), + validation=NativePayloadValidation( + valid=True, + contract="uninstall-survivor", + ), + ) + ) + + reconciled = DeploymentReconciler( + deploy_root, + KNOWN_TARGETS, + diagnostics=DiagnosticCollector(), + ).reconcile( + previous_ledger, + materializations, + DeploymentIntent( + active_targets=frozenset(), + declared_targets=None, + desired_owners=frozenset({".", *lockfile.dependencies}), + authoritative_targets=False, + ), + ) + if reconciled.changed: + DeploymentLedgerCodec.apply_to_lockfile(reconciled.ledger, lockfile) + return reconciled.changed + + +def lockfile_has_persisted_state(lockfile) -> bool: + """Return whether uninstall should keep rather than remove the lockfile.""" + return bool( + lockfile.dependencies + or lockfile.mcp_servers + or lockfile.lsp_servers + or lockfile.local_deployed_files + or lockfile.deployment_ledger.records + ) diff --git a/src/apm_cli/commands/update.py b/src/apm_cli/commands/update.py index 7b719fc14..340b2d098 100644 --- a/src/apm_cli/commands/update.py +++ b/src/apm_cli/commands/update.py @@ -37,10 +37,9 @@ * ``--force`` -- overwrite locally-authored files on collision. * ``--parallel-downloads`` -- max concurrent package downloads (0 disables parallelism). -* ``--target``/``-t`` -- agent harness(es) to deploy to (e.g. - ``claude``, ``copilot``, ``cursor``, ``windsurf``, ``kiro``, - ``codex``, ``opencode``, ``gemini``); comma-separated for multiple targets. - Overrides ``apm.yml targets:`` and auto-detection. +* ``--target``/``-t`` -- agent harness(es) to deploy to; comma-separated + for multiple targets. The generated command help lists every accepted + value. Overrides ``apm.yml targets:`` and auto-detection. These flags make ``apm update`` a strict superset of the deprecated ``apm deps update`` (issue #1525). ``apm install --update`` remains the @@ -60,6 +59,7 @@ from ..core.auth import AuthResolver from ..core.command_logger import InstallLogger +from ..core.target_catalog import target_help_fragment from ..core.target_detection import TargetParamType from ..deps.github_downloader import GitHubPackageDownloader from ..deps.revision_pins import ( @@ -224,10 +224,12 @@ def _run_mcp_lsp_integration( old_mcp_servers: set = set() old_mcp_configs: dict = {} old_mcp_provenance: dict = {} + old_mcp_target_servers: dict = {} if existing_lock: old_mcp_servers = set(existing_lock.mcp_servers) old_mcp_configs = dict(existing_lock.mcp_configs) old_mcp_provenance = dict(existing_lock.mcp_config_provenance) + old_mcp_target_servers = dict(existing_lock.mcp_target_servers) apm_modules_path = get_modules_dir(scope) user_scope = scope is InstallScope.USER @@ -241,6 +243,7 @@ def _run_mcp_lsp_integration( old_mcp_servers=old_mcp_servers, old_mcp_configs=old_mcp_configs, old_mcp_provenance=old_mcp_provenance, + old_mcp_target_servers=old_mcp_target_servers, project_root=project_root, user_scope=user_scope, should_install=True, @@ -333,8 +336,7 @@ def _run_mcp_lsp_integration( type=TargetParamType(), default=None, help=( - "Agent target(s) to update for " - "(e.g. claude, copilot, cursor, windsurf, kiro, codex, opencode, gemini). " + f"Agent target(s) to update for. {target_help_fragment('update')} " "Comma-separated for multiple: --target claude,cursor. " "Highest-priority entry in the resolution chain " "(--target > apm.yml targets: > auto-detect)." @@ -675,6 +677,19 @@ def _plan_callback(plan: UpdatePlan) -> bool: if plan is None or not isinstance(plan, UpdatePlan): return + reconcile_noop = not dry_run and not plan.has_changes and not revision_pin_updates + if plan_state.proceeded or reconcile_noop: + from apm_cli.install.manifest_reconcile import reconcile_project_deployed_state + + reconcile_project_deployed_state( + Path.cwd(), + explicit_target=target, + deploy_root=_mcp_lsp_project_root, + lock_root=_apm_dir, + user_scope=scope is InstallScope.USER, + verbose=verbose, + ) + if plan_state.proceeded: if revision_pin_updates: try: diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index 52a6c1c76..7e50a4ea2 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Any, Callable, NamedTuple # noqa: UP035 +from ..core.target_catalog import accepted_target_values, get_target_capability from ..core.target_detection import ( CompileTargetType, get_dedup_rules_dir, @@ -21,7 +22,6 @@ ) from ..primitives.discovery import discover_primitives from ..primitives.models import PrimitiveCollection -from ..utils.atomic_io import write_text_lf from ..utils.path_security import PathTraversalError, ensure_path_within from ..utils.paths import portable_relpath from ..version import get_version @@ -38,23 +38,16 @@ _logger = logging.getLogger(__name__) -# User-facing target aliases that map to the canonical "vscode" target. -# Kept in sync with target_detection.detect_target(). -_VSCODE_TARGET_ALIASES = ("copilot", "agents") -_KNOWN_TARGETS = ( # noqa: RUF005 - "vscode", - "claude", - "cursor", - "opencode", - "codex", - "agent-skills", - "gemini", - "antigravity", - "windsurf", - "kiro", - "all", - "minimal", -) + _VSCODE_TARGET_ALIASES +_COPILOT_CAPABILITY = get_target_capability("copilot") +_VSCODE_TARGET_ALIASES = ( + _COPILOT_CAPABILITY.name, + *( + alias + for alias in _COPILOT_CAPABILITY.aliases + if alias != _COPILOT_CAPABILITY.compile_family + ), +) +_KNOWN_TARGETS = accepted_target_values("compile") | {"minimal"} _COPILOT_ROOT_GENERATED_MARKER = "" @@ -309,14 +302,18 @@ def is_apm_managed(self) -> bool: def _hand_authored_claude_skip_message( - rel: str, *, dry_run: bool = False, preview: bool = False + rel: str, + *, + dry_run: bool = False, + preview: bool = False, + duplicate_context: bool = True, ) -> str: """Build consistent skip guidance for hand-authored CLAUDE.md files.""" prefix = "[dry-run] would skip removal" if preview or dry_run else "Skipped removal" - return ( - f"{prefix} of {rel}: hand-authored file will not be deleted." - " Delete or rename it manually if duplicate context is unwanted." - ) + message = f"{prefix} of {rel}: hand-authored file will not be deleted." + if duplicate_context: + message += " Delete or rename it manually if duplicate context is unwanted." + return message class AgentsCompiler: @@ -444,8 +441,8 @@ def compile( if should_compile_gemini_md(routing_target): results.append(self._compile_gemini_md(config, primitives)) - # Some targets (e.g. cursor, agent-skills) use the data-driven - # integration layer and don't need compilation. + # Some targets (e.g. agent-skills) use only the data-driven + # integration layer and have no root-context compilation output. if not results: if logger and config.target == "agent-skills": logger.progress( @@ -676,12 +673,22 @@ def _compile_distributed( successful_writes = 0 total_content_entries = len(distributed_result.content_map) # noqa: F841 + pending_outputs: dict[Path, str] = {} for agents_path, content in distributed_result.content_map.items(): try: - self._write_distributed_file(agents_path, content, config) - successful_writes += 1 + pending_outputs[agents_path] = self._prepare_distributed_file( + agents_path, content, config + ) except (OSError, ValueError) as e: self.errors.append(f"Failed to write {agents_path}: {e!s}") + if pending_outputs: + from .output_writer import CompiledOutputPolicyError, CompiledOutputWriter + + try: + CompiledOutputWriter().write_many(pending_outputs) + successful_writes = len(pending_outputs) + except (OSError, CompiledOutputPolicyError) as exc: + self.errors.append(f"Failed to write distributed output batch: {exc}") # Update stats with actual files written if distributed_result.stats: @@ -872,11 +879,14 @@ def _compile_claude_md( all_warnings = self.warnings + claude_result.warnings all_errors = self.errors + claude_result.errors - # would_emit_no_claude_md is True when the formatter produced no CLAUDE.md - # files because skip_instructions fired (all content already in .claude/rules/). - # Used symmetrically in the dry-run preview block and the live-removal block so - # both paths share a single, precise emptiness signal. - would_emit_no_claude_md = len(claude_result.content_map) == 0 and skip_instructions + # Fires when content moved to .claude/rules/ or no source primitives remain. + # Dry-run preview, live removal, and formatter suppression share this signal. + would_emit_no_claude_md = len(claude_result.content_map) == 0 + orphan_reason = ( + "instructions now live in .claude/rules/" + if skip_instructions + else "no source primitives remain" + ) # Handle dry-run mode if config.dry_run: @@ -913,10 +923,7 @@ def _compile_claude_md( # result.content -- issue #1729 fix). self._log("warning", det.read_error) elif det.is_apm_managed: - removal_msg = ( - f"[dry-run] would remove stale {det.rel} -- instructions now" - " live in .claude/rules/" - ) + removal_msg = f"[dry-run] would remove stale {det.rel} -- {orphan_reason}" preview_lines.append(f" {removal_msg}") # Emit the preview through the logger so it reaches the terminal # on the distributed dry-run path (cli.py does `pass` on dry-run @@ -931,11 +938,17 @@ def _compile_claude_md( # would skip deletion and warn. Surface the same intent in # dry-run so users are not surprised by the live outcome. hand_authored_preview = _hand_authored_claude_skip_message( - det.rel, preview=True + det.rel, + preview=True, + duplicate_context=skip_instructions, ) preview_lines.append(f" {hand_authored_preview}") all_warnings.append( - _hand_authored_claude_skip_message(det.rel, dry_run=True) + _hand_authored_claude_skip_message( + det.rel, + dry_run=True, + duplicate_context=skip_instructions, + ) ) # Emit via logger so it surfaces on the distributed dry-run path # (cli.py does `pass` on dry-run success and never prints @@ -954,54 +967,53 @@ def _compile_claude_md( # Write CLAUDE.md files files_written = 0 critical_security_found = False - from ..security.gate import WARN_POLICY, SecurityGate - from .output_writer import CompiledOutputWriter + from .output_writer import CompiledOutputPolicyError, CompiledOutputWriter writer = CompiledOutputWriter() + pending_outputs: dict[Path, str] = {} for claude_path, content in claude_result.content_map.items(): - try: - # Handle constitution injection if enabled - final_content = content - if config.with_constitution: - try: - from .injector import ConstitutionInjector - - injector = ConstitutionInjector(str(claude_path.parent)) - final_content, _, _ = injector.inject( - content, with_constitution=True, output_path=claude_path - ) - except Exception as exc: - _logger.debug("Constitution injection failed for %s: %s", claude_path, exc) + final_content = content + if config.with_constitution: + try: + from .injector import ConstitutionInjector - # Defense-in-depth: scan compiled output before writing - verdict = SecurityGate.scan_text( - final_content, str(claude_path), policy=WARN_POLICY - ) - actionable = verdict.critical_count + verdict.warning_count - if actionable: - if verdict.has_critical: - critical_security_found = True - all_warnings.append( - f"CLAUDE.md contains {actionable} hidden character(s) " - f"— run 'apm audit --file {claude_path}' to inspect" + injector = ConstitutionInjector(str(claude_path.parent)) + final_content, _, _ = injector.inject( + content, with_constitution=True, output_path=claude_path ) - - writer.write(claude_path, final_content) - files_written += 1 - except OSError as e: - all_errors.append(f"Failed to write {claude_path}: {e!s}") + except Exception as exc: + _logger.debug("Constitution injection failed for %s: %s", claude_path, exc) + pending_outputs[claude_path] = final_content + try: + verdict = writer.write_many(pending_outputs) + files_written = len(pending_outputs) + for filename, findings in verdict.findings_by_file.items(): + all_warnings.append( + f"CLAUDE.md contains {len(findings)} hidden character(s) " + f"-- run 'apm audit --file {filename}' to inspect" + ) + except CompiledOutputPolicyError as exc: + critical_security_found = True + all_errors.append(str(exc)) + except OSError as exc: + all_errors.append(f"Failed to write CLAUDE.md output batch: {exc!s}") # Update stats stats = claude_result.stats.copy() stats["claude_files_written"] = files_written + stats["claude_empty_due_to_no_primitives"] = ( + would_emit_no_claude_md and not skip_instructions + ) if would_emit_no_claude_md: - self._log( - "progress", - "CLAUDE.md not generated -- Claude Code reads .claude/rules/ directly," - " no further action needed", - symbol="info", - ) + if skip_instructions: + no_output_message = ( + "CLAUDE.md not generated -- Claude Code reads .claude/rules/" + " directly, no further action needed" + ) + else: + no_output_message = "CLAUDE.md not generated -- no source primitives remain" + self._log("progress", no_output_message, symbol="info") # Remove a stale APM-generated CLAUDE.md when --clean is set. # A hand-authored file (no CLAUDE_HEADER marker) is never deleted; # a warning is emitted instead to match the Copilot-root convention. @@ -1023,14 +1035,17 @@ def _compile_claude_md( # needed (unlike the progress() calls elsewhere here). self._log( "success", - f"Removed stale {det.rel} -- instructions now live in .claude/rules/", + f"Removed stale {det.rel} -- {orphan_reason}", ) except (OSError, PathTraversalError) as exc: warning = f"Could not remove {det.rel}: {exc!s}" all_warnings.append(warning) self._log("warning", warning) else: - warning = _hand_authored_claude_skip_message(det.rel) + warning = _hand_authored_claude_skip_message( + det.rel, + duplicate_context=skip_instructions, + ) all_warnings.append(warning) self._log("warning", warning) elif distributed_compiler is None and files_written > 0 and not config.dry_run: @@ -1056,7 +1071,7 @@ def _compile_claude_md( if distributed_compiler is not None else None ) - if compilation_results and not (skip_instructions and files_written == 0): + if compilation_results and not would_emit_no_claude_md: # Update target name for CLAUDE.md output formatter_results = CompilationResults( project_analysis=compilation_results.project_analysis, @@ -1135,15 +1150,14 @@ def _compile_gemini_md( ) files_written = 0 - from .output_writer import CompiledOutputWriter + from .output_writer import CompiledOutputPolicyError, CompiledOutputWriter writer = CompiledOutputWriter() - for gemini_path, content in gemini_result.content_map.items(): - try: - writer.write(gemini_path, content) - files_written += 1 - except OSError as e: - all_errors.append(f"Failed to write {gemini_path}: {e!s}") + try: + writer.write_many(gemini_result.content_map) + files_written = len(gemini_result.content_map) + except (OSError, CompiledOutputPolicyError) as exc: + all_errors.append(f"Failed to write GEMINI.md output batch: {exc!s}") stats = gemini_result.stats.copy() stats["gemini_files_written"] = files_written @@ -1397,24 +1411,27 @@ def _maybe_emit_copilot_root_instructions( if config.dry_run: return result - from ..security.gate import WARN_POLICY, SecurityGate - - verdict = SecurityGate.scan_text(content, str(output_path), policy=WARN_POLICY) - actionable = verdict.critical_count + verdict.warning_count - if actionable: - if verdict.has_critical: - result.has_critical_security = True - result.warnings.append( - f"copilot-instructions.md contains {actionable} hidden character(s) " - f"-- run 'apm audit --file {output_path}' to inspect" - ) - try: - output_path.parent.mkdir(parents=True, exist_ok=True) - write_text_lf(output_path, content) + from .output_writer import CompiledOutputPolicyError, CompiledOutputWriter + + verdict = CompiledOutputWriter().write(output_path, content) + actionable = verdict.critical_count + verdict.warning_count + if actionable: + result.warnings.append( + f"copilot-instructions.md contains {actionable} hidden character(s) " + f"-- run 'apm audit --file {output_path}' to inspect" + ) result.stats["copilot_root_instructions_written"] = 1 result.stats["copilot_root_instructions_unchanged"] = 0 return result + except CompiledOutputPolicyError as exc: + result.has_critical_security = True + message = str(exc) + self.errors.append(message) + result.errors.append(message) + result.success = False + result.stats["copilot_root_instructions_written"] = 0 + return result except OSError as exc: message = f"Failed to write {output_path}: {exc}" self.errors.append(message) @@ -1532,9 +1549,20 @@ def _write_output_file_with_config( content (str): Generated content for this compilation. config (CompilationConfig): Compilation configuration. """ - from .managed_section import ManagedSectionError, apply_managed_section from .output_writer import CompiledOutputWriter + content = self._prepare_output_content_with_config(output_path, content, config) + try: + CompiledOutputWriter().write(Path(output_path), content) + except OSError as e: + self.errors.append(f"Failed to write output file {output_path}: {e!s}") + + def _prepare_output_content_with_config( + self, output_path: str, content: str, config: "CompilationConfig" + ) -> str: + """Build managed-section or full content without mutating disk.""" + from .managed_section import ManagedSectionError, apply_managed_section + if config.agents_md_mode == "managed_section": target = Path(output_path) if not target.is_file(): @@ -1559,10 +1587,7 @@ def _write_output_file_with_config( "Supported values: 'full', 'managed_section'." ) - try: - CompiledOutputWriter().write(Path(output_path), content) - except OSError as e: - self.errors.append(f"Failed to write output file {output_path}: {e!s}") + return content def _compile_stats( self, primitives: PrimitiveCollection, template_data: TemplateData @@ -1586,10 +1611,10 @@ def _compile_stats( "version": template_data.version, } - def _write_distributed_file( + def _prepare_distributed_file( self, agents_path: Path, content: str, config: CompilationConfig - ) -> None: - """Write a distributed AGENTS.md file with constitution injection support. + ) -> str: + """Build one distributed AGENTS.md output without mutating disk. Args: agents_path (Path): Path to write the AGENTS.md file. @@ -1616,15 +1641,22 @@ def _write_distributed_file( # Sub-directory files are fully APM-generated and always overwritten. is_root = agents_path.parent.resolve() == self.base_dir.resolve() if is_root and config.agents_md_mode == "managed_section": - self._write_output_file_with_config(str(agents_path), final_content, config) - return + return self._prepare_output_content_with_config( + str(agents_path), final_content, config + ) + return final_content - from .output_writer import CompiledOutputWriter + except OSError as e: + raise OSError(f"Failed to prepare distributed AGENTS.md file {agents_path}: {e!s}") # noqa: B904 - CompiledOutputWriter().write(agents_path, final_content) + def _write_distributed_file( + self, agents_path: Path, content: str, config: CompilationConfig + ) -> None: + """Write one distributed file through the canonical writer.""" + from .output_writer import CompiledOutputWriter - except OSError as e: - raise OSError(f"Failed to write distributed AGENTS.md file {agents_path}: {e!s}") # noqa: B904 + final_content = self._prepare_distributed_file(agents_path, content, config) + CompiledOutputWriter().write(agents_path, final_content) def _display_placement_preview(self, distributed_result) -> None: """Display placement preview for --show-placement mode. diff --git a/src/apm_cli/compilation/output_writer.py b/src/apm_cli/compilation/output_writer.py index 65693789f..396e9528e 100644 --- a/src/apm_cli/compilation/output_writer.py +++ b/src/apm_cli/compilation/output_writer.py @@ -27,23 +27,57 @@ so it surfaces as a loud traceback rather than a silent skip. """ +from collections.abc import Mapping from pathlib import Path +from ..security.gate import BLOCK_POLICY, ScanVerdict, SecurityGate from ..utils.atomic_io import atomic_write_text from .build_id import stabilize_build_id from .constants import BUILD_ID_PLACEHOLDER +class CompiledOutputPolicyError(RuntimeError): + """Raised before mutation when compiled output violates blocking policy.""" + + def __init__(self, verdict: ScanVerdict): + super().__init__( + f"Compiled output blocked: {verdict.critical_count} critical " + "hidden-character finding(s)" + ) + self.verdict = verdict + + class CompiledOutputWriter: """Persist compiled output with cross-cutting concerns applied.""" - def write(self, path: Path, content: str) -> None: - final = stabilize_build_id(content) - if BUILD_ID_PLACEHOLDER in final: - raise RuntimeError( - "build_id stabilization bypassed: " - f"{BUILD_ID_PLACEHOLDER!r} still present after stabilization " - f"(target={path})" - ) - path.parent.mkdir(parents=True, exist_ok=True) - atomic_write_text(path, final) + def prepare(self, outputs: Mapping[Path, str]) -> tuple[dict[Path, str], ScanVerdict]: + """Stabilize and scan a complete output batch before mutation.""" + prepared: dict[Path, str] = {} + for path, content in outputs.items(): + final = stabilize_build_id(content) + if BUILD_ID_PLACEHOLDER in final: + raise RuntimeError( + "build_id stabilization bypassed: " + f"{BUILD_ID_PLACEHOLDER!r} still present after stabilization " + f"(target={path})" + ) + prepared[path] = final + verdict = SecurityGate.scan_texts( + {str(path): content for path, content in prepared.items()}, + policy=BLOCK_POLICY, + ) + if verdict.should_block: + raise CompiledOutputPolicyError(verdict) + return prepared, verdict + + def write_many(self, outputs: Mapping[Path, str]) -> ScanVerdict: + """Validate the whole batch, then persist only through atomic writes.""" + prepared, verdict = self.prepare(outputs) + for path, final in prepared.items(): + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_text(path, final) + return verdict + + def write(self, path: Path, content: str) -> ScanVerdict: + """Validate and persist one compiled output.""" + return self.write_many({path: content}) diff --git a/src/apm_cli/compilation/user_root_context.py b/src/apm_cli/compilation/user_root_context.py index b5dbff7f4..2b1769009 100644 --- a/src/apm_cli/compilation/user_root_context.py +++ b/src/apm_cli/compilation/user_root_context.py @@ -28,8 +28,6 @@ from pathlib import Path from typing import TYPE_CHECKING -from ..utils.atomic_io import write_text_lf - if TYPE_CHECKING: import logging as _logging_module @@ -186,6 +184,7 @@ def compile_user_root_contexts( log = logger or logging.getLogger(__name__) results: list[UserRootCompileResult] = [] + pending: list[tuple[int, str, Path, str]] = [] apm_modules = source_root / "apm_modules" if not apm_modules.is_dir(): @@ -264,42 +263,41 @@ def compile_user_root_contexts( results.append(UserRootCompileResult(scoped.name, output_path, "would-write")) continue + index = len(results) + results.append(UserRootCompileResult(scoped.name, output_path, "pending")) + pending.append((index, scoped.name, output_path, content)) + + if pending: + from .output_writer import CompiledOutputPolicyError, CompiledOutputWriter + try: - output_path.parent.mkdir(parents=True, exist_ok=True) - from ..security.gate import BLOCK_POLICY, SecurityGate - - verdict = SecurityGate.scan_text(content, str(output_path), policy=BLOCK_POLICY) - actionable = verdict.critical_count + verdict.warning_count - if actionable: - log.warning( - "user_root_context: %s contains %s hidden character(s) " - "-- run 'apm audit --file %s' to inspect", - output_path, - actionable, - output_path, - ) - if verdict.should_block: - results.append( - UserRootCompileResult( - scoped.name, - output_path, - "error:critical hidden characters in compiled output", - has_critical_security=True, - ) - ) - continue - write_text_lf(output_path, content) - log.debug("user_root_context: wrote %s", output_path) - results.append( - UserRootCompileResult( - scoped.name, - output_path, - "written", - has_critical_security=verdict.has_critical, - ) + verdict = CompiledOutputWriter().write_many( + {path: content for _, _, path, content in pending} ) + except CompiledOutputPolicyError: + for index, name, path, _ in pending: + results[index] = UserRootCompileResult( + name, + path, + "error:critical hidden characters in compiled output", + has_critical_security=True, + ) except OSError as exc: - log.warning("user_root_context: failed to write %s: %s", output_path, exc) - results.append(UserRootCompileResult(scoped.name, output_path, f"error:{exc}")) + log.warning("user_root_context: failed to write output batch: %s", exc) + for index, name, path, _ in pending: + results[index] = UserRootCompileResult(name, path, f"error:{exc}") + else: + for index, name, path, _ in pending: + findings = verdict.findings_by_file.get(str(path), []) + if findings: + log.warning( + "user_root_context: %s contains %s hidden character(s) " + "-- run 'apm audit --file %s' to inspect", + path, + len(findings), + path, + ) + log.debug("user_root_context: wrote %s", path) + results[index] = UserRootCompileResult(name, path, "written") return results diff --git a/src/apm_cli/core/apm_yml.py b/src/apm_cli/core/apm_yml.py index 91fcf8e36..24eff320a 100644 --- a/src/apm_cli/core/apm_yml.py +++ b/src/apm_cli/core/apm_yml.py @@ -20,28 +20,17 @@ render_conflicting_schema_error, render_unknown_target_error, ) +from apm_cli.core.target_catalog import TARGET_CAPABILITIES, manifest_target_names # Canonical target names accepted by APM. -CANONICAL_TARGETS: frozenset[str] = frozenset( - { - "claude", - "copilot", - "cursor", - "opencode", - "codex", - "gemini", - "antigravity", - "windsurf", - "kiro", - "agent-skills", - } -) +CANONICAL_TARGETS: frozenset[str] = manifest_target_names() def _validate_canonical(tokens: list[str]) -> None: """Validate every token is in CANONICAL_TARGETS. Raises UnknownTargetError.""" for token in tokens: - if token not in CANONICAL_TARGETS: + capability = TARGET_CAPABILITIES.get(token) + if capability is None or capability.experimental_flag is not None or capability.mcp_only: raise UnknownTargetError(render_unknown_target_error(token, sorted(CANONICAL_TARGETS))) diff --git a/src/apm_cli/core/auth.py b/src/apm_cli/core/auth.py index 9d5e19347..ebe865867 100644 --- a/src/apm_cli/core/auth.py +++ b/src/apm_cli/core/auth.py @@ -33,16 +33,20 @@ import re import sys import threading +import traceback from collections.abc import Callable from dataclasses import dataclass, field from typing import TYPE_CHECKING, NamedTuple, TypeVar +from apm_cli.core.host_providers import ( + HOST_PROVIDERS, + classify_host_provider, +) from apm_cli.core.token_manager import GitHubTokenManager from apm_cli.utils.github_host import ( default_host, is_azure_devops_hostname, is_gitlab_hostname, - is_valid_fqdn, ) if TYPE_CHECKING: @@ -103,6 +107,10 @@ def filter(self, record: logging.LogRecord) -> bool: if redacted != msg: record.msg = redacted record.args = () + if record.exc_info is not None: + formatted = "".join(traceback.format_exception(*record.exc_info)) + record.exc_text = _redact_secrets(formatted) + record.exc_info = None except Exception: pass return True @@ -128,6 +136,7 @@ class HostInfo: has_public_repos: bool api_base: str port: int | None = None # Non-standard git port (e.g. 7999 for Bitbucket DC) + credential_purpose: str | None = None @property def display_name(self) -> str: @@ -254,85 +263,14 @@ def classify_host( ``host_type`` is an explicit manifest hint for hosts whose names do not reveal the backing service. """ - h = host.lower() - host_type_value = (host_type or "").strip().lower() - - if h == "github.com": - return HostInfo( - host=host, - kind="github", - has_public_repos=True, - api_base="https://api.github.com", - port=port, - ) - - if h.endswith(".ghe.com"): - return HostInfo( - host=host, - kind="ghe_cloud", - has_public_repos=False, - api_base=f"https://{host}/api/v3", - port=port, - ) - - if is_azure_devops_hostname(host): - return HostInfo( - host=host, - kind="ado", - has_public_repos=True, - api_base="https://dev.azure.com", - port=port, - ) - - if host_type_value == "gitlab": - api_base = "https://gitlab.com/api/v4" if h == "gitlab.com" else f"https://{h}/api/v4" - return HostInfo( - host=host, - kind="gitlab", - has_public_repos=True, - api_base=api_base, - port=port, - ) - if host_type_value: - raise ValueError( - f"Unsupported dependency host type: {host_type_value}. Supported values: gitlab" - ) - - # GHES: GITHUB_HOST is set to a non-github.com, non-ghe.com FQDN - ghes_host = os.environ.get("GITHUB_HOST", "").lower() - if ( - ghes_host - and ghes_host == h - and ghes_host not in {"github.com", "gitlab.com"} - and not ghes_host.endswith(".ghe.com") - ): - if is_valid_fqdn(ghes_host): - return HostInfo( - host=host, - kind="ghes", - has_public_repos=True, - api_base=f"https://{host}/api/v3", - port=port, - ) - - # GitLab (SaaS + env-configured self-managed) -- after GHES per spec (no silent GHES -> GitLab) - if is_gitlab_hostname(host): - api_base = "https://gitlab.com/api/v4" if h == "gitlab.com" else f"https://{h}/api/v4" - return HostInfo( - host=host, - kind="gitlab", - has_public_repos=True, - api_base=api_base, - port=port, - ) - - # Generic FQDN (Bitbucket, self-hosted non-GitLab, etc.) + provider = classify_host_provider(host, host_type=host_type) return HostInfo( host=host, - kind="generic", - has_public_repos=True, - api_base=f"https://{host}/api/v3", + kind=provider.kind, + has_public_repos=provider.has_public_repos, + api_base=provider.api_base(host.lower()), port=port, + credential_purpose=provider.credential_purpose, ) # -- token type detection ----------------------------------------------- @@ -507,6 +445,7 @@ def try_with_fallback( auth_ctx = self.resolve(host, org, port=port) host_info = auth_ctx.host_info git_env = auth_ctx.git_env + unauth_env = self._build_git_env(None, host_kind=host_info.kind) def _log(msg: str) -> None: if verbose_callback: @@ -634,7 +573,7 @@ def _try_ado_bearer_fallback(exc: Exception) -> T: # Validation path: save rate limits, EMU-safe try: _log(f"Trying unauthenticated access to {host_info.display_name}") - return operation(None, git_env) + return operation(None, unauth_env) except Exception as exc: # operation is caller-provided; broad catch required -- cannot narrow # without restricting the caller API. Use %r so the type is visible. @@ -675,7 +614,7 @@ def _try_ado_bearer_fallback(exc: Exception) -> T: if host_info.has_public_repos: _log("Authenticated failed, retrying without token") try: - return operation(None, git_env) + return operation(None, unauth_env) except Exception as unauth_exc: # operation is caller-provided; broad catch required. logger.debug( @@ -687,7 +626,7 @@ def _try_ado_bearer_fallback(exc: Exception) -> T: return _try_credential_fallback(exc) else: _log(f"No token available, trying unauthenticated access to {host_info.display_name}") - return operation(None, git_env) + return operation(None, unauth_env) # -- error context ------------------------------------------------------ @@ -979,13 +918,7 @@ def _resolve_token(self, host_info: HostInfo, org: str | None) -> tuple[str | No @staticmethod def _purpose_for_host(host_info: HostInfo) -> str: - if host_info.kind == "ado": - return "ado_modules" - if host_info.kind == "gitlab": - return "gitlab_modules" - if host_info.kind == "generic": - return "generic_modules" - return "modules" + return host_info.credential_purpose or HOST_PROVIDERS[host_info.kind].credential_purpose def _identify_env_source(self, purpose: str) -> str: """Return the name of the first env var that matched for *purpose*.""" @@ -1008,6 +941,7 @@ def _build_git_env( For all other cases, behavior is unchanged. """ env = os.environ.copy() + AuthResolver._clear_git_auth_env(env) env["GIT_TERMINAL_PROMPT"] = "0" env["GIT_ASKPASS"] = "echo" if scheme == "bearer" and token and host_kind == "ado": @@ -1015,11 +949,6 @@ def _build_git_env( # GIT_CONFIG_VALUE_0 only; GIT_TOKEN here would leak it into every # child-process env (visible in /proc//environ, ps eww). # - # #1214 follow-up: a stale GIT_TOKEN already in the parent env - # (set by a prior shell, CI step, or another tool) would survive - # the os.environ.copy() above and defeat the isolation guarantee. - # Drop it explicitly so the bearer env is clean by construction. - env.pop("GIT_TOKEN", None) from apm_cli.utils.github_host import build_ado_bearer_git_env env.update(build_ado_bearer_git_env(token)) @@ -1027,6 +956,34 @@ def _build_git_env( env["GIT_TOKEN"] = token return env + @staticmethod + def _clear_git_auth_env(env: dict) -> None: + """Remove inherited Git authorization channels before an attempt.""" + env.pop("GIT_TOKEN", None) + env.pop("GIT_HTTP_EXTRAHEADER", None) + env.pop("GIT_CONFIG_PARAMETERS", None) + try: + count = int(env.pop("GIT_CONFIG_COUNT", "0")) + except ValueError: + count = 0 + retained: list[tuple[str, str]] = [] + for index in range(max(0, count)): + key = env.pop(f"GIT_CONFIG_KEY_{index}", "") + value = env.pop(f"GIT_CONFIG_VALUE_{index}", "") + normalized = key.lower() + if "extraheader" in normalized or "authorization" in value.lower(): + continue + if key: + retained.append((key, value)) + for key in tuple(env): + if key.startswith(("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")): + env.pop(key, None) + if retained: + env["GIT_CONFIG_COUNT"] = str(len(retained)) + for index, (key, value) in enumerate(retained): + env[f"GIT_CONFIG_KEY_{index}"] = key + env[f"GIT_CONFIG_VALUE_{index}"] = value + def emit_stale_pat_diagnostic(self, host_display: str) -> None: """Emit a [!] warning when PAT was rejected but bearer succeeded. @@ -1150,7 +1107,12 @@ def execute_with_bearer_fallback( raises (exceptions from ``bearer_op`` are swallowed). """ primary = primary_op() - if dep_ref is None or not getattr(dep_ref, "is_azure_devops", lambda: False)(): + is_ado = ( + is_azure_devops_hostname(dep_ref) + if isinstance(dep_ref, str) + else dep_ref is not None and getattr(dep_ref, "is_azure_devops", lambda: False)() + ) + if not is_ado: return BearerFallbackOutcome(primary, False) if not is_auth_failure(primary): return BearerFallbackOutcome(primary, False) @@ -1182,7 +1144,8 @@ def execute_with_bearer_fallback( return BearerFallbackOutcome(primary, True) if fallback is None or is_auth_failure(fallback): return BearerFallbackOutcome(primary, True) - host_display = getattr(dep_ref, "host", None) or "dev.azure.com" + host_display = dep_ref if isinstance(dep_ref, str) else getattr(dep_ref, "host", None) + host_display = host_display or "dev.azure.com" self.emit_stale_pat_diagnostic(host_display) return BearerFallbackOutcome(fallback, True) diff --git a/src/apm_cli/core/command_logger.py b/src/apm_cli/core/command_logger.py index f45a9c8de..b32f33d92 100644 --- a/src/apm_cli/core/command_logger.py +++ b/src/apm_cli/core/command_logger.py @@ -7,6 +7,7 @@ from dataclasses import dataclass +from apm_cli.models.results import InstallDisposition from apm_cli.utils.console import ( _rich_echo, _rich_error, @@ -128,6 +129,16 @@ def error(self, message: str, symbol: str = "error"): """Log an error.""" _rich_error(message, symbol=symbol) + def error_detail(self, message: str): + """Log supporting error context that must remain visible by default.""" + _rich_echo(message) + + def exception(self, message: str): + """Log an unexpected error and own the verbose recovery hint.""" + self.error(message) + if not self.verbose: + self.info("Run with --verbose for detailed diagnostics") + def verbose_detail(self, message: str): """Log a detail only when verbose mode is enabled.""" if self.verbose: @@ -762,6 +773,7 @@ def install_summary( errors: int = 0, stale_cleaned: int = 0, elapsed_seconds: float | None = None, + disposition: InstallDisposition = InstallDisposition.SUCCESS, ): """Log final install summary. @@ -798,13 +810,33 @@ def install_summary( if elapsed_seconds is not None: timing_suffix = f" in {elapsed_seconds:.1f}s" - if parts: + if disposition is InstallDisposition.DRY_RUN: + summary = " and ".join(parts) if parts else "no changes" + _rich_info( + f"Dry run completed: would install {summary}{timing_suffix}.", + symbol="info", + ) + elif disposition is InstallDisposition.FAILED: + error_suffix = f" with {errors} error(s)" if errors > 0 else "" + _rich_error( + "Installation failed" + f"{error_suffix}{timing_suffix}. " + "No install transaction changes were committed.", + symbol="error", + ) + elif parts: summary = " and ".join(parts) if errors > 0: _rich_warning( f"Installed {summary}{cleanup_suffix}{timing_suffix} with {errors} error(s).", symbol="warning", ) + elif disposition is InstallDisposition.PARTIAL_SUCCESS: + _rich_warning( + f"Installed {summary}{cleanup_suffix}{timing_suffix}; " + "some requested packages were skipped.", + symbol="warning", + ) else: _rich_success( f"Installed {summary}{cleanup_suffix}{timing_suffix}.", @@ -834,3 +866,10 @@ def install_interrupted(self, elapsed_seconds: float): f"Install interrupted after {elapsed_seconds:.1f}s.", symbol="warning", ) + + def install_failed(self, elapsed_seconds: float) -> None: + """Render a handled terminal failure when no full summary ran.""" + _rich_error( + f"Install failed after {elapsed_seconds:.1f}s.", + symbol="error", + ) diff --git a/src/apm_cli/core/deployment_ledger.py b/src/apm_cli/core/deployment_ledger.py new file mode 100644 index 000000000..030462c1a --- /dev/null +++ b/src/apm_cli/core/deployment_ledger.py @@ -0,0 +1,380 @@ +"""Lockfile codec for canonical deployment ownership state.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from apm_cli.core.deployment_state import ( + DeploymentLedger, + DeploymentLocator, + DeploymentRecord, + LocatorKind, +) +from apm_cli.utils.path_security import PathTraversalError, ensure_path_within +from apm_cli.utils.paths import portable_relpath + +if TYPE_CHECKING: + from apm_cli.core.scope import InstallScope + from apm_cli.deps.lockfile import LockFile + from apm_cli.integration.targets import TargetProfile + + +_LEGACY_TARGET_PREFIXES = { + ".github/": "copilot", + ".claude/": "claude", + ".cursor/": "cursor", + ".windsurf/": "windsurf", + ".kiro/": "kiro", + ".gemini/": "gemini", + ".codex/": "codex", + ".opencode/": "opencode", + ".agents/": "agents", +} + + +class DeploymentLedgerCodec: + """Translate canonical deployment records to and from lockfile views.""" + + @staticmethod + def from_lockfile(lockfile: LockFile) -> DeploymentLedger: + """Read canonical rows or synthesize them from legacy ownership views.""" + current = getattr(lockfile, "deployment_ledger", None) + if current is not None and ( + current.records or getattr(lockfile, "_deployments_present", False) + ): + return current + + records: dict[str, DeploymentRecord] = {} + for owner, dependency in lockfile.dependencies.items(): + if owner == ".": + continue + DeploymentLedgerCodec._add_legacy_paths( + records, + owner, + dependency.deployed_files, + dependency.deployed_file_hashes, + ) + DeploymentLedgerCodec._add_legacy_paths( + records, + ".", + lockfile.local_deployed_files, + lockfile.local_deployed_file_hashes, + ) + for runtime, servers in getattr(lockfile, "mcp_target_servers", {}).items(): + for server in servers: + locator = DeploymentLocator( + kind=LocatorKind.URI, + target="mcp", + value=server, + runtime=runtime, + scope="project", + ) + records[locator.key] = DeploymentRecord( + locator=locator, + owners=(".",), + active_owner=".", + content_hash=None, + ) + return DeploymentLedger(records=records) + + @staticmethod + def apply_to_lockfile( + ledger: DeploymentLedger, + lockfile: LockFile, + *, + write_legacy_views: bool = True, + ) -> None: + """Apply the ledger once and optionally maintain one-cycle legacy views.""" + lockfile.deployment_ledger = ledger + lockfile._deployments_present = True + if not write_legacy_views: + return + + dependency_files: dict[str, list[str]] = { + owner: [] for owner in lockfile.dependencies if owner != "." + } + dependency_hashes: dict[str, dict[str, str]] = { + owner: {} for owner in lockfile.dependencies if owner != "." + } + local_files: list[str] = [] + local_hashes: dict[str, str] = {} + mcp_targets: dict[str, list[str]] = {} + + for record in ledger.records.values(): + locator = record.locator + if locator.target == "mcp" and locator.runtime: + mcp_targets.setdefault(locator.runtime, []).append(locator.value) + continue + path = DeploymentLedgerCodec._legacy_value(locator) + for owner in record.owners: + if owner == ".": + local_files.append(path) + if record.content_hash is not None: + local_hashes[path] = record.content_hash + elif owner in dependency_files: + dependency_files[owner].append(path) + if record.content_hash is not None: + dependency_hashes[owner][path] = record.content_hash + + for owner, dependency in lockfile.dependencies.items(): + if owner == ".": + continue + dependency.deployed_files = sorted(dict.fromkeys(dependency_files[owner])) + dependency.deployed_file_hashes = dict(sorted(dependency_hashes[owner].items())) + lockfile.local_deployed_files = sorted(dict.fromkeys(local_files)) + lockfile.local_deployed_file_hashes = dict(sorted(local_hashes.items())) + lockfile.mcp_target_servers = { + runtime: sorted(dict.fromkeys(servers)) + for runtime, servers in sorted(mcp_targets.items()) + } + + @staticmethod + def replace_legacy_owner( + lockfile: LockFile, + owner: str, + files: list[str], + hashes: dict[str, str], + ) -> None: + """Update one compatibility ownership view and invalidate its projection.""" + if owner == ".": + lockfile.local_deployed_files = list(files) + lockfile.local_deployed_file_hashes = dict(hashes) + else: + dependency = lockfile.dependencies[owner] + dependency.deployed_files = list(files) + dependency.deployed_file_hashes = dict(hashes) + lockfile.deployment_ledger = DeploymentLedger(records={}) + lockfile._deployments_present = False + lockfile.deployment_ledger = DeploymentLedgerCodec.from_lockfile(lockfile) + lockfile._deployments_present = True + + @staticmethod + def replace_mcp_target_servers( + lockfile: LockFile, + target_servers: dict[str, list[str]], + ) -> None: + """Update the MCP compatibility view and invalidate its projection.""" + lockfile.mcp_target_servers = { + runtime: list(servers) for runtime, servers in target_servers.items() + } + lockfile._mcp_target_servers_present = True + lockfile.deployment_ledger = DeploymentLedger(records={}) + lockfile._deployments_present = False + lockfile.deployment_ledger = DeploymentLedgerCodec.from_lockfile(lockfile) + lockfile._deployments_present = True + + @staticmethod + def replace_context_local_files(context: Any, files: list[str]) -> None: + """Route transitional install-context ownership mutation through one owner.""" + context.local_deployed_files = list(files) + + @staticmethod + def refresh_from_legacy(lockfile: LockFile) -> None: + """Rebuild canonical rows after a compatibility view mutates in place.""" + lockfile.deployment_ledger = DeploymentLedger(records={}) + lockfile._deployments_present = False + lockfile.deployment_ledger = DeploymentLedgerCodec.from_lockfile(lockfile) + lockfile._deployments_present = True + + @staticmethod + def locator_for_path( + path: Path | str, + *, + project_root: Path, + target: TargetProfile, + runtime: str | None = None, + scope: InstallScope, + ) -> DeploymentLocator: + """Encode a path without forcing external roots into project strings.""" + scope_value = getattr(scope, "value", str(scope)) + if isinstance(path, str) and "://" in path: + return DeploymentLocator( + kind=LocatorKind.URI, + target=target.name, + value=path, + runtime=runtime, + scope=scope_value, + ) + + candidate = Path(path).resolve() + root = project_root.resolve() + try: + project_path = ensure_path_within(candidate, root) + except PathTraversalError: + project_path = None + if project_path is not None: + return DeploymentLocator( + kind=LocatorKind.PROJECT_RELATIVE, + target=target.name, + value=portable_relpath(project_path, root), + runtime=runtime, + scope=scope_value, + ) + + deploy_root = target.managed_deploy_root + if deploy_root is not None: + resolved_deploy_root = deploy_root.resolve() + try: + target_path = ensure_path_within(candidate, resolved_deploy_root) + except PathTraversalError: + target_path = None + if target_path is not None: + return DeploymentLocator( + kind=LocatorKind.TARGET_RELATIVE, + target=target.name, + value=portable_relpath(target_path, resolved_deploy_root), + runtime=runtime, + scope=scope_value, + ) + + raise RuntimeError( + f"Cannot encode deployment path {candidate!r}: path is outside " + "the project and target managed roots." + ) + + @staticmethod + def resolve_locator( + locator: DeploymentLocator, + *, + project_root: Path, + target: TargetProfile, + ) -> Path | str: + """Resolve a persisted locator through its owning target profile.""" + if locator.kind == LocatorKind.URI: + return locator.value + if locator.kind == LocatorKind.PROJECT_RELATIVE: + return ensure_path_within(project_root / locator.value, project_root) + deploy_root = target.managed_deploy_root + if deploy_root is None: + raise RuntimeError(f"Target {target.name!r} has no managed root for {locator.key}") + return ensure_path_within(deploy_root / locator.value, deploy_root) + + @staticmethod + def rows(ledger: DeploymentLedger) -> list[dict[str, Any]]: + """Return deterministic additive lockfile rows.""" + rows: list[dict[str, Any]] = [] + for key in sorted(ledger.records): + record = ledger.records[key] + locator = record.locator + rows.append( + { + "kind": locator.kind.value, + "target": locator.target, + "value": locator.value, + "runtime": locator.runtime, + "scope": locator.scope, + "owners": list(record.owners), + "active_owner": record.active_owner, + "content_hash": record.content_hash, + } + ) + return rows + + @staticmethod + def from_rows(rows: Any) -> DeploymentLedger: + """Parse additive rows, rejecting malformed entries.""" + DeploymentLedgerCodec.validate_rows(rows) + records: dict[str, DeploymentRecord] = {} + for row in rows: + owners = tuple(str(owner) for owner in row.get("owners", ()) if owner) + active_owner = str(row.get("active_owner") or "") + locator = DeploymentLocator( + kind=LocatorKind(str(row["kind"])), + target=str(row["target"]), + value=str(row["value"]), + runtime=str(row["runtime"]) if row.get("runtime") is not None else None, + scope=str(row["scope"]), + ) + records[locator.key] = DeploymentRecord( + locator=locator, + owners=owners, + active_owner=active_owner, + content_hash=( + str(row["content_hash"]) if row.get("content_hash") is not None else None + ), + ) + return DeploymentLedger(records=records) + + @staticmethod + def validate_rows(rows: Any) -> None: + """Reject any malformed deployment row before ledger construction.""" + if not isinstance(rows, list): + raise ValueError("Lockfile deployments must be a list") + for index, row in enumerate(rows): + if not isinstance(row, dict): + raise ValueError(f"Lockfile deployment row {index} must be a mapping") + try: + LocatorKind(str(row["kind"])) + target = row["target"] + value = row["value"] + scope = row["scope"] + except (KeyError, TypeError, ValueError) as exc: + raise ValueError(f"Lockfile deployment row {index} has invalid locator") from exc + if not all(isinstance(item, str) and item for item in (target, value, scope)): + raise ValueError( + f"Lockfile deployment row {index} locator fields must be non-empty strings" + ) + owners = row.get("owners") + active_owner = row.get("active_owner") + if ( + not isinstance(owners, list) + or not owners + or not all(isinstance(owner, str) and owner for owner in owners) + or not isinstance(active_owner, str) + or active_owner not in owners + ): + raise ValueError(f"Lockfile deployment row {index} has invalid owners") + content_hash = row.get("content_hash") + if content_hash is not None and not isinstance(content_hash, str): + raise ValueError( + f"Lockfile deployment row {index} content_hash must be a string or null" + ) + + @staticmethod + def _add_legacy_paths( + records: dict[str, DeploymentRecord], + owner: str, + paths: list[str], + hashes: dict[str, str], + ) -> None: + for value in paths: + locator = DeploymentLedgerCodec._legacy_locator(value) + prior = records.get(locator.key) + owners = list(prior.owners) if prior else [] + if owner in owners: + owners.remove(owner) + owners.append(owner) + records[locator.key] = DeploymentRecord( + locator=locator, + owners=tuple(owners), + active_owner=owner, + content_hash=hashes.get(value) or (prior.content_hash if prior else None), + ) + + @staticmethod + def _legacy_locator(value: str) -> DeploymentLocator: + if "://" in value: + target = value.split("://", 1)[0].removesuffix("-db") + kind = LocatorKind.URI + else: + target = next( + ( + name + for prefix, name in _LEGACY_TARGET_PREFIXES.items() + if value.startswith(prefix) + ), + "legacy", + ) + kind = LocatorKind.PROJECT_RELATIVE + return DeploymentLocator( + kind=kind, + target=target, + value=value, + runtime=None, + scope="project", + ) + + @staticmethod + def _legacy_value(locator: DeploymentLocator) -> str: + return locator.value diff --git a/src/apm_cli/core/deployment_state.py b/src/apm_cli/core/deployment_state.py new file mode 100644 index 000000000..50f108514 --- /dev/null +++ b/src/apm_cli/core/deployment_state.py @@ -0,0 +1,358 @@ +"""Canonical deployment ownership and reconciliation types.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from apm_cli.integration.targets import TargetProfile + from apm_cli.utils.diagnostics import DiagnosticCollector + + +class LocatorKind(str, Enum): + """Storage form used to persist a deployed resource locator.""" + + PROJECT_RELATIVE = "project-relative" + TARGET_RELATIVE = "target-relative" + URI = "uri" + + +class MaterializationStatus(str, Enum): + """Outcome reported by a deployment adapter.""" + + WRITTEN = "written" + ADOPTED = "adopted" + UNCHANGED = "unchanged" + REMOVED = "removed" + SKIPPED = "skipped" + FAILED = "failed" + + +@dataclass(frozen=True) +class DeploymentLocator: + """Stable identity for one deployed file or native runtime entry.""" + + kind: LocatorKind + target: str + value: str + runtime: str | None + scope: str + + @property + def key(self) -> str: + """Return the stable composite ledger key.""" + return f"{self.target}|{self.runtime or ''}|{self.scope}|{self.value}" + + +@dataclass(frozen=True) +class NativePayloadValidation: + """Validation result for an adapter's native payload.""" + + valid: bool + contract: str + errors: tuple[str, ...] = () + + +@dataclass(frozen=True) +class MaterializationResult: + """One adapter materialization and its ownership proof.""" + + locator: DeploymentLocator + owners: frozenset[str] + status: MaterializationStatus + content_hash: str | None + validation: NativePayloadValidation + + +@dataclass(frozen=True) +class DeploymentRecord: + """Persisted ownership metadata for one locator.""" + + locator: DeploymentLocator + owners: tuple[str, ...] + active_owner: str + content_hash: str | None + + def __post_init__(self) -> None: + """Reject records whose active owner has no ownership claim.""" + if self.active_owner not in self.owners: + raise ValueError("active_owner must be present in owners") + + +@dataclass(frozen=True) +class DeploymentLedger: + """Immutable deployment record collection keyed by locator key.""" + + records: Mapping[str, DeploymentRecord] + + +@dataclass(frozen=True) +class DeploymentIntent: + """Target and package universe governing one reconciliation.""" + + active_targets: frozenset[str] + declared_targets: frozenset[str] | None + desired_owners: frozenset[str] | None + authoritative_targets: bool + dry_run: bool = False + + +@dataclass(frozen=True) +class DeploymentReconcileResult: + """Atomic next-ledger decision and cleanup work.""" + + ledger: DeploymentLedger + removed: tuple[DeploymentLocator, ...] + retained: tuple[DeploymentLocator, ...] + owner_handoffs: tuple[tuple[str, str, str], ...] + failed: tuple[DeploymentLocator, ...] + changed: bool + + +@dataclass(frozen=True) +class PackageDeploymentClaims: + """Legacy per-package claims after deterministic ownership handoff.""" + + current_files: tuple[str, ...] + prior_files: tuple[str, ...] + prior_hashes: dict[str, str] + + +_SUCCESS_STATUSES = frozenset( + { + MaterializationStatus.WRITTEN, + MaterializationStatus.ADOPTED, + MaterializationStatus.UNCHANGED, + } +) + + +class DeploymentReconciler: + """Compute the next canonical ledger from adapter materializations.""" + + def __init__( + self, + project_root: Path, + target_profiles: Mapping[str, TargetProfile], + *, + diagnostics: DiagnosticCollector, + ) -> None: + self.project_root = project_root + self.target_profiles = target_profiles + self.diagnostics = diagnostics + + @staticmethod + def reconcile_package_claims( + *, + package_keys: Iterable[str], + current_claims: Mapping[str, Iterable[str]], + prior_files: Mapping[str, Iterable[str]], + prior_hashes: Mapping[str, Mapping[str, str]], + ) -> dict[str, PackageDeploymentClaims]: + """Resolve legacy last-writer claims and prior carry-forward eligibility.""" + normalized_current = {owner: tuple(paths) for owner, paths in current_claims.items()} + last_owner: dict[str, str] = {} + for owner, paths in normalized_current.items(): + for path in paths: + last_owner[path] = owner + + claims: dict[str, PackageDeploymentClaims] = {} + for owner in package_keys: + current = tuple( + path for path in normalized_current.get(owner, ()) if last_owner[path] == owner + ) + eligible_prior = tuple( + path for path in prior_files.get(owner, ()) if last_owner.get(path, owner) == owner + ) + eligible_prior_set = set(eligible_prior) + claims[owner] = PackageDeploymentClaims( + current_files=current, + prior_files=eligible_prior, + prior_hashes={ + path: content_hash + for path, content_hash in prior_hashes.get(owner, {}).items() + if path in eligible_prior_set + }, + ) + return claims + + def reconcile( + self, + previous: DeploymentLedger, + materializations: Iterable[MaterializationResult], + intent: DeploymentIntent, + ) -> DeploymentReconcileResult: + """Return one deterministic next-ledger decision. + + Adapters own native encoding, validation, and physical cleanup. This + method owns ordering, target contraction, ownership transfer, and the + single ledger transition. + """ + results = tuple(materializations) + invalid = tuple(result for result in results if not result.validation.valid) + if invalid: + for result in invalid: + detail = "; ".join(result.validation.errors) + self.diagnostics.error( + ( + f"Rejected invalid {result.validation.contract} payload " + f"for {result.locator.key}" + ), + detail=detail, + ) + return self._unchanged(previous, tuple(result.locator for result in invalid)) + if intent.dry_run: + return self._unchanged(previous, ()) + + successful: dict[str, list[MaterializationResult]] = {} + failed_by_key: dict[str, DeploymentLocator] = {} + removed_by_key: dict[str, DeploymentLocator] = {} + for result in results: + key = result.locator.key + if result.status in _SUCCESS_STATUSES: + successful.setdefault(key, []).append(result) + elif result.status == MaterializationStatus.REMOVED: + removed_by_key[key] = result.locator + elif result.status in { + MaterializationStatus.FAILED, + MaterializationStatus.SKIPPED, + }: + failed_by_key[key] = result.locator + + next_records: dict[str, DeploymentRecord] = {} + removed: list[DeploymentLocator] = [] + retained: list[DeploymentLocator] = [] + handoffs: list[tuple[str, str, str]] = [] + + for key, prior in previous.records.items(): + proofs = successful.get(key) + if proofs: + next_record = self._record_from_proofs(proofs, intent) + if next_record is None: + failed_by_key[key] = prior.locator + next_records[key] = prior + retained.append(prior.locator) + continue + if ( + prior.active_owner != next_record.active_owner + and prior.active_owner not in next_record.owners + ): + handoffs.append((key, prior.active_owner, next_record.active_owner)) + next_records[key] = next_record + continue + + if key in failed_by_key: + next_records[key] = prior + retained.append(prior.locator) + continue + if key in removed_by_key or self._is_stale(prior, intent): + removed.append(prior.locator) + continue + + preserved = self._filter_absent_owners(prior, intent) + if preserved is None: + removed.append(prior.locator) + else: + next_records[key] = preserved + retained.append(prior.locator) + + for key, proofs in successful.items(): + if key in previous.records: + continue + record = self._record_from_proofs(proofs, intent) + if record is None: + failed_by_key[key] = proofs[-1].locator + continue + next_records[key] = record + + next_ledger = DeploymentLedger(records=next_records) + return DeploymentReconcileResult( + ledger=next_ledger, + removed=tuple(removed), + retained=tuple(retained), + owner_handoffs=tuple(handoffs), + failed=tuple(failed_by_key.values()), + changed=dict(previous.records) != next_records, + ) + + def _record_from_proofs( + self, + proofs: list[MaterializationResult], + intent: DeploymentIntent, + ) -> DeploymentRecord | None: + owner_order: list[str] = [] + for proof in proofs: + for owner in sorted(proof.owners): + if intent.desired_owners is not None and owner not in intent.desired_owners: + continue + if owner in owner_order: + owner_order.remove(owner) + owner_order.append(owner) + if not owner_order: + proof = proofs[-1] + self.diagnostics.error(f"Materialization {proof.locator.key} has no ownership metadata") + return None + active_proof = next( + proof + for proof in reversed(proofs) + if any(owner in owner_order for owner in proof.owners) + ) + active_candidates = sorted(set(active_proof.owners).intersection(owner_order)) + active_owner = active_candidates[-1] + return DeploymentRecord( + locator=proofs[-1].locator, + owners=tuple(owner_order), + active_owner=active_owner, + content_hash=active_proof.content_hash, + ) + + def _filter_absent_owners( + self, + prior: DeploymentRecord, + intent: DeploymentIntent, + ) -> DeploymentRecord | None: + if intent.desired_owners is None: + return prior + survivors = tuple(owner for owner in prior.owners if owner in intent.desired_owners) + if not survivors: + return None + if survivors == prior.owners: + return prior + active_owner = ( + prior.active_owner if prior.active_owner in survivors else sorted(survivors)[-1] + ) + return DeploymentRecord( + locator=prior.locator, + owners=survivors, + active_owner=active_owner, + content_hash=prior.content_hash, + ) + + def _is_stale(self, record: DeploymentRecord, intent: DeploymentIntent) -> bool: + target = record.locator.target + if intent.authoritative_targets and target in intent.active_targets: + return True + return ( + intent.declared_targets is not None + and target in self.target_profiles + and target not in intent.declared_targets + and target not in intent.active_targets + ) + + @staticmethod + def _unchanged( + previous: DeploymentLedger, + failed: tuple[DeploymentLocator, ...], + ) -> DeploymentReconcileResult: + return DeploymentReconcileResult( + ledger=previous, + removed=(), + retained=tuple(record.locator for record in previous.records.values()), + owner_handoffs=(), + failed=failed, + changed=False, + ) diff --git a/src/apm_cli/core/errors.py b/src/apm_cli/core/errors.py index b137adc0c..d2b2ebf7c 100644 --- a/src/apm_cli/core/errors.py +++ b/src/apm_cli/core/errors.py @@ -116,28 +116,19 @@ def render_ambiguous_error(project_root: Path | None, detected: list[str]) -> st ) -def render_unknown_target_error(value: str, valid: list[str]) -> str: +def render_unknown_target_error( + value: str, + valid: list[str], + *, + command: str = "install", +) -> str: """Render the 3-section error for unknown target token.""" - # Hide ``agent-skills`` from the user-facing suggestion surface (#1208). - # ``agent-skills`` is a meta-target (multi-harness fan-out to - # ``.agents/skills/``) and is intentionally excluded from the - # ``apm targets`` table -- it has no single ``deploy_dir`` and is not - # what a beginner who mistyped a harness name should be steered toward. - # The canonical set still accepts it so power users who pass it - # explicitly via ``--target agent-skills`` (or list it in apm.yml) - # continue to work; we just don't advertise it here. - visible = [t for t in valid if t != "agent-skills"] - visible_sorted = sorted(visible) + visible_sorted = sorted(valid) suggestion = ( "copilot" if "copilot" in visible_sorted else (visible_sorted[0] if visible_sorted else "claude") ) - # When the caller passes only the hidden meta-target (or an empty - # list), fall back to the safety-net suggestion so the "Valid - # targets:" line never renders as a bare colon. In practice all - # production call sites pass the full canonical set, so this is a - # defense-in-depth path for tests and future callers. valid_csv = ", ".join(visible_sorted) if visible_sorted else suggestion # Strip bracket/quote noise that can leak in from misparsed tokens # (e.g. "['copilot'"). Defense-in-depth: callers should pass clean @@ -145,6 +136,18 @@ def render_unknown_target_error(value: str, valid: list[str]) -> str: # back to the raw value (or "") if stripping consumes # everything, so the headline remains actionable. display_value = value.strip("[]'\" ") or value or "" + if command == "compile": + return ( + f"[x] Unknown target '{display_value}'\n" + "\n" + f"Valid targets: {valid_csv}\n" + "\n" + "Fix with one of:\n" + "\n" + " apm targets # see all supported harnesses\n" + f" apm compile --target {suggestion}\n" + " apm compile --dry-run" + ) return ( f"[x] Unknown target '{display_value}'\n" "\n" diff --git a/src/apm_cli/core/host_providers.py b/src/apm_cli/core/host_providers.py new file mode 100644 index 000000000..1a87bfa62 --- /dev/null +++ b/src/apm_cli/core/host_providers.py @@ -0,0 +1,197 @@ +"""Canonical registry for remote Git host capabilities.""" + +from __future__ import annotations + +import os +from collections.abc import Callable +from dataclasses import dataclass, replace +from types import MappingProxyType +from typing import Any + +from apm_cli.utils.github_host import ( + is_azure_devops_hostname, + is_gitlab_hostname, + is_valid_fqdn, +) + +ApiBaseBuilder = Callable[[str], str] +HostMatcher = Callable[[str], bool] + + +@dataclass(frozen=True) +class HostProviderDescriptor: + """Describe host classification, credentials, and API capabilities.""" + + kind: str + matcher: HostMatcher + api_base: ApiBaseBuilder + has_public_repos: bool + credential_purpose: str + allow_credential_helper: bool = True + manifest_types: tuple[str, ...] = () + + +def _github_api(_host: str) -> str: + return "https://api.github.com" + + +def _ado_api(_host: str) -> str: + return "https://dev.azure.com" + + +def _api_v3(host: str) -> str: + return f"https://{host}/api/v3" + + +def _gitlab_api(host: str) -> str: + return "https://gitlab.com/api/v4" if host == "gitlab.com" else f"https://{host}/api/v4" + + +def _matches_github(host: str) -> bool: + return host == "github.com" + + +def _matches_ghe_cloud(host: str) -> bool: + return host.endswith(".ghe.com") + + +def _matches_ado(host: str) -> bool: + return is_azure_devops_hostname(host) + + +def _matches_ghes(host: str) -> bool: + configured = os.environ.get("GITHUB_HOST", "").lower() + return bool( + configured + and configured == host + and configured not in {"github.com", "gitlab.com"} + and not configured.endswith(".ghe.com") + and is_valid_fqdn(configured) + ) + + +def _matches_gitlab(host: str) -> bool: + return is_gitlab_hostname(host) + + +def _matches_any(_host: str) -> bool: + return True + + +_HOST_PROVIDERS = ( + HostProviderDescriptor( + kind="github", + matcher=_matches_github, + api_base=_github_api, + has_public_repos=True, + credential_purpose="modules", + ), + HostProviderDescriptor( + kind="ghe_cloud", + matcher=_matches_ghe_cloud, + api_base=_api_v3, + has_public_repos=False, + credential_purpose="modules", + ), + HostProviderDescriptor( + kind="ado", + matcher=_matches_ado, + api_base=_ado_api, + has_public_repos=True, + credential_purpose="ado_modules", + allow_credential_helper=False, + ), + HostProviderDescriptor( + kind="ghes", + matcher=_matches_ghes, + api_base=_api_v3, + has_public_repos=True, + credential_purpose="modules", + ), + HostProviderDescriptor( + kind="gitlab", + matcher=_matches_gitlab, + api_base=_gitlab_api, + has_public_repos=True, + credential_purpose="gitlab_modules", + manifest_types=("gitlab",), + ), + HostProviderDescriptor( + kind="generic", + matcher=_matches_any, + api_base=_api_v3, + has_public_repos=True, + credential_purpose="generic_modules", + ), +) + +HOST_PROVIDERS = MappingProxyType({provider.kind: provider for provider in _HOST_PROVIDERS}) +_BACKEND_FACTORIES: dict[str, type[Any]] = {} + + +def host_provider_descriptors() -> tuple[HostProviderDescriptor, ...]: + """Return providers in classification precedence order.""" + return _HOST_PROVIDERS + + +def accepted_host_types() -> tuple[str, ...]: + """Return manifest host-type hints accepted by the registry.""" + return tuple(host_type for provider in _HOST_PROVIDERS for host_type in provider.manifest_types) + + +def classify_host_provider( + host: str, + *, + host_type: str | None = None, +) -> HostProviderDescriptor: + """Classify one host through the canonical provider registry.""" + normalized_host = host.lower() + normalized_type = host_type.strip().lower() if isinstance(host_type, str) else "" + recognized = next( + ( + provider + for provider in _HOST_PROVIDERS + if provider.kind != "generic" and provider.matcher(normalized_host) + ), + None, + ) + if recognized is not None: + if normalized_type and normalized_type not in recognized.manifest_types: + raise ValueError( + f"Dependency host type {normalized_type!r} conflicts with " + f"recognized {recognized.kind!r} host {host!r}" + ) + return recognized + if normalized_type: + for provider in _HOST_PROVIDERS: + if normalized_type in provider.manifest_types: + if not provider.matcher(normalized_host): + # Manifest type controls API routing, not credential trust. + return replace(provider, credential_purpose="generic_modules") + return provider + supported = ", ".join(accepted_host_types()) or "(none)" + raise ValueError( + f"Unsupported dependency host type: {normalized_type}. Supported values: {supported}" + ) + for provider in _HOST_PROVIDERS: + if provider.matcher(normalized_host): + return provider + raise RuntimeError(f"No host provider registered for {host!r}") + + +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: + raise ValueError(f"Cannot register backend for unknown host provider: {kind}") + existing = _BACKEND_FACTORIES.get(kind) + if existing is not None and existing is not backend_factory: + raise ValueError(f"Host backend already registered for provider: {kind}") + _BACKEND_FACTORIES[kind] = backend_factory + + +def host_backend_factory(kind: str) -> type[Any]: + """Return the backend registered for a canonical provider.""" + try: + return _BACKEND_FACTORIES[kind] + except KeyError: + raise RuntimeError(f"No host backend registered for provider: {kind}") from None diff --git a/src/apm_cli/core/output_mode.py b/src/apm_cli/core/output_mode.py new file mode 100644 index 000000000..e32a12536 --- /dev/null +++ b/src/apm_cli/core/output_mode.py @@ -0,0 +1,66 @@ +"""Process-wide stdout mode selected before any CLI notification.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +_MACHINE_FORMATS = frozenset({"json", "sarif"}) + + +@dataclass(frozen=True) +class OutputMode: + """Describe whether stdout is reserved for machine-readable output.""" + + machine_readable: bool = False + + +def _option_has_value( + args: tuple[str, ...], + long_name: str, + short_name: str, + values: frozenset[str], +) -> bool: + """Return whether a Click option carries one of the requested values.""" + for index, arg in enumerate(args): + lower = arg.lower() + if lower in {long_name, short_name}: + if index + 1 < len(args) and args[index + 1].lower() in values: + return True + continue + for prefix in (f"{long_name}=", f"{short_name}=", short_name): + if lower.startswith(prefix) and lower[len(prefix) :] in values: + return True + return False + + +def _contains_command(args: tuple[str, ...], command: tuple[str, ...]) -> bool: + """Return whether command tokens occur contiguously in the raw argv.""" + width = len(command) + return any(args[index : index + width] == command for index in range(len(args) - width + 1)) + + +def detect_output_mode(argv: Sequence[str]) -> OutputMode: + """Detect machine output from the complete command-line intent.""" + args = tuple(argv) + if "--json" in args: + return OutputMode(machine_readable=True) + if _option_has_value(args, "--format", "-f", _MACHINE_FORMATS): + return OutputMode(machine_readable=True) + if _contains_command(args, ("lock", "export")): + return OutputMode(machine_readable=True) + if _contains_command(args, ("policy", "status")) and _option_has_value( + args, + "--output", + "-o", + frozenset({"json"}), + ): + return OutputMode(machine_readable=True) + return OutputMode() + + +def configure_output_mode(mode: OutputMode) -> None: + """Apply process output routing before any console singleton is created.""" + from apm_cli.utils.console import set_console_stderr + + set_console_stderr(mode.machine_readable) diff --git a/src/apm_cli/core/script_runner.py b/src/apm_cli/core/script_runner.py index 3cf115396..e1e00ed27 100644 --- a/src/apm_cli/core/script_runner.py +++ b/src/apm_cli/core/script_runner.py @@ -8,6 +8,11 @@ from pathlib import Path from ..output.script_formatters import ScriptExecutionFormatter +from ..runtime.registry import ( + get_runtime_descriptor, + runtime_descriptors, + runtime_names, +) from ..runtime.utils import find_runtime_binary from .token_manager import setup_runtime_environment @@ -251,7 +256,7 @@ def _auto_compile_prompts( # Check if this is a runtime command before transformation is_runtime_cmd = any( re.search(r"(?:^|\s)" + runtime + r"(?:\s|$)", command) - for runtime in ["copilot", "codex", "llm", "gemini"] + for runtime in runtime_names() ) and re.search(re.escape(prompt_file), command) # Transform command based on runtime pattern @@ -284,7 +289,7 @@ def _transform_runtime_command( """ # Handle environment variables prefix (e.g., "ENV1=val1 ENV2=val2 codex [args] file.prompt.md") # More robust approach: split by runtime commands to separate env vars from command - runtime_commands = ["codex", "copilot", "llm", "gemini"] + runtime_commands = runtime_names() # Try matching with env-var prefix (e.g. "ENV=val codex args file.prompt.md") for runtime_cmd in runtime_commands: @@ -355,13 +360,12 @@ def _parse_and_build_runtime_command( if env_prefix is not None and runtime_cmd != "codex": args_before = args_before.replace("-p", "").strip() - builders = { - "codex": self._build_codex_command, - "copilot": self._build_copilot_command, - "llm": self._build_llm_command, - "gemini": self._build_gemini_command, - } - builder = builders.get(runtime_cmd) + descriptor = get_runtime_descriptor(runtime_cmd) + builder = ( + getattr(self, descriptor.script_builder) + if descriptor.script_builder is not None + else None + ) if builder: return builder(args_before, args_after, env_prefix) return None @@ -480,16 +484,10 @@ def _detect_runtime(self, command: str) -> str: Name of the detected runtime (copilot, codex, llm, gemini, or unknown) """ command_lower = command.lower().strip() - if re.search(r"(?:^|\s)copilot(?:\s|$)", command_lower): - return "copilot" - elif re.search(r"(?:^|\s)codex(?:\s|$)", command_lower): - return "codex" - elif re.search(r"(?:^|\s)llm(?:\s|$)", command_lower): - return "llm" - elif re.search(r"(?:^|\s)gemini(?:\s|$)", command_lower): - return "gemini" - else: - return "unknown" + for runtime_name in runtime_names(): + if re.search(rf"(?:^|\s){re.escape(runtime_name)}(?:\s|$)", command_lower): + return runtime_name + return "unknown" def _execute_runtime_command( self, command: str, content: str, env: dict @@ -534,20 +532,13 @@ def _execute_runtime_command( # Determine how to pass content based on runtime runtime = self._detect_runtime(" ".join(actual_command_args)) - if runtime == "copilot": - # Copilot uses -p flag - actual_command_args.extend(["-p", content]) - elif runtime == "codex": - # Codex exec expects content as the last argument - actual_command_args.append(content) - elif runtime == "llm": - # LLM expects content as argument - actual_command_args.append(content) - elif runtime == "gemini": - # Gemini uses -p flag for prompt content + try: + content_argument = get_runtime_descriptor(runtime).content_argument + except ValueError: + content_argument = "positional" + if content_argument == "prompt_flag": actual_command_args.extend(["-p", content]) else: - # Default: assume content as last argument actual_command_args.append(content) # Show subprocess details for debugging @@ -920,22 +911,20 @@ def _detect_installed_runtime(self) -> str: Raises: RuntimeError: If no compatible runtime is found """ - if find_runtime_binary("copilot"): - return "copilot" - elif find_runtime_binary("codex"): - return "codex" - elif find_runtime_binary("gemini"): - return "gemini" - else: - raise RuntimeError( - "No compatible runtime found.\n" - "Install GitHub Copilot CLI with:\n" - " apm runtime setup copilot\n" - "Or install Codex CLI with:\n" - " apm runtime setup codex\n" - "Or install Gemini CLI with:\n" - " apm runtime setup gemini" - ) + executable_descriptors = [ + descriptor + for descriptor in runtime_descriptors() + if descriptor.default_command is not None + ] + for descriptor in executable_descriptors: + if find_runtime_binary(descriptor.binary): + return descriptor.name + setup_lines = "\n".join( + f" apm runtime setup {descriptor.name}" for descriptor in executable_descriptors + ) + raise RuntimeError( + f"No compatible runtime found.\nInstall a supported runtime with one of:\n{setup_lines}" + ) def _generate_runtime_command(self, runtime: str, prompt_file: Path) -> str: """Generate appropriate runtime command with proper defaults. @@ -947,16 +936,13 @@ def _generate_runtime_command(self, runtime: str, prompt_file: Path) -> str: Returns: Full command string with runtime-specific defaults """ - if runtime == "copilot": - return ( - f"copilot --log-level all --log-dir copilot-logs --allow-all-tools -p {prompt_file}" - ) - elif runtime == "codex": - return f"codex -s workspace-write --skip-git-repo-check {prompt_file}" - elif runtime == "gemini": - return f"gemini -p {prompt_file}" - else: + try: + descriptor = get_runtime_descriptor(runtime) + except ValueError: + raise ValueError(f"Unsupported runtime: {runtime}") from None + if descriptor.default_command is None: raise ValueError(f"Unsupported runtime: {runtime}") + return descriptor.default_command.format(prompt_file=prompt_file) class PromptCompiler: diff --git a/src/apm_cli/core/target_catalog.py b/src/apm_cli/core/target_catalog.py new file mode 100644 index 000000000..28a4c436b --- /dev/null +++ b/src/apm_cli/core/target_catalog.py @@ -0,0 +1,282 @@ +"""Canonical capability metadata for accepted APM targets.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from types import MappingProxyType + + +@dataclass(frozen=True) +class TargetCapability: + """Command-facing capabilities of one native agent target.""" + + name: str + aliases: tuple[str, ...] + description: str + in_all: bool + explicit_only: bool + experimental_flag: str | None + mcp_only: bool + primitive_profile: str | None + compile_family: str | None + runtimes: tuple[str, ...] + commands: frozenset[str] + + +_TARGET_COMMANDS = frozenset({"compile", "install", "update"}) + + +def _capability( + name: str, + description: str, + *, + aliases: tuple[str, ...] = (), + in_all: bool = False, + explicit_only: bool = False, + experimental_flag: str | None = None, + mcp_only: bool = False, + primitive_profile: str | None = None, + compile_family: str | None = None, + runtimes: tuple[str, ...] = (), +) -> TargetCapability: + """Create catalog data shared by all target-selecting commands.""" + return TargetCapability( + name=name, + aliases=aliases, + description=description, + in_all=in_all, + explicit_only=explicit_only, + experimental_flag=experimental_flag, + mcp_only=mcp_only, + primitive_profile=primitive_profile, + compile_family=compile_family, + runtimes=runtimes, + commands=_TARGET_COMMANDS, + ) + + +def _build_target_catalog( + capabilities: Iterable[TargetCapability], +) -> Mapping[str, TargetCapability]: + """Validate and freeze target capability definitions.""" + catalog: dict[str, TargetCapability] = {} + value_owners: dict[str, str] = {} + runtime_owners: dict[str, str] = {} + + for capability in capabilities: + if not capability.name: + raise ValueError("Target capability name must not be empty") + if not capability.description: + raise ValueError(f"Target '{capability.name}' must have a description") + if not capability.commands: + raise ValueError(f"Target '{capability.name}' must declare at least one command") + if capability.name in value_owners: + raise ValueError(f"Duplicate target value '{capability.name}'") + if capability.mcp_only and capability.primitive_profile is None: + raise ValueError(f"MCP-only target '{capability.name}' must declare primitive_profile") + if capability.in_all and ( + capability.experimental_flag is not None or capability.explicit_only + ): + raise ValueError( + f"Target '{capability.name}' cannot set in_all with " + "experimental_flag or explicit_only" + ) + + catalog[capability.name] = capability + value_owners[capability.name] = capability.name + for alias in capability.aliases: + if alias in value_owners: + raise ValueError(f"Duplicate target value '{alias}'") + value_owners[alias] = capability.name + for runtime in capability.runtimes: + owner = runtime_owners.get(runtime) + if owner is not None: + raise ValueError( + f"Runtime '{runtime}' maps to multiple targets: " + f"'{owner}' and '{capability.name}'" + ) + runtime_owners[runtime] = capability.name + + return MappingProxyType(catalog) + + +TARGET_CAPABILITIES: Mapping[str, TargetCapability] = _build_target_catalog( + ( + _capability( + "copilot", + "GitHub Copilot native .github configuration", + aliases=("vscode", "agents"), + in_all=True, + primitive_profile="copilot", + compile_family="vscode", + runtimes=("vscode", "agents"), + ), + _capability( + "claude", + "Claude Code native .claude configuration", + in_all=True, + primitive_profile="claude", + compile_family="claude", + ), + _capability( + "cursor", + "Cursor native .cursor configuration", + in_all=True, + primitive_profile="cursor", + compile_family="agents", + ), + _capability( + "kiro", + "Kiro native .kiro configuration", + in_all=True, + primitive_profile="kiro", + compile_family="agents", + ), + _capability( + "opencode", + "OpenCode native .opencode configuration", + in_all=True, + primitive_profile="opencode", + compile_family="agents", + ), + _capability( + "gemini", + "Gemini CLI native .gemini configuration", + in_all=True, + primitive_profile="gemini", + compile_family="gemini", + ), + _capability( + "antigravity", + "Antigravity native .agents configuration", + aliases=("agy",), + explicit_only=True, + primitive_profile="antigravity", + compile_family="agents", + ), + _capability( + "codex", + "Codex native .codex and .agents configuration", + in_all=True, + primitive_profile="codex", + compile_family="agents", + ), + _capability( + "windsurf", + "Windsurf native .windsurf and .agents configuration", + in_all=True, + primitive_profile="windsurf", + compile_family="agents", + ), + _capability( + "agent-skills", + "Cross-client native .agents skills configuration", + explicit_only=True, + primitive_profile="agent-skills", + ), + _capability( + "openclaw", + "OpenClaw native skills configuration", + experimental_flag="openclaw", + primitive_profile="openclaw", + ), + _capability( + "hermes", + "Hermes native skills and MCP configuration", + experimental_flag="hermes", + primitive_profile="hermes", + compile_family="agents", + ), + _capability( + "copilot-cowork", + "Microsoft 365 Copilot Cowork native skills configuration", + experimental_flag="copilot_cowork", + primitive_profile="copilot-cowork", + ), + _capability( + "copilot-app", + "GitHub Copilot desktop app native workflow configuration", + experimental_flag="copilot_app", + primitive_profile="copilot-app", + ), + _capability( + "intellij", + "IntelliJ MCP integration using the Copilot primitive profile", + mcp_only=True, + primitive_profile="copilot", + compile_family="agents", + runtimes=("intellij",), + ), + ) +) + + +def get_target_capability(name_or_alias: str) -> TargetCapability: + """Return the capability selected by a canonical name or alias.""" + capability = TARGET_CAPABILITIES.get(name_or_alias) + if capability is not None: + return capability + for candidate in TARGET_CAPABILITIES.values(): + if name_or_alias in candidate.aliases: + return candidate + raise KeyError(name_or_alias) + + +def accepted_target_values(command: str | None = None) -> frozenset[str]: + """Return every target spelling accepted by a command.""" + capabilities = ( + TARGET_CAPABILITIES.values() + if command is None + else ( + capability + for capability in TARGET_CAPABILITIES.values() + if command in capability.commands + ) + ) + values = { + value for capability in capabilities for value in (capability.name, *capability.aliases) + } + values.add("all") + return frozenset(values) + + +def manifest_target_names() -> frozenset[str]: + """Return canonical target identifiers accepted in ``apm.yml``.""" + return frozenset( + capability.name + for capability in TARGET_CAPABILITIES.values() + if capability.experimental_flag is None and not capability.mcp_only + ) + + +def normalize_target_name(name_or_alias: str) -> str: + """Normalize a target spelling to its native capability name.""" + if name_or_alias == "all": + return name_or_alias + return get_target_capability(name_or_alias).name + + +def expand_all(command: str) -> tuple[str, ...]: + """Return the stable targets selected by ``all`` for a command.""" + expanded = [] + for capability in TARGET_CAPABILITIES.values(): + if not capability.in_all or command not in capability.commands: + continue + legacy_selector = ( + capability.compile_family + if capability.compile_family in capability.aliases + else capability.name + ) + expanded.append(legacy_selector) + return tuple(sorted(expanded)) + + +def target_help_fragment(command: str) -> str: + """Return the generated accepted-values fragment for command help.""" + return f"Values: {', '.join(sorted(accepted_target_values(command)))}." + + +def target_error_values(command: str) -> tuple[str, ...]: + """Return accepted target values in deterministic error-display order.""" + return tuple(sorted(accepted_target_values(command))) diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index 549014b0e..0577be703 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -27,6 +27,15 @@ import click +from apm_cli.core.target_catalog import ( + TARGET_CAPABILITIES, + accepted_target_values, + expand_all, + get_target_capability, + normalize_target_name, + target_error_values, +) + class AgentsTargetDeprecationWarning(DeprecationWarning): """Raised when the legacy ``--target agents`` alias is used. @@ -229,7 +238,7 @@ def detect_target( # noqa: PLR0911 def should_compile_agents_md(target: CompileTargetType) -> bool: """Check if AGENTS.md should be compiled. - AGENTS.md is generated for vscode, codex, gemini, all, and minimal + AGENTS.md is generated for vscode, cursor, codex, gemini, all, and minimal targets. Gemini needs it because GEMINI.md imports AGENTS.md. Args: @@ -243,6 +252,7 @@ def should_compile_agents_md(target: CompileTargetType) -> bool: return "agents" in target or "gemini" in target return target in ( "vscode", + "cursor", "opencode", "codex", "gemini", @@ -400,16 +410,16 @@ def get_target_description(target: UserTargetType) -> str: #: The complete set of real (non-pseudo) canonical targets. #: "minimal" is intentionally excluded -- it is a fallback pseudo-target. -ALL_CANONICAL_TARGETS = frozenset( - {"vscode", "claude", "cursor", "opencode", "codex", "gemini", "windsurf", "kiro"} -) +ALL_CANONICAL_TARGETS = frozenset(expand_all("install")) #: Targets that the parser must accept but that are gated at runtime by #: ``is_enabled()`` in ``core/experimental.py`` and ``_flag_gated()`` in #: ``integration/targets.py``. They are NOT included in the #: ``parse_target_arg("all")`` expansion -- explicit opt-in only. EXPERIMENTAL_TARGETS: frozenset[str] = frozenset( - {"copilot-cowork", "copilot-app", "openclaw", "hermes"} + capability.name + for capability in TARGET_CAPABILITIES.values() + if capability.experimental_flag is not None ) #: Stable targets excluded from "all" expansion (cross-client deploy @@ -417,21 +427,30 @@ def get_target_description(target: UserTargetType) -> str: #: not represent a single client tool. Antigravity is explicit-only #: because its workspace config lives under the SHARED ``.agents/`` root, #: so there is no Antigravity-unique signal to auto-detect on. -EXPLICIT_ONLY_TARGETS: frozenset[str] = frozenset({"agent-skills", "antigravity"}) +EXPLICIT_ONLY_TARGETS: frozenset[str] = frozenset( + capability.name for capability in TARGET_CAPABILITIES.values() if capability.explicit_only +) #: MCP-only pseudo-targets that have a client adapter but no #: ``KNOWN_TARGETS`` entry (they map to a canonical target for primitive #: deployment via ``RUNTIME_TO_CANONICAL_TARGET``). They must be accepted #: by ``--target`` so the CLI validates them, but they are excluded from #: ``"all"`` expansion and do not participate in target-profile machinery. -MCP_ONLY_TARGETS: frozenset[str] = frozenset({"intellij"}) +MCP_ONLY_TARGETS: frozenset[str] = frozenset( + capability.name for capability in TARGET_CAPABILITIES.values() if capability.mcp_only +) #: Alias mapping: user-facing name -> canonical internal name. TARGET_ALIASES: dict[str, str] = { - "copilot": "vscode", - "agents": "vscode", - "vscode": "vscode", - "agy": "antigravity", + value: ( + capability.compile_family + if capability.compile_family in capability.aliases + else capability.name + ) + for capability in TARGET_CAPABILITIES.values() + for value in (capability.name, *capability.aliases) + if capability.aliases + and (value != capability.name or capability.compile_family in capability.aliases) } @@ -441,15 +460,15 @@ def manifest_targets_from_target_option(target: str | list[str] | None) -> list[ return None from apm_cli.core.apm_yml import CANONICAL_TARGETS - from apm_cli.integration.targets import RUNTIME_TO_CANONICAL_TARGET raw_targets = [target] if isinstance(target, str) else list(target) seen: set[str] = set() manifest_targets: list[str] = [] for raw_target in raw_targets: - expanded = sorted(ALL_CANONICAL_TARGETS) if raw_target == "all" else [str(raw_target)] + expanded = expand_all("install") if raw_target == "all" else [str(raw_target)] for item in expanded: - canonical = RUNTIME_TO_CANONICAL_TARGET.get(item, item) + capability = get_target_capability(item) + canonical = capability.primitive_profile if item in capability.runtimes else item if canonical in CANONICAL_TARGETS and canonical not in seen: seen.add(canonical) manifest_targets.append(canonical) @@ -484,12 +503,17 @@ def normalize_target_list( # "all" anywhere in the input means "every target" -- expand to the # full sorted list of canonical targets. if "all" in raw: - return sorted(ALL_CANONICAL_TARGETS) + return list(expand_all("install")) seen: set[str] = set() result: list[str] = [] for item in raw: - canonical = TARGET_ALIASES.get(item, item) + capability = get_target_capability(item) + canonical = ( + capability.compile_family + if capability.compile_family in capability.aliases + else normalize_target_name(item) + ) if canonical not in seen: seen.add(canonical) result.append(canonical) @@ -505,13 +529,18 @@ def normalize_policy_targets(value: str | list[str] | None) -> str | list[str] | if value is None: return None - from apm_cli.integration.targets import RUNTIME_TO_CANONICAL_TARGET - values = [value] if isinstance(value, str) else list(value) normalized: list[str] = [] for target in values: + if target == "all": + if target not in normalized: + normalized.append(target) + continue if target in MCP_ONLY_TARGETS: - canonical = RUNTIME_TO_CANONICAL_TARGET.get(target) + try: + canonical = get_target_capability(target).primitive_profile + except KeyError: + canonical = None if canonical is None: raise RuntimeError(f"MCP-only target '{target}' has no canonical policy mapping") target = canonical @@ -527,14 +556,10 @@ def normalize_policy_targets(value: str | list[str] | None) -> str | list[str] | #: All values accepted by the ``--target`` CLI option. #: Derived from canonical targets, alias keys, and the ``"all"`` keyword. -VALID_TARGET_VALUES: frozenset[str] = ( - ALL_CANONICAL_TARGETS - | EXPERIMENTAL_TARGETS - | EXPLICIT_ONLY_TARGETS - | MCP_ONLY_TARGETS - | frozenset(TARGET_ALIASES) - | frozenset({"all"}) -) +VALID_TARGET_VALUES: frozenset[str] = accepted_target_values() + +#: Stable user-facing projection of every value accepted by ``--target``. +TARGET_VALUES_HELP = ", ".join(sorted(accepted_target_values())) def parse_target_field( @@ -623,11 +648,11 @@ def parse_target_field( # ---- validate every token ---- for p in raw_parts: - if p not in VALID_TARGET_VALUES: + if p not in accepted_target_values(): raise ValueError( _target_error( f"'{p}' is not a valid target. " - f"Choose from: {', '.join(sorted(VALID_TARGET_VALUES))}", + f"Choose from: {', '.join(sorted(accepted_target_values()))}", source_path, ) ) @@ -677,7 +702,12 @@ def parse_target_field( seen: set[str] = set() result: list[str] = [] for p in raw_parts: - canonical = TARGET_ALIASES.get(p, p) + capability = get_target_capability(p) + canonical = ( + capability.compile_family + if capability.compile_family in capability.aliases + else normalize_target_name(p) + ) if canonical not in seen: seen.add(canonical) result.append(canonical) @@ -723,13 +753,17 @@ def convert( # Use the v2 three-section error renderer for unknown targets # so that CLI, apm.yml, and auto-detect all share the same # error format (#1154). - from apm_cli.core.apm_yml import CANONICAL_TARGETS from apm_cli.core.errors import UnknownTargetError, render_unknown_target_error err_msg = str(e) if "is not a valid target" in err_msg: target_name = value if isinstance(value, str) else ",".join(value or []) - rendered = render_unknown_target_error(target_name, sorted(CANONICAL_TARGETS)) + command = ctx.command.name if ctx is not None and ctx.command.name else "install" + rendered = render_unknown_target_error( + target_name, + list(target_error_values(command)), + command=command, + ) raise UnknownTargetError(rendered) from None # Click idiom: route validation errors through self.fail so the # user sees a clean "Invalid value for '--target': ..." message diff --git a/src/apm_cli/deps/apm_resolver.py b/src/apm_cli/deps/apm_resolver.py index 08e3bf4f4..14d18cf8c 100644 --- a/src/apm_cli/deps/apm_resolver.py +++ b/src/apm_cli/deps/apm_resolver.py @@ -5,6 +5,7 @@ import os import threading from collections import deque +from collections.abc import Iterable from concurrent.futures import ThreadPoolExecutor from dataclasses import replace from pathlib import Path, PureWindowsPath @@ -12,6 +13,7 @@ from ..models.apm_package import APMPackage, DependencyReference from ..utils.path_security import PathTraversalError, ensure_path_within, validate_path_segments +from ..utils.paths import portable_relpath from .dependency_graph import ( CircularRef, DependencyGraph, @@ -32,6 +34,20 @@ _DEFAULT_RESOLVE_PARALLEL = 4 +def _select_dependency_winners( + nodes: Iterable[DependencyNode], +) -> tuple[tuple[DependencyNode, ...], dict[str, str]]: + """Return canonical dependency order and first-wins node IDs.""" + ordered = tuple(sorted(nodes, key=lambda node: (node.depth, node.get_id()))) + winner_ids: dict[str, str] = {} + for node in ordered: + winner_ids.setdefault( + node.dependency_ref.get_unique_key(), + node.get_id(), + ) + return ordered, winner_ids + + # Type alias for the download callback. # Takes (dep_ref, apm_modules_dir, parent_chain, parent_pkg) and returns the # install path if successful. ``parent_chain`` is a human-readable breadcrumb @@ -308,6 +324,31 @@ def _is_absolute_local_path(local_path: str) -> bool: raw = local_path.strip() return Path(raw).expanduser().is_absolute() or PureWindowsPath(raw).is_absolute() + @staticmethod + def _portable_anchor_identity(anchored: Path, base: Path | None) -> str: + """Return a machine-independent identity string for a resolved local dep. + + ``anchored`` is the absolute, resolved on-disk location of a local + package. The returned value is the package's stable identity only -- + it feeds the ``local:`` cycle key, the lockfile + ``dependencies[].anchored_local_path`` field, and (via that same owner + identity) the deployment-ledger owner rows. It is never re-opened as a + filesystem path. + + A lockfile is a committed, cross-machine artifact, so this identity + MUST NOT carry a developer's home directory or any absolute prefix. + Packages inside ``base`` (the project root) are recorded relative to + it in forward-slash POSIX form -- matching how directly-declared local + owners already serialize -- which keeps a regenerated lockfile + deterministic across machines and CI. Packages outside ``base`` (or + when ``base`` is unknown) fall back to the resolved absolute POSIX + path: such out-of-project local deps are inherently non-committable, + and the fallback preserves a unique identity without inventing one. + """ + if base is None: + return anchored.resolve().as_posix() + return portable_relpath(anchored, base) + def _remote_repo_root_for_parent( self, parent_dep: DependencyReference, @@ -532,8 +573,11 @@ def build_dependency_tree(self, root_apm_yml: Path) -> DependencyTree: deque() ) - # Set to track queued unique keys for O(1) lookup instead of O(n) list comprehension + # Track full edge constraints, not package identity alone. Different + # refs for one package must survive until the flattening phase reports + # the conflict. queued_keys: set[str] = set() + winner_candidates: list[DependencyNode] = [] # Add root dependencies to queue root_deps = root_package.get_apm_dependencies() @@ -551,7 +595,7 @@ def build_dependency_tree(self, root_apm_yml: Path) -> DependencyTree: else: continue processing_queue.append((dep_ref, 1, None, False)) - queued_keys.add(dep_ref.get_unique_key()) + queued_keys.add(dep_ref.get_resolution_key()) # Add root devDependencies to queue (marked is_dev=True) root_dev_deps = root_package.get_dev_apm_dependencies() @@ -568,7 +612,7 @@ def build_dependency_tree(self, root_apm_yml: Path) -> DependencyTree: dep_ref = resolved else: continue - key = dep_ref.get_unique_key() + key = dep_ref.get_resolution_key() if key not in queued_keys: processing_queue.append((dep_ref, 1, None, True)) queued_keys.add(key) @@ -607,21 +651,23 @@ def build_dependency_tree(self, root_apm_yml: Path) -> DependencyTree: work_items = [] for dep_ref, depth, parent_node, is_dev in level_items: # Remove from queued set since we're now processing this dependency - queued_keys.discard(dep_ref.get_unique_key()) + queued_keys.discard(dep_ref.get_resolution_key()) # Check maximum depth to prevent infinite recursion if depth > self.max_depth: continue # Check if we already processed this dependency at this level or higher - existing_node = tree.get_node(dep_ref.get_unique_key()) + existing_node = tree.nodes.get(dep_ref.get_resolution_key()) if existing_node and existing_node.depth <= depth: # Prod wins over dev: if existing was dev and this is prod, promote it if existing_node.is_dev and not is_dev: existing_node.is_dev = False # We've already processed this dependency at a shallower or equal depth # Create parent-child relationship if parent exists - if parent_node and existing_node not in parent_node.children: + if parent_node and all( + child is not existing_node for child in parent_node.children + ): parent_node.children.append(existing_node) continue @@ -649,6 +695,14 @@ def build_dependency_tree(self, root_apm_yml: Path) -> DependencyTree: work_items.append((node, dep_ref, parent_node, is_dev)) + winner_candidates.extend(item[0] for item in work_items) + _, winner_ids = _select_dependency_winners(winner_candidates) + work_items = [ + item + for item in work_items + if winner_ids[item[1].get_unique_key()] == item[0].get_id() + ] + # --- Phase B (workers): load packages --- if not work_items: results: list[ @@ -708,6 +762,37 @@ def build_dependency_tree(self, root_apm_yml: Path) -> DependencyTree: ) if sub_dep is None: continue + if ( + sub_dep.is_local + and sub_dep.local_path + and node.package.source_path is not None + ): + local_path = Path(sub_dep.local_path).expanduser() + anchored = ( + local_path.resolve() + if local_path.is_absolute() + else (node.package.source_path / local_path).resolve() + ) + sub_dep = replace( + sub_dep, + declaring_parent=node.get_id(), + anchored_local_path=self._portable_anchor_identity( + anchored, root_package.source_path + ), + ) + ancestor: DependencyNode | None = node + while ancestor is not None: + if ( + ancestor.dependency_ref.get_cycle_key() + == sub_dep.get_cycle_key() + ): + if all(child is not ancestor for child in node.children): + node.children.append(ancestor) + sub_dep = None + break + ancestor = ancestor.parent + if sub_dep is None: + continue if sub_dep.is_marketplace: resolved = self._resolve_marketplace_or_record_error( sub_dep, tree, f"required by {node.dependency_ref.repo_url}" @@ -718,9 +803,10 @@ def build_dependency_tree(self, root_apm_yml: Path) -> DependencyTree: continue # Avoid infinite recursion by checking if we're already processing this dep # Use O(1) set lookup instead of O(n) list comprehension - if sub_dep.get_unique_key() not in queued_keys: + queue_key = sub_dep.get_resolution_key() + if queue_key not in queued_keys: processing_queue.append((sub_dep, node.depth + 1, node, is_dev)) - queued_keys.add(sub_dep.get_unique_key()) + queued_keys.add(queue_key) return tree @@ -748,7 +834,7 @@ def dfs_detect_cycles(node: DependencyNode) -> None: node_id = node.get_id() # Use unique key (includes subdirectory path) to distinguish monorepo packages # e.g., vineethsoma/agent-packages/agents/X vs vineethsoma/agent-packages/skills/Y - unique_key = node.dependency_ref.get_unique_key() + unique_key = node.dependency_ref.get_cycle_key() # Check if this unique key is already in our current path (cycle detected) if unique_key in current_path_set: @@ -772,7 +858,7 @@ def dfs_detect_cycles(node: DependencyNode) -> None: # Only recurse if we haven't processed this subtree completely if ( child_id not in visited - or child.dependency_ref.get_unique_key() in current_path_set + or child.dependency_ref.get_cycle_key() in current_path_set ): dfs_detect_cycles(child) @@ -803,27 +889,15 @@ def flatten_dependencies(self, tree: DependencyTree) -> FlatDependencyMap: FlatDependencyMap: Flattened dependencies ready for installation """ flat_map = FlatDependencyMap() - seen_keys: set[str] = set() - - # Process dependencies level by level (breadth-first) - # This ensures that dependencies declared earlier in the tree get priority - for depth in range(1, tree.max_depth + 1): - nodes_at_depth = tree.get_nodes_at_depth(depth) - - # Sort nodes by their position in the tree to ensure deterministic ordering - # In a real implementation, this would be based on declaration order - nodes_at_depth.sort(key=lambda node: node.get_id()) - - for node in nodes_at_depth: - unique_key = node.dependency_ref.get_unique_key() - - if unique_key not in seen_keys: - # First occurrence - add without conflict - flat_map.add_dependency(node.dependency_ref, is_conflict=False) - seen_keys.add(unique_key) - else: - # Conflict - record it but keep the first one - flat_map.add_dependency(node.dependency_ref, is_conflict=True) + ordered, winner_ids = _select_dependency_winners( + node for node in tree.nodes.values() if node.depth >= 1 + ) + for node in ordered: + unique_key = node.dependency_ref.get_unique_key() + flat_map.add_dependency( + node.dependency_ref, + is_conflict=winner_ids[unique_key] != node.get_id(), + ) return flat_map @@ -1088,7 +1162,8 @@ def _download_dedup_key(dep_ref: DependencyReference, parent_pkg: APMPackage | N Includes the parent's source_path so two parents anchoring the same local dep at different absolute locations don't collide on the first one's resolved path. For non-local deps, the parent anchor doesn't - affect resolution, so the bare unique key suffices. + affect resolution, so the package identity suffices. Conflicting refs + are reduced to one winner before worker dispatch. """ base = dep_ref.get_unique_key() if dep_ref.is_local and parent_pkg is not None and parent_pkg.source_path: diff --git a/src/apm_cli/deps/dependency_graph.py b/src/apm_cli/deps/dependency_graph.py index 880071451..3e5333009 100644 --- a/src/apm_cli/deps/dependency_graph.py +++ b/src/apm_cli/deps/dependency_graph.py @@ -7,7 +7,7 @@ from ..models.apm_package import APMPackage, DependencyReference -@dataclass +@dataclass(eq=False) class DependencyNode: """Represents a single dependency node in the dependency graph.""" diff --git a/src/apm_cli/deps/host_backends.py b/src/apm_cli/deps/host_backends.py index b14552fcd..9a86ea686 100644 --- a/src/apm_cli/deps/host_backends.py +++ b/src/apm_cli/deps/host_backends.py @@ -10,9 +10,8 @@ Pattern: Strategy via Protocol + dispatch dict. The three GitHub-family backends (GitHub, GHE Cloud, GHES) share URL builders through a small ``_GitHubFamilyBase`` to avoid copy/paste; ADO and Generic stand alone. -There is no runtime registry. Adding a new vendor is one new class plus -one new entry in ``_BACKEND_BY_KIND``, never a new branch in an -``if/elif`` ladder. +Adding a new vendor registers one backend with the canonical provider +registry, never a new branch in an ``if/elif`` ladder. Design constraints (see plan in WIP/host-backends-refactor): @@ -39,6 +38,11 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable from ..core.auth import HostInfo +from ..core.host_providers import ( + classify_host_provider, + host_backend_factory, + register_host_backend, +) from ..utils.github_host import ( build_ado_https_clone_url, build_ado_ssh_url, @@ -46,7 +50,6 @@ build_https_clone_url, build_ssh_url, default_host, - is_github_hostname, ) if TYPE_CHECKING: @@ -512,14 +515,12 @@ def build_contents_api_urls( # --------------------------------------------------------------------------- -_BACKEND_BY_KIND: dict[str, type] = { - "github": GitHubBackend, - "ghe_cloud": GHECloudBackend, - "ghes": GHESBackend, - "ado": ADOBackend, - "gitlab": GitLabBackend, - "generic": GenericGitBackend, -} +register_host_backend("github", GitHubBackend) +register_host_backend("ghe_cloud", GHECloudBackend) +register_host_backend("ghes", GHESBackend) +register_host_backend("ado", ADOBackend) +register_host_backend("gitlab", GitLabBackend) +register_host_backend("generic", GenericGitBackend) def _host_type_for_backend_dispatch(dep_ref: DependencyReference | None) -> str | None: @@ -560,72 +561,22 @@ def backend_for( host = fallback_host or default_host() port = None - # ADO short-circuit: when ``dep_ref`` itself reports Azure DevOps the - # backend is unambiguous regardless of ``classify_host`` (which may be - # mocked or defective in tests/diagnostic paths). - if dep_ref is not None: - try: - if dep_ref.is_azure_devops(): - info = auth_resolver.classify_host( - host, - port=port, - host_type=host_type, - ) - if not isinstance(info, HostInfo): - info = HostInfo( - host=host, - kind="ado", - has_public_repos=False, - api_base=f"https://{host}", - port=port, - ) - return ADOBackend(host_info=info) - except (AttributeError, TypeError): - pass - info = auth_resolver.classify_host( host, port=port, host_type=host_type, ) - cls: type | None = None - if isinstance(info, HostInfo): - cls = _BACKEND_BY_KIND.get(info.kind) - if cls is None: - # Defensive fallback path for mocked / future ``classify_host`` - # results: route by hostname so callers that wire only a partial - # mock (typical in unit tests) still get the right backend. - host_lower = (host or "").lower() - if is_github_hostname(host): - if host_lower == "github.com": - cls = GitHubBackend - kind = "github" - api_base = "https://api.github.com" - elif host_lower.endswith(".ghe.com"): - cls = GHECloudBackend - kind = "ghe_cloud" - api_base = f"https://{host}/api/v3" - else: - cls = GHESBackend - kind = "ghes" - api_base = f"https://{host}/api/v3" - info = HostInfo( - host=host, - kind=kind, - has_public_repos=host_lower == "github.com", - api_base=api_base, - port=port, - ) - else: - cls = GenericGitBackend - if not isinstance(info, HostInfo): - info = HostInfo( - host=host, - kind="generic", - has_public_repos=False, - api_base=f"https://{host}", - port=port, - ) + if not isinstance(info, HostInfo): + provider = classify_host_provider(host, host_type=host_type) + info = HostInfo( + host=host, + kind=provider.kind, + has_public_repos=provider.has_public_repos, + api_base=provider.api_base(host.lower()), + port=port, + credential_purpose=provider.credential_purpose, + ) + cls = host_backend_factory(info.kind) return cls(host_info=info) @@ -642,5 +593,5 @@ def backend_for_host( builder). """ info = auth_resolver.classify_host(host, port=port) - cls = _BACKEND_BY_KIND.get(info.kind, GenericGitBackend) + cls = host_backend_factory(info.kind) return cls(host_info=info) diff --git a/src/apm_cli/deps/lockfile.py b/src/apm_cli/deps/lockfile.py index b968d0280..3c4826e1e 100644 --- a/src/apm_cli/deps/lockfile.py +++ b/src/apm_cli/deps/lockfile.py @@ -13,6 +13,8 @@ import yaml +from ..core.deployment_state import DeploymentLedger +from ..core.host_providers import accepted_host_types from ..models.apm_package import DependencyReference from ..models.dependency.identity import normalize_package_repo_url from ..models.dependency.reference import ( @@ -23,8 +25,72 @@ logger = logging.getLogger(__name__) _SELF_KEY = "." -_ALLOWED_HOST_TYPES = {"gitlab"} +_ALLOWED_HOST_TYPES = set(accepted_host_types()) _ALLOWED_EXEC_STATUS = {"deployed", "gated_pending_approval", "denied", "absent"} +SUPPORTED_LOCKFILE_VERSIONS = frozenset({"1", "2"}) + + +class LockfileFormatError(ValueError): + """Raised when a lockfile container does not match its schema.""" + + +class UnsupportedLockfileVersionError(LockfileFormatError): + """Raised when a lockfile declares a version this client cannot read.""" + + +def _validate_lockfile_container(data: object) -> dict[str, Any]: + """Validate version and top-level container shapes before construction.""" + if not isinstance(data, dict): + raise LockfileFormatError("Lockfile root must be a mapping") + data = dict(data) + # Pre-versioned lockfiles are a supported legacy v1 migration input. + # Explicit unknown/newer versions still fail closed below. + version = data.get("lockfile_version", "1") + if not isinstance(version, str) or version not in SUPPORTED_LOCKFILE_VERSIONS: + supported = ", ".join(sorted(SUPPORTED_LOCKFILE_VERSIONS)) + raise UnsupportedLockfileVersionError( + f"Unsupported lockfile version {version!r}; supported versions: {supported}" + ) + list_fields = ( + "dependencies", + "deployments", + "mcp_servers", + "lsp_servers", + "local_deployed_files", + ) + mapping_fields = ( + "mcp_configs", + "mcp_target_servers", + "mcp_config_provenance", + "lsp_configs", + "local_deployed_file_hashes", + ) + for field_name in list_fields: + if field_name in data and data[field_name] is None: + data[field_name] = [] + elif field_name in data and not isinstance(data[field_name], list): + raise LockfileFormatError(f"Lockfile field {field_name!r} must be a list") + for field_name in mapping_fields: + if field_name in data and data[field_name] is None: + data[field_name] = {} + elif field_name in data and not isinstance(data[field_name], dict): + raise LockfileFormatError(f"Lockfile field {field_name!r} must be a mapping") + for index, dependency in enumerate(data.get("dependencies", [])): + if not isinstance(dependency, dict): + raise LockfileFormatError(f"Lockfile dependency at index {index} must be a mapping") + for target, servers in (data.get("mcp_target_servers") or {}).items(): + if not isinstance(target, str) or not isinstance(servers, list): + raise LockfileFormatError( + "Lockfile mcp_target_servers values must be string-to-list mappings" + ) + if "deployments" in data: + from ..core.deployment_ledger import DeploymentLedgerCodec + + try: + DeploymentLedgerCodec.validate_rows(data["deployments"]) + except ValueError as exc: + raise LockfileFormatError(str(exc)) from exc + return data def _normalize_lockfile_host_type(raw: Any) -> str | None: @@ -86,6 +152,8 @@ class LockedDependency: # the project root (``./packages/foo``). Transitive deps: relative to the # package that declared them (``../sibling``), anchored via ``resolved_by`` # (issue #857; see apm_cli.deps.path_anchoring). + declaring_parent: str | None = None + anchored_local_path: str | None = None content_hash: str | None = None # SHA-256 of package file tree is_dev: bool = False # True for devDependencies discovered_via: str | None = None # Marketplace name (provenance) @@ -158,6 +226,8 @@ def get_unique_key(self) -> str: is_virtual=self.is_virtual, virtual_path=self.virtual_path, registry_prefix=self.registry_prefix, + declaring_parent=self.declaring_parent, + anchored_local_path=self.anchored_local_path, ) def get_canonical_dependency_string(self) -> str: @@ -213,6 +283,10 @@ def to_dict(self) -> dict[str, Any]: result["source"] = self.source if self.local_path: result["local_path"] = self.local_path + if self.declaring_parent: + result["declaring_parent"] = self.declaring_parent + if self.anchored_local_path: + result["anchored_local_path"] = self.anchored_local_path if self.content_hash: result["content_hash"] = self.content_hash if self.is_dev: @@ -306,6 +380,8 @@ def from_dict(cls, data: dict[str, Any]) -> LockedDependency: "deployed_file_hashes", "source", "local_path", + "declaring_parent", + "anchored_local_path", "content_hash", "is_dev", "discovered_via", @@ -347,6 +423,8 @@ def from_dict(cls, data: dict[str, Any]) -> LockedDependency: deployed_file_hashes=dict(data.get("deployed_file_hashes") or {}), source=data.get("source"), local_path=data.get("local_path"), + declaring_parent=data.get("declaring_parent"), + anchored_local_path=data.get("anchored_local_path"), content_hash=data.get("content_hash"), is_dev=data.get("is_dev", False), discovered_via=data.get("discovered_via"), @@ -478,6 +556,8 @@ def from_dependency_ref( resolved_by=resolved_by, source=source, local_path=dep_ref.local_path if dep_ref.is_local else None, + declaring_parent=dep_ref.declaring_parent if dep_ref.is_local else None, + anchored_local_path=dep_ref.anchored_local_path if dep_ref.is_local else None, is_dev=is_dev, is_insecure=dep_ref.is_insecure, allow_insecure=dep_ref.allow_insecure, @@ -530,6 +610,8 @@ def to_dependency_ref(self) -> DependencyReference: artifactory_prefix=self.registry_prefix, is_local=(self.source == "local"), local_path=self.local_path, + declaring_parent=self.declaring_parent, + anchored_local_path=self.anchored_local_path, is_insecure=self.is_insecure, allow_insecure=self.allow_insecure, source=self.source, @@ -547,18 +629,23 @@ class LockFile: dependencies: dict[str, LockedDependency] = field(default_factory=dict) mcp_servers: list[str] = field(default_factory=list) mcp_configs: dict[str, dict] = field(default_factory=dict) + mcp_target_servers: dict[str, list[str]] = field(default_factory=dict) # Provenance for transitively-contributed MCP servers: name -> declaring # package identity. Only servers NOT declared in the root manifest's mcp: # block appear here (absent == direct, mirroring the dependency-side # ``resolved_by is None`` convention). Kept OUT of ``mcp_configs`` values so - # it never pollutes ``detect_config_drift`` byte comparisons. Consumed by - # ``_check_config_consistency`` to exempt transitive servers from the - # orphaned-MCP branch (#2081; MCP-side sibling of #1846/#1855). + # it never pollutes config comparisons. Consistency diagnostics use this as + # ownership context only; provenance never exempts a lock-only server. mcp_config_provenance: dict[str, str] = field(default_factory=dict) lsp_servers: list[str] = field(default_factory=list) lsp_configs: dict[str, dict] = field(default_factory=dict) local_deployed_files: list[str] = field(default_factory=list) local_deployed_file_hashes: dict[str, str] = field(default_factory=dict) + deployment_ledger: DeploymentLedger = field( + default_factory=lambda: DeploymentLedger(records={}) + ) + _deployments_present: bool = field(default=False, repr=False, compare=False) + _mcp_target_servers_present: bool = field(default=False, repr=False, compare=False) def add_dependency(self, dep: LockedDependency) -> None: """Add a dependency to the lock file. @@ -570,6 +657,9 @@ def add_dependency(self, dep: LockedDependency) -> None: """ dep.deployed_files = _dedupe_preserving_order(dep.deployed_files) self.dependencies[dep.get_unique_key()] = dep + if dep.deployed_files or dep.deployed_file_hashes: + self.deployment_ledger = DeploymentLedger(records={}) + self._deployments_present = False if self.lockfile_version == "1" and ( dep.source == "registry" or dep.constraint or dep.resolved_tag or dep.resolved_at ): @@ -579,6 +669,21 @@ def get_dependency(self, key: str) -> LockedDependency | None: """Get a dependency by its unique key.""" return self.dependencies.get(key) + def rename_local_deployed_path(self, old_value: str, new_value: str) -> None: + """Rename one locally deployed path and carry its content hash.""" + if old_value not in self.local_deployed_files: + return + self.local_deployed_files = [ + value for value in self.local_deployed_files if value != old_value + ] + if new_value not in self.local_deployed_files: + self.local_deployed_files.append(new_value) + if old_value in self.local_deployed_file_hashes: + old_hash = self.local_deployed_file_hashes.pop(old_value) + self.local_deployed_file_hashes.setdefault(new_value, old_hash) + self.deployment_ledger = DeploymentLedger(records={}) + self._deployments_present = False + def has_dependency(self, key: str) -> bool: """Check if a dependency exists.""" return key in self.dependencies @@ -610,6 +715,11 @@ def _needs_v2(self) -> bool: def to_yaml(self) -> str: """Serialize to YAML string.""" + from ..core.deployment_ledger import DeploymentLedgerCodec + + if not self.deployment_ledger.records: + self.deployment_ledger = DeploymentLedgerCodec.from_lockfile(self) + DeploymentLedgerCodec.apply_to_lockfile(self.deployment_ledger, self) # Opportunistic v1<->v2 derivation (design §6.1, invariant §2.1.4): # the lockfile_version field always reflects current content at # emit time. ``add_dependency`` bumps to "2" eagerly, but callers @@ -631,10 +741,16 @@ def to_yaml(self) -> str: if self.apm_version: data["apm_version"] = self.apm_version data["dependencies"] = [dep.to_dict() for dep in self.get_all_dependencies()] + data["deployments"] = DeploymentLedgerCodec.rows(self.deployment_ledger) if self.mcp_servers: data["mcp_servers"] = sorted(self.mcp_servers) if self.mcp_configs: data["mcp_configs"] = dict(sorted(self.mcp_configs.items())) + if self.mcp_target_servers: + data["mcp_target_servers"] = { + target: sorted(servers) + for target, servers in sorted(self.mcp_target_servers.items()) + } if self.mcp_config_provenance: data["mcp_config_provenance"] = dict(sorted(self.mcp_config_provenance.items())) if self.lsp_servers: @@ -663,11 +779,11 @@ def from_yaml(cls, yaml_str: str) -> LockFile: # parser with a merge-key bomb (the surrounding LockFile.read guard # cannot catch a non-terminating safe_load loop -- it can catch the # YAMLError this raises instead). - data = load_yaml_str(yaml_str) - if not data: - return cls() - if not isinstance(data, dict): - return cls() + try: + loaded = load_yaml_str(yaml_str) + except (yaml.YAMLError, ValueError) as exc: + raise LockfileFormatError(f"Invalid lockfile YAML: {exc}") from exc + data = _validate_lockfile_container(loaded) lock = cls( lockfile_version=data.get("lockfile_version", "1"), generated_at=data.get("generated_at", ""), @@ -677,6 +793,11 @@ def from_yaml(cls, yaml_str: str) -> LockFile: lock.add_dependency(LockedDependency.from_dict(dep_data)) lock.mcp_servers = list(data.get("mcp_servers", [])) lock.mcp_configs = dict(data.get("mcp_configs") or {}) + lock.mcp_target_servers = { + target: list(servers) + for target, servers in (data.get("mcp_target_servers") or {}).items() + } + lock._mcp_target_servers_present = "mcp_target_servers" in data lock.mcp_config_provenance = dict(data.get("mcp_config_provenance") or {}) lock.lsp_servers = list(data.get("lsp_servers", [])) lock.lsp_configs = dict(data.get("lsp_configs") or {}) @@ -695,11 +816,23 @@ def from_yaml(cls, yaml_str: str) -> LockFile: deployed_files=list(lock.local_deployed_files), deployed_file_hashes=dict(lock.local_deployed_file_hashes), ) + from ..core.deployment_ledger import DeploymentLedgerCodec + + if "deployments" in data: + deployment_rows = data["deployments"] + lock.deployment_ledger = DeploymentLedgerCodec.from_rows(deployment_rows) + lock._deployments_present = isinstance(deployment_rows, list) and ( + not deployment_rows or bool(lock.deployment_ledger.records) + ) + else: + lock.deployment_ledger = DeploymentLedgerCodec.from_lockfile(lock) return lock def write(self, path: Path) -> None: """Write lock file to disk.""" - path.write_text(self.to_yaml(), encoding="utf-8") + from ..utils.atomic_io import atomic_write_text + + atomic_write_text(path, self.to_yaml()) @classmethod def read(cls, path: Path) -> LockFile | None: @@ -708,8 +841,10 @@ def read(cls, path: Path) -> LockFile | None: return None try: return cls.from_yaml(path.read_text(encoding="utf-8")) - except (yaml.YAMLError, ValueError, KeyError): - return None + except (LockfileFormatError, UnsupportedLockfileVersionError): + raise + except (yaml.YAMLError, ValueError, KeyError, TypeError) as exc: + raise LockfileFormatError(f"Invalid lockfile at {path}: {exc}") from exc @classmethod def load_or_create(cls, path: Path) -> LockFile: @@ -823,9 +958,12 @@ def is_semantically_equivalent(self, other: LockFile) -> bool: """ if self.lockfile_version != other.lockfile_version: return False - if set(self.dependencies.keys()) != set(other.dependencies.keys()): + self_dependency_keys = set(self.dependencies).difference({_SELF_KEY}) + other_dependency_keys = set(other.dependencies).difference({_SELF_KEY}) + if self_dependency_keys != other_dependency_keys: return False - for key, dep in self.dependencies.items(): + for key in self_dependency_keys: + dep = self.dependencies[key] other_dep = other.dependencies[key] if dep.to_dict() != other_dep.to_dict(): return False @@ -833,6 +971,10 @@ def is_semantically_equivalent(self, other: LockFile) -> bool: return False if self.mcp_configs != other.mcp_configs: return False + if self.mcp_target_servers != other.mcp_target_servers or dict( + self.deployment_ledger.records + ) != dict(other.deployment_ledger.records): + return False if self.mcp_config_provenance != other.mcp_config_provenance: return False if sorted(self.lsp_servers) != sorted(other.lsp_servers): diff --git a/src/apm_cli/deps/plugin_parser.py b/src/apm_cli/deps/plugin_parser.py index cfb57c864..f98804860 100644 --- a/src/apm_cli/deps/plugin_parser.py +++ b/src/apm_cli/deps/plugin_parser.py @@ -67,6 +67,10 @@ class PluginIntegrityError(RuntimeError): """ +class DeclaredPluginComponentError(PluginIntegrityError): + """Raised when a plugin explicitly declares an unsatisfied component path.""" + + def _assert_no_symlink_descendants(target: Path) -> None: """Refuse to copy when *target* or any of its descendants is a symlink. @@ -193,6 +197,42 @@ def normalize_plugin_directory(plugin_path: Path, plugin_json_path: Path | None return synthesize_apm_yml_from_plugin(plugin_path, manifest) +def _validate_declared_component_paths(plugin_path: Path, manifest: dict[str, Any]) -> None: + """Fail when a plugin manifest declares a component that cannot be resolved.""" + plugin_name = str(manifest.get("name") or plugin_path.name) + for field in ("agents", "skills", "commands", "hooks"): + declared = manifest.get(field) + if declared is None or declared == [] or (field == "hooks" and isinstance(declared, dict)): + continue + values = declared if isinstance(declared, list) else [declared] + for value in values: + declared_path = str(value) + if not declared_path.strip(): + raise DeclaredPluginComponentError( + f"Plugin '{plugin_name}' declares an empty '{field}' component path " + f"in plugin root '{plugin_path}'. Remove the empty declaration " + "from plugin.json, then reinstall." + ) + candidate = plugin_path / declared_path + try: + ensure_path_within(candidate, plugin_path) + resolved = candidate.resolve() + except (OSError, PathTraversalError, ValueError) as exc: + raise DeclaredPluginComponentError( + f"Plugin '{plugin_name}' declares an invalid '{field}' component path " + f"'{declared_path}' outside plugin root '{plugin_path}'. " + "Move the component inside the plugin root or remove the declaration " + "from plugin.json, then reinstall." + ) from exc + if resolved.exists() and not candidate.is_symlink(): + continue + raise DeclaredPluginComponentError( + f"Plugin '{plugin_name}' declares missing '{field}' component path " + f"'{declared_path}' in plugin root '{plugin_path}'. " + "Add the component or remove the declaration from plugin.json, then reinstall." + ) + + def synthesize_apm_yml_from_plugin(plugin_path: Path, manifest: dict[str, Any]) -> Path: """Synthesize apm.yml from plugin metadata. @@ -217,6 +257,8 @@ def synthesize_apm_yml_from_plugin(plugin_path: Path, manifest: dict[str, Any]) if not manifest.get("name"): manifest["name"] = plugin_path.name + _validate_declared_component_paths(plugin_path, manifest) + # Create .apm directory structure apm_dir = plugin_path / ".apm" apm_dir.mkdir(exist_ok=True) diff --git a/src/apm_cli/drift.py b/src/apm_cli/drift.py index cc8462598..cc656fae3 100644 --- a/src/apm_cli/drift.py +++ b/src/apm_cli/drift.py @@ -267,12 +267,9 @@ def detect_config_drift( Returns: A set of names (strings) whose configuration has drifted. """ - drifted: builtins.set = builtins.set() - for name, current in current_configs.items(): - stored = stored_configs.get(name) - if stored is not None and stored != current: - drifted.add(name) - return drifted + from apm_cli.integration.mcp_config_view import McpConfigDiff + + return builtins.set(McpConfigDiff.between(current_configs, stored_configs).changed) # --------------------------------------------------------------------------- diff --git a/src/apm_cli/install/context.py b/src/apm_cli/install/context.py index 51184e640..9263985b8 100644 --- a/src/apm_cli/install/context.py +++ b/src/apm_cli/install/context.py @@ -64,6 +64,7 @@ class InstallContext: target_override_source: str | None = None allow_insecure: bool = False allow_insecure_hosts: tuple[str, ...] = () + transaction: Any = None # InstallTransaction dry_run: bool = False lockfile_only: bool = False diff --git a/src/apm_cli/install/deployed_paths.py b/src/apm_cli/install/deployed_paths.py index 11dfbd686..edb1e2b5e 100644 --- a/src/apm_cli/install/deployed_paths.py +++ b/src/apm_cli/install/deployed_paths.py @@ -5,43 +5,61 @@ from pathlib import Path from typing import Any +from apm_cli.utils.path_security import PathTraversalError, ensure_path_within +from apm_cli.utils.paths import portable_relpath + def deployed_path_entry( target_path: Path, project_root: Path, targets: Any, ) -> str: - """Return the lockfile-safe path string for a deployed file.""" + """Return the compatibility path view produced by the canonical codec.""" + from apm_cli.core.deployment_ledger import DeploymentLedgerCodec + from apm_cli.core.scope import InstallScope + from apm_cli.integration.targets import encode_external_target_locator - def _try_dynamic_root(tgts, *, strict: bool = False) -> str | None: + def _try_target(tgts) -> str | None: for _t in tgts: - if _t.resolved_deploy_root is None: - continue - if not strict: + deploy_root = _t.managed_deploy_root + if deploy_root is not None: try: - target_path.relative_to(_t.resolved_deploy_root) + encoded = encode_external_target_locator(_t, target_path) + except PathTraversalError: + raise except ValueError: - continue - if _t.name == "copilot-app": - from apm_cli.integration.copilot_app_db import to_lockfile_uri - - return to_lockfile_uri(target_path.name) - from apm_cli.integration.copilot_cowork_paths import to_lockfile_path - - return to_lockfile_path(target_path, _t.resolved_deploy_root) + encoded = None + if encoded is not None: + return encoded + absolute_static_root = _t.resolved_deploy_root is None and deploy_root is not None + if absolute_static_root: + try: + target_path.relative_to(deploy_root) + except ValueError: + pass + else: + resolved_target = ensure_path_within(target_path, deploy_root) + return portable_relpath(resolved_target, project_root) + try: + locator = DeploymentLedgerCodec.locator_for_path( + target_path, + project_root=project_root, + target=_t, + scope=InstallScope.PROJECT, + ) + except RuntimeError: + continue + return locator.value return None if targets: - result = _try_dynamic_root(targets) + result = _try_target(targets) if result is not None: return result try: - return target_path.relative_to(project_root).as_posix() - except ValueError: - if targets: - result = _try_dynamic_root(targets, strict=True) - if result is not None: - return result + project_path = ensure_path_within(target_path, project_root) + return portable_relpath(project_path, project_root) + except (PathTraversalError, ValueError): raise RuntimeError( # noqa: B904 f"Cannot translate {target_path!r} to a lockfile path: " f"path is outside the project tree and no dynamic-root " diff --git a/src/apm_cli/install/errors.py b/src/apm_cli/install/errors.py index aa877191e..c587cc3d3 100644 --- a/src/apm_cli/install/errors.py +++ b/src/apm_cli/install/errors.py @@ -43,6 +43,10 @@ class DirectDependencyError(RuntimeError): """ +class InstallFailureAlreadyRendered(RuntimeError): + """Signal a failed install whose user-facing diagnostics are complete.""" + + class AuthenticationError(RuntimeError): """Raised when a remote host rejects credentials or none are available. diff --git a/src/apm_cli/install/heals/branch_ref_drift.py b/src/apm_cli/install/heals/branch_ref_drift.py index 372170385..88c7599d1 100644 --- a/src/apm_cli/install/heals/branch_ref_drift.py +++ b/src/apm_cli/install/heals/branch_ref_drift.py @@ -55,7 +55,7 @@ def execute(self, hctx: HealContext) -> None: # dep is LEGITIMATE (caused by upstream branch advancing past the # lockfile-recorded SHA), not a supply-chain attack. Without # this the supply-chain hard-block at sources.py would abort the - # install with sys.exit(1) before the repaired lockfile is + # install before the repaired lockfile is # written. hctx.add_bypass_key(hctx.package_key) hctx.emit( diff --git a/src/apm_cli/install/heals/buggy_lockfile_recovery.py b/src/apm_cli/install/heals/buggy_lockfile_recovery.py index e5e36dc77..f68bdd08a 100644 --- a/src/apm_cli/install/heals/buggy_lockfile_recovery.py +++ b/src/apm_cli/install/heals/buggy_lockfile_recovery.py @@ -88,7 +88,7 @@ def execute(self, hctx: HealContext) -> None: # will produce NEW bytes whose hash differs from the OLD # lockfile hash. Tell the FreshDependencySource that this # change is LEGITIMATE -- otherwise the supply-chain hard-block - # would call sys.exit(1) before we can repair the lockfile. + # would abort before we can repair the lockfile. hctx.add_bypass_key(hctx.package_key) hctx.emit( HealMessageLevel.WARN, diff --git a/src/apm_cli/install/helpers/ref_reuse.py b/src/apm_cli/install/helpers/ref_reuse.py index ee31cd36f..0afb61955 100644 --- a/src/apm_cli/install/helpers/ref_reuse.py +++ b/src/apm_cli/install/helpers/ref_reuse.py @@ -28,22 +28,27 @@ def _token_fingerprint(token: str | None) -> str | None: return "sha256:" + hashlib.sha256(token.encode("utf-8")).hexdigest()[:16] -def resolve_dep_token(dep_ref: Any, auth_resolver: Any) -> str | None: - """Resolve the per-dep token via AuthResolver for use by ``git ls-remote``. - - Uses the same credential source the downstream clone will use. Without - this threading, ls-remote on a private repo would rely on the host's git - credential helper (present on dev laptops, absent in CI). Best-effort: - on any failure the unauth path remains and the downstream clone surfaces - the real auth error with its own diagnostic. +def resolve_dep_auth( + dep_ref: Any, + auth_resolver: Any, +) -> tuple[str | None, str, dict[str, str] | None]: + """Resolve per-dependency authentication for use by ``git ls-remote``. + + Uses the same token and scheme the downstream clone will use. Best-effort: + when no real token is resolved (or on any failure) the unauthenticated + basic path remains and the downstream clone surfaces the real auth error + with its own diagnostic. A ``bearer`` scheme is only forwarded alongside a + non-empty token, so a token-less context never triggers a bearer request. """ if auth_resolver is None: - return None + return None, "basic", None try: auth_ctx = auth_resolver.resolve_for_dep(dep_ref) - return auth_ctx.token if auth_ctx is not None else None + if auth_ctx is None or not auth_ctx.token: + return None, "basic", getattr(auth_ctx, "git_env", None) + return auth_ctx.token, auth_ctx.auth_scheme, getattr(auth_ctx, "git_env", None) except Exception: - return None + return None, "basic", None def get_shared_ref_resolver( @@ -51,13 +56,18 @@ def get_shared_ref_resolver( token: str | None, cache: dict[Any, Any] | None, lock: Any = None, + *, + auth_scheme: str = "basic", + git_env: dict[str, str] | None = None, + auth_resolver: Any = None, + auth_target: Any = None, ) -> Any: - """Return a ``RefResolver`` for ``(host, token)``, reused across a run. + """Return a ``RefResolver`` for ``(host, token, auth_scheme)``, reused across a run. When ``cache`` is provided, resolvers are memoized so the second and later deps from a repo reuse the instance (and its ref cache). The cache - key is ``(normalized_host, fingerprint(token))`` -- a non-reversible token - fingerprint, never the raw PAT, so the credential is not exposed via the + key includes ``(normalized_host, fingerprint(token), auth_scheme)``. The + fingerprint is non-reversible and never stores the raw credential in the context object this cache lives on. ``host`` is normalized to ``None`` meaning "use RefResolver default (github.com)", so a dep written with an explicit ``host='github.com'`` and one with no host collapse to the same @@ -77,20 +87,33 @@ def get_shared_ref_resolver( _DEFAULT_HOST = "github.com" canonical_host = host if host and host != _DEFAULT_HOST else None + resolver_kwargs = { + "host": host, + "token": token, + "auth_scheme": auth_scheme, + } + if git_env is not None: + resolver_kwargs["git_env"] = git_env + if auth_resolver is not None: + resolver_kwargs.update( + auth_resolver=auth_resolver, + auth_target=auth_target, + ) + if cache is None: - return RefResolver(host=host, token=token) + return RefResolver(**resolver_kwargs) - key = (canonical_host, _token_fingerprint(token)) + key = (canonical_host, _token_fingerprint(token), auth_scheme) if lock is not None: with lock: resolver = cache.get(key) if resolver is None: - resolver = RefResolver(host=host, token=token) + resolver = RefResolver(**resolver_kwargs) cache[key] = resolver return resolver resolver = cache.get(key) if resolver is None: - resolver = RefResolver(host=host, token=token) + resolver = RefResolver(**resolver_kwargs) cache[key] = resolver return resolver diff --git a/src/apm_cli/install/local_bundle_handler.py b/src/apm_cli/install/local_bundle_handler.py index 72a97bd7d..9959353b2 100644 --- a/src/apm_cli/install/local_bundle_handler.py +++ b/src/apm_cli/install/local_bundle_handler.py @@ -195,10 +195,13 @@ def install_local_bundle( lockfile = LockFile.read(lockfile_path) or LockFile() existing = set(lockfile.local_deployed_files) existing.update(deployed) - lockfile.local_deployed_files = sorted(existing) existing_hashes = dict(lockfile.local_deployed_file_hashes) existing_hashes.update(deployed_hashes) - lockfile.local_deployed_file_hashes = existing_hashes + from ..core.deployment_ledger import DeploymentLedgerCodec + + DeploymentLedgerCodec.replace_legacy_owner( + lockfile, ".", sorted(existing), existing_hashes + ) # Auto-migrate legacy per-client skill paths (#737). # After deploying new .agents/skills/ files, detect and clean up diff --git a/src/apm_cli/install/manifest_reconcile.py b/src/apm_cli/install/manifest_reconcile.py index 7f4ef5751..3fc362903 100644 --- a/src/apm_cli/install/manifest_reconcile.py +++ b/src/apm_cli/install/manifest_reconcile.py @@ -26,10 +26,13 @@ from __future__ import annotations from collections.abc import Callable +from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: + from apm_cli.deps.lockfile import LockFile from apm_cli.integration.targets import TargetProfile + from apm_cli.utils.diagnostics import DiagnosticCollector def install_governance(targets: list[TargetProfile]) -> tuple[set[str], set[str]]: @@ -44,18 +47,14 @@ def install_governance(targets: list[TargetProfile]) -> tuple[set[str], set[str] user-machine targets (``copilot-app`` -> ``copilot-app-db://``, ``copilot-cowork`` -> ``cowork://``). """ - from apm_cli.integration.copilot_app_db import COPILOT_APP_URI_SCHEME - from apm_cli.integration.copilot_cowork_paths import COWORK_URI_SCHEME - file_prefixes: set[str] = set() uri_schemes: set[str] = set() + from apm_cli.integration.targets import target_lockfile_uri_schemes + for target in targets or []: - name = getattr(target, "name", None) - if name == "copilot-app": - uri_schemes.add(COPILOT_APP_URI_SCHEME) - continue - if name == "copilot-cowork": - uri_schemes.add(COWORK_URI_SCHEME) + target_schemes = target_lockfile_uri_schemes(target) + if target_schemes: + uri_schemes.update(target_schemes) continue root = getattr(target, "root_dir", None) if root and str(root).rstrip("/") != ".agents": @@ -131,42 +130,304 @@ def union_preserving( legacy preserve-all behaviour is kept so a genuine multi-target deploy is never clobbered (issue #1716). """ - file_prefixes, uri_schemes = install_governance(targets) - allowed_prefixes: set[str] | None = None - allowed_schemes: set[str] | None = None - known_prefixes: set[str] | None = None - known_schemes: set[str] | None = None - if declared_targets is not None: - from apm_cli.integration.targets import KNOWN_TARGETS - - declared_prefixes, d_schemes = install_governance(declared_targets) - known_prefixes, known_schemes = install_governance(list(KNOWN_TARGETS.values())) - # Active targets are always legitimate (this run selected them), so a - # ``--target`` that reaches outside the declared set is still honoured. - allowed_prefixes = file_prefixes | declared_prefixes - allowed_schemes = uri_schemes | d_schemes + from apm_cli.core.deployment_state import ( + DeploymentIntent, + DeploymentLedger, + DeploymentLocator, + DeploymentReconciler, + DeploymentRecord, + LocatorKind, + MaterializationResult, + MaterializationStatus, + NativePayloadValidation, + ) + from apm_cli.integration.targets import KNOWN_TARGETS + from apm_cli.utils.diagnostics import DiagnosticCollector + + active_by_name = {target.name: target for target in targets} + declared_by_name = ( + {target.name: target for target in declared_targets} + if declared_targets is not None + else None + ) + + def _target_for(path: str) -> str: + ordered = [ + *targets, + *(declared_targets or []), + *KNOWN_TARGETS.values(), + ] + seen_names: set[str] = set() + for profile in ordered: + if profile.name in seen_names: + continue + seen_names.add(profile.name) + prefixes, schemes = install_governance([profile]) + if is_governed_by_install(path, prefixes, schemes): + return profile.name + return "legacy" + + def _locator(path: str) -> DeploymentLocator: + return DeploymentLocator( + kind=LocatorKind.URI if "://" in path else LocatorKind.PROJECT_RELATIVE, + target=_target_for(path), + value=path, + runtime=None, + scope="project", + ) + + prior_records: dict[str, DeploymentRecord] = {} + for path in prior_files or (): + locator = _locator(path) + prior_records[locator.key] = DeploymentRecord( + locator=locator, + owners=("legacy",), + active_owner="legacy", + content_hash=prior_hashes.get(path), + ) + current_results = [ + MaterializationResult( + locator=_locator(path), + owners=frozenset({"legacy"}), + status=MaterializationStatus.UNCHANGED, + content_hash=current_hashes.get(path), + validation=NativePayloadValidation(valid=True, contract="legacy-file"), + ) + for path in current_files or () + ] + reconciled = DeploymentReconciler( + Path.cwd(), + KNOWN_TARGETS, + diagnostics=DiagnosticCollector(), + ).reconcile( + DeploymentLedger(records=prior_records), + current_results, + DeploymentIntent( + active_targets=frozenset(active_by_name), + declared_targets=( + frozenset(declared_by_name) if declared_by_name is not None else None + ), + desired_owners=frozenset({"legacy"}), + authoritative_targets=True, + ), + ) + retained_values = {record.locator.value for record in reconciled.ledger.records.values()} current_set = set(current_files or ()) + if on_ghost_drop is not None: + for locator in reconciled.removed: + if locator.value not in current_set and locator.target not in active_by_name: + on_ghost_drop(locator.value) + preserved = [ + path for path in prior_files or () if path not in current_set and path in retained_values + ] merged_hashes = dict(current_hashes or {}) - preserved: list[str] = [] - for path in prior_files or (): - if path in current_set: + for path in preserved: + if path in prior_hashes: + merged_hashes[path] = prior_hashes[path] + return list(current_files or ()) + preserved, merged_hashes + + +def declared_target_profiles( + project_root: Path, + *, + user_scope: bool = False, +) -> list[TargetProfile] | None: + """Resolve the target universe declared by a project manifest.""" + from apm_cli.core.apm_yml import CANONICAL_TARGETS, parse_targets_field + from apm_cli.core.errors import TargetResolutionError + from apm_cli.integration.targets import KNOWN_TARGETS + from apm_cli.utils.yaml_io import load_yaml + + try: + data = load_yaml(project_root / "apm.yml") + except (AttributeError, KeyError, OSError, TypeError, ValueError): + return None + if not isinstance(data, dict): + return None + try: + names = parse_targets_field(data) + except TargetResolutionError: + return None + if not names: + return None + + profiles: list[TargetProfile] = [] + for name in dict.fromkeys(names): + profile = KNOWN_TARGETS.get(name) + if profile is None: continue - if is_governed_by_install(path, file_prefixes, uri_schemes): + scoped = profile.for_scope(user_scope=user_scope) + if scoped is not None: + profiles.append(scoped) + for name, profile in KNOWN_TARGETS.items(): + if name in CANONICAL_TARGETS: continue - if ( - allowed_prefixes is not None - and allowed_schemes is not None - and not is_governed_by_install(path, allowed_prefixes, allowed_schemes) - and known_prefixes is not None - and known_schemes is not None - and is_governed_by_install(path, known_prefixes, known_schemes) - ): - # Ghost: attributable to a known target the consumer does not - # declare. Unknown patterns are indeterminate and stay preserved. - if on_ghost_drop is not None: - on_ghost_drop(path) + scoped = profile.for_scope(user_scope=user_scope) + profiles.append(scoped if scoped is not None else profile) + return profiles or None + + +def reconcile_deployed_block( + *, + project_root: Path, + dep_key: str, + current_files: list[str], + current_hashes: dict[str, str], + prior_files: list[str], + prior_hashes: dict[str, str], + active_targets: list[TargetProfile], + declared_targets: list[TargetProfile] | None, + diagnostics: DiagnosticCollector, + on_ghost_drop: Callable[[str], None] | None = None, +) -> tuple[list[str], dict[str, str]]: + """Reconcile one deployed-state block and safely remove dropped paths.""" + files, hashes = union_preserving( + current_files, + current_hashes, + prior_files, + prior_hashes, + active_targets, + declared_targets=declared_targets, + on_ghost_drop=on_ghost_drop, + ) + dropped = set(prior_files) - set(files) + if not dropped: + return files, hashes + + from apm_cli.integration.base_integrator import BaseIntegrator + from apm_cli.integration.cleanup import remove_stale_deployed_files + + cleanup = remove_stale_deployed_files( + dropped, + project_root, + dep_key=dep_key, + targets=None, + diagnostics=diagnostics, + recorded_hashes=prior_hashes, + ) + for path in cleanup.failed: + if path not in files: + files.append(path) + if path in prior_hashes: + hashes[path] = prior_hashes[path] + if cleanup.deleted_targets: + BaseIntegrator.cleanup_empty_parents(cleanup.deleted_targets, project_root) + return files, hashes + + +def reconcile_deployed_state( + *, + project_root: Path, + lockfile: LockFile, + active_targets: list[TargetProfile], + declared_targets: list[TargetProfile] | None, + diagnostics: DiagnosticCollector, +) -> bool: + """Prune undeclared-target ownership from every lockfile deployment block.""" + from apm_cli.deps.lockfile import _SELF_KEY + from apm_cli.integration.targets import KNOWN_TARGETS + + allowed_targets = [*active_targets, *(declared_targets or [])] + allowed_prefixes, allowed_schemes = install_governance(allowed_targets) + known_prefixes, known_schemes = install_governance(list(KNOWN_TARGETS.values())) + + def _retained(files: list[str]) -> list[str]: + if declared_targets is None: + return list(files) + return [ + path + for path in files + if not ( + is_governed_by_install(path, known_prefixes, known_schemes) + and not is_governed_by_install(path, allowed_prefixes, allowed_schemes) + ) + ] + + changed = False + for dep_key, dependency in lockfile.dependencies.items(): + if dep_key == _SELF_KEY: continue - preserved.append(path) - if prior_hashes and path in prior_hashes: - merged_hashes[path] = prior_hashes[path] - return list(current_files or ()) + preserved, merged_hashes + prior_files = list(dependency.deployed_files) + prior_hashes = dict(dependency.deployed_file_hashes) + current_files = _retained(prior_files) + current_hashes = { + path: value for path, value in prior_hashes.items() if path in current_files + } + files, hashes = reconcile_deployed_block( + project_root=project_root, + dep_key=dep_key, + current_files=current_files, + current_hashes=current_hashes, + prior_files=prior_files, + prior_hashes=prior_hashes, + active_targets=active_targets, + declared_targets=declared_targets, + diagnostics=diagnostics, + ) + if files != prior_files or hashes != prior_hashes: + from apm_cli.core.deployment_ledger import DeploymentLedgerCodec + + DeploymentLedgerCodec.replace_legacy_owner(lockfile, dep_key, files, hashes) + changed = True + + prior_local = list(lockfile.local_deployed_files) + prior_local_hashes = dict(lockfile.local_deployed_file_hashes) + current_local = _retained(prior_local) + current_local_hashes = { + path: value for path, value in prior_local_hashes.items() if path in current_local + } + local_files, local_hashes = reconcile_deployed_block( + project_root=project_root, + dep_key="", + current_files=current_local, + current_hashes=current_local_hashes, + prior_files=prior_local, + prior_hashes=prior_local_hashes, + active_targets=active_targets, + declared_targets=declared_targets, + diagnostics=diagnostics, + ) + if local_files != prior_local or local_hashes != prior_local_hashes: + from apm_cli.core.deployment_ledger import DeploymentLedgerCodec + + DeploymentLedgerCodec.replace_legacy_owner(lockfile, ".", local_files, local_hashes) + changed = True + return changed + + +def reconcile_project_deployed_state( + manifest_root: Path, + *, + explicit_target: str | list[str] | None, + deploy_root: Path | None = None, + lock_root: Path | None = None, + user_scope: bool = False, + verbose: bool = False, +) -> bool: + """Reconcile and persist a project's deployed state after a command.""" + from apm_cli.deps.lockfile import LockFile, get_lockfile_path + from apm_cli.integration.targets import active_targets, active_targets_user_scope + from apm_cli.utils.diagnostics import DiagnosticCollector + + deploy_root = deploy_root or manifest_root + lock_path = get_lockfile_path(lock_root or manifest_root) + lockfile = LockFile.read(lock_path) + if lockfile is None: + return False + declared = declared_target_profiles(manifest_root, user_scope=user_scope) + if explicit_target is None and declared is not None: + targets = declared + elif user_scope: + targets = active_targets_user_scope(explicit_target=explicit_target) + else: + targets = active_targets(deploy_root, explicit_target=explicit_target) + changed = reconcile_deployed_state( + project_root=deploy_root, + lockfile=lockfile, + active_targets=targets, + declared_targets=declared, + diagnostics=DiagnosticCollector(verbose=verbose), + ) + if changed: + lockfile.save(lock_path) + return changed diff --git a/src/apm_cli/install/mcp/integration.py b/src/apm_cli/install/mcp/integration.py index 3543196d8..519c1697c 100644 --- a/src/apm_cli/install/mcp/integration.py +++ b/src/apm_cli/install/mcp/integration.py @@ -21,6 +21,8 @@ def run_mcp_integration( # noqa: PLR0913 old_mcp_servers: builtins.set, old_mcp_configs: builtins.dict, old_mcp_provenance: builtins.dict, + old_mcp_target_servers: builtins.dict | None = None, + old_mcp_target_servers_present: bool = True, project_root: Path, user_scope: bool, should_install: bool, @@ -54,6 +56,7 @@ def run_mcp_integration( # noqa: PLR0913 old_mcp_configs: MCP server configs from the lockfile before this run. old_mcp_provenance: Transitive MCP provenance from the lockfile before this run. + old_mcp_target_servers: APM-owned server names previously written per target. project_root: Project root directory. user_scope: If True, write to user-scope runtime config paths. should_install: Whether MCP integration should run. @@ -79,20 +82,26 @@ def run_mcp_integration( # noqa: PLR0913 report the violation, and exit non-zero; already-installed APM packages are left in place. """ + from apm_cli.deps.lockfile import LockFile + from apm_cli.integration.mcp_config_view import CurrentMcpConfigView from apm_cli.integration.mcp_integrator import MCPIntegrator from apm_cli.policy.install_preflight import run_policy_preflight - # Collect transitive MCP deps from installed packages - if should_install and apm_modules_path.exists(): - transitive_mcp = MCPIntegrator.collect_transitive( + current_view = None + if should_install: + lockfile = LockFile.read(lock_path) if lock_path.exists() else None + current_view = CurrentMcpConfigView.derive( + apm_package, + lockfile, apm_modules_path, - lock_path, - trust_transitive_mcp, + trust_transitive_self_defined=trust_transitive_mcp, diagnostics=diagnostics, ) - if transitive_mcp: - logger.verbose_detail(f"Collected {len(transitive_mcp)} transitive MCP dependency(ies)") - mcp_deps = MCPIntegrator.deduplicate(mcp_deps + transitive_mcp) + root_count = len(apm_package.get_all_mcp_dependencies()) + transitive_count = max(0, len(current_view.dependencies) - root_count) + if transitive_count: + logger.verbose_detail(f"Collected {transitive_count} transitive MCP dependency(ies)") + mcp_deps = list(current_view.dependencies) # allowExecutables MCP gate. from apm_cli.security.executables import filter_mcp_by_allow_executables @@ -120,20 +129,25 @@ def run_mcp_integration( # noqa: PLR0913 mcp_count = 0 new_mcp_servers: builtins.set = builtins.set() - # Forward only the targets-key the user actually declared so parse_targets_field - # in the gate sees the same dict shape it sees from raw apm.yml. Including a - # `targets: None` placeholder when the user wrote `target:` (singular) would - # falsely trip the conflict-mutex check (see core.apm_yml.parse_targets_field). - # This restores parity with `apm install` for users on the modern `targets:` - # plural form -- without this, `targets:` was silently dropped at the call - # site and the gate fell back to permissive directory detection (#1335). mcp_apm_config: dict = {"scripts": apm_package.scripts or {}} - if apm_package.targets is not None: - mcp_apm_config["targets"] = apm_package.targets - elif apm_package.target is not None: - mcp_apm_config["target"] = apm_package.target + from apm_cli.models.apm_package import canonical_package_target_config + + mcp_apm_config.update(canonical_package_target_config(apm_package)) if should_install and mcp_deps: + old_mcp_target_servers = old_mcp_target_servers or {} + if not old_mcp_target_servers_present and old_mcp_servers and old_mcp_configs: + from apm_cli.install.mcp.ownership import adopt_legacy_mcp_target_servers + + old_mcp_target_servers = adopt_legacy_mcp_target_servers( + server_names=builtins.set(old_mcp_servers), + stored_configs=old_mcp_configs, + project_root=project_root, + user_scope=user_scope, + ) + managed_target_servers = { + target: builtins.set(servers) for target, servers in old_mcp_target_servers.items() + } mcp_count = MCPIntegrator.install( mcp_deps, runtime, @@ -146,10 +160,22 @@ def run_mcp_integration( # noqa: PLR0913 explicit_target=explicit_target, diagnostics=diagnostics, scope=scope, + managed_target_servers=managed_target_servers, ) new_mcp_servers = MCPIntegrator.get_server_names(mcp_deps) - new_mcp_configs = MCPIntegrator.get_server_configs(mcp_deps) - new_mcp_provenance = MCPIntegrator.get_server_provenance(mcp_deps) + new_mcp_configs = dict(current_view.configs) if current_view is not None else {} + new_mcp_provenance = dict(current_view.provenance) if current_view is not None else {} + + for removed_target in sorted( + builtins.set(old_mcp_target_servers) - builtins.set(managed_target_servers) + ): + MCPIntegrator.remove_stale( + builtins.set(old_mcp_target_servers[removed_target]), + runtime=removed_target, + project_root=project_root, + user_scope=user_scope, + scope=scope, + ) # Remove stale MCP servers that are no longer needed stale_servers = old_mcp_servers - new_mcp_servers @@ -168,6 +194,7 @@ def run_mcp_integration( # noqa: PLR0913 new_mcp_servers, lock_path, mcp_configs=new_mcp_configs, + mcp_target_servers=managed_target_servers, mcp_config_provenance=new_mcp_provenance, ) elif should_install and not mcp_deps: @@ -182,7 +209,11 @@ def run_mcp_integration( # noqa: PLR0913 scope=scope, ) MCPIntegrator.update_lockfile( - builtins.set(), lock_path, mcp_configs={}, mcp_config_provenance={} + builtins.set(), + lock_path, + mcp_configs={}, + mcp_target_servers={}, + mcp_config_provenance={}, ) logger.verbose_detail("No MCP dependencies found in apm.yml") elif not should_install and old_mcp_servers: @@ -192,6 +223,7 @@ def run_mcp_integration( # noqa: PLR0913 old_mcp_servers, lock_path, mcp_configs=old_mcp_configs, + mcp_target_servers=old_mcp_target_servers, mcp_config_provenance=old_mcp_provenance, ) diff --git a/src/apm_cli/install/mcp/ownership.py b/src/apm_cli/install/mcp/ownership.py new file mode 100644 index 000000000..1b9875379 --- /dev/null +++ b/src/apm_cli/install/mcp/ownership.py @@ -0,0 +1,68 @@ +"""Forward migration for per-target MCP deployment ownership.""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def adopt_legacy_mcp_target_servers( + *, + server_names: set[str], + stored_configs: dict[str, dict], + project_root, + user_scope: bool, +) -> dict[str, set[str]]: + """Adopt legacy native entries only when they exactly match their baseline.""" + from apm_cli.core.conflict_detector import MCPConflictDetector + from apm_cli.factory import ClientFactory + from apm_cli.integration.mcp_integrator import MCPIntegrator + from apm_cli.models.dependency.mcp import MCPDependency + + baselines: dict[str, Any] = {} + for name in sorted(server_names): + raw = stored_configs.get(name) + if not isinstance(raw, dict): + continue + try: + dependency = MCPDependency.from_dict(raw) + except (TypeError, ValueError): + continue + if not dependency.is_self_defined: + continue + baselines[name] = dependency + + if not baselines: + return {} + + adopted: dict[str, set[str]] = {} + for runtime in ClientFactory.supported_clients(): + try: + client = ClientFactory.create_client( + runtime, + project_root=project_root, + user_scope=user_scope, + ) + existing = MCPConflictDetector(client).get_existing_server_configs() + except Exception: + logger.debug("Could not inspect legacy MCP target %s", runtime, exc_info=True) + continue + + for name, dependency in baselines.items(): + try: + expected = client.render_server_config( + MCPIntegrator._build_self_defined_info(dependency) + ) + except Exception: + logger.debug( + "Could not render legacy MCP baseline %s for %s", + name, + runtime, + exc_info=True, + ) + continue + if existing.get(name) == expected: + adopted.setdefault(runtime, set()).add(name) + return adopted diff --git a/src/apm_cli/install/outcome.py b/src/apm_cli/install/outcome.py new file mode 100644 index 000000000..8db021a0d --- /dev/null +++ b/src/apm_cli/install/outcome.py @@ -0,0 +1,100 @@ +"""Canonical install outcome classification.""" + +from __future__ import annotations + +from pathlib import PurePath +from typing import TYPE_CHECKING + +from apm_cli.models.results import InstallDisposition, InstallResult + +if TYPE_CHECKING: + from collections.abc import Collection, Iterable + + from apm_cli.install.context import InstallContext + + +def diagnostic_error_count(diagnostics: object | None) -> int: + """Return a defensive integer error count.""" + if diagnostics is None: + return 0 + try: + return int(getattr(diagnostics, "error_count", 0)) + except (TypeError, ValueError): + return 0 + + +def _component_name(value: str) -> str: + """Return the leaf name used by selective component integration.""" + return PurePath(value.replace("\\", "/")).name or value + + +def require_requested_components( + diagnostics: object, + *, + option: str, + component: str, + requested: Iterable[str], + available: Collection[str], + package: str, +) -> bool: + """Record one canonical failure when requested components are unavailable.""" + requested_values = tuple(str(value) for value in requested) + available_names = frozenset(str(value) for value in available) + missing = tuple( + value for value in requested_values if _component_name(value) not in available_names + ) + if not missing: + return True + + available_display = ", ".join(sorted(available_names)) or "(none)" + qualifier = "matched no declared" if len(missing) == len(requested_values) else "did not match" + message = ( + f"{option} {qualifier} {component}s in '{package}'. " + f"Requested: {', '.join(missing)}. Available: {available_display}. " + f"Choose an available {component} or update the package manifest, then reinstall." + ) + diagnostics.error(message, package=package) + return False + + +def result_from_install_context(ctx: InstallContext) -> InstallResult: + """Build and classify the canonical result carried by an install context.""" + return finalize_install_result( + InstallResult( + ctx.installed_count, + ctx.total_prompts_integrated, + ctx.total_agents_integrated, + ctx.diagnostics, + package_types=dict(ctx.package_types), + ), + force=bool(getattr(ctx, "force", False)), + ) + + +def finalize_install_result( + result: InstallResult, + *, + force: bool, +) -> InstallResult: + """Classify diagnostics before hooks, transaction completion, or return.""" + if result.disposition in { + InstallDisposition.CANCELLED, + InstallDisposition.DRY_RUN, + InstallDisposition.VALIDATION_FAILED, + }: + result.exit_code = 1 if result.disposition is InstallDisposition.VALIDATION_FAILED else 0 + return result + diagnostics = result.diagnostics + has_critical = bool( + diagnostics is not None and getattr(diagnostics, "has_critical_security", False) + ) + if ( + result.disposition is InstallDisposition.FAILED + or diagnostic_error_count(diagnostics) > 0 + or (has_critical and not force) + ): + result.disposition = InstallDisposition.FAILED + result.exit_code = 1 + else: + result.exit_code = 0 + return result diff --git a/src/apm_cli/install/phases/finalize.py b/src/apm_cli/install/phases/finalize.py index a0db5ab03..77b6c02cd 100644 --- a/src/apm_cli/install/phases/finalize.py +++ b/src/apm_cli/install/phases/finalize.py @@ -82,7 +82,6 @@ def _hint_global_root_context(ctx: InstallContext) -> None: def run(ctx: InstallContext) -> InstallResult: """Emit verbose stats, fallback success, unpinned warning, and return final result.""" from apm_cli.commands import install as _install_mod - from apm_cli.models.results import InstallResult # Show integration stats (verbose-only when logger is available) if ctx.total_links_resolved > 0: @@ -152,10 +151,6 @@ def run(ctx: InstallContext) -> InstallResult: if ctx.scope is InstallScope.USER: _hint_global_root_context(ctx) - return InstallResult( - ctx.installed_count, - ctx.total_prompts_integrated, - ctx.total_agents_integrated, - ctx.diagnostics, - package_types=dict(ctx.package_types), - ) + from apm_cli.install.outcome import result_from_install_context + + return result_from_install_context(ctx) diff --git a/src/apm_cli/install/phases/integrate.py b/src/apm_cli/install/phases/integrate.py index ebec390af..74987a1b7 100644 --- a/src/apm_cli/install/phases/integrate.py +++ b/src/apm_cli/install/phases/integrate.py @@ -354,7 +354,11 @@ def _integrate_root_project( # Track deployed files for the post-deps-local phase (stale # cleanup + lockfile persistence of local_deployed_files). - ctx.local_deployed_files = _root_result.get("deployed_files", []) + from apm_cli.core.deployment_ledger import DeploymentLedgerCodec + + DeploymentLedgerCodec.replace_context_local_files( + ctx, _root_result.get("deployed_files", []) + ) _local_total = sum( _root_result.get(k, 0) diff --git a/src/apm_cli/install/phases/lockfile.py b/src/apm_cli/install/phases/lockfile.py index 959e1635e..1be635705 100644 --- a/src/apm_cli/install/phases/lockfile.py +++ b/src/apm_cli/install/phases/lockfile.py @@ -178,34 +178,6 @@ def _has_orphan_lockfile_entries(self) -> bool: intended = self.ctx.intended_dep_keys or set() return any(key != _SELF_KEY and key not in intended for key in existing.dependencies) - def _reconcile_cross_package_deployed_files(self) -> None: - """Strip a stale ownership claim when two dep_keys report the same path. - - ``ctx.package_deployed_files`` is populated once per dep_key, - independently, by that dep's own integration call (see - ``install/template.py``). When two different packages' primitives - resolve to the same on-disk path -- a name collision, e.g. two repos - both shipping a skill called ``shared-topic`` -- each package's own - integration call correctly and independently reports "I wrote this - path" at the moment it ran. Under sequential integration - (``install_order``), a later dep_key's write physically overwrites an - earlier one's at the same path, so only the last dep_key to claim a - given path is telling the truth by the time the lockfile is written. - - Without this pass, every dep_key that ever claimed a colliding path - keeps it in the final lockfile, even though only one of them actually - owns it on disk -- a lockfile integrity bug: a future - ``apm uninstall``/``apm audit`` on a "losing" dep would act on a file - it does not control. - """ - package_deployed_files = self.ctx.package_deployed_files - last_owner: dict[str, str] = {} - for dep_key, files in package_deployed_files.items(): - for f in files: - last_owner[f] = dep_key # dict iteration is insertion order -> last write wins - for dep_key, files in package_deployed_files.items(): - package_deployed_files[dep_key] = [f for f in files if last_owner[f] == dep_key] - def _attach_deployed_files(self, lockfile: LockFile) -> None: """Attach per-dependency deployed-file manifests, unioning targets. @@ -216,19 +188,41 @@ def _attach_deployed_files(self, lockfile: LockFile) -> None: committed lockfile and they stay covered by the audit gates (issue #1716). See :mod:`apm_cli.install.manifest_reconcile`. """ - from apm_cli.install.manifest_reconcile import union_preserving + from apm_cli.install.manifest_reconcile import reconcile_deployed_block from apm_cli.install.phases.targets import declared_target_profiles - self._reconcile_cross_package_deployed_files() existing = self.ctx.existing_lockfile + prior_files_by_package: dict[str, list[str]] = {} + prior_hashes_by_package: dict[str, dict[str, str]] = {} + for dep_key in lockfile.dependencies: + previous = existing.get_dependency(dep_key) if existing is not None else None + prior_files_by_package[dep_key] = ( + previous.deployed_files if previous is not None else [] + ) + prior_hashes_by_package[dep_key] = ( + previous.deployed_file_hashes if previous is not None else {} + ) + from apm_cli.core.deployment_state import DeploymentReconciler + + package_claims = DeploymentReconciler.reconcile_package_claims( + package_keys=lockfile.dependencies, + current_claims=self.ctx.package_deployed_files, + prior_files=prior_files_by_package, + prior_hashes=prior_hashes_by_package, + ) declared = declared_target_profiles(self.ctx) + diagnostics = getattr(self.ctx, "diagnostics", None) + if diagnostics is None: + from apm_cli.utils.diagnostics import DiagnosticCollector + + diagnostics = DiagnosticCollector() ghost_count = 0 - for dep_key, locked_dep in lockfile.dependencies.items(): - current = list(self.ctx.package_deployed_files.get(dep_key, [])) + for dep_key in lockfile.dependencies: + claim = package_claims[dep_key] + current = list(claim.current_files) current_hashes = compute_deployed_hashes(current, self.ctx.project_root) - prev = existing.get_dependency(dep_key) if existing is not None else None - prior_files = prev.deployed_files if prev is not None else [] - prior_hashes = prev.deployed_file_hashes if prev is not None else {} + prior_files = list(claim.prior_files) + prior_hashes = claim.prior_hashes def _log_ghost_drop(path: str, package_key: str = dep_key) -> None: nonlocal ghost_count @@ -240,13 +234,16 @@ def _log_ghost_drop(path: str, package_key: str = dep_key) -> None: f"(target not declared in apm.yml) for {package_key}" ) - files, hashes = union_preserving( - current, - current_hashes, - prior_files, - prior_hashes, - self.ctx.targets, + files, hashes = reconcile_deployed_block( + project_root=self.ctx.project_root, + dep_key=dep_key, + current_files=current, + current_hashes=current_hashes, + prior_files=prior_files, + prior_hashes=prior_hashes, + active_targets=self.ctx.targets, declared_targets=declared, + diagnostics=diagnostics, on_ghost_drop=_log_ghost_drop, ) if not files: @@ -254,8 +251,9 @@ def _log_ghost_drop(path: str, package_key: str = dep_key) -> None: # leave deployed_files untouched so the whole-dep # _merge_existing path can preserve it intact. continue - locked_dep.deployed_files = files - locked_dep.deployed_file_hashes = hashes + from apm_cli.core.deployment_ledger import DeploymentLedgerCodec + + DeploymentLedgerCodec.replace_legacy_owner(lockfile, dep_key, files, hashes) logger = getattr(self.ctx, "logger", None) if logger and ghost_count: noun = "entry" if ghost_count == 1 else "entries" @@ -390,9 +388,13 @@ def _preserve_existing_lsp_state(self, lockfile: LockFile) -> None: def _preserve_existing_local_state(self, lockfile: LockFile) -> None: """Keep local fields until post_deps_local reconciles content hashes.""" if self.ctx.existing_lockfile: - lockfile.local_deployed_files = list(self.ctx.existing_lockfile.local_deployed_files) - lockfile.local_deployed_file_hashes = copy.deepcopy( - self.ctx.existing_lockfile.local_deployed_file_hashes + from apm_cli.core.deployment_ledger import DeploymentLedgerCodec + + DeploymentLedgerCodec.replace_legacy_owner( + lockfile, + ".", + list(self.ctx.existing_lockfile.local_deployed_files), + copy.deepcopy(self.ctx.existing_lockfile.local_deployed_file_hashes), ) if "." in self.ctx.existing_lockfile.dependencies: lockfile.dependencies["."] = copy.deepcopy( diff --git a/src/apm_cli/install/phases/post_deps_local.py b/src/apm_cli/install/phases/post_deps_local.py index 311d3ec57..68e000a4c 100644 --- a/src/apm_cli/install/phases/post_deps_local.py +++ b/src/apm_cli/install/phases/post_deps_local.py @@ -86,7 +86,12 @@ def run(ctx: InstallContext) -> None: recorded_hashes=_prev_hashes, ) # Failed paths stay in lockfile so we retry next time. - ctx.local_deployed_files.extend(_cleanup_result.failed) + from apm_cli.core.deployment_ledger import DeploymentLedgerCodec + + DeploymentLedgerCodec.replace_context_local_files( + ctx, + [*ctx.local_deployed_files, *_cleanup_result.failed], + ) if _cleanup_result.deleted_targets: BaseIntegrator.cleanup_empty_parents( _cleanup_result.deleted_targets, ctx.project_root @@ -112,7 +117,7 @@ def run(ctx: InstallContext) -> None: # the per-dependency reconciliation in phases/lockfile.py and with # on-disk stale cleanup, so a multi-target deploy keeps content-integrity # coverage for every committed deploy target (issue #1716). - from apm_cli.install.manifest_reconcile import union_preserving as _union + from apm_cli.install.manifest_reconcile import reconcile_deployed_block from apm_cli.install.phases.targets import declared_target_profiles _current_files = sorted(ctx.local_deployed_files) @@ -127,17 +132,21 @@ def _log_local_ghost_drop(path: str) -> None: f"Removed stale local lockfile path {path} (target not declared in apm.yml)" ) - _files, _hashes = _union( - _current_files, - _current_hashes, - list(_persist_lock.local_deployed_files), - dict(_persist_lock.local_deployed_file_hashes), - ctx.targets, + _files, _hashes = reconcile_deployed_block( + project_root=ctx.project_root, + dep_key="", + current_files=_current_files, + current_hashes=_current_hashes, + prior_files=list(_persist_lock.local_deployed_files), + prior_hashes=dict(_persist_lock.local_deployed_file_hashes), + active_targets=ctx.targets, declared_targets=declared_target_profiles(ctx), + diagnostics=ctx.diagnostics, on_ghost_drop=_log_local_ghost_drop, ) - _persist_lock.local_deployed_files = sorted(_files) - _persist_lock.local_deployed_file_hashes = _hashes + from apm_cli.core.deployment_ledger import DeploymentLedgerCodec + + DeploymentLedgerCodec.replace_legacy_owner(_persist_lock, ".", sorted(_files), _hashes) if logger and _ghost_count: noun = "entry" if _ghost_count == 1 else "entries" logger.info(f"Repaired {_ghost_count} inactive-target local lockfile {noun}") diff --git a/src/apm_cli/install/phases/resolve.py b/src/apm_cli/install/phases/resolve.py index b2b3393bb..d95b9721a 100644 --- a/src/apm_cli/install/phases/resolve.py +++ b/src/apm_cli/install/phases/resolve.py @@ -24,11 +24,13 @@ from typing import TYPE_CHECKING from apm_cli.install.helpers.ref_seed import seed_ref_resolver_from_lockfile +from apm_cli.install.transaction import resolution_for_context from apm_cli.models.apm_package import GitReferenceType, ResolvedReference from apm_cli.utils.short_sha import format_short_sha if TYPE_CHECKING: from apm_cli.install.context import InstallContext + from apm_cli.install.resolution_staging import ResolutionStagingSession from apm_cli.models.dependency.reference import DependencyReference _logger = logging.getLogger(__name__) @@ -103,15 +105,12 @@ def _maybe_resolve_git_semver( Auth ---- When ``auth_resolver`` is supplied, the per-dep ``AuthContext`` is - resolved before constructing :class:`RefResolver` and its token is - embedded in the ``https://`` URL used by ``git ls-remote``. This - mirrors the auth path used by the clone step downstream, so a - private-repo semver dep that clones successfully also enumerates - its tags successfully in CI environments where ``GITHUB_APM_PAT`` / - ``ADO_APM_PAT`` are the only credential source (no system - credential helper available). Passing ``auth_resolver=None`` (the - legacy path) preserves the previous unauthenticated behaviour for - public repos and for callers that intentionally skip auth. + resolved before constructing :class:`RefResolver`; its token and + authentication scheme are forwarded to ``git ls-remote``, mirroring + the clone step downstream. A private-repo semver dep that clones + successfully then also enumerates its tags in CI environments where + ``GITHUB_APM_PAT`` / ``ADO_APM_PAT`` are the only credential source. + ``auth_resolver=None`` preserves unauthenticated public-repo behavior. """ # Only git-source deps with a semver-range reference are eligible. if dep_ref.is_local: @@ -153,18 +152,23 @@ def _maybe_resolve_git_semver( resolved_at=locked.resolved_at or "", ) - # Fresh resolution: call git ls-remote and pick the highest matching tag. from apm_cli.deps.git_semver_resolver import GitSemverResolver from apm_cli.install.helpers.ref_reuse import ( get_shared_ref_resolver, - resolve_dep_token, + resolve_dep_auth, ) - # Reuse one RefResolver per (host, token) so same-repo deps share a - # single ls-remote tag listing (see helpers.ref_reuse for rationale). - token = resolve_dep_token(dep_ref, auth_resolver) + # Reuse one resolver per auth context so same-repo deps share tag listing. + token, auth_scheme, git_env = resolve_dep_auth(dep_ref, auth_resolver) ref_resolver = get_shared_ref_resolver( - dep_ref.host, token, ref_resolver_cache, ref_resolver_cache_lock + dep_ref.host, + token, + ref_resolver_cache, + ref_resolver_cache_lock, + auth_scheme=auth_scheme, + git_env=git_env, + auth_resolver=auth_resolver, + auth_target=dep_ref.host, ) resolver = GitSemverResolver(ref_resolver) return resolver.resolve( @@ -179,6 +183,7 @@ def _purge_cached_semver_paths_for_update( all_apm_deps, apm_modules_dir, logger, + staging_session: ResolutionStagingSession, ) -> None: """Pre-purge on-disk install paths for direct git-source and registry semver deps when ``--update`` / ``--refresh`` is set. @@ -215,6 +220,7 @@ def _purge_cached_semver_paths_for_update( # here -- the resolver will surface a real error downstream. continue if _ip.exists(): + staging_session.prepare_path(_ip) with suppress(Exception): _rrm(_ip, ignore_errors=True) if logger: @@ -374,13 +380,8 @@ def _fail_on_resolution_errors(ctx: InstallContext, dependency_graph) -> None: raise RuntimeError(f"Dependency resolution failed: {joined_errors}") -def _resolve_dependencies(ctx: InstallContext) -> None: - """Run ``APMDependencyResolver``, handle errors; populate ``ctx.deps_to_install`` and ``ctx.dependency_graph``. - - Also wires the download callback (which handles transitive package fetching), - builds ``ctx.dep_base_dirs``, writes ancillary state to ``ctx``, and cleans up - the shared clone cache. - """ +def _resolve_dependencies(ctx: InstallContext, staging_session: ResolutionStagingSession) -> None: + """Resolve dependencies and populate the resolution fields on ``ctx``.""" import threading as _threading from apm_cli.core.scope import InstallScope @@ -499,6 +500,7 @@ def download_callback(dep_ref, modules_dir, parent_chain="", parent_pkg=None): ) if not _force_semver_resolve: return install_path + staging_session.prepare_path(install_path) # F1 (#1116): surface a heartbeat BEFORE the network/copy work so # users see the install advancing past silent transitive lookups. # Under F7's parallel BFS this callback may run on a worker @@ -761,6 +763,7 @@ def download_callback(dep_ref, modules_dir, parent_chain="", parent_pkg=None): all_apm_deps=ctx.all_apm_deps, apm_modules_dir=ctx.apm_modules_dir, logger=ctx.logger, + staging_session=staging_session, ) resolver = APMDependencyResolver( @@ -991,7 +994,7 @@ def run(ctx: InstallContext) -> None: _ensure_modules_dir(ctx) _setup_downloader(ctx) seed_ref_resolver_from_lockfile(ctx) - _resolve_dependencies(ctx) + _resolve_dependencies(ctx, resolution_for_context(ctx)) if ctx.only_packages: _apply_only_filter(ctx) _compute_intended_dep_keys(ctx) diff --git a/src/apm_cli/install/phases/targets.py b/src/apm_cli/install/phases/targets.py index 6904c7ac0..d7f154416 100644 --- a/src/apm_cli/install/phases/targets.py +++ b/src/apm_cli/install/phases/targets.py @@ -133,9 +133,10 @@ def declared_target_profiles(ctx: InstallContext) -> list[TargetProfile] | None: permanently on fresh checkouts. Knowing the declared universe lets the union drop those ghosts while still honouring the #1716 multi-target contract. """ - from apm_cli.core.apm_yml import CANONICAL_TARGETS from apm_cli.core.scope import InstallScope - from apm_cli.integration.targets import KNOWN_TARGETS + from apm_cli.install.manifest_reconcile import ( + declared_target_profiles as profiles_for_project, + ) try: names = _read_yaml_targets(ctx) @@ -146,35 +147,10 @@ def declared_target_profiles(ctx: InstallContext) -> list[TargetProfile] | None: if not names: return None is_user = getattr(ctx, "scope", None) is InstallScope.USER - - profiles: list[TargetProfile] = [] - # Declared canonical targets: scope-applied, mirroring ``ctx.targets``. A - # scope that drops a target (``for_scope`` -> None) means it does not apply - # here, so it is skipped. - for name in dict.fromkeys(names): - profile = KNOWN_TARGETS.get(name) - if profile is None: - continue - scoped = profile.for_scope(user_scope=is_user) - if scoped is not None: - profiles.append(scoped) - - # Gated / dynamic targets (``copilot-app``, ``copilot-cowork``, ``openclaw``, - # ``hermes`` -- KNOWN but NON-canonical) are activated by flag or detection - # and can NEVER be declared in apm.yml, so their entries (e.g. - # ``copilot-app-db://`` rows) must always count as legitimate, not ghosts -- - # otherwise a run that does not activate them would wrongly drop their prior - # lockfile rows and regress the #1716 multi-target contract. Their - # governance is name/scheme based, so fall back to the raw profile when a - # dynamic target has no project-scope form (``copilot-app`` -> None). - for name in KNOWN_TARGETS: - if name in CANONICAL_TARGETS: - continue - profile = KNOWN_TARGETS[name] - scoped = profile.for_scope(user_scope=is_user) - profiles.append(scoped if scoped is not None else profile) - - return profiles or None + package_path = getattr(ctx.apm_package, "package_path", None) + if package_path is None: + return None + return profiles_for_project(Path(package_path), user_scope=is_user) def _create_target_dirs( @@ -439,6 +415,13 @@ def _fmt_target(t: Any) -> str: parts = [t.strip() for t in raw_override.split(",") if t.strip()] else: parts = list(raw_override) + from apm_cli.core.target_catalog import expand_all + + parts = [ + expanded + for part in parts + for expanded in (expand_all("install") if part == "all" else (part,)) + ] # Multi-token CLI parsing returns runtime aliases; convert them before filtering. parts = _normalize_runtime_target_aliases(parts) parts = [p for p in parts if p in _CANONICAL] @@ -545,6 +528,10 @@ def run(ctx: InstallContext) -> None: # Resolve effective explicit target: CLI --target wins, then apm.yml, # then user-scoped config default target. _explicit = ctx.target_override or config_target or None + if _explicit == "all": + from apm_cli.core.target_catalog import expand_all + + _explicit = list(expand_all("install")) # ------------------------------------------------------------------ # Deprecation warning for legacy '--target agents' alias (cli-review §1) diff --git a/src/apm_cli/install/pipeline.py b/src/apm_cli/install/pipeline.py index dd392b064..cceb98d87 100644 --- a/src/apm_cli/install/pipeline.py +++ b/src/apm_cli/install/pipeline.py @@ -42,15 +42,16 @@ import builtins import contextlib -import sys import time +from functools import wraps from typing import TYPE_CHECKING -from ..models.results import InstallResult +from ..models.results import InstallDisposition, InstallResult from ..utils.console import _rich_error from ..utils.diagnostics import DiagnosticCollector from ..utils.path_security import PathTraversalError -from .errors import AuthenticationError, DirectDependencyError +from .errors import AuthenticationError, DirectDependencyError, InstallFailureAlreadyRendered +from .transaction import InstallTransaction if TYPE_CHECKING: from ..core.auth import AuthResolver @@ -117,7 +118,6 @@ def _preflight_auth_check(ctx, auth_resolver, verbose: bool) -> None: Raises :class:`AuthenticationError` (with ``build_error_context`` payload) on the first auth failure that survives the fallback. """ - import os import subprocess as _sp from ..utils.github_host import ( @@ -175,7 +175,13 @@ def _trace(line: str) -> None: auth_scheme=_auth_scheme, ) _ctx_env = getattr(dep_ctx, "git_env", {}) or {} - probe_env = {**os.environ, **_dl.git_env, **_ctx_env} + safe_overlay_keys = ( + "GIT_SSH_COMMAND", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_NOSYSTEM", + ) + _dl_overlay = {key: value for key, value in _dl.git_env.items() if key in safe_overlay_keys} + probe_env = {**_ctx_env, **_dl_overlay} # GIT_CONFIG_GLOBAL / GIT_CONFIG_NOSYSTEM carve-out: GitAuthEnvBuilder # forces an empty global gitconfig for ALL hosts to prevent a user's # ~/.gitconfig insteadOf rewrites or credential helpers from leaking @@ -394,6 +400,47 @@ def _is_no_work_install( return True +def _transactional_pipeline(run): + """Supply a compatibility transaction and rollback every exceptional exit.""" + + @wraps(run) + def wrapped(*args, **kwargs): + transaction = kwargs.get("transaction") + owns_transaction = transaction is None + if transaction is None: + from ..core.scope import InstallScope, get_manifest_path, get_modules_dir + + scope = kwargs.get("scope") or InstallScope.PROJECT + try: + manifest_path = get_manifest_path(scope) + modules_dir = get_modules_dir(scope) + except (AttributeError, TypeError): + from pathlib import Path + + manifest_path = Path.cwd() / "apm.yml" + modules_dir = Path.cwd() / "apm_modules" + transaction = InstallTransaction( + manifest_path=manifest_path, + apm_modules_dir=modules_dir, + validation=None, + logger=kwargs.get("logger"), + ) + kwargs["transaction"] = transaction + try: + result = run(*args, **kwargs) + except BaseException: + transaction.rollback() + raise + if result is None: + result = InstallResult() + if owns_transaction: + result = transaction.complete(result) + return result + + return wrapped + + +@_transactional_pipeline def run_install_pipeline( # noqa: PLR0913, RUF100 apm_package: APMPackage, update_refs: bool = False, @@ -418,6 +465,7 @@ def run_install_pipeline( # noqa: PLR0913, RUF100 plan_callback=None, refresh: bool = False, lockfile_only: bool = False, + transaction: InstallTransaction | None = None, ): """Install APM package dependencies. @@ -547,6 +595,7 @@ def run_install_pipeline( # noqa: PLR0913, RUF100 legacy_skill_paths=legacy_skill_paths, refresh=refresh, lockfile_only=lockfile_only, + transaction=transaction, ) # ------------------------------------------------------------------ @@ -598,7 +647,8 @@ def run_install_pipeline( # noqa: PLR0913, RUF100 plan = build_update_plan(_early_lockfile, ctx.deps_to_install) proceed = plan_callback(plan) if not proceed: - return InstallResult() + transaction.rollback() + return InstallResult(disposition=InstallDisposition.CANCELLED) ctx.tui.__enter__() try: @@ -718,7 +768,9 @@ def run_install_pipeline( # noqa: PLR0913, RUF100 "Re-run with 'apm install --update' to re-resolve " "through the registry, or unset PROXY_REGISTRY_ONLY." ) - sys.exit(1) + raise InstallFailureAlreadyRendered( + "Proxy-only lockfile contains direct VCS dependencies" + ) # Supply chain warning: registry-proxy entries without a # content_hash cannot be verified on re-install. @@ -780,6 +832,12 @@ def run_install_pipeline( # noqa: PLR0913, RUF100 "One or more direct dependencies failed validation. Run with --verbose for details." ) + from .outcome import result_from_install_context + + precommit_result = result_from_install_context(ctx) + if precommit_result.disposition is InstallDisposition.FAILED: + return precommit_result + # Update .gitignore only for project-scoped installs, not in lockfile_only mode. if scope == InstallScope.PROJECT and not lockfile_only: from apm_cli.commands._helpers import _update_gitignore_for_apm_modules @@ -922,6 +980,8 @@ def run_install_pipeline( # noqa: PLR0913, RUF100 # #946: same pattern -- surface the message as-is instead of # double-wrapping it through the generic RuntimeError below. raise + except InstallFailureAlreadyRendered: + raise except PathTraversalError: # Path-safety violation in SKILL_BUNDLE or other nested # resolution -- surface as-is for actionable user guidance. diff --git a/src/apm_cli/install/request.py b/src/apm_cli/install/request.py index 31270dbea..b97a4864c 100644 --- a/src/apm_cli/install/request.py +++ b/src/apm_cli/install/request.py @@ -16,6 +16,7 @@ from apm_cli.core.command_logger import InstallLogger from apm_cli.core.scope import InstallScope from apm_cli.install.plan import UpdatePlan + from apm_cli.install.transaction import InstallTransaction from apm_cli.models.apm_package import APMPackage @@ -69,3 +70,4 @@ class InstallRequest: # False to abort cleanly with a "no changes applied" message. Used # by ``apm update`` to render the plan and prompt the user. plan_callback: Callable[[UpdatePlan], bool] | None = None + transaction: InstallTransaction | None = None diff --git a/src/apm_cli/install/resolution_staging.py b/src/apm_cli/install/resolution_staging.py new file mode 100644 index 000000000..2f2c225fd --- /dev/null +++ b/src/apm_cli/install/resolution_staging.py @@ -0,0 +1,59 @@ +"""Rollback-scoped staging for dependency resolution writes.""" + +from __future__ import annotations + +import threading +import uuid +from pathlib import Path + +from apm_cli.utils.path_security import ensure_path_within, safe_rmtree + + +class ResolutionStagingSession: + """Track paths mutated during resolution and restore them on failure.""" + + def __init__(self, apm_modules_dir: Path) -> None: + """Create an empty staging session rooted below ``apm_modules``.""" + self._modules_dir = apm_modules_dir + self._staging_root = apm_modules_dir / ".apm-resolution-staging" / uuid.uuid4().hex + self._backups: dict[Path, Path | None] = {} + self._lock = threading.Lock() + + def prepare_path(self, path: Path) -> None: + """Record *path* and preserve its pre-resolution contents if present.""" + resolved = ensure_path_within(path, self._modules_dir) + with self._lock: + if resolved in self._backups: + return + backup: Path | None = None + if resolved.exists(): + resolved_base = ensure_path_within(self._modules_dir, self._modules_dir) + relative = resolved.relative_to(resolved_base) + backup = self._staging_root / relative + backup.parent.mkdir(parents=True, exist_ok=True) + resolved.replace(backup) + self._backups[resolved] = backup + + def commit(self) -> None: + """Discard preserved pre-resolution contents after successful validation.""" + self._remove_staging_root() + self._backups.clear() + + def rollback(self) -> None: + """Remove session-created paths and restore every replaced path.""" + with self._lock: + for path, backup in reversed(self._backups.items()): + if path.exists(): + safe_rmtree(path, self._modules_dir) + if backup is not None and backup.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + backup.replace(path) + self._remove_staging_root() + self._backups.clear() + + def _remove_staging_root(self) -> None: + if self._staging_root.exists(): + safe_rmtree(self._staging_root, self._modules_dir) + staging_parent = self._staging_root.parent + if staging_parent.exists() and not any(staging_parent.iterdir()): + staging_parent.rmdir() diff --git a/src/apm_cli/install/service.py b/src/apm_cli/install/service.py index 312ac661c..944936e44 100644 --- a/src/apm_cli/install/service.py +++ b/src/apm_cli/install/service.py @@ -4,7 +4,7 @@ Adapters (the Click handler today; programmatic / API callers tomorrow) build an :class:`InstallRequest` and call :meth:`InstallService.run`, which returns a :class:`InstallResult`. Adapters own presentation, -``sys.exit``, and CLI option parsing -- the service does not. +process-exit translation, and CLI option parsing -- the service does not. Why a class rather than a free function? ---------------------------------------- @@ -25,6 +25,7 @@ from typing import TYPE_CHECKING from apm_cli.install.request import InstallRequest +from apm_cli.models.results import InstallDisposition if TYPE_CHECKING: from apm_cli.core.lifecycle_scripts import LifecycleEvent, LifecycleScriptRunner @@ -101,10 +102,15 @@ def run(self, request: InstallRequest) -> InstallResult: plan_callback=request.plan_callback, refresh=request.refresh, lockfile_only=request.lockfile_only, + transaction=request.transaction, ) - post_event = self._build_event("post-install", request) - runner.fire("post-install", post_event) + if result.disposition in { + InstallDisposition.SUCCESS, + InstallDisposition.PARTIAL_SUCCESS, + }: + post_event = self._build_event("post-install", request) + runner.fire("post-install", post_event) return result diff --git a/src/apm_cli/install/services.py b/src/apm_cli/install/services.py index e7438152d..f0a1e197b 100644 --- a/src/apm_cli/install/services.py +++ b/src/apm_cli/install/services.py @@ -281,37 +281,16 @@ def integrate_package_primitives( # noqa: PLR0913 package_name, package_info, allow_executables, ctx=ctx ) - # --- Amendment 6: cowork non-skill primitive warning (once per run) --- - _cowork_active = any(t.name == "copilot-cowork" for t in targets) - if _cowork_active and ctx is not None and not ctx.cowork_nonsupported_warned: - _apm_dir = Path(package_info.install_path) / ".apm" - _NON_SKILL_DIRS = { - "agents": "agents", - "prompts": "prompts", - "instructions": "instructions", - "hooks": "hooks", - # Commands live under ``.apm/prompts/`` and cannot be - # distinguished from general prompts at directory level - # without inspecting frontmatter. Omitted to avoid - # misleading duplicate warnings. - } - _found_types = [ - ptype - for ptype, subdir in _NON_SKILL_DIRS.items() - if (_apm_dir / subdir).is_dir() and any((_apm_dir / subdir).iterdir()) - ] - if _found_types: - _pkg_label = package_name or getattr(package_info, "name", "unknown") - _types_str = ", ".join(sorted(builtins.set(_found_types))) - _warn_msg = ( - f"copilot-cowork target only supports skills; " - f"non-skill primitives in {_pkg_label} " - f"({_types_str}) will not deploy to cowork" - ) - if logger: - logger.warning(_warn_msg, symbol="warning") - diagnostics.warn(_warn_msg) - ctx.cowork_nonsupported_warned = True + from apm_cli.install.target_warnings import warn_unsupported_primitives + + warn_unsupported_primitives( + package_info, + package_name, + targets, + ctx, + diagnostics, + logger, + ) def _log_integration(msg): if logger: @@ -541,8 +520,25 @@ def _format_target_collapse(paths: list[str], verbose: bool) -> tuple[str, list[ if rel.parts: _skill_target_dirs.add(rel.parts[0]) except ValueError: - # Dynamic-root target (copilot-cowork) -- path is outside project tree. - _skill_target_dirs.add("copilot-cowork") + from apm_cli.integration.targets import target_name_for_locator + + owner = next( + ( + target + for target in targets + if target.managed_deploy_root is not None + and tp.is_relative_to(target.managed_deploy_root) + ), + None, + ) + locator_name = target_name_for_locator(_deployed_path_entry(tp, project_root, targets)) + _skill_target_dirs.add( + owner.name + if owner is not None + else locator_name + if locator_name is not None + else "external" + ) _skill_target_paths = [f"{d}/skills/" for d in sorted(_skill_target_dirs)] if not _skill_target_paths: _skill_target_paths = ["skills/"] diff --git a/src/apm_cli/install/skill_path_migration.py b/src/apm_cli/install/skill_path_migration.py index 3f0a8f484..53742f691 100644 --- a/src/apm_cli/install/skill_path_migration.py +++ b/src/apm_cli/install/skill_path_migration.py @@ -234,6 +234,10 @@ def execute_migration( _update_lockfile_entry(lockfile, plan, result) + if result.updated_deps: + from apm_cli.core.deployment_ledger import DeploymentLedgerCodec + + DeploymentLedgerCodec.refresh_from_legacy(lockfile) return result @@ -265,16 +269,8 @@ def _update_lockfile_entry( if plan.dst_path not in hashes: hashes[plan.dst_path] = hash_val - # Also update the flat local_deployed_files / local_deployed_file_hashes - # that the lockfile serializer uses as source of truth. - if plan.src_path in lockfile.local_deployed_files: - lockfile.local_deployed_files.remove(plan.src_path) - if plan.dst_path not in lockfile.local_deployed_files: - lockfile.local_deployed_files.append(plan.dst_path) - if plan.src_path in lockfile.local_deployed_file_hashes: - h = lockfile.local_deployed_file_hashes.pop(plan.src_path) - if plan.dst_path not in lockfile.local_deployed_file_hashes: - lockfile.local_deployed_file_hashes[plan.dst_path] = h + # Keep the flat local ownership view in sync through its canonical owner. + lockfile.rename_local_deployed_path(plan.src_path, plan.dst_path) result.updated_deps.add(plan.dep_name) diff --git a/src/apm_cli/install/sources.py b/src/apm_cli/install/sources.py index c79537985..49d765a9d 100644 --- a/src/apm_cli/install/sources.py +++ b/src/apm_cli/install/sources.py @@ -26,19 +26,19 @@ from __future__ import annotations -import sys from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING, Any +from apm_cli.install.errors import DirectDependencyError from apm_cli.install.registry_wiring import ( get_registry_resolver, registry_resolution_for_cached_registry_dep, resolver_last_registry_resolution, ) -from apm_cli.utils.console import _rich_error, _rich_success +from apm_cli.utils.console import _rich_success from apm_cli.utils.short_sha import format_short_sha if TYPE_CHECKING: @@ -599,10 +599,8 @@ def acquire(self) -> Materialization | None: class FreshDependencySource(DependencySource): """Fresh dependency: needs a network download. - Performs supply-chain hash verification (#763) and, on mismatch, - aborts the entire process via ``sys.exit(1)`` -- this matches the - legacy behaviour because content drift from the lockfile is treated - as a possible tampering event. + Performs supply-chain hash verification (#763) and raises on mismatch + because content drift from the lockfile is treated as possible tampering. """ # Inherits the default "Failed to integrate primitives" prefix. @@ -844,18 +842,13 @@ def acquire(self) -> Materialization | None: _fresh_hash = ctx.package_hashes[dep_key] if _fresh_hash != dep_locked_chk.content_hash: safe_rmtree(install_path, ctx.apm_modules_dir) - _rich_error( - f"Content hash mismatch for " - f"{dep_key}: " - f"expected {dep_locked_chk.content_hash}, " - f"got {_fresh_hash}. " - "The downloaded content differs from the " - "lockfile record. This may indicate a " - "supply-chain attack. Use 'apm install " - "--update' to accept new content and " - "update the lockfile." + raise DirectDependencyError( + f"Content hash mismatch for {dep_key}: " + f"expected {dep_locked_chk.content_hash}, got {_fresh_hash}. " + "The downloaded content differs from the lockfile record. " + "This may indicate a supply-chain attack. Use " + "'apm install --update' to accept new content and update the lockfile." ) - sys.exit(1) if hasattr(package_info, "package_type") and package_info.package_type: ctx.package_types[dep_key] = package_info.package_type.value @@ -883,6 +876,8 @@ def acquire(self) -> Materialization | None: deltas=deltas, ) + except DirectDependencyError: + raise except Exception as e: display_name = str(dep_ref) if dep_ref.is_virtual else dep_ref.repo_url # task_id may not exist if progress.add_task failed; guard it. diff --git a/src/apm_cli/install/summary.py b/src/apm_cli/install/summary.py index d90243f4d..f287e1b6b 100644 --- a/src/apm_cli/install/summary.py +++ b/src/apm_cli/install/summary.py @@ -2,10 +2,9 @@ Extracted from ``apm_cli.commands.install`` to keep the command file under its architectural LOC budget while we layer on the perf+UX -findings F1-F7 (microsoft/apm#1116). This module is a *pure* renderer: -it takes already-collected diagnostics, formats them through the -``InstallLogger``, and decides whether the command should hard-fail on -critical security findings. +findings F1-F7 (microsoft/apm#1116). This module classifies the result +without output, then renders already-collected diagnostics through the +``InstallLogger`` after transaction completion. Keeping it free of the install pipeline state (no ``InstallContext``) lets the unit tests exercise summary behaviour without spinning up @@ -14,9 +13,33 @@ from __future__ import annotations -import sys - from apm_cli.commands._helpers import _rich_blank_line +from apm_cli.install.outcome import ( + diagnostic_error_count, + finalize_install_result, +) +from apm_cli.models.results import InstallResult + + +def _error_count(apm_diagnostics) -> int: + """Return a defensive integer count from optional diagnostics.""" + return diagnostic_error_count(apm_diagnostics) + + +def classify_post_install_result( + *, + apm_count: int, + apm_diagnostics=None, + force: bool, +) -> InstallResult: + """Classify completion without rendering user-facing output.""" + return finalize_install_result( + InstallResult( + installed_count=apm_count, + diagnostics=apm_diagnostics, + ), + force=force, + ) def render_post_install_summary( @@ -28,9 +51,9 @@ def render_post_install_summary( apm_diagnostics=None, force: bool, elapsed_seconds: float | None = None, -) -> None: - """Render diagnostics, the final summary line, and (optionally) - hard-fail on critical security findings. + result: InstallResult | None = None, +) -> InstallResult: + """Render diagnostics and the final disposition-aware summary line. Args: logger: An ``InstallLogger`` instance. @@ -45,22 +68,24 @@ def render_post_install_summary( command, captured by the caller immediately after logger construction. ``None`` keeps the legacy "... ." suffix; a float appends `` in {x:.1f}s`` before the period (F5). + result: Final result after commit or rollback. When omitted, + classify from diagnostics for backward-compatible callers. - Side effects: - Writes to stdout via the logger and may call ``sys.exit(1)`` to - propagate a critical-security hard-fail. + Returns: + Structured completion state for the command adapter. """ if apm_diagnostics and apm_diagnostics.has_diagnostics: apm_diagnostics.render_summary() else: _rich_blank_line() - error_count = 0 - if apm_diagnostics: - try: - error_count = int(apm_diagnostics.error_count) - except (TypeError, ValueError): - error_count = 0 + error_count = _error_count(apm_diagnostics) + if result is None: + result = classify_post_install_result( + apm_count=apm_count, + apm_diagnostics=apm_diagnostics, + force=force, + ) logger.install_summary( apm_count=apm_count, mcp_count=mcp_count, @@ -68,18 +93,6 @@ def render_post_install_summary( errors=error_count, stale_cleaned=logger.stale_cleaned_total, elapsed_seconds=elapsed_seconds, + disposition=result.disposition, ) - - # Hard-fail when critical security findings blocked any package - # (consistent with ``apm unpack``). ``--force`` overrides. - if not force and apm_diagnostics and apm_diagnostics.has_critical_security: - sys.exit(1) - - # Hard-fail when ANY per-dep install error was reported. Matches - # the npm / pip / cargo convention: any install failure -> non-zero - # exit so CI scripts can detect failure without parsing stderr. - # ``--force`` covers critical-security overrides only; it does NOT - # suppress this hard-fail (Bug 2 fix on #1496, where the CLI used - # to exit 0 even after printing "Installation failed with N error(s)"). - if error_count > 0: - sys.exit(1) + return result diff --git a/src/apm_cli/install/target_warnings.py b/src/apm_cli/install/target_warnings.py new file mode 100644 index 000000000..e2b750177 --- /dev/null +++ b/src/apm_cli/install/target_warnings.py @@ -0,0 +1,57 @@ +"""Target-capability warnings emitted during package integration.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from apm_cli.integration.targets import ( + target_supports_primitive, + target_warns_unsupported_primitives, +) + +if TYPE_CHECKING: + from apm_cli.install.context import InstallContext + from apm_cli.install.logging import InstallLogger + from apm_cli.utils.diagnostics import DiagnosticCollector + + +def warn_unsupported_primitives( + package_info: Any, + package_name: str, + targets: Any, + ctx: InstallContext | None, + diagnostics: DiagnosticCollector, + logger: InstallLogger | None, +) -> None: + """Warn once for profiles that intentionally omit package primitives.""" + limited_targets = [target for target in targets if target_warns_unsupported_primitives(target)] + if not limited_targets or ctx is None or ctx.cowork_nonsupported_warned: + return + apm_dir = Path(package_info.install_path) / ".apm" + primitive_dirs = { + "agents": "agents", + "prompts": "prompts", + "instructions": "instructions", + "hooks": "hooks", + } + for target in limited_targets: + found_types = [ + primitive + for primitive, subdir in primitive_dirs.items() + if not target_supports_primitive(target, primitive) + and (apm_dir / subdir).is_dir() + and any((apm_dir / subdir).iterdir()) + ] + if not found_types: + continue + package_label = package_name or getattr(package_info, "name", "unknown") + types_text = ", ".join(sorted(set(found_types))) + message = ( + f"{target.name} target does not support these primitives; " + f"{package_label} ({types_text}) will not deploy them" + ) + if logger: + logger.warning(message, symbol="warning") + diagnostics.warn(message) + ctx.cowork_nonsupported_warned = True diff --git a/src/apm_cli/install/template.py b/src/apm_cli/install/template.py index e6641b77a..3fa3b4c2c 100644 --- a/src/apm_cli/install/template.py +++ b/src/apm_cli/install/template.py @@ -84,7 +84,13 @@ def run_integration_template( Returns a counter-delta dict for accumulation by the caller, or ``None`` if the source declined to acquire (skipped, failed). """ - materialization = source.acquire() + from apm_cli.deps.plugin_parser import DeclaredPluginComponentError + + try: + materialization = source.acquire() + except DeclaredPluginComponentError as exc: + source.ctx.diagnostics.error(str(exc), package=source.dep_key) + return {} if materialization is None: return None @@ -110,6 +116,22 @@ def _integrate_materialization( diagnostics = ctx.diagnostics logger = ctx.logger + if ctx.skill_subset_from_cli and ctx.skill_subset: + from apm_cli.install.outcome import require_requested_components + from apm_cli.integration.skill_integrator import SkillIntegrator + + available_skills = SkillIntegrator.available_skill_names(m.package_info) + if available_skills is not None and not require_requested_components( + diagnostics, + option="--skill", + component="skill", + requested=ctx.skill_subset, + available=available_skills, + package=dep_key, + ): + ctx.package_deployed_files[dep_key] = [] + return deltas + # No-op when targets are empty or acquire decided to skip integration # (signalled by package_info=None). Still record an empty deployed # list so cleanup phase has a deterministic state. diff --git a/src/apm_cli/install/transaction.py b/src/apm_cli/install/transaction.py new file mode 100644 index 000000000..334ef2ad7 --- /dev/null +++ b/src/apm_cli/install/transaction.py @@ -0,0 +1,204 @@ +"""Canonical completion and rollback owner for one install attempt.""" + +from __future__ import annotations + +import contextlib +import os +import tempfile +import threading +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from apm_cli.install.resolution_staging import ResolutionStagingSession +from apm_cli.models.results import InstallDisposition, InstallResult + +if TYPE_CHECKING: + from apm_cli.core.command_logger import InstallLogger, _ValidationOutcome + + +def _restore_manifest_from_snapshot(manifest_path: Path, snapshot: bytes) -> None: + """Atomically replace *manifest_path* with byte-exact *snapshot*.""" + fd, temporary_name = tempfile.mkstemp( + prefix="apm-restore-", + dir=str(manifest_path.parent), + ) + try: + with os.fdopen(fd, "wb") as handle: + handle.write(snapshot) + os.replace(temporary_name, manifest_path) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(temporary_name) + raise + + +def _maybe_rollback_manifest( + manifest_path: Path, + snapshot: bytes | None, + logger: InstallLogger, + manifest_existed: bool | None = None, +) -> None: + """Best-effort restore or removal of the attempt's manifest.""" + if manifest_existed is None: + manifest_existed = snapshot is not None or manifest_path.exists() + if not manifest_existed: + if not manifest_path.exists(): + return + try: + manifest_path.unlink() + if logger is not None: + logger.progress("Removed apm.yml created by the failed install.") + except Exception as exc: + if logger is not None: + logger.warning( + "Failed to remove apm.yml created by this install. " + "Delete apm.yml manually before retrying." + ) + logger.verbose_detail(f"Manifest rollback error: {exc}") + return + try: + if snapshot is None: + return + if manifest_path.exists() and manifest_path.read_bytes() == snapshot: + return + _restore_manifest_from_snapshot(manifest_path, snapshot) + if logger is not None: + logger.progress("apm.yml restored to its previous state.") + except Exception as exc: + if logger is not None: + logger.warning("Failed to restore apm.yml. Inspect apm.yml before retrying.") + logger.verbose_detail(f"Manifest rollback error: {exc}") + + +class InstallTransaction: + """Own install completion meaning and rollback-scoped filesystem state. + + The resolution journal is intentionally limited to paths prepared below + ``apm_modules``. Native target integrations are outside this transaction. + """ + + def __init__( + self, + *, + manifest_path: Path, + apm_modules_dir: Path, + validation: _ValidationOutcome | None, + logger: InstallLogger, + ) -> None: + """Capture the manifest and create one resolution staging session.""" + self.manifest_path = manifest_path + self.apm_modules_dir = apm_modules_dir + self._validation = validation + self._logger = logger + self._manifest_existed = manifest_path.exists() + self._manifest_snapshot = manifest_path.read_bytes() if self._manifest_existed else None + self._resolution = ResolutionStagingSession(apm_modules_dir) + self._lock = threading.RLock() + self.committed = False + self._completed = False + self._rolled_back = False + + @property + def resolution(self) -> ResolutionStagingSession: + """Return the single resolution journal owned by this attempt.""" + return self._resolution + + def record_validation(self, validation: _ValidationOutcome) -> None: + """Attach the validation outcome produced after transaction creation.""" + self._validation = validation + + def validation_result(self) -> InstallResult | None: + """Return the terminal result for an all-invalid positional batch.""" + if self._validation is None or not self._validation.all_failed: + return None + self.rollback() + return InstallResult( + disposition=InstallDisposition.VALIDATION_FAILED, + exit_code=1, + ) + + def commit(self, result: InstallResult) -> InstallResult: + """Finalize staged resolution paths and mark *result* committed.""" + with self._lock: + if self._rolled_back: + raise RuntimeError("Cannot commit an install transaction after rollback") + if not self.committed: + self._resolution.commit() + self.committed = True + self._completed = True + if ( + self._validation is not None + and self._validation.has_failures + and result.disposition is InstallDisposition.SUCCESS + ): + result.disposition = InstallDisposition.PARTIAL_SUCCESS + result.committed = True + return result + + def complete(self, result: InstallResult) -> InstallResult: + """Finalize one result according to its canonical disposition.""" + if result.disposition is InstallDisposition.DRY_RUN: + with self._lock: + if self._rolled_back: + raise RuntimeError("Cannot complete an install transaction after rollback") + self._resolution.rollback() + self._completed = True + return result + if result.disposition in { + InstallDisposition.CANCELLED, + InstallDisposition.FAILED, + InstallDisposition.VALIDATION_FAILED, + }: + self.rollback() + return result + return self.commit(result) + + def rollback(self) -> None: + """Restore the manifest and only resolution paths prepared here.""" + with self._lock: + if self.committed or self._rolled_back: + return + self._resolution.rollback() + self._restore_manifest() + self._rolled_back = True + self._completed = True + + def fail(self, error: BaseException) -> InstallResult: + """Rollback and return a structured failed install result.""" + self.rollback() + return InstallResult( + disposition=InstallDisposition.FAILED, + exit_code=1, + error=error, + ) + + def __enter__(self) -> InstallTransaction: + """Enter this install attempt.""" + return self + + def __exit__(self, exc_type, exc, tb) -> bool: + """Rollback every uncommitted exit and preserve exception semantics.""" + if exc is not None or not self._completed: + self.rollback() + return False + + def _restore_manifest(self) -> None: + """Atomically restore the byte-exact manifest snapshot when present.""" + _maybe_rollback_manifest( + self.manifest_path, + self._manifest_snapshot, + self._logger, + self._manifest_existed, + ) + + +def resolution_for_context(ctx: Any) -> ResolutionStagingSession: + """Return the context transaction's journal, creating a legacy adapter.""" + if ctx.transaction is None: + ctx.transaction = InstallTransaction( + manifest_path=ctx.source_root / "apm.yml", + apm_modules_dir=ctx.apm_modules_dir, + validation=None, + logger=ctx.logger, + ) + return ctx.transaction.resolution diff --git a/src/apm_cli/integration/__init__.py b/src/apm_cli/integration/__init__.py index 07b367f90..d99b44e49 100644 --- a/src/apm_cli/integration/__init__.py +++ b/src/apm_cli/integration/__init__.py @@ -7,6 +7,7 @@ from .hook_integrator import HookIntegrator from .instruction_integrator import InstructionIntegrator from .lsp_integrator import LSPIntegrator +from .mcp_config_view import CurrentMcpConfigView, McpConfigDiff, McpSourceProblem from .mcp_integrator import MCPIntegrator from .prompt_integrator import PromptIntegrator from .skill_integrator import ( @@ -32,11 +33,14 @@ "KNOWN_TARGETS", "AgentIntegrator", "BaseIntegrator", + "CurrentMcpConfigView", "HookIntegrator", "InstructionIntegrator", "IntegrationResult", "LSPIntegrator", "MCPIntegrator", + "McpConfigDiff", + "McpSourceProblem", "PrimitiveDispatch", "PrimitiveMapping", "PromptIntegrator", diff --git a/src/apm_cli/integration/base_integrator.py b/src/apm_cli/integration/base_integrator.py index 73bee4d44..bc8dbc7d6 100644 --- a/src/apm_cli/integration/base_integrator.py +++ b/src/apm_cli/integration/base_integrator.py @@ -5,11 +5,55 @@ import re from dataclasses import dataclass, field from pathlib import Path +from typing import Any from apm_cli.compilation.link_resolver import UnifiedLinkResolver +from apm_cli.core.deployment_state import MaterializationResult from apm_cli.primitives.discovery import discover_primitives from apm_cli.utils.atomic_io import normalize_crlf_to_lf from apm_cli.utils.console import _rich_warning +from apm_cli.utils.path_security import PathTraversalError, ensure_path_within + + +def _managed_absolute_target_root(candidate: Path, targets: Any) -> Path | None: + """Return the managed root for *candidate*, or ``None`` if unmanaged.""" + from apm_cli.integration.targets import KNOWN_TARGETS + + source = targets + if source is None: + source = [] + for profile in KNOWN_TARGETS.values(): + if not profile.user_supported or profile.user_root_resolver is not None: + continue + scoped = profile.for_scope(user_scope=True) + if scoped is not None: + source.append(scoped) + try: + resolved = candidate.resolve() + for target_profile in source: + if target_profile is None: + continue + deploy_root = target_profile.managed_deploy_root + if deploy_root is None: + continue + resolved_root = deploy_root.resolve() + for mapping in target_profile.primitives.values(): + if not mapping.subdir: + continue + primitive_root = (resolved_root / mapping.subdir).resolve() + try: + contained = ensure_path_within(resolved, primitive_root) + except PathTraversalError: + continue + if contained != primitive_root: + return resolved_root + if target_profile.hooks_config_display: + hooks_file = resolved_root / Path(target_profile.hooks_config_display).name + if resolved == hooks_file.resolve(): + return resolved_root + except (ValueError, OSError): + return None + return None class _SymlinkRaceError(OSError): @@ -32,6 +76,7 @@ class IntegrationResult: files_skipped: int target_paths: list[Path] links_resolved: int = 0 + materializations: tuple[MaterializationResult, ...] = () # Hook-specific (default 0 when not applicable) scripts_copied: int = 0 @@ -411,16 +456,21 @@ def validate_deploy_path( Checks: 1. No path-traversal components (``..``) 2. Starts with an allowed integration prefix - 3. Resolves within *project_root* (or within the cowork root - for ``cowork://`` paths) + 3. Resolves within *project_root*, a configured absolute target root, + or the cowork root for ``cowork://`` paths """ from apm_cli.integration.copilot_cowork_paths import COWORK_URI_SCHEME - if allowed_prefixes is None: - allowed_prefixes = BaseIntegrator._get_integration_prefixes(targets=targets) if ".." in rel_path: return False + candidate = Path(rel_path) + if candidate.is_absolute(): + return _managed_absolute_target_root(candidate, targets) is not None + + if allowed_prefixes is None: + allowed_prefixes = BaseIntegrator._get_integration_prefixes(targets=targets) + # --- cowork:// paths: validate against cowork root --- if rel_path.startswith(COWORK_URI_SCHEME): if not rel_path.startswith(allowed_prefixes): @@ -615,8 +665,16 @@ def cleanup_empty_parents( # Collect unique parents (skip stop_at itself) candidates: set = set() for p in deleted_paths: + cleanup_boundary = stop_resolved + try: + if not p.resolve().is_relative_to(stop_resolved): + cleanup_boundary = _managed_absolute_target_root(p, targets=None) + if cleanup_boundary is None: + cleanup_boundary = p.parent.resolve() + except (ValueError, OSError): + cleanup_boundary = p.parent.resolve() parent = p.parent - while parent != stop_at and parent.resolve() != stop_resolved: + while parent not in (parent.parent, stop_at) and parent.resolve() != cleanup_boundary: candidates.add(parent) parent = parent.parent # Sort deepest-first for safe bottom-up removal diff --git a/src/apm_cli/integration/hook_integrator.py b/src/apm_cli/integration/hook_integrator.py index 412a60f74..95bab13af 100644 --- a/src/apm_cli/integration/hook_integrator.py +++ b/src/apm_cli/integration/hook_integrator.py @@ -53,9 +53,22 @@ import yaml +from apm_cli.core.deployment_ledger import DeploymentLedgerCodec +from apm_cli.core.deployment_state import ( + MaterializationResult, + MaterializationStatus, + NativePayloadValidation, +) +from apm_cli.core.scope import InstallScope from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult from apm_cli.integration.hook_bundle import copy_deployed_hook_bundle from apm_cli.integration.hook_file_routing import filter_hook_files_for_target +from apm_cli.integration.hook_native_formats import ( + _to_antigravity_hook_entries, + _to_claude_hook_entries, + _to_gemini_hook_entries, +) +from apm_cli.utils.atomic_io import atomic_write_text from apm_cli.utils.console import _rich_warning from apm_cli.utils.path_security import ( PathTraversalError, @@ -98,7 +111,7 @@ class _MergeHookConfig: config_filename: str # e.g. "settings.json" or "hooks.json" target_key: str # target name passed to _rewrite_hooks_data require_dir: bool # True = skip if target dir doesn't exist - schema_strict: bool = False # True = strip _apm_source before writing to disk + schema_strict: bool = True # Ownership always lives outside native files. # Top-level JSON key the merged event map lives under. Defaults to # "hooks" (Claude/Cursor/Codex/Gemini/Windsurf). Antigravity's native # schema keys hooks by an arbitrary hook *name*, so APM reserves the @@ -148,22 +161,29 @@ class _MergeHookConfig: "Stop": "SessionEnd", }, "kiro": { - # Copilot / Claude -> Kiro camelCase events - "PreToolUse": "preToolUse", - "preToolUse": "preToolUse", - "PostToolUse": "postToolUse", - "postToolUse": "postToolUse", - "UserPromptSubmit": "promptSubmit", - "userPromptSubmit": "promptSubmit", - "promptSubmit": "promptSubmit", - "Stop": "agentStop", - "stop": "agentStop", - "AgentStop": "agentStop", - "agentStop": "agentStop", - "PreTaskExecution": "preTaskExecution", - "preTaskExecution": "preTaskExecution", - "PostTaskExecution": "postTaskExecution", - "postTaskExecution": "postTaskExecution", + # Portable and legacy spellings -> Kiro v1 PascalCase triggers. + "PreToolUse": "PreToolUse", + "preToolUse": "PreToolUse", + "PostToolUse": "PostToolUse", + "postToolUse": "PostToolUse", + "UserPromptSubmit": "UserPromptSubmit", + "userPromptSubmit": "UserPromptSubmit", + "promptSubmit": "UserPromptSubmit", + "Stop": "Stop", + "stop": "Stop", + "AgentStop": "Stop", + "agentStop": "Stop", + "SessionStart": "SessionStart", + "sessionStart": "SessionStart", + "PreTaskExecution": "PreTaskExec", + "preTaskExecution": "PreTaskExec", + "PreTaskExec": "PreTaskExec", + "PostTaskExecution": "PostTaskExec", + "postTaskExecution": "PostTaskExec", + "PostTaskExec": "PostTaskExec", + "PostFileCreate": "PostFileCreate", + "PostFileSave": "PostFileSave", + "PostFileDelete": "PostFileDelete", }, } @@ -179,7 +199,7 @@ class _MergeHookConfig: "gemini": "PascalCase", "antigravity": "PascalCase", "windsurf": "PascalCase", - "kiro": "camelCase", + "kiro": "PascalCase", } @@ -243,114 +263,29 @@ def _emit_hook_event_diagnostics( ) -def _to_nested_hook_entries(entries: list, key_fixer) -> list: - """Wrap flat Copilot hook entries in the ``{"hooks": [...]}`` nesting. - - Shared by the Gemini and Antigravity transforms (both use the Claude - nested matcher shape for tool events). *key_fixer* renames the inner - command/timeout keys in place for the specific target. Entries already - in nested form have only their inner keys fixed. - """ - result = [] - for entry in entries: - if not isinstance(entry, dict): - result.append(entry) - continue - # Already nested (Claude / Gemini format) -- just fix inner keys - if "hooks" in entry and isinstance(entry["hooks"], list): - for hook in entry["hooks"]: - key_fixer(hook) - result.append(entry) +def _validate_copilot_payload(payload: dict) -> list[str]: + """Return native payload shape errors before any filesystem mutation.""" + errors: list[str] = [] + if payload.get("version") != 1: + errors.append("top-level version must equal 1") + hooks = payload.get("hooks") + if not isinstance(hooks, dict): + return [*errors, "top-level hooks must be an object"] + for event, entries in hooks.items(): + if not isinstance(entries, list): + errors.append(f"hook event {event!r} must contain a list") continue - # Flat Copilot entry -- wrap in nested format - inner = dict(entry) - key_fixer(inner) - apm_source = inner.pop("_apm_source", None) - outer: dict = {"hooks": [inner]} - if apm_source: - outer["_apm_source"] = apm_source - result.append(outer) - return result - - -def _to_gemini_hook_entries(entries: list) -> list: - """Transform hook entries into Gemini CLI format. - - Gemini requires ``{"hooks": [...]}`` nesting, uses ``command`` (not - ``bash``), and ``timeout`` in milliseconds (not ``timeoutSec`` in - seconds). Entries already in Claude/Gemini nested format are left - unchanged. - """ - return _to_nested_hook_entries(entries, _copilot_keys_to_gemini) - - -def _copilot_keys_to_gemini(hook: dict) -> None: - """Rename Copilot hook keys to Gemini equivalents in-place.""" - # bash / powershell -> command - if "command" not in hook: - for key in ("bash", "powershell", "windows"): - if key in hook: - hook["command"] = hook.pop(key) - break - # timeoutSec (seconds) -> timeout (milliseconds) - if "timeoutSec" in hook: - hook["timeout"] = hook.pop("timeoutSec") * 1000 - - -# Antigravity events that use the nested ``{matcher, hooks:[...]}`` matcher -# shape. All other events (PreInvocation/PostInvocation/Stop) take a flat -# list of handler dicts; matcher has no meaning there. -_ANTIGRAVITY_NESTED_EVENTS: frozenset[str] = frozenset({"PreToolUse", "PostToolUse"}) - - -def _to_antigravity_hook_entries(entries: list, event_name: str) -> list: - """Transform hook entries into Antigravity CLI native format. - - Antigravity's ``hooks.json`` uses TWO entry shapes: - - * ``PreToolUse`` / ``PostToolUse`` -- nested - ``[{"matcher": "*", "hooks": [handler, ...]}]``. - * ``PreInvocation`` / ``PostInvocation`` / ``Stop`` -- a flat list of - handler dicts (``matcher`` is ignored). - - A handler is ``{"type": "command", "command": ..., "timeout": }``. - Unlike Gemini, ``timeout`` stays in SECONDS (no ms conversion). - """ - if event_name in _ANTIGRAVITY_NESTED_EVENTS: - return _to_nested_hook_entries(entries, _copilot_keys_to_antigravity) - # Flat handler list -- fix inner keys without wrapping. - result = [] - for entry in entries: - if not isinstance(entry, dict): - result.append(entry) - continue - # A pre-nested entry (matcher + hooks[]) is flattened to its handlers. - if "hooks" in entry and isinstance(entry["hooks"], list): - apm_source = entry.get("_apm_source") - for hook in entry["hooks"]: - if isinstance(hook, dict): - _copilot_keys_to_antigravity(hook) - if apm_source and "_apm_source" not in hook: - hook["_apm_source"] = apm_source - result.append(hook) - continue - handler = dict(entry) - _copilot_keys_to_antigravity(handler) - result.append(handler) - return result - - -def _copilot_keys_to_antigravity(hook: dict) -> None: - """Rename Copilot hook keys to Antigravity equivalents in-place.""" - # bash / powershell -> command - if "command" not in hook: - for key in ("bash", "powershell", "windows"): - if key in hook: - hook["command"] = hook.pop(key) - break - # timeoutSec (seconds) -> timeout (SECONDS -- Antigravity uses seconds) - if "timeoutSec" in hook: - hook["timeout"] = hook.pop("timeoutSec") + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + errors.append(f"hook event {event!r} entry {index} must be an object") + continue + handlers = entry.get("hooks") + if handlers is not None and ( + not isinstance(handlers, list) + or not all(isinstance(handler, dict) for handler in handlers) + ): + errors.append(f"hook event {event!r} entry {index} handlers must be objects") + return errors _MERGE_HOOK_TARGETS: dict[str, _MergeHookConfig] = { @@ -1242,6 +1177,7 @@ def integrate_package_hooks( scripts_adopted = 0 target_paths: list[Path] = [] display_payloads: list = [] + materializations: list[MaterializationResult] = [] for hook_file in hook_files: data = self._parse_hook_json(hook_file) @@ -1283,6 +1219,38 @@ def integrate_package_hooks( renamed_hooks[event_name] = entries rewritten["hooks"] = renamed_hooks + rewritten.setdefault("version", 1) + errors = _validate_copilot_payload(rewritten) + validation = NativePayloadValidation( + valid=not errors, + contract="copilot-hooks-v1", + errors=tuple(errors), + ) + if not validation.valid: + if diagnostics is not None: + diagnostics.error( + f"Invalid Copilot hook payload for {rel_path}", + package=package_name, + detail="; ".join(validation.errors), + ) + from apm_cli.integration.targets import KNOWN_TARGETS + + materializations.append( + MaterializationResult( + locator=DeploymentLedgerCodec.locator_for_path( + target_path, + project_root=project_root, + target=KNOWN_TARGETS["copilot"], + scope=InstallScope.PROJECT, + ), + owners=frozenset({package_info.get_canonical_dependency_string()}), + status=MaterializationStatus.FAILED, + content_hash=None, + validation=validation, + ) + ) + continue + # Write rewritten JSON with open(target_path, "w", encoding="utf-8") as f: json.dump(rewritten, f, indent=2) @@ -1290,6 +1258,27 @@ def integrate_package_hooks( hooks_integrated += 1 target_paths.append(target_path) + from apm_cli.integration.targets import KNOWN_TARGETS + from apm_cli.utils.content_hash import compute_file_hash + + materializations.append( + MaterializationResult( + locator=DeploymentLedgerCodec.locator_for_path( + target_path, + project_root=project_root, + target=KNOWN_TARGETS["copilot"], + scope=InstallScope.PROJECT, + ), + owners=frozenset({package_info.get_canonical_dependency_string()}), + status=( + MaterializationStatus.WRITTEN + if validation.valid + else MaterializationStatus.FAILED + ), + content_hash=compute_file_hash(target_path), + validation=validation, + ) + ) display_payloads.append( self._build_display_payload( f"{root_dir}/hooks/", @@ -1322,6 +1311,7 @@ def integrate_package_hooks( scripts_copied=scripts_copied, files_adopted=scripts_adopted, display_payloads=display_payloads, + materializations=tuple(materializations), ) # ------------------------------------------------------------------ @@ -1356,6 +1346,7 @@ def _integrate_merged_hooks( root_dir = target.root_dir if target else f".{config.target_key}" target_dir = project_root / root_dir + container = config.event_container_key # Opt-in check: some targets only deploy when their dir exists if config.require_dir and not target_dir.exists(): @@ -1418,7 +1409,7 @@ def _integrate_merged_hooks( except (json.JSONDecodeError, OSError): json_config = {} - # Load sidecar ownership metadata (schema-strict targets) + # Load external ownership metadata before reconciling native entries. sidecar_path = target_dir / _APM_HOOKS_SIDECAR sidecar_data: dict = {} if config.schema_strict and sidecar_path.exists(): @@ -1438,15 +1429,14 @@ def _integrate_merged_hooks( sidecar_data = {} # Re-inject _apm_source from sidecar into matching in-memory entries - if sidecar_data and "hooks" in json_config: - _reinject_apm_source_from_sidecar(json_config["hooks"], sidecar_data) + if sidecar_data and container in json_config: + _reinject_apm_source_from_sidecar(json_config[container], sidecar_data) # Top-level container key for the merged event map. Most targets # use "hooks"; Antigravity nests its events under the reserved # hook-name "apm" so sibling user hook-names are preserved. Only # the container key is created so non-"hooks" targets never gain a # stray empty "hooks" object in their native file. - container = config.event_container_key if container not in json_config: json_config[container] = {} _log.debug("Seeded hook container '%s' in %s", container, config.config_filename) @@ -1504,7 +1494,9 @@ def _integrate_merged_hooks( # Transform flat Copilot entries to the target's nested / # native hook shape. - if config.target_key == "gemini": + if config.target_key == "claude": + entries = _to_claude_hook_entries(entries) + elif config.target_key == "gemini": entries = _to_gemini_hook_entries(entries) elif config.target_key == "antigravity": entries = _to_antigravity_hook_entries(entries, event_name) @@ -1670,7 +1662,7 @@ def _integrate_merged_hooks( if config.schema_strict: # Build sidecar from entries that have _apm_source sidecar_out: dict = {} - for event_name, entries_list in json_config.get("hooks", {}).items(): + for event_name, entries_list in json_config.get(container, {}).items(): if not isinstance(entries_list, list): continue owned = [e for e in entries_list if isinstance(e, dict) and "_apm_source" in e] @@ -1678,7 +1670,7 @@ def _integrate_merged_hooks( sidecar_out[event_name] = [dict(e) for e in owned] # Strip _apm_source from entries before writing to disk - for entries_list in json_config.get("hooks", {}).values(): + for entries_list in json_config.get(container, {}).values(): if isinstance(entries_list, list): for entry in entries_list: if isinstance(entry, dict): @@ -1687,12 +1679,10 @@ def _integrate_merged_hooks( # Write sidecar sidecar_path = target_dir / _APM_HOOKS_SIDECAR if sidecar_out: - try: - with open(sidecar_path, "w", encoding="utf-8") as f: - json.dump(sidecar_out, f, indent=2) - f.write("\n") - except OSError as exc: - _log.warning("Failed to write sidecar %s: %s", sidecar_path, exc) + atomic_write_text( + sidecar_path, + json.dumps(sidecar_out, indent=2) + "\n", + ) elif sidecar_path.exists(): sidecar_path.unlink() @@ -1939,91 +1929,28 @@ def sync_integration( except Exception: stats["errors"] += 1 - # Clean APM entries from merged-hook JSON configs (uses _apm_source marker) + # Clean APM entries from merged-hook JSON configs using external ownership. for t in source: config = _MERGE_HOOK_TARGETS.get(t.name) if config is not None: json_path = project_root / t.root_dir / config.config_filename - if t.name == "claude": - # Claude uses settings.json with special structure - if json_path.exists(): - try: - with open(json_path, encoding="utf-8") as f: - settings = json.load(f) - - # Load sidecar to restore _apm_source markers - sidecar_path = json_path.parent / _APM_HOOKS_SIDECAR - sidecar_data: dict = {} - if sidecar_path.exists(): - try: - with open(sidecar_path, encoding="utf-8") as sf: - _raw = json.load(sf) - if isinstance(_raw, dict): - sidecar_data = _raw - else: - _log.warning( - "Sidecar file %s contains non-dict JSON; treating as empty.", - sidecar_path, - ) - sidecar_data = {} - except (json.JSONDecodeError, OSError) as exc: - _log.warning( - "Failed to read sidecar %s: %s; treating as empty.", - sidecar_path, - exc, - ) - sidecar_data = {} - - # Re-inject _apm_source from sidecar - if sidecar_data and "hooks" in settings: - _reinject_apm_source_from_sidecar(settings["hooks"], sidecar_data) - - if "hooks" in settings: - modified = False - for event_name in list(settings["hooks"].keys()): - matchers = settings["hooks"][event_name] - if isinstance(matchers, list): - filtered = [ - m - for m in matchers - if not (isinstance(m, dict) and "_apm_source" in m) - ] - if len(filtered) != len(matchers): - modified = True - settings["hooks"][event_name] = filtered - if not filtered: - del settings["hooks"][event_name] - - if not settings["hooks"]: - del settings["hooks"] - - if modified: - with open(json_path, "w", encoding="utf-8") as f: - json.dump(settings, f, indent=2) - f.write("\n") - stats["files_removed"] += 1 - - # Clean up sidecar - if sidecar_path.exists(): - sidecar_path.unlink() - - # Remove stale sidecar when no hooks section remains - if sidecar_path.exists() and "hooks" not in settings: - sidecar_path.unlink() - except (json.JSONDecodeError, OSError): - stats["errors"] += 1 - else: - self._clean_apm_entries_from_json( - json_path, stats, container=config.event_container_key - ) + self._clean_apm_entries_from_json( + json_path, + stats, + container=config.event_container_key, + sidecar_path=json_path.parent / _APM_HOOKS_SIDECAR, + ) return stats @staticmethod def _clean_apm_entries_from_json( - json_path: Path, stats: dict[str, int], container: str = "hooks" + json_path: Path, + stats: dict[str, int], + container: str = "hooks", + sidecar_path: Path | None = None, ) -> None: - """Remove APM-tagged entries from a hooks JSON file. + """Remove externally-owned entries from a native hooks JSON file. Filters out entries with ``_apm_source`` markers and cleans up empty event arrays and the *container* key itself. *container* @@ -2037,8 +1964,16 @@ def _clean_apm_entries_from_json( data = json.load(f) if container not in data: + if sidecar_path is not None and sidecar_path.exists(): + sidecar_path.unlink() return + if sidecar_path is not None and sidecar_path.exists(): + with open(sidecar_path, encoding="utf-8") as f: + sidecar_data = json.load(f) + if isinstance(sidecar_data, dict): + _reinject_apm_source_from_sidecar(data[container], sidecar_data) + modified = False for event_name in list(data[container].keys()): entries = data[container][event_name] @@ -2060,5 +1995,7 @@ def _clean_apm_entries_from_json( json.dump(data, f, indent=2) f.write("\n") stats["files_removed"] += 1 + if sidecar_path is not None and sidecar_path.exists(): + sidecar_path.unlink() except (json.JSONDecodeError, OSError): stats["errors"] += 1 diff --git a/src/apm_cli/integration/hook_ir.py b/src/apm_cli/integration/hook_ir.py new file mode 100644 index 000000000..3477fd167 --- /dev/null +++ b/src/apm_cli/integration/hook_ir.py @@ -0,0 +1,54 @@ +"""Vendor-neutral intermediate representation for executable hook intent.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Any + + +def _freeze(value: Any) -> Any: + """Recursively snapshot portable metadata.""" + if isinstance(value, Mapping): + return MappingProxyType({key: _freeze(item) for key, item in value.items()}) + if isinstance(value, (list, tuple)): + return tuple(_freeze(item) for item in value) + if isinstance(value, set): + return frozenset(_freeze(item) for item in value) + return value + + +@dataclass(frozen=True) +class HookHandler: + """One portable command handler.""" + + command: str | None + platform: str = "all" + timeout_seconds: float | None = None + provenance: str | None = None + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "metadata", _freeze(self.metadata)) + + +@dataclass(frozen=True) +class HookBinding: + """Handlers bound to one event and optional matcher.""" + + event: str + handlers: tuple[HookHandler, ...] + matcher: str | None = None + provenance: str | None = None + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "metadata", _freeze(self.metadata)) + + +@dataclass(frozen=True) +class HookDocument: + """Portable hook bindings translated only by native edge adapters.""" + + bindings: tuple[HookBinding, ...] diff --git a/src/apm_cli/integration/hook_native_formats.py b/src/apm_cli/integration/hook_native_formats.py new file mode 100644 index 000000000..6cb3c839a --- /dev/null +++ b/src/apm_cli/integration/hook_native_formats.py @@ -0,0 +1,176 @@ +"""Native hook schema adapters around the vendor-neutral hook IR.""" + +from __future__ import annotations + +from typing import Any + +from .hook_ir import HookBinding, HookDocument, HookHandler + +_ANTIGRAVITY_NESTED_EVENTS: frozenset[str] = frozenset({"PreToolUse", "PostToolUse"}) + + +def _handler_to_ir(raw: dict[str, Any], inherited_source: str | None) -> HookHandler: + """Translate one native source handler into portable intent.""" + data = dict(raw) + command = data.pop("command", None) + platform = "all" + if command is None: + for key, candidate_platform in ( + ("bash", "posix"), + ("powershell", "windows"), + ("windows", "windows"), + ): + if key in data: + command = data.pop(key) + platform = candidate_platform + break + timeout_seconds = data.pop("timeoutSec", None) + if timeout_seconds is None and "timeout" in data: + timeout_seconds = data.pop("timeout") + provenance = data.pop("_apm_source", None) or inherited_source + return HookHandler( + command=command, + platform=platform, + timeout_seconds=timeout_seconds, + provenance=provenance, + metadata=data, + ) + + +def _entries_to_ir(entries: list, event: str = "") -> HookDocument: + """Translate accepted source shapes into neutral bindings at the edge.""" + bindings: list[HookBinding] = [] + for entry in entries: + if not isinstance(entry, dict): + bindings.append( + HookBinding( + event=event, + handlers=(), + metadata={"raw_entry": entry}, + ) + ) + continue + data = dict(entry) + nested = data.pop("hooks", None) + matcher = data.pop("matcher", None) + provenance = data.pop("_apm_source", None) + if isinstance(nested, list): + handlers = tuple( + _handler_to_ir(handler, provenance) + for handler in nested + if isinstance(handler, dict) + ) + bindings.append( + HookBinding( + event=event, + handlers=handlers, + matcher=matcher, + provenance=provenance, + metadata=data, + ) + ) + continue + bindings.append( + HookBinding( + event=event, + handlers=(_handler_to_ir(data, provenance),), + matcher=matcher, + provenance=provenance, + ) + ) + return HookDocument(bindings=tuple(bindings)) + + +def _handler_from_ir(handler: HookHandler, *, timeout_milliseconds: bool) -> dict[str, Any]: + """Render a portable handler into one native command object.""" + result = dict(handler.metadata) + if handler.command is not None: + result["command"] = handler.command + if handler.timeout_seconds is not None: + result["timeout"] = ( + handler.timeout_seconds * 1000 if timeout_milliseconds else handler.timeout_seconds + ) + if handler.provenance: + result["_apm_source"] = handler.provenance + return result + + +def _render_nested_document( + document: HookDocument, + *, + timeout_milliseconds: bool, + default_matcher: str | None = None, +) -> list: + """Render neutral bindings into a matcher plus nested-handlers schema.""" + result: list = [] + for binding in document.bindings: + if "raw_entry" in binding.metadata: + result.append(binding.metadata["raw_entry"]) + continue + outer = dict(binding.metadata) + if binding.matcher is not None or default_matcher is not None: + outer["matcher"] = binding.matcher or default_matcher + outer["hooks"] = [ + _handler_from_ir(handler, timeout_milliseconds=timeout_milliseconds) + for handler in binding.handlers + ] + provenance = binding.provenance or next( + (handler.provenance for handler in binding.handlers if handler.provenance is not None), + None, + ) + if provenance: + outer["_apm_source"] = provenance + for handler in outer["hooks"]: + handler.pop("_apm_source", None) + result.append(outer) + return result + + +def _copilot_keys_to_gemini(hook: dict) -> None: + """Compatibility edge helper backed by the neutral handler model.""" + rendered = _handler_from_ir( + _handler_to_ir(hook, None), + timeout_milliseconds=True, + ) + hook.clear() + hook.update(rendered) + + +def _to_gemini_hook_entries(entries: list) -> list: + """Render portable bindings in Gemini's nested millisecond schema.""" + return _render_nested_document( + _entries_to_ir(entries), + timeout_milliseconds=True, + ) + + +def _to_claude_hook_entries(entries: list) -> list: + """Render portable bindings in Claude's nested matcher schema.""" + return _render_nested_document( + _entries_to_ir(entries), + timeout_milliseconds=False, + default_matcher="*", + ) + + +def _to_antigravity_hook_entries(entries: list, event_name: str) -> list: + """Render portable bindings in Antigravity's event-dependent schema.""" + document = _entries_to_ir(entries, event_name) + if event_name in _ANTIGRAVITY_NESTED_EVENTS: + return _render_nested_document( + document, + timeout_milliseconds=False, + default_matcher="*", + ) + + flat: list[dict[str, Any]] = [] + for binding in document.bindings: + if "raw_entry" in binding.metadata: + flat.append(binding.metadata["raw_entry"]) + continue + for handler in binding.handlers: + rendered = _handler_from_ir(handler, timeout_milliseconds=False) + if binding.provenance and "_apm_source" not in rendered: + rendered["_apm_source"] = binding.provenance + flat.append(rendered) + return flat diff --git a/src/apm_cli/integration/kiro_hook_integrator.py b/src/apm_cli/integration/kiro_hook_integrator.py index b1a6acfd0..a7f32a889 100644 --- a/src/apm_cli/integration/kiro_hook_integrator.py +++ b/src/apm_cli/integration/kiro_hook_integrator.py @@ -20,6 +20,8 @@ _emit_hook_event_diagnostics, _filter_hook_files_for_target, ) +from apm_cli.integration.hook_ir import HookBinding, HookHandler +from apm_cli.integration.hook_native_formats import _entries_to_ir from apm_cli.utils.atomic_io import atomic_write_text from apm_cli.utils.path_security import ensure_path_within from apm_cli.utils.paths import portable_relpath @@ -36,69 +38,49 @@ def _safe_hook_slug(value: str, fallback: str = "hook") -> str: return safe or fallback -def _kiro_patterns_from_matcher(matcher: dict) -> list[str]: - """Extract Kiro file patterns from an APM hook matcher, if present.""" - patterns = matcher.get("patterns") +def _kiro_matcher(binding: HookBinding) -> str | None: + """Render a neutral matcher into Kiro's scalar matcher field.""" + if binding.matcher: + return binding.matcher + patterns = binding.metadata.get("patterns") if isinstance(patterns, str) and patterns.strip(): - return [patterns.strip()] - if isinstance(patterns, list): - return [str(item).strip() for item in patterns if str(item).strip()] - matcher_value = matcher.get("matcher") - if isinstance(matcher_value, str) and matcher_value.strip(): - return [matcher_value.strip()] - return [] - - -def _kiro_then_from_action(action: dict, command_keys: tuple[str, ...]) -> dict | None: - """Convert one APM hook action to Kiro's ``then`` object.""" - prompt = action.get("prompt") - if action.get("type") == "askAgent" or isinstance(prompt, str): - prompt_text = prompt if isinstance(prompt, str) else action.get("command") - if isinstance(prompt_text, str) and prompt_text.strip(): - return {"type": "askAgent", "prompt": prompt_text} - return None - - for key in command_keys: - command = action.get(key) - if isinstance(command, str) and command.strip(): - return {"type": "runCommand", "command": command} + return patterns.strip() + if isinstance(patterns, (list, tuple)): + values = [str(item).strip() for item in patterns if str(item).strip()] + return "|".join(values) if values else None return None -def _kiro_actions_from_matcher(matcher: dict, command_keys: tuple[str, ...]) -> list[dict]: - """Return flat action dicts from both Copilot-flat and Claude-nested shapes.""" - actions: list[dict] = [] - if any(isinstance(matcher.get(key), str) for key in command_keys): - actions.append(matcher) - if isinstance(matcher.get("prompt"), str): - actions.append(matcher) - nested_hooks = matcher.get("hooks", []) - if isinstance(nested_hooks, list): - actions.extend(hook for hook in nested_hooks if isinstance(hook, dict)) - return actions +def _kiro_action(handler: HookHandler) -> dict | None: + """Render one neutral handler in Kiro v1 action form.""" + if handler.command: + action: dict = {"type": "command", "command": handler.command} + else: + prompt = handler.metadata.get("prompt") + if not isinstance(prompt, str) or not prompt.strip(): + return None + action = {"type": "agent", "prompt": prompt} + if handler.timeout_seconds is not None: + action["timeout"] = handler.timeout_seconds + return action def _kiro_hook_document( *, name: str, - description: str | None, event_name: str, - patterns: list[str], - then: dict, + matcher: str | None, + action: dict, ) -> dict: - """Build one Kiro hook JSON document.""" - when: dict[str, object] = {"type": event_name} - if patterns: - when["patterns"] = patterns - doc = { + """Build one current Kiro v1 hook JSON document.""" + hook = { "name": name, - "version": "1.0.0", - "when": when, - "then": then, + "trigger": event_name, + "action": action, } - if description: - doc["description"] = description - return doc + if matcher: + hook["matcher"] = matcher + return {"version": "v1", "hooks": [hook]} def _write_kiro_hook_docs( @@ -120,32 +102,26 @@ def _write_kiro_hook_docs( files_adopted = 0 hooks = rewritten.get("hooks", {}) _emit_hook_event_diagnostics(list(hooks.keys()), "kiro", _KIRO_EVENT_MAP) - description = rewritten.get("description") - if not isinstance(description, str) or not description.strip(): - description = None - per_event_counts: dict[str, int] = {} - for raw_event_name, matchers in hooks.items(): - if not isinstance(matchers, list): + for raw_event_name, entries in hooks.items(): + if not isinstance(entries, list): continue event_name = _KIRO_EVENT_MAP.get(raw_event_name, raw_event_name) event_slug = _safe_hook_slug(event_name) - for matcher in matchers: - if not isinstance(matcher, dict): - continue - patterns = _kiro_patterns_from_matcher(matcher) - for action in _kiro_actions_from_matcher(matcher, integrator.HOOK_COMMAND_KEYS): - then = _kiro_then_from_action(action, integrator.HOOK_COMMAND_KEYS) - if then is None: + document = _entries_to_ir(entries, event_name) + for binding in document.bindings: + matcher = _kiro_matcher(binding) + for handler in binding.handlers: + action = _kiro_action(handler) + if action is None: continue per_event_counts[event_name] = per_event_counts.get(event_name, 0) + 1 index = per_event_counts[event_name] doc = _kiro_hook_document( name=f"{package_name} {event_name} {index}", - description=description, event_name=event_name, - patterns=patterns, - then=then, + matcher=matcher, + action=action, ) target_filename = ( f"{_safe_hook_slug(package_name)}-{_safe_hook_slug(hook_file.stem)}-" @@ -178,7 +154,12 @@ def _write_kiro_hook_docs( target_paths.append(target_path) display_payloads.append( _display_payload( - integrator, target_filename, hook_file, event_name, then, rendered + integrator, + target_filename, + hook_file, + event_name, + action, + rendered, ) ) return files_integrated, files_skipped, files_adopted @@ -189,13 +170,13 @@ def _display_payload( target_filename: str, hook_file: Path, event_name: str, - then: dict, + action: dict, rendered: str, ) -> dict: """Build install-log metadata for one generated Kiro hook file.""" summary = ( - integrator._summarize_command({"command": then.get("command", "")}) - if then.get("type") == "runCommand" + integrator._summarize_command({"command": action.get("command", "")}) + if action.get("type") == "command" else "asks agent" ) return { diff --git a/src/apm_cli/integration/mcp_config_view.py b/src/apm_cli/integration/mcp_config_view.py new file mode 100644 index 000000000..0db2c7bdc --- /dev/null +++ b/src/apm_cli/integration/mcp_config_view.py @@ -0,0 +1,327 @@ +"""Canonical read-only view of current MCP source configuration. + +The view derives current MCP declarations from the root manifest and only the +package manifests bounded by the current lockfile. It owns first-wins +projection and the symmetric comparison with the stored lockfile baseline. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, replace +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from apm_cli.deps.path_anchoring import LocalResolutionError, resolve_local_dep_dir +from apm_cli.integration._shared import deduplicate_deps +from apm_cli.models.apm_package import APMPackage +from apm_cli.models.dependency.mcp import MCPDependency + +if TYPE_CHECKING: + from apm_cli.deps.lockfile import LockedDependency, LockFile + from apm_cli.utils.diagnostics import DiagnosticCollector + + +@dataclass(frozen=True) +class McpSourceProblem: + """A locked package manifest that could not provide current MCP truth.""" + + package_key: str + manifest_path: Path + message: str + + +@dataclass(frozen=True) +class CurrentMcpConfigView: + """Current root plus lockfile-bounded MCP declarations and projections.""" + + dependencies: tuple[MCPDependency, ...] + configs: Mapping[str, Mapping[str, Any]] + provenance: Mapping[str, str] + problems: tuple[McpSourceProblem, ...] + + @classmethod + def derive( + cls, + root: APMPackage, + lockfile: LockFile | None, + modules_root: Path, + *, + trust_transitive_self_defined: bool, + diagnostics: DiagnosticCollector | None = None, + ) -> CurrentMcpConfigView: + """Derive current MCP truth without mutating manifests or the lockfile.""" + project_root = root.package_path or Path.cwd() + package_deps, problems = _collect_locked_dependencies( + lockfile, + modules_root, + project_root, + trust_transitive_self_defined=trust_transitive_self_defined, + diagnostics=diagnostics, + ) + dependencies = tuple(_deduplicate(root.get_all_mcp_dependencies() + package_deps)) + return cls( + dependencies=dependencies, + configs=_get_server_configs(dependencies), + provenance=_get_server_provenance(dependencies), + problems=tuple(problems), + ) + + def diff(self, stored_configs: Mapping[str, Mapping[str, Any]]) -> McpConfigDiff: + """Compare this current view with a stored lockfile baseline.""" + return McpConfigDiff.between(self.configs, stored_configs) + + +@dataclass(frozen=True) +class McpConfigDiff: + """Symmetric name and value differences between current and stored MCP config.""" + + changed: frozenset[str] + source_only: frozenset[str] + lock_only: frozenset[str] + + @classmethod + def between( + cls, + current: Mapping[str, Mapping[str, Any]], + stored: Mapping[str, Mapping[str, Any]], + ) -> McpConfigDiff: + """Partition changed, source-only, and lock-only server names.""" + current_names = frozenset(current) + stored_names = frozenset(stored) + shared = current_names & stored_names + return cls( + changed=frozenset(name for name in shared if current[name] != stored[name]), + source_only=current_names - stored_names, + lock_only=stored_names - current_names, + ) + + @property + def is_empty(self) -> bool: + """Return whether current and stored configurations are identical.""" + return not (self.changed or self.source_only or self.lock_only) + + +def _deduplicate(dependencies: list[Any] | tuple[Any, ...]) -> list[Any]: + """Return first-wins dependencies using the shared MCP/LSP semantics.""" + return deduplicate_deps(list(dependencies)) + + +def _get_server_configs(dependencies: list[Any] | tuple[Any, ...]) -> dict[str, dict[str, Any]]: + """Project dependencies to their serialized server configurations.""" + configs: dict[str, dict[str, Any]] = {} + for dependency in dependencies: + if hasattr(dependency, "to_dict") and hasattr(dependency, "name"): + configs[dependency.name] = dependency.to_dict() + elif isinstance(dependency, str): + configs[dependency] = {"name": dependency} + return configs + + +def _get_server_provenance(dependencies: list[Any] | tuple[Any, ...]) -> dict[str, str]: + """Project surviving transitive dependencies to their declaring package.""" + provenance: dict[str, str] = {} + for dependency in dependencies: + resolved_by = getattr(dependency, "resolved_by", None) + if resolved_by and hasattr(dependency, "name"): + provenance[dependency.name] = resolved_by + return provenance + + +def _fallback_manifest_path( + dependency: LockedDependency, + modules_root: Path, + project_root: Path, +) -> Path: + """Return a useful manifest location when canonical path resolution fails.""" + if dependency.source == "local" and dependency.local_path: + raw = Path(dependency.local_path).expanduser() + package_dir = raw if raw.is_absolute() else project_root / raw + return (package_dir / "apm.yml").resolve() + return (modules_root / dependency.repo_url / "apm.yml").resolve() + + +def _package_manifest_path( + dependency: LockedDependency, + lockfile: LockFile, + modules_root: Path, + project_root: Path, +) -> Path: + """Resolve a locked package's canonical current manifest path.""" + if dependency.source == "local": + package_dir = resolve_local_dep_dir(dependency, lockfile, project_root) + else: + package_dir = dependency.to_dependency_ref().get_install_path(modules_root) + return (package_dir / "apm.yml").resolve() + + +def _collect_locked_dependencies( + lockfile: LockFile | None, + modules_root: Path, + project_root: Path, + *, + trust_transitive_self_defined: bool, + diagnostics: DiagnosticCollector | None, + logger: Any | None = None, +) -> tuple[list[MCPDependency], list[McpSourceProblem]]: + """Collect MCP declarations from only package manifests named by the lockfile.""" + if lockfile is None: + return [], [] + + collected: list[MCPDependency] = [] + problems: list[McpSourceProblem] = [] + for package_key, dependency in lockfile.dependencies.items(): + if package_key == ".": + continue + fallback_path = _fallback_manifest_path(dependency, modules_root, project_root) + try: + manifest_path = _package_manifest_path( + dependency, + lockfile, + modules_root, + project_root, + ) + except LocalResolutionError as exc: + problems.append( + McpSourceProblem( + package_key=package_key, + manifest_path=fallback_path, + message=( + f"cannot resolve local package: {exc}; " + "re-run 'apm install' to rebuild the lockfile" + ), + ) + ) + continue + except (OSError, ValueError) as exc: + problems.append( + McpSourceProblem( + package_key=package_key, + manifest_path=fallback_path, + message=f"cannot locate package manifest: {exc}", + ) + ) + continue + + if not manifest_path.exists(): + if dependency.package_type == "skill_bundle": + continue + problems.append( + McpSourceProblem( + package_key=package_key, + manifest_path=manifest_path, + message=( + f"package manifest not found at {manifest_path}; " + "re-run 'apm install' to restore it" + ), + ) + ) + continue + + try: + package = APMPackage.from_apm_yml( + manifest_path, + source_path=manifest_path.parent, + ) + except (OSError, ValueError, UnicodeError) as exc: + problems.append( + McpSourceProblem( + package_key=package_key, + manifest_path=manifest_path, + message=f"cannot parse package manifest at {manifest_path}: {exc}", + ) + ) + continue + + declarer = package.name or dependency.name or manifest_path.parent.name + for mcp_dependency in package.get_all_mcp_dependencies(): + if mcp_dependency.is_self_defined: + if dependency.depth == 1: + if logger is not None: + logger.progress( + f"Trusting direct dependency MCP '{mcp_dependency.name}' " + f"from '{declarer}'" + ) + elif trust_transitive_self_defined: + if logger is not None: + logger.progress( + f"Trusting self-defined MCP server '{mcp_dependency.name}' " + f"from transitive package '{declarer}' (--trust-transitive-mcp)" + ) + else: + message = ( + f"Transitive package '{declarer}' declares self-defined MCP server " + f"'{mcp_dependency.name}' (registry: false). Re-declare it in your " + "apm.yml or use --trust-transitive-mcp." + ) + if diagnostics is not None: + diagnostics.warn(message) + elif logger is not None: + logger.warning(message) + continue + collected.append(replace(mcp_dependency, resolved_by=declarer)) + return collected, problems + + +def _collect_unlocked_compat( + modules_root: Path, + *, + trust_transitive_self_defined: bool, + diagnostics: DiagnosticCollector | None, + logger: Any | None, +) -> list[MCPDependency]: + """Preserve the legacy no-lock fallback for the compatibility wrapper only.""" + collected: list[MCPDependency] = [] + for manifest_path in modules_root.rglob("apm.yml"): + try: + package = APMPackage.from_apm_yml(manifest_path, source_path=manifest_path.parent) + except (OSError, ValueError, UnicodeError): + continue + declarer = package.name or manifest_path.parent.name + for dependency in package.get_all_mcp_dependencies(): + if dependency.is_self_defined and not trust_transitive_self_defined: + message = ( + f"Transitive package '{declarer}' declares self-defined MCP server " + f"'{dependency.name}' (registry: false). Re-declare it in your apm.yml " + "or use --trust-transitive-mcp." + ) + if diagnostics is not None: + diagnostics.warn(message) + elif logger is not None: + logger.warning(message) + continue + collected.append(replace(dependency, resolved_by=declarer)) + return collected + + +def _collect_transitive_compat( + modules_root: Path, + lock_path: Path | None, + trust_private: bool, + *, + logger: Any | None, + diagnostics: DiagnosticCollector | None, +) -> list[MCPDependency]: + """Implement the legacy MCPIntegrator traversal through this owner.""" + from apm_cli.deps.lockfile import LockFile + + if not modules_root.exists(): + return [] + lockfile = LockFile.read(lock_path) if lock_path is not None and lock_path.exists() else None + if lockfile is None: + return _collect_unlocked_compat( + modules_root, + trust_transitive_self_defined=trust_private, + diagnostics=diagnostics, + logger=logger, + ) + project_root = lock_path.parent if lock_path is not None else Path.cwd() + dependencies, _ = _collect_locked_dependencies( + lockfile, + modules_root, + project_root, + trust_transitive_self_defined=trust_private, + diagnostics=diagnostics, + logger=logger, + ) + return dependencies diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index 9f2323147..be63f9e15 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -25,7 +25,12 @@ from apm_cli.core.null_logger import NullCommandLogger from apm_cli.deps.lockfile import LockFile, get_lockfile_path -from apm_cli.integration._shared import deduplicate_deps, resolve_locked_apm_yml_paths +from apm_cli.integration.mcp_config_view import ( + _collect_transitive_compat, + _deduplicate, + _get_server_configs, + _get_server_provenance, +) from apm_cli.runtime.utils import find_runtime_binary from apm_cli.utils.atomic_io import atomic_write_text, write_text_lf from apm_cli.utils.console import ( @@ -214,73 +219,14 @@ def collect_transitive( logger=None, diagnostics=None, ) -> list: - """Collect MCP dependencies from resolved APM packages listed in apm.lock. - - Only scans apm.yml files for packages present in apm.lock to avoid - picking up stale/orphaned packages from previous installs. - Falls back to scanning all apm.yml files if no lock file is available. - - Self-defined servers (registry: false) from direct dependencies - (depth == 1) are auto-trusted. Self-defined servers from transitive - dependencies (depth > 1) are skipped with a warning unless - *trust_private* is True. - """ - if logger is None: - logger = NullCommandLogger() - if not apm_modules_dir.exists(): - return [] - - from apm_cli.models.apm_package import APMPackage - - # Build set of expected apm.yml paths from apm.lock - resolved, direct_paths = resolve_locked_apm_yml_paths(apm_modules_dir, lock_path) - apm_yml_paths = resolved if resolved is not None else apm_modules_dir.rglob("apm.yml") - - collected = [] - for apm_yml_path in apm_yml_paths: - try: - pkg = APMPackage.from_apm_yml(apm_yml_path) - mcp = pkg.get_mcp_dependencies() - if mcp: - is_direct = apm_yml_path.resolve() in direct_paths - for dep in mcp: - if hasattr(dep, "is_self_defined") and dep.is_self_defined: - if is_direct: - logger.progress( - f"Trusting direct dependency MCP '{dep.name}' from '{pkg.name}'" - ) - elif trust_private: - logger.progress( - f"Trusting self-defined MCP server '{dep.name}' " - f"from transitive package '{pkg.name}' (--trust-transitive-mcp)" - ) - else: - _trust_msg = ( - f"Transitive package '{pkg.name}' declares self-defined " - f"MCP server '{dep.name}' (registry: false). " - f"Re-declare it in your apm.yml or use --trust-transitive-mcp." - ) - if diagnostics: - diagnostics.warn(_trust_msg) - else: - logger.warning(_trust_msg) - continue - # Record provenance: every server collected here is - # contributed by a sub-package's apm.yml, so it is - # transitive w.r.t. the ROOT manifest even when the - # declaring package is itself a direct dependency. The - # audit's config-consistency check uses this to exempt - # such servers from the orphaned-MCP branch (#2081). - dep.resolved_by = pkg.name or apm_yml_path.parent.name - collected.append(dep) - except Exception: - _log.debug( - "Skipping package at %s: failed to parse apm.yml", - apm_yml_path, - exc_info=True, - ) - continue - return collected + """Compatibility delegate for canonical MCP source traversal.""" + return _collect_transitive_compat( + apm_modules_dir, + lock_path, + trust_private, + logger=logger, + diagnostics=diagnostics, + ) # ------------------------------------------------------------------ # Deduplication @@ -293,7 +239,7 @@ def deduplicate(deps: list) -> list: Root deps are listed before transitive, so root overlays take precedence. """ - return deduplicate_deps(deps) + return _deduplicate(deps) # ------------------------------------------------------------------ # Server info helpers @@ -453,13 +399,7 @@ def get_server_names(mcp_deps: list) -> builtins.set: @staticmethod def get_server_configs(mcp_deps: list) -> builtins.dict: """Extract server configs as {name: config_dict} from MCP dependencies.""" - configs: builtins.dict = {} - for dep in mcp_deps: - if hasattr(dep, "to_dict") and hasattr(dep, "name"): - configs[dep.name] = dep.to_dict() - elif isinstance(dep, str): - configs[dep] = {"name": dep} - return configs + return _get_server_configs(mcp_deps) @staticmethod def get_server_provenance(mcp_deps: list) -> builtins.dict: @@ -474,12 +414,7 @@ def get_server_provenance(mcp_deps: list) -> builtins.dict: server declared both in the root and transitively resolves to the root entry and is correctly treated as direct here (#2081). """ - provenance: builtins.dict = {} - for dep in mcp_deps: - resolved_by = getattr(dep, "resolved_by", None) - if resolved_by and hasattr(dep, "name"): - provenance[dep.name] = resolved_by - return provenance + return _get_server_provenance(mcp_deps) @staticmethod def _append_drifted_to_install_list( @@ -808,6 +743,7 @@ def update_lockfile( lock_path: Path | None = None, *, mcp_configs: builtins.dict | None = None, + mcp_target_servers: builtins.dict | None = None, mcp_config_provenance: builtins.dict | None = None, ) -> None: """Update the lockfile with the current set of APM-managed MCP server names. @@ -820,6 +756,7 @@ def update_lockfile( lock_path: Path to the lockfile. Defaults to ``apm.lock.yaml`` in CWD. mcp_configs: Keyword-only. When provided, overwrites ``mcp_configs`` in the lockfile (used for drift-detection baseline). + mcp_target_servers: Keyword-only. Per-target APM-owned server names. mcp_config_provenance: Keyword-only. When provided, overwrites ``mcp_config_provenance`` (name -> declaring package for transitively-contributed servers). Passed in lockstep @@ -838,6 +775,17 @@ def update_lockfile( lockfile.mcp_servers = sorted(mcp_server_names) if mcp_configs is not None: lockfile.mcp_configs = mcp_configs + if mcp_target_servers is not None: + from apm_cli.core.deployment_ledger import DeploymentLedgerCodec + + DeploymentLedgerCodec.replace_mcp_target_servers( + lockfile, + { + target: sorted(servers) + for target, servers in sorted(mcp_target_servers.items()) + if servers + }, + ) if mcp_config_provenance is not None: lockfile.mcp_config_provenance = mcp_config_provenance # Invariant: provenance only carries entries that still have a live @@ -1155,6 +1103,15 @@ def _gate_project_scoped_runtimes( return out # --- step 3 (project scope): delegate to the v2 resolver ------- + if flag is not None: + project_tokens = flag if isinstance(flag, list) else [flag] + if "all" in project_tokens: + from apm_cli.core.target_catalog import expand_all + + flag = [ + RUNTIME_TO_CANONICAL_TARGET.get(target, target) + for target in expand_all("install") + ] from apm_cli.core.errors import ( AmbiguousHarnessError, NoHarnessError, @@ -1214,6 +1171,7 @@ def install( logger=None, diagnostics=None, scope=None, + managed_target_servers: builtins.dict | None = None, ) -> int: """Install MCP dependencies. @@ -1234,6 +1192,9 @@ def install( scope: InstallScope (PROJECT or USER). When USER, only runtimes whose adapter declares ``supports_user_scope`` are targeted; workspace-only runtimes are skipped. + managed_target_servers: Mutable per-target ownership state. Existing + entries are reconciled to active targets and successful writes + are recorded in place. Returns: Number of MCP servers newly configured or updated. @@ -1253,4 +1214,5 @@ def install( logger=logger, diagnostics=diagnostics, scope=scope, + managed_target_servers=managed_target_servers, ) diff --git a/src/apm_cli/integration/mcp_integrator_install.py b/src/apm_cli/integration/mcp_integrator_install.py index 3016e7cf6..c38bbf6fe 100644 --- a/src/apm_cli/integration/mcp_integrator_install.py +++ b/src/apm_cli/integration/mcp_integrator_install.py @@ -32,6 +32,7 @@ def _install_registry_group( verbose: bool, console: Any, logger: Any, + managed_target_servers: dict[str, set[str]] | None, ) -> int: """Process one group of registry deps through a single ``MCPServerOperations`` instance. @@ -161,6 +162,7 @@ def _install_registry_group( logger=logger, ): any_ok = True + _record_managed_server(managed_target_servers, rt, dep) if any_ok: if console: @@ -184,6 +186,16 @@ def _install_registry_group( return configured_count +def _record_managed_server( + managed_target_servers: dict[str, set[str]] | None, + runtime: str, + server_name: str, +) -> None: + """Record a server only after APM successfully writes its target config.""" + if managed_target_servers is not None: + managed_target_servers.setdefault(runtime, set()).add(server_name) + + def _hermes_runtime_opted_in() -> bool: """Return ``True`` when Hermes MCP writes are opted into. @@ -496,6 +508,7 @@ def _install_self_defined_deps( verbose: bool, console, logger, + managed_target_servers: dict[str, set[str]] | None, ) -> int: """Install self-defined (``registry: false``) MCP deps for all target runtimes. @@ -585,6 +598,7 @@ def _install_self_defined_deps( logger=logger, ): any_ok = True + _record_managed_server(managed_target_servers, rt, dep.name) if any_ok: if console: @@ -644,6 +658,7 @@ def run_mcp_install( logger=None, diagnostics=None, scope: InstallScope | None = None, + managed_target_servers: dict[str, set[str]] | None = None, ) -> int: """Install MCP dependencies. @@ -665,6 +680,7 @@ def run_mcp_install( scope: InstallScope (PROJECT or USER). When USER, only runtimes whose adapter declares ``supports_user_scope`` are targeted; workspace-only runtimes are skipped. + managed_target_servers: Mutable per-target APM ownership state. Returns: Number of MCP servers newly configured or updated. @@ -742,6 +758,19 @@ def run_mcp_install( if target_runtimes is None: return 0 + if managed_target_servers is not None: + active_targets = set(target_runtimes) + current_names = { + dep.name if hasattr(dep, "name") else dep + for dep in mcp_deps + if isinstance(dep, str) or hasattr(dep, "name") + } + for target in list(managed_target_servers): + if target not in active_targets: + del managed_target_servers[target] + else: + managed_target_servers[target].intersection_update(current_names) + # Use the new registry operations module for better server detection configured_count = 0 @@ -782,6 +811,7 @@ def run_mcp_install( verbose=verbose, console=console, logger=logger, + managed_target_servers=managed_target_servers, ) except ImportError: @@ -802,6 +832,7 @@ def run_mcp_install( verbose=verbose, console=console, logger=logger, + managed_target_servers=managed_target_servers, ) # Close the panel diff --git a/src/apm_cli/integration/skill_integrator.py b/src/apm_cli/integration/skill_integrator.py index fc59b03d0..5ddfc3fb1 100644 --- a/src/apm_cli/integration/skill_integrator.py +++ b/src/apm_cli/integration/skill_integrator.py @@ -10,6 +10,7 @@ from dataclasses import dataclass, replace from pathlib import Path +from apm_cli.core.deployment_state import MaterializationResult from apm_cli.integration.base_integrator import BaseIntegrator from apm_cli.integration.targets import TargetProfile from apm_cli.utils.atomic_io import write_text_lf @@ -60,6 +61,7 @@ class SkillIntegrationResult: # layer can surface an actionable hint: "project_scope" | "no_claude_target". bin_skipped_reason: str | None = None target_paths: list[Path] = None # All deployed directories (for deployed_files manifest) + materializations: tuple[MaterializationResult, ...] = () def __post_init__(self): if self.target_paths is None: @@ -617,6 +619,34 @@ def _skill_subset_name_filter(skill_subset: tuple[str, ...] | None) -> set[str] name_filter.add(leaf_name) return name_filter or None + @staticmethod + def available_skill_names(package_info) -> frozenset[str] | None: + """Return names selectable through ``--skill`` for one package.""" + package_path = package_info.install_path + if (package_path / "SKILL.md").is_file(): + return None + + from apm_cli.models.validation import PackageType + + normalized = package_path / ".apm" / "skills" + root_bundle = package_path / "skills" + if package_info.package_type is PackageType.MARKETPLACE_PLUGIN: + skills_dir = normalized + elif root_bundle.is_dir() and any( + (child / "SKILL.md").is_file() for child in root_bundle.iterdir() if child.is_dir() + ): + skills_dir = root_bundle + else: + skills_dir = normalized + + if not skills_dir.is_dir(): + return frozenset() + return frozenset( + child.name + for child in skills_dir.iterdir() + if child.is_dir() and (child / "SKILL.md").is_file() + ) + @staticmethod def _promote_sub_skills( sub_skills_dir: Path, diff --git a/src/apm_cli/integration/targets.py b/src/apm_cli/integration/targets.py index 5889d1469..d881ef2d0 100644 --- a/src/apm_cli/integration/targets.py +++ b/src/apm_cli/integration/targets.py @@ -20,10 +20,14 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING +from pathlib import Path -if TYPE_CHECKING: - from pathlib import Path +from apm_cli.core.target_catalog import ( + TARGET_CAPABILITIES, + TargetCapability, + expand_all, + normalize_target_name, +) RULE_FORMATS: frozenset[str] = frozenset( {"cursor_rules", "claude_rules", "windsurf_rules", "kiro_steering", "antigravity_rules"} @@ -118,8 +122,8 @@ def __post_init__(self) -> None: class TargetProfile: """Capabilities and layout of a single target tool.""" - name: str - """Short unique identifier (``"copilot"``, ``"claude"``, ``"cursor"``).""" + capability: TargetCapability + """Command-facing metadata for this native deployment profile.""" root_dir: str """Top-level directory in the workspace (e.g. ``".github"``).""" @@ -195,13 +199,6 @@ class TargetProfile: through this root instead of ``project_root / root_dir``. """ - requires_flag: str | None = None - """When set, the target is only returned by ``active_targets`` / - ``active_targets_user_scope`` / ``resolve_targets`` when the named - experimental flag is enabled. The target entry is always visible - in ``KNOWN_TARGETS`` for tooling introspection. - """ - scope_invariant_resolver: bool = False """When True, ``user_root_resolver`` runs in BOTH project and user scope (the resolved deploy root does not depend on install intent). @@ -241,21 +238,6 @@ class TargetProfile: ``.agents/``). """ - compile_family: str | None = None - """Compiler family this target belongs to for ``apm compile`` routing. - - Recognised values: - - * ``"vscode"`` -- emits ``.github/copilot-instructions.md`` *and* AGENTS.md. - * ``"claude"`` -- emits ``CLAUDE.md`` and ``.claude/rules/`` files. - * ``"gemini"`` -- emits ``GEMINI.md``. - * ``"agents"`` -- emits AGENTS.md only (cursor, opencode, codex, windsurf). - * ``None`` -- target has no compile output (agent-skills, copilot-cowork). - - Used by :func:`apm_cli.commands.compile.cli._resolve_compile_target` to - derive multi-target routing from the registry instead of hard-coded sets. - """ - hooks_config_display: str | None = None """Human-readable path shown in the install log for hooks integration. @@ -264,6 +246,30 @@ class TargetProfile: install log falls back to the generic ``"{root}/{subdir}/"`` formula. """ + external_locator_encoder: Callable[[Path, Path], str] | None = None + """Encode managed-root paths that require a native lockfile URI.""" + + lockfile_uri_schemes: tuple[str, ...] = () + """URI prefixes governed by this target during reconciliation.""" + + warn_unsupported_primitives: bool = False + """Warn when a package contains primitives omitted by this profile.""" + + @property + def name(self) -> str: + """Return the canonical native target name.""" + return self.capability.name + + @property + def compile_family(self) -> str | None: + """Return the compiler family declared by the capability catalog.""" + return self.capability.compile_family + + @property + def requires_flag(self) -> str | None: + """Return the experimental feature flag declared by the catalog.""" + return self.capability.experimental_flag + @property def prefix(self) -> str: """Return the path prefix for this target (e.g. ``".github/"``). @@ -295,6 +301,14 @@ def effective_root(self, user_scope: bool = False) -> str: return self.user_root_dir return self.root_dir + @property + def managed_deploy_root(self) -> Path | None: + """Return the resolved or absolute static deployment root.""" + if self.resolved_deploy_root is not None: + return self.resolved_deploy_root + root = Path(self.root_dir) + return root if root.is_absolute() else None + def supports_at_user_scope(self, primitive: str) -> bool: """Return ``True`` if *primitive* can be deployed at user scope.""" if not self.user_supported: @@ -321,6 +335,13 @@ def deploy_path(self, project_root: Path, *parts: str) -> Path: base = project_root / self.root_dir return base.joinpath(*parts) if parts else base + def encode_external_locator(self, path: Path) -> str | None: + """Encode a managed-root path through the target adapter.""" + deploy_root = self.managed_deploy_root + if self.external_locator_encoder is None or deploy_root is None: + return None + return self.external_locator_encoder(path, deploy_root) + def for_scope(self, user_scope: bool = False) -> TargetProfile | None: """Return a scope-resolved copy of this profile. @@ -407,14 +428,8 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: # Fallback: when CLAUDE_CONFIG_DIR points outside $HOME we # store an absolute path. ``pathlib.Path / `` is # ```` so deploy + cleanup write to the right - # place. Caveat: the lockfile path translator - # (``install/services._deployed_path_entry``) calls - # ``relative_to(project_root)`` and raises ``RuntimeError`` - # for out-of-tree paths that are not dynamic-root targets. - # Today this is unreachable because user-scope CLAUDE - # installs do not flow through that translator, but any - # future refactor that lockfiles user-scope deploys must - # treat absolute ``root_dir`` as a dynamic-root case. + # place. The lockfile path translator treats an absolute + # ``root_dir`` as a dynamic root. new_root = str(abs_path) if self.unsupported_user_primitives: @@ -434,6 +449,20 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: return replace(self, root_dir=new_root, primitives=filtered) +def _encode_cowork_locator(path: Path, deploy_root: Path) -> str: + """Translate a Cowork path through its native target adapter.""" + from apm_cli.integration.copilot_cowork_paths import to_lockfile_path + + return to_lockfile_path(path, deploy_root) + + +def _encode_copilot_app_locator(path: Path) -> str: + """Translate an app workflow row through its native target adapter.""" + from apm_cli.integration.copilot_app_db import to_lockfile_uri + + return to_lockfile_uri(path.name) + + # ------------------------------------------------------------------ # Runtime -> canonical target alias map # ------------------------------------------------------------------ @@ -447,9 +476,10 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: # strip a runtime even when its canonical target is active (the same class # of bug as #1335). RUNTIME_TO_CANONICAL_TARGET: dict[str, str] = { - "vscode": "copilot", - "agents": "copilot", - "intellij": "copilot", + runtime: capability.primitive_profile + for capability in TARGET_CAPABILITIES.values() + if capability.primitive_profile is not None + for runtime in capability.runtimes } @@ -464,7 +494,7 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: # that single file at user scope (not individual *.instructions.md). # Ref: https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/create-custom-agents-for-cli "copilot": TargetProfile( - name="copilot", + capability=TARGET_CAPABILITIES["copilot"], root_dir=".github", primitives={ "instructions": PrimitiveMapping( @@ -489,7 +519,6 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: "instructions": PrimitiveMapping("", ".md", "copilot_user_instructions"), }, generated_files=("copilot-instructions.md",), - compile_family="vscode", ), # Claude Code -- the user-level config directory is whatever # ``CLAUDE_CONFIG_DIR`` points to (default ``~/.claude``). The env @@ -499,7 +528,7 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: # Instructions deploy to /rules/*.md with paths: frontmatter. # Ref: https://code.claude.com/docs/en/memory#organize-rules-with-claude%2Frules%2F "claude": TargetProfile( - name="claude", + capability=TARGET_CAPABILITIES["claude"], root_dir=".claude", primitives={ "instructions": PrimitiveMapping( @@ -516,7 +545,6 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: auto_create=False, detect_by_dir=True, user_supported=True, - compile_family="claude", hooks_config_display=".claude/settings.json", ), # Cursor -- at user scope, ~/.cursor/ supports skills, agents, hooks, @@ -524,7 +552,7 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: # (not file-based), so "instructions" is excluded from user scope. # Ref: https://cursor.com/docs/rules "cursor": TargetProfile( - name="cursor", + capability=TARGET_CAPABILITIES["cursor"], root_dir=".cursor", primitives={ "instructions": PrimitiveMapping( @@ -559,7 +587,6 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: user_supported="partial", user_root_dir=".cursor", unsupported_user_primitives=("instructions",), - compile_family="agents", hooks_config_display=".cursor/hooks.json", ), # Kiro IDE -- spec-driven development editor. @@ -572,7 +599,7 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: # Ref: https://kiro.dev/docs/skills/ # Ref: https://kiro.dev/docs/hooks/ "kiro": TargetProfile( - name="kiro", + capability=TARGET_CAPABILITIES["kiro"], root_dir=".kiro", primitives={ "instructions": PrimitiveMapping( @@ -588,12 +615,11 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: detect_by_dir=True, user_supported=True, user_root_dir=".kiro", - compile_family="agents", ), # OpenCode -- at user scope, ~/.config/opencode/ supports skills, agents, # and commands. OpenCode has no hooks concept, so "hooks" is excluded. "opencode": TargetProfile( - name="opencode", + capability=TARGET_CAPABILITIES["opencode"], root_dir=".opencode", primitives={ "agents": PrimitiveMapping("agents", ".md", "opencode_agent"), @@ -610,7 +636,6 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: user_supported="partial", user_root_dir=".config/opencode", unsupported_user_primitives=("hooks",), - compile_family="agents", ), # Gemini CLI -- ~/.gemini/ is the documented user-level config directory. # Instructions are compile-only (GEMINI.md) -- Gemini CLI does not read @@ -620,7 +645,7 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: # Ref: https://geminicli.com/docs/cli/gemini-md/ # Ref: https://geminicli.com/docs/reference/configuration/ "gemini": TargetProfile( - name="gemini", + capability=TARGET_CAPABILITIES["gemini"], root_dir=".gemini", primitives={ "commands": PrimitiveMapping("commands", ".toml", "gemini_command"), @@ -636,7 +661,6 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: detect_by_dir=True, user_supported=True, user_root_dir=".gemini", - compile_family="gemini", hooks_config_display=".gemini/settings.json", ), # Antigravity CLI (agy) -- Google's Gemini-derived agentic CLI. @@ -665,7 +689,7 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: # Ref: https://antigravity.google/docs/hooks # Ref: https://antigravity.google/docs/mcp "antigravity": TargetProfile( - name="antigravity", + capability=TARGET_CAPABILITIES["antigravity"], root_dir=".agents", primitives={ "instructions": PrimitiveMapping( @@ -683,14 +707,13 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: user_supported="partial", user_root_dir=".gemini/antigravity-cli", unsupported_user_primitives=("instructions", "hooks"), - compile_family="agents", hooks_config_display=".agents/hooks.json", ), # Codex CLI: skills use the cross-tool .agents/ dir (agent skills standard), # agents are TOML under .codex/agents/, hooks merge into .codex/hooks.json. # Instructions are compile-only (AGENTS.md) -- not installed. "codex": TargetProfile( - name="codex", + capability=TARGET_CAPABILITIES["codex"], root_dir=".codex", primitives={ "agents": PrimitiveMapping("agents", ".toml", "codex_agent"), @@ -706,7 +729,6 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: detect_by_dir=True, user_supported="partial", pack_prefixes=(".codex/", ".agents/"), - compile_family="agents", hooks_config_display=".codex/hooks.json", ), # Windsurf/Cascade (now Devin Desktop) -- .windsurf/ is the workspace @@ -730,7 +752,7 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: # Ref: https://docs.windsurf.com/windsurf/cascade/memories # Ref: https://docs.windsurf.com/windsurf/cascade/mcp "windsurf": TargetProfile( - name="windsurf", + capability=TARGET_CAPABILITIES["windsurf"], root_dir=".windsurf", primitives={ "instructions": PrimitiveMapping( @@ -754,7 +776,6 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: user_root_dir=".codeium/windsurf", unsupported_user_primitives=("instructions",), pack_prefixes=(".windsurf/", ".agents/"), - compile_family="agents", hooks_config_display=".windsurf/hooks.json", ), # Agent-skills: cross-client shared skills directory (.agents/skills/). @@ -762,7 +783,7 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: # Not auto-detected (detect_by_dir=False) because .agents/ is shared by # multiple tools (Codex, etc.). Explicit --target agent-skills only. "agent-skills": TargetProfile( - name="agent-skills", + capability=TARGET_CAPABILITIES["agent-skills"], root_dir=".agents", primitives={ "skills": PrimitiveMapping( @@ -787,7 +808,7 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: # the --global user path is the distinguishing capability. # Ref: https://docs.openclaw.ai/tools/skills "openclaw": TargetProfile( - name="openclaw", + capability=TARGET_CAPABILITIES["openclaw"], root_dir=".agents", primitives={ "skills": PrimitiveMapping( @@ -800,7 +821,6 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: detect_by_dir=False, user_supported=True, user_root_dir=".openclaw", - requires_flag="openclaw", ), # Hermes agent (Nous Research) -- experimental. Hermes natively reads # the agentskills.io SKILL.md format and the AGENTS.md context-file @@ -811,7 +831,7 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: # are written separately by HermesClientAdapter to ~/.hermes/config.yaml. # $HERMES_HOME overrides the user-scope root (handled in for_scope). "hermes": TargetProfile( - name="hermes", + capability=TARGET_CAPABILITIES["hermes"], root_dir=".agents", primitives={ "skills": PrimitiveMapping( @@ -824,8 +844,6 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: detect_by_dir=False, user_supported=True, user_root_dir=".hermes", - compile_family="agents", - requires_flag="hermes", ), # Microsoft 365 Copilot (Cowork) -- experimental, user-scope only. # Skills are deployed to /Documents/Cowork/skills/. @@ -833,7 +851,7 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: # copilot_cowork_paths.resolve_copilot_cowork_skills_dir(). # Non-skill primitives are not supported. "copilot-cowork": TargetProfile( - name="copilot-cowork", + capability=TARGET_CAPABILITIES["copilot-cowork"], root_dir="copilot-cowork", # display grouping placeholder only primitives={ "skills": PrimitiveMapping( @@ -846,7 +864,11 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: detect_by_dir=False, user_supported=True, user_root_resolver=lambda: _resolve_copilot_cowork_root(), - requires_flag="copilot_cowork", + external_locator_encoder=lambda path, deploy_root: _encode_cowork_locator( + path, deploy_root + ), + lockfile_uri_schemes=("cowork://",), + warn_unsupported_primitives=True, ), # GitHub Copilot desktop App -- experimental, user-scope only. # Prompts whose frontmatter carries workflow-shape keys (``interval``, @@ -859,7 +881,7 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: # the existing target machinery can address rows via the # ``copilot-app-db://workflows/`` lockfile URI scheme. "copilot-app": TargetProfile( - name="copilot-app", + capability=TARGET_CAPABILITIES["copilot-app"], root_dir="copilot-app", # display grouping placeholder only primitives={ "prompts": PrimitiveMapping( @@ -872,12 +894,69 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: detect_by_dir=False, user_supported=True, user_root_resolver=lambda: _resolve_copilot_app_root(), - requires_flag="copilot_app", scope_invariant_resolver=True, + external_locator_encoder=lambda path, _deploy_root: _encode_copilot_app_locator(path), + lockfile_uri_schemes=("copilot-app-db://",), ), } +def encode_external_target_locator(target: object, path: Path) -> str | None: + """Encode an external path using profile metadata. + + Lightweight target stand-ins used by compatibility callers inherit the + canonical profile's adapter while supplying their own managed root. + """ + if isinstance(target, TargetProfile): + return target.encode_external_locator(path) + name = getattr(target, "name", None) + profile = KNOWN_TARGETS.get(name) if isinstance(name, str) else None + deploy_root = getattr(target, "managed_deploy_root", None) + if ( + profile is None + or profile.external_locator_encoder is None + or not isinstance(deploy_root, Path) + ): + return None + return profile.external_locator_encoder(path, deploy_root) + + +def target_lockfile_uri_schemes(target: object) -> tuple[str, ...]: + """Return governed URI schemes from canonical target metadata.""" + if isinstance(target, TargetProfile): + return target.lockfile_uri_schemes + name = getattr(target, "name", None) + profile = KNOWN_TARGETS.get(name) if isinstance(name, str) else None + return profile.lockfile_uri_schemes if profile is not None else () + + +def target_warns_unsupported_primitives(target: object) -> bool: + """Return the unsupported-primitive warning capability.""" + if isinstance(target, TargetProfile): + return target.warn_unsupported_primitives + name = getattr(target, "name", None) + profile = KNOWN_TARGETS.get(name) if isinstance(name, str) else None + return bool(profile and profile.warn_unsupported_primitives) + + +def target_supports_primitive(target: object, primitive: str) -> bool: + """Read primitive support from a concrete or lightweight profile.""" + primitives = getattr(target, "primitives", None) + if isinstance(primitives, dict): + return primitive in primitives + name = getattr(target, "name", None) + profile = KNOWN_TARGETS.get(name) if isinstance(name, str) else None + return bool(profile and profile.supports(primitive)) + + +def target_name_for_locator(locator: str) -> str | None: + """Resolve a native target name from a registered locator URI.""" + for profile in KNOWN_TARGETS.values(): + if any(locator.startswith(scheme) for scheme in profile.lockfile_uri_schemes): + return profile.name + return None + + def apply_legacy_skill_paths(profiles: list[TargetProfile]) -> list[TargetProfile]: """Reset ``deploy_root`` on every ``skills`` primitive to ``None``. @@ -1053,14 +1132,24 @@ def active_targets_user_scope( profiles: list = [] seen: set = set() for t in raw: - canonical = RUNTIME_TO_CANONICAL_TARGET.get(t, t) + try: + canonical = normalize_target_name(t) + except KeyError: + continue if canonical == "all": - from apm_cli.core.target_detection import EXPLICIT_ONLY_TARGETS - + all_targets = {normalize_target_name(target) for target in expand_all("install")} + all_targets.update( + capability.name + for capability in TARGET_CAPABILITIES.values() + if capability.experimental_flag is not None + ) return [ p for p in KNOWN_TARGETS.values() - if p.user_supported and _flag_gated(p) and p.name not in EXPLICIT_ONLY_TARGETS + if p.name in all_targets + and not p.capability.explicit_only + and p.user_supported + and _flag_gated(p) ] profile = KNOWN_TARGETS.get(canonical) if ( @@ -1126,7 +1215,10 @@ def active_targets( profiles: list = [] seen: set = set() for t in raw: - canonical = RUNTIME_TO_CANONICAL_TARGET.get(t, t) + try: + canonical = normalize_target_name(t) + except KeyError: + continue if canonical == "all": # Exclude explicit-only targets (agent-skills) -- they must # be requested individually. @@ -1136,16 +1228,8 @@ def active_targets( # core/target_detection.py. Including cowork in `all` for # project scope hits the unconditional project-scope gate in # phases/targets.py and aborts the entire install (#1185 b). - from apm_cli.core.target_detection import ( - EXPERIMENTAL_TARGETS, - EXPLICIT_ONLY_TARGETS, - ) - - return [ - p - for p in KNOWN_TARGETS.values() - if p.name not in EXPLICIT_ONLY_TARGETS and p.name not in EXPERIMENTAL_TARGETS - ] + all_targets = {normalize_target_name(target) for target in expand_all("install")} + return [p for p in KNOWN_TARGETS.values() if p.name in all_targets] profile = KNOWN_TARGETS.get(canonical) if profile and _flag_gated(profile) and profile.name not in seen: seen.add(profile.name) diff --git a/src/apm_cli/marketplace/models.py b/src/apm_cli/marketplace/models.py index 95d8e40d5..7a1b0e0c8 100644 --- a/src/apm_cli/marketplace/models.py +++ b/src/apm_cli/marketplace/models.py @@ -413,36 +413,31 @@ def _parse_plugin_entry(entry: dict[str, Any], source_name: str) -> MarketplaceP # Optional dedicated-registry routing (design §4.5). When ``registry`` # is set, ``version`` is interpreted as a semver range and the plugin # resolves via the dedicated-registry resolver. The marketplace.json - # parser is intentionally permissive — invalid values are downgraded - # to "no registry routing" and a debug log line, so a malformed entry - # doesn't break a whole marketplace fetch. + # parser fails closed when registry intent is malformed so resolution + # never silently falls back to Git. registry_name = "" raw_registry = entry.get("registry") if raw_registry is not None: if isinstance(raw_registry, str) and raw_registry.strip(): registry_name = raw_registry.strip() else: - logger.debug( - "Plugin '%s' has invalid 'registry' field (expected non-empty string), ignoring", - name, + raise ValueError( + f"Plugin {name!r} has invalid 'registry' field; expected a non-empty string" ) - if registry_name and version: - # Validate semver range for registry-routed plugins. A bad ref - # surfaces here as a debug log + downgrade to "no registry" - # rather than a hard fail, since one malformed plugin shouldn't - # poison a whole marketplace. + if registry_name: + if not version: + raise ValueError( + f"Plugin {name!r} routes through registry {registry_name!r} " + "but declares no version selector" + ) from apm_cli.deps.registry.semver import is_semver_range if not is_semver_range(version): - logger.debug( - "Plugin '%s' has registry='%s' but version '%s' is not a " - "semver range; ignoring registry routing for this entry", - name, - registry_name, - version, + raise ValueError( + f"Plugin {name!r} routes through registry {registry_name!r} " + f"but version {version!r} is not a valid semver selector" ) - registry_name = "" return MarketplacePlugin( name=name, diff --git a/src/apm_cli/marketplace/ref_resolver.py b/src/apm_cli/marketplace/ref_resolver.py index 529c4a44d..a1b0d6239 100644 --- a/src/apm_cli/marketplace/ref_resolver.py +++ b/src/apm_cli/marketplace/ref_resolver.py @@ -17,7 +17,6 @@ from __future__ import annotations import base64 -import os import re import subprocess import threading @@ -29,6 +28,7 @@ build_authorization_header_git_env, build_https_clone_url, default_host, + is_ado_auth_failure_signal, is_azure_devops_hostname, ) from ._git_utils import redact_token as _redact_token @@ -159,6 +159,9 @@ def __init__( host: str | None = None, token: str | None = None, auth_scheme: str = "basic", + git_env: dict[str, str] | None = None, + auth_resolver=None, + auth_target=None, ) -> None: self._timeout = timeout_seconds self._offline = offline @@ -166,6 +169,9 @@ def __init__( self._host: str = host or default_host() or "github.com" self._token: str | None = token self._auth_scheme = auth_scheme + self._git_env = dict(git_env) if git_env is not None else None + self._auth_resolver = auth_resolver + self._auth_target = auth_target self._cache = RefCache() self._lock = threading.Lock() # Per-remote locks to serialise calls to the same remote while @@ -199,7 +205,19 @@ def _git_url_and_env(self, owner_repo: str) -> tuple[str, dict[str, str]]: url = f"https://{self._host}/{owner_repo}" else: url = build_https_clone_url(self._host, owner_repo, token=url_token) - env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "echo"} + if self._git_env is not None: + env = dict(self._git_env) + else: + from apm_cli.core.auth import AuthResolver + + host_kind = "ado" if ado_host else "github" + env = AuthResolver._build_git_env( + self._token, + scheme=self._auth_scheme, + host_kind=host_kind, + ) + env["GIT_TERMINAL_PROMPT"] = "0" + env["GIT_ASKPASS"] = "echo" if bearer and self._token: env.pop("GIT_TOKEN", None) env.update(build_ado_bearer_git_env(self._token)) @@ -263,6 +281,11 @@ def list_remote_refs(self, owner_repo: str) -> list[RemoteRef]: hint=f"Ensure git is installed and on PATH. Error: {exc}", ) + fallback_refs = self._retry_rejected_ado_pat(result, owner_repo) + if fallback_refs is not None: + self._cache.put(owner_repo, fallback_refs) + return fallback_refs + if result.returncode != 0: stderr = _redact_token(result.stderr) if self._stderr_translator: @@ -287,6 +310,60 @@ def list_remote_refs(self, owner_repo: str) -> list[RemoteRef]: self._cache.put(owner_repo, refs) return refs + def _retry_rejected_ado_pat( + self, + result: subprocess.CompletedProcess, + owner_repo: str, + ) -> list[RemoteRef] | None: + """Retry one rejected ADO basic credential with an Azure CLI bearer.""" + eligible = ( + result.returncode != 0 + and self._auth_resolver is not None + and self._auth_target is not None + and self._auth_scheme == "basic" + and bool(self._token) + and is_azure_devops_hostname(self._host) + ) + if not eligible: + return None + + def _bearer_op(bearer: str) -> list[RemoteRef]: + from apm_cli.core.auth import AuthResolver + + bearer_env = ( + dict(self._git_env) if self._git_env is not None else AuthResolver._build_git_env() + ) + AuthResolver._clear_git_auth_env(bearer_env) + bearer_env.update(build_ado_bearer_git_env(bearer)) + bearer_env["GIT_TERMINAL_PROMPT"] = "0" + bearer_env["GIT_ASKPASS"] = "echo" + resolver = RefResolver( + timeout_seconds=self._timeout, + offline=self._offline, + stderr_translator_enabled=self._stderr_translator, + host=self._host, + token=bearer, + auth_scheme="bearer", + git_env=bearer_env, + ) + try: + return resolver.list_remote_refs(owner_repo) + finally: + resolver.close() + + fallback = self._auth_resolver.execute_with_bearer_fallback( + self._auth_target, + lambda: result, + _bearer_op, + lambda outcome: ( + getattr(outcome, "returncode", 0) != 0 + and is_ado_auth_failure_signal(getattr(outcome, "stderr", "")) + ), + ) + if isinstance(fallback.outcome, list): + return fallback.outcome + return None + # ----------------------------------------------------------------- # Single-ref resolution (no cache) # ----------------------------------------------------------------- diff --git a/src/apm_cli/marketplace/registry.py b/src/apm_cli/marketplace/registry.py index b08e20b43..3eb87840b 100644 --- a/src/apm_cli/marketplace/registry.py +++ b/src/apm_cli/marketplace/registry.py @@ -4,6 +4,8 @@ import logging import os import threading +from contextlib import contextmanager +from pathlib import Path from .errors import MarketplaceNotFoundError from .models import MarketplaceSource @@ -15,6 +17,7 @@ # Process-lifetime cache -------------------------------------------------- _registry_cache: list[MarketplaceSource] | None = None _registry_lock = threading.Lock() +_mutation_lock = threading.Lock() def _marketplaces_path() -> str: @@ -42,6 +45,19 @@ def _invalidate_cache() -> None: _registry_cache = None +@contextmanager +def _marketplace_mutation(): + """Lock the complete cross-process load-modify-save transaction.""" + from ..cache.locking import shard_lock + from ..config import ensure_config_exists + + ensure_config_exists() + path = Path(_marketplaces_path()) + with _mutation_lock, shard_lock(path): + _invalidate_cache() + yield + + def _load() -> list[MarketplaceSource]: """Load registered marketplaces from disk (cached per-process).""" global _registry_cache @@ -103,9 +119,10 @@ def get_marketplace_by_name(name: str) -> MarketplaceSource: def add_marketplace(source: MarketplaceSource) -> None: """Register a marketplace (replaces if same name exists).""" - sources = [s for s in _load() if s.name.lower() != source.name.lower()] - sources.append(source) - _save(sources) + with _marketplace_mutation(): + sources = [s for s in _load() if s.name.lower() != source.name.lower()] + sources.append(source) + _save(sources) logger.debug("Registered marketplace '%s'", source.name) @@ -115,13 +132,14 @@ def remove_marketplace(name: str) -> None: Raises: MarketplaceNotFoundError: If not found. """ - before = _load() - after = [s for s in before if s.name.lower() != name.lower()] - if len(after) == len(before): - from ..utils.github_host import default_host - - raise MarketplaceNotFoundError(name, host=default_host()) - _save(after) + with _marketplace_mutation(): + before = _load() + after = [s for s in before if s.name.lower() != name.lower()] + if len(after) == len(before): + from ..utils.github_host import default_host + + raise MarketplaceNotFoundError(name, host=default_host()) + _save(after) logger.debug("Removed marketplace '%s'", name) diff --git a/src/apm_cli/marketplace/resolver.py b/src/apm_cli/marketplace/resolver.py index a9dff0891..1cd7cfd73 100644 --- a/src/apm_cli/marketplace/resolver.py +++ b/src/apm_cli/marketplace/resolver.py @@ -720,16 +720,20 @@ def resolve_plugin_source( raise ValueError(f"Plugin '{plugin.name}' has unsupported source type: '{source_type}'") -def _extract_token(auth_resolver: object | None, host: str, org: str | None = None) -> str | None: - """Extract a token from the auth resolver for the given host.""" +def _extract_auth( + auth_resolver: object | None, host: str, org: str | None = None +) -> tuple[str | None, str]: + """Extract the token and scheme from the auth resolver for the given host.""" if auth_resolver is None: - return None + return None, "basic" try: ctx = auth_resolver.resolve(host, org=org) # type: ignore[union-attr] - return ctx.token if ctx and ctx.token else None + if ctx is None or not ctx.token: + return None, "basic" + return ctx.token, ctx.auth_scheme except Exception as exc: - logger.debug("Could not extract token for host '%s': %s", host, type(exc).__name__) - return None + logger.debug("Could not extract auth for host '%s': %s", host, type(exc).__name__) + return None, "basic" def resolve_marketplace_plugin( @@ -788,6 +792,32 @@ def _emit_warning(msg: str) -> None: if plugin is None: raise PluginNotFoundError(plugin_name, marketplace_name) + if plugin.registry: + selector = version_spec or plugin.version + if not selector: + raise ValueError(f"Registry-routed plugin {plugin.name!r} has no version selector") + source_data = plugin.source if isinstance(plugin.source, dict) else {} + package_id = source_data.get("repo") or source_data.get("repository") + if not isinstance(package_id, str) or package_id.count("/") != 1: + raise ValueError( + f"Registry-routed plugin {plugin.name!r} must declare an " + "owner/repo repository identity" + ) + dep_ref = DependencyReference( + repo_url=package_id, + reference=selector, + source="registry", + registry_name=plugin.registry, + ) + return MarketplacePluginResolution( + canonical=dep_ref.to_canonical(), + plugin=plugin, + dependency_reference=dep_ref, + cross_repo_misconfig_risk=None, + source_url=manifest.source_url, + source_digest=manifest.source_digest, + ) + source_kind = source.kind # ---- Local marketplace fast-path ---- @@ -929,7 +959,7 @@ def _emit_warning(msg: str) -> None: from .version_resolver import resolve_version_constraint owner_repo = f"{source.owner}/{source.repo}" - token = _extract_token(auth_resolver, source.host, org=source.owner) + token, auth_scheme = _extract_auth(auth_resolver, source.host, org=source.owner) try: tag_name, _sha = resolve_version_constraint( plugin_name, @@ -937,6 +967,8 @@ def _emit_warning(msg: str) -> None: version_spec, host=source.host, token=token, + auth_scheme=auth_scheme, + auth_resolver=auth_resolver, ) canonical = f"{base}#{tag_name}" logger.debug( diff --git a/src/apm_cli/marketplace/version_resolver.py b/src/apm_cli/marketplace/version_resolver.py index 052c1ece1..defb9d292 100644 --- a/src/apm_cli/marketplace/version_resolver.py +++ b/src/apm_cli/marketplace/version_resolver.py @@ -54,6 +54,8 @@ def resolve_version_constraint( tag_pattern: str = DEFAULT_TAG_PATTERN, host: str | None = None, token: str | None = None, + auth_scheme: str = "basic", + auth_resolver=None, ) -> tuple[str, str]: """Resolve a semver range to the highest matching git tag. @@ -70,6 +72,7 @@ def resolve_version_constraint( ``"{name}--v{version}"``. host: Git host for ``git ls-remote``. Defaults to github.com. token: Optional auth token for private repos. + auth_scheme: Authentication scheme from ``AuthContext``. Returns: ``(tag_name, commit_sha)`` of the highest matching version. @@ -80,7 +83,17 @@ def resolve_version_constraint( pinned_pattern = tag_pattern.replace("{name}", plugin_name) tag_rx = build_tag_regex(pinned_pattern) - resolver = RefResolver(host=host, token=token) + resolver_kwargs = { + "host": host, + "token": token, + "auth_scheme": auth_scheme, + } + if auth_resolver is not None: + resolver_kwargs.update( + auth_resolver=auth_resolver, + auth_target=host, + ) + resolver = RefResolver(**resolver_kwargs) try: refs = resolver.list_remote_refs(owner_repo) finally: diff --git a/src/apm_cli/models/apm_package.py b/src/apm_cli/models/apm_package.py index 7a4e6adcf..49851e3e7 100644 --- a/src/apm_cli/models/apm_package.py +++ b/src/apm_cli/models/apm_package.py @@ -13,6 +13,8 @@ import yaml +from ..core.apm_yml import parse_targets_field +from ..core.target_catalog import normalize_target_name from ..core.target_detection import parse_target_field from .dependency import ( DependencyReference, @@ -69,7 +71,43 @@ def clear_apm_yml_cache() -> None: _apm_yml_cache.clear() -def _parse_registries_block(data: dict, apm_yml_path: Path): +def _parse_v01_registries_block( + data: dict, + apm_yml_path: Path, +) -> tuple[dict[str, str] | None, str | None]: + """Parse the normative OpenAPM v0.1 registry map.""" + raw = data.get("registries") + if raw is None: + return None, None + if not isinstance(raw, dict): + raise ValueError(f"OpenAPM v0.1 'registries:' in {apm_yml_path} must be a mapping") + registries: dict[str, str] = {} + default_name = raw.get("default") + for name, value in raw.items(): + if name == "default": + continue + body = {"url": value} if isinstance(value, str) else value + if not isinstance(name, str) or not isinstance(body, dict): + raise ValueError("OpenAPM v0.1 registry entries must be named strings or objects") + url = body.get("url") + if not isinstance(url, str) or not url.startswith(("https://", "http://")): + raise ValueError(f"OpenAPM v0.1 registry {name!r} requires an HTTP(S) url") + registries[name] = url + aliases = body.get("aliases", []) + if aliases is not None and not isinstance(aliases, list): + raise ValueError(f"OpenAPM v0.1 registry {name!r} aliases must be a list") + for alias in aliases or []: + if not isinstance(alias, str) or not alias: + raise ValueError(f"OpenAPM v0.1 registry {name!r} has an invalid alias") + registries[alias] = url + if default_name is not None and ( + not isinstance(default_name, str) or default_name not in registries + ): + raise ValueError("OpenAPM v0.1 registries.default must name a declared registry") + return registries or None, default_name + + +def _parse_registries_block(data: dict, apm_yml_path: Path, manifest_contract): """Parse the top-level ``registries:`` block per design §3.1. Schema:: @@ -85,6 +123,11 @@ def _parse_registries_block(data: dict, apm_yml_path: Path): ``{name: url}`` and *default_name* is the value of ``default:`` (or ``None``). Absent block returns ``(None, None)``. """ + from .manifest_contract import ManifestContract + + if manifest_contract is ManifestContract.OPENAPM_V01: + return _parse_v01_registries_block(data, apm_yml_path) + raw = data.get("registries") if raw is None: return None, None @@ -257,6 +300,12 @@ class APMPackage: # re-validate via parse_targets_field with the same dict shape it sees from # raw apm.yml. None means the user did not declare 'targets:' at all. ) + canonical_targets: tuple[str, ...] = () + """Validated target names consumed by compile, install, and pack. + + ``target`` and ``targets`` remain compatibility projections of the + author-facing spellings. Runtime consumers must use this tuple. + """ type: PackageContentType | None = ( None # Package content type: instructions, skill, hybrid, or prompts ) @@ -267,6 +316,7 @@ class APMPackage: registries: dict[str, str] | None = None # Value of ``registries.default:`` -- routes unscoped deps to this registry. default_registry: str | None = None + manifest_contract: str = "working-draft" # Top-level ``allowExecutables:`` block -- per-package approval for # executable primitives (hooks, MCP servers, bin/ executables). @@ -275,6 +325,21 @@ class APMPackage: # to boolean (e.g. ``{"owner/repo#v1.0": {"hooks": true}}``). allow_executables: dict[str, dict[str, bool]] | None = None + def __post_init__(self) -> None: + """Derive the canonical target projection for compatibility callers.""" + if self.canonical_targets: + return + raw_targets: list[str] = [] + if self.targets is not None: + raw_targets.extend(self.targets) + elif isinstance(self.target, list): + raw_targets.extend(self.target) + elif isinstance(self.target, str): + raw_targets.append(self.target) + self.canonical_targets = tuple( + name if name == "all" else normalize_target_name(name) for name in raw_targets + ) + @classmethod def _parse_dependency_dict(cls, raw_deps: dict, label: str = "") -> dict: """Parse a dependencies or devDependencies dict from apm.yml. @@ -383,14 +448,26 @@ def from_apm_yml( if not isinstance(data, dict): raise ValueError(f"apm.yml must contain a YAML object, got {type(data)}") + from .manifest_contract import negotiate_manifest_contract + + manifest_contract = negotiate_manifest_contract(data) + # Required fields if "name" not in data: raise ValueError("Missing required field 'name' in apm.yml") if "version" not in data: raise ValueError("Missing required field 'version' in apm.yml") + if not isinstance(data["name"], str) or not data["name"].strip(): + raise ValueError("Invalid apm.yml identity: 'name' must be a non-empty string") + if not isinstance(data["version"], str) or not data["version"].strip(): + raise ValueError("Invalid apm.yml identity: 'version' must be a non-empty string") # Top-level ``registries:`` block per design §3.1. - registries, default_registry = _parse_registries_block(data, apm_yml_path) + registries, default_registry = _parse_registries_block( + data, + apm_yml_path, + manifest_contract, + ) # Parse dependencies dependencies = None @@ -473,25 +550,39 @@ def from_apm_yml( # string like ``target: "claude,copilot"`` resolves identically to # ``--target claude,copilot`` and unknown tokens fail at parse time # (see apm_cli.core.target_detection.parse_target_field). - target_value = parse_target_field( - data.get("target"), - source_path=apm_yml_path, - ) - - # Plural 'targets:' field is stored raw (no canonical validation here) - # so the MCP install gate at mcp_integrator._gate_project_scoped_runtimes - # can re-run parse_targets_field on a dict that mirrors apm.yml shape - # and surface the same conflict / empty-list errors uniformly. Without - # this passthrough, the call site at commands/install.py would silently - # bypass the targets whitelist for any user on the modern plural form - # (#1335 regression caught in PR #1336 audit). + target_value = None targets_value: list[str] | None = None - if "targets" in data and data["targets"] is not None: - raw_targets = data["targets"] - if isinstance(raw_targets, list): - targets_value = [str(t).strip() for t in raw_targets if str(t).strip()] - else: - targets_value = [str(raw_targets).strip()] + if "targets" in data and "target" in data: + target_value = parse_target_field( + data.get("target"), + source_path=apm_yml_path, + ) + raw_targets = data.get("targets") + targets_value = ( + [str(item).strip() for item in raw_targets if str(item).strip()] + if isinstance(raw_targets, list) + else [str(raw_targets).strip()] + ) + canonical_targets = () + elif "targets" in data: + targets_value = parse_targets_field(data) + canonical_targets = tuple(targets_value) + else: + target_value = parse_target_field( + data.get("target"), + source_path=apm_yml_path, + ) + parsed_target_values = ( + target_value + if isinstance(target_value, list) + else [target_value] + if isinstance(target_value, str) + else [] + ) + canonical_targets = tuple( + name if name == "all" else normalize_target_name(name) + for name in parsed_target_values + ) result = cls( name=data["name"], @@ -506,10 +597,12 @@ def from_apm_yml( source_path=resolved_source, target=target_value, targets=targets_value, + canonical_targets=canonical_targets, type=pkg_type, includes=includes, registries=registries, default_registry=default_registry, + manifest_contract=manifest_contract.value, allow_executables=allow_executables, ) _apm_yml_cache[cache_key] = result @@ -581,6 +674,48 @@ def get_dev_lsp_dependencies(self) -> list["LSPDependency"]: ] +def canonical_package_targets(package: object) -> tuple[str, ...]: + """Return canonical targets for packages and compatibility stand-ins.""" + canonical = getattr(package, "canonical_targets", ()) + if isinstance(canonical, (tuple, list)) and canonical: + return tuple(canonical) + plural = getattr(package, "targets", None) + if isinstance(plural, list) and plural: + return tuple(name if name == "all" else normalize_target_name(name) for name in plural) + singular = getattr(package, "target", None) + values = singular if isinstance(singular, list) else [singular] + return tuple( + name if name == "all" else normalize_target_name(name) + for name in values + if isinstance(name, str) and name + ) + + +def canonical_package_target_config(package: object) -> dict[str, object]: + """Project canonical targets into the compatibility config shape.""" + canonical = canonical_package_targets(package) + if not canonical: + return {} + if isinstance(package, APMPackage): + return {"targets": list(canonical)} + plural = getattr(package, "targets", None) + if isinstance(plural, list): + return {"targets": list(canonical)} + singular = getattr(package, "target", None) + if isinstance(singular, str) and len(canonical) == 1: + return {"target": singular} + return {"targets": list(canonical)} + + +def package_target_selection(package: APMPackage) -> str | list[str] | None: + """Return pack-compatible selection from the canonical package owner.""" + if package.targets is not None: + return list(package.canonical_targets) + if package.target is not None: + return package.target + return list(package.canonical_targets) or None + + @dataclass class PackageInfo: """Information about a downloaded/installed package.""" diff --git a/src/apm_cli/models/dependency/identity.py b/src/apm_cli/models/dependency/identity.py index b9d21b5ff..a85800c4a 100644 --- a/src/apm_cli/models/dependency/identity.py +++ b/src/apm_cli/models/dependency/identity.py @@ -71,6 +71,8 @@ def build_dependency_unique_key( is_virtual: bool = False, virtual_path: str | None = None, registry_prefix: str | None = None, + declaring_parent: str | None = None, + anchored_local_path: str | None = None, ) -> str: """Return the lockfile/dedup key for a dependency identity. @@ -88,6 +90,8 @@ def build_dependency_unique_key( lockfile key correspondence used by re-install and orphan detection. """ if source == "local" and local_path: + if anchored_local_path: + return f"local:{anchored_local_path}" return local_path key = repo_url diff --git a/src/apm_cli/models/dependency/mcp.py b/src/apm_cli/models/dependency/mcp.py index c466ba5f3..136d64fc3 100644 --- a/src/apm_cli/models/dependency/mcp.py +++ b/src/apm_cli/models/dependency/mcp.py @@ -27,8 +27,8 @@ # Install-time provenance field: reserved here so a manifest key named # ``resolved_by`` is treated as known (ignored by from_dict, never # constructed from user input) instead of passing through ``extra`` into - # the serialized config. Only MCPIntegrator.collect_transitive may set - # it. Keeps the "never leaks into mcp_configs" invariant airtight (#2081). + # the serialized config. Only current-source derivation may set it. + # Keeps the "never leaks into mcp_configs" invariant airtight (#2081). "resolved_by", } ) @@ -73,7 +73,7 @@ class MCPDependency: # Install-time provenance: the declaring package identity when this server # was contributed transitively (via a sub-package's apm.yml), else None for # servers declared directly in the root manifest. Set by - # ``MCPIntegrator.collect_transitive``; deliberately NOT serialized in + # ``CurrentMcpConfigView``; deliberately NOT serialized in # ``to_dict`` so it never leaks into the lockfile ``mcp_configs`` values or # the config-drift comparison (#2081). resolved_by: str | None = None diff --git a/src/apm_cli/models/dependency/reference.py b/src/apm_cli/models/dependency/reference.py index b64373202..97ef05f81 100644 --- a/src/apm_cli/models/dependency/reference.py +++ b/src/apm_cli/models/dependency/reference.py @@ -77,6 +77,8 @@ class DependencyReference: # Local path dependency fields is_local: bool = False # True if this is a local filesystem dependency local_path: str | None = None # Original local path string (e.g., "./packages/my-pkg") + declaring_parent: str | None = None + anchored_local_path: str | None = None # Monorepo inheritance: { git: parent, path: ... } — expanded in resolver is_parent_repo_inheritance: bool = False @@ -316,8 +318,22 @@ def get_unique_key(self) -> str: is_virtual=self.is_virtual, virtual_path=self.virtual_path, registry_prefix=self.artifactory_prefix, + declaring_parent=self.declaring_parent, + anchored_local_path=self.anchored_local_path, ) + def get_resolution_key(self) -> str: + """Return identity plus the declared ref constraint.""" + if self.reference: + return f"{self.get_unique_key()}#{self.reference}" + return self.get_unique_key() + + def get_cycle_key(self) -> str: + """Return physical local identity for recursion detection.""" + if self.is_local and self.anchored_local_path: + return f"local:{self.anchored_local_path}" + return self.get_unique_key() + def to_canonical(self) -> str: """Return the canonical scheme-free identity string for this dependency. @@ -430,21 +446,6 @@ def get_install_path(self, apm_modules_dir: Path) -> Path: This is the single source of truth for where a package lives in apm_modules/. - For regular packages: - - GitHub: apm_modules/owner/repo/ - - ADO: apm_modules/org/project/repo/ - - For virtual file/collection packages: - - GitHub: apm_modules/owner// - - ADO: apm_modules/org/project// - - For subdirectory packages (Claude Skills, nested APM packages): - - GitHub: apm_modules/owner/repo/subdir/path/ - - ADO: apm_modules/org/project/repo/subdir/path/ - - For local packages: - - apm_modules/_local// - Args: apm_modules_dir: Path to the apm_modules directory @@ -467,7 +468,14 @@ def get_install_path(self, apm_modules_dir: Path) -> Path: context="local package path", reject_empty=True, ) - result = apm_modules_dir / "_local" / pkg_dir_name + if self.declaring_parent: + import hashlib + + identity = self.anchored_local_path or self.local_path + parent_slot = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:12] + result = apm_modules_dir / "_local" / parent_slot / pkg_dir_name + else: + result = apm_modules_dir / "_local" / pkg_dir_name ensure_path_within(result, apm_modules_dir) return result @@ -920,21 +928,19 @@ def parse_from_dict(cls, entry: dict) -> "DependencyReference": def _parse_host_type(raw: object) -> str | None: """Parse the optional object-form ``type`` host-kind hint. - Currently only ``gitlab`` is accepted; any other value fails closed with - a ``ValueError``. This is a deliberate gate, not an oversight: future - host kinds (e.g. ``gitea``, ``bitbucket``) would extend the accepted set - here and thread a matching branch through ``AuthResolver.classify_host`` - and ``host_backends.backend_for``. Until those backends exist, rejecting - unknown hints keeps classification explicit rather than silently - mis-routing a bespoke host to the GitHub path. + Values come from the canonical host-provider registry. Unknown hints + fail closed rather than silently selecting a generic transport. """ if raw is None: return None if not isinstance(raw, str) or not raw.strip(): raise ValueError("'type' field must be a non-empty string") value = raw.strip().lower() - if value != "gitlab": - raise ValueError(f"'type' field only supports 'gitlab'; got {raw!r}") + from apm_cli.core.host_providers import accepted_host_types + + supported = accepted_host_types() + if value not in supported: + raise ValueError(f"'type' field supports {', '.join(supported)}; got {raw!r}") return value @classmethod diff --git a/src/apm_cli/models/manifest_contract.py b/src/apm_cli/models/manifest_contract.py new file mode 100644 index 000000000..321896938 --- /dev/null +++ b/src/apm_cli/models/manifest_contract.py @@ -0,0 +1,31 @@ +"""Explicit negotiation between normative and working manifest contracts.""" + +from __future__ import annotations + +from enum import Enum + +OPENAPM_V01_SCHEMA_URI = "https://microsoft.github.io/apm/specs/schemas/manifest-v0.1.schema.json" + + +class ManifestContract(str, Enum): + """Manifest contracts understood by this loader.""" + + WORKING_DRAFT = "working-draft" + OPENAPM_V01 = "openapm-v0.1" + + +class UnsupportedManifestContractError(ValueError): + """Raised when ``$schema`` names a contract this client cannot load.""" + + +def negotiate_manifest_contract(data: dict) -> ManifestContract: + """Select a loader contract from the standard ``$schema`` identity.""" + schema_uri = data.get("$schema") + if schema_uri is None: + return ManifestContract.WORKING_DRAFT + if schema_uri == OPENAPM_V01_SCHEMA_URI: + return ManifestContract.OPENAPM_V01 + raise UnsupportedManifestContractError( + f"Unsupported apm.yml $schema contract: {schema_uri!r}. " + f"Supported explicit contract: {OPENAPM_V01_SCHEMA_URI}" + ) diff --git a/src/apm_cli/models/results.py b/src/apm_cli/models/results.py index 4d34919bd..5449e3746 100644 --- a/src/apm_cli/models/results.py +++ b/src/apm_cli/models/results.py @@ -1,6 +1,18 @@ """Typed result containers for APM operations.""" from dataclasses import dataclass, field +from enum import Enum + + +class InstallDisposition(str, Enum): + """Canonical completion state for an install attempt.""" + + SUCCESS = "success" + PARTIAL_SUCCESS = "partial-success" + VALIDATION_FAILED = "validation-failed" + CANCELLED = "cancelled" + DRY_RUN = "dry-run" + FAILED = "failed" @dataclass @@ -12,6 +24,10 @@ class InstallResult: agents_integrated: int = 0 diagnostics: object = None # DiagnosticCollector or None package_types: dict[str, str] = field(default_factory=dict) # dep_key -> type string + disposition: InstallDisposition = InstallDisposition.SUCCESS + exit_code: int = 0 + committed: bool = False + error: BaseException | None = field(default=None, repr=False) @dataclass diff --git a/src/apm_cli/policy/ci_checks.py b/src/apm_cli/policy/ci_checks.py index 918b27919..48149c250 100644 --- a/src/apm_cli/policy/ci_checks.py +++ b/src/apm_cli/policy/ci_checks.py @@ -231,43 +231,42 @@ def _check_config_consistency( lock: LockFile, ) -> CheckResult: """Verify MCP server configs match lockfile baseline.""" - from ..drift import detect_config_drift - from ..integration.mcp_integrator import MCPIntegrator - - mcp_deps = manifest.get_all_mcp_dependencies() - current_configs = MCPIntegrator.get_server_configs(mcp_deps) + from ..constants import APM_MODULES_DIR + from ..integration.mcp_config_view import CurrentMcpConfigView + + project_root = manifest.package_path or Path.cwd() + view = CurrentMcpConfigView.derive( + manifest, + lock, + project_root / APM_MODULES_DIR, + trust_transitive_self_defined=True, + ) stored_configs = lock.mcp_configs or {} + diff = view.diff(stored_configs) # No MCP deps at all -- nothing to check - if not current_configs and not stored_configs: + if not view.configs and not stored_configs and not view.problems: return CheckResult( name="config-consistency", passed=True, message="No MCP configs to check", ) - details: list[str] = [] + details = [f"{problem.package_key}: {problem.message}" for problem in view.problems] - # Detect drift on servers that exist in both sets - drifted = detect_config_drift(current_configs, stored_configs) - for name in sorted(drifted): + # Preserve the established diagnostics while sourcing every partition from + # the canonical symmetric diff. + for name in sorted(diff.changed): details.append(f"{name}: config differs from lockfile baseline") - # Servers in lockfile but not in manifest (orphaned MCP). - # Transitively-contributed servers (declared by a local-path sub-package, - # recorded with provenance in mcp_config_provenance) are exempted: the root - # manifest cannot declare them, so flagging them creates an unfixable CI - # failure. This mirrors the resolved_by-based exemption in _check_no_orphans - # (#2081; MCP-side sibling of #1846/#1855). provenance = lock.mcp_config_provenance or {} - for name in sorted(stored_configs): - if name not in current_configs and name not in provenance: - details.append(f"{name}: in lockfile but not in manifest") + for name in sorted(diff.lock_only): + owner = provenance.get(name) + suffix = f" (declared by {owner})" if owner else "" + details.append(f"{name}: in lockfile but not in manifest{suffix}") - # Servers in manifest but not in lockfile (new, not installed) - for name in sorted(current_configs): - if name not in stored_configs: - details.append(f"{name}: in manifest but not in lockfile") + for name in sorted(diff.source_only): + details.append(f"{name}: in manifest but not in lockfile") if not details: return CheckResult( @@ -292,8 +291,9 @@ def _check_content_integrity( Two signals are evaluated: * Critical hidden Unicode (steganographic markers) via the file scanner. - * SHA-256 drift between the on-disk content and the hash recorded - in ``deployed_file_hashes`` at install time. + * SHA-256 drift between the on-disk content and the canonical deployment + ledger hash recorded at install time. + * Missing canonical ownership metadata for a legacy deployed-file hash. Missing files are deliberately skipped here -- ``_check_deployed_files_present`` already reports those, and double-reporting muddies the audit output. @@ -312,37 +312,63 @@ def _check_content_integrity( if any(f.severity == "critical" for f in findings): critical_files.append(rel_path) - # Per-file hash verification across all dependencies (the synthesized - # self-entry is included in ``lock.dependencies`` so local content is - # covered through the same iteration). + from ..core.deployment_ledger import DeploymentLedgerCodec + from ..core.deployment_state import LocatorKind + from ..integration.targets import KNOWN_TARGETS + + ledger = DeploymentLedgerCodec.from_lockfile(lock) + ledger_values = { + record.locator.value + for record in ledger.records.values() + if record.owners and record.active_owner + } + legacy_hash_paths = set(lock.local_deployed_file_hashes) + for dependency in lock.dependencies.values(): + legacy_hash_paths.update(dependency.deployed_file_hashes) + missing_ownership = sorted(legacy_hash_paths.difference(ledger_values)) + + # Per-file hash verification across canonical deployment records. hash_mismatches: list[tuple] = [] # (dep_key, rel_path, expected, actual) # Local import: matches the scoping pattern used in # _check_deployed_files_present (line 131); avoids cycles. from ..integration.base_integrator import BaseIntegrator as _BaseIntegrator - for dep_key, dep in lock.dependencies.items(): - if not dep.deployed_file_hashes: + for record in ledger.records.values(): + expected_hash = record.content_hash + if expected_hash is None: + continue + locator = record.locator + if locator.kind == LocatorKind.URI: continue - for rel_path, expected_hash in dep.deployed_file_hashes.items(): - # Path safety: silently skip any rel_path that escapes - # project_root or targets a non-allowlisted prefix. Mirrors - # the guard in _check_deployed_files_present so a forged - # lockfile cannot induce reads outside managed locations. - safe_rel = rel_path.rstrip("/") + if locator.kind == LocatorKind.PROJECT_RELATIVE: + safe_rel = locator.value.rstrip("/") if not _BaseIntegrator.validate_deploy_path(safe_rel, project_root): continue file_path = project_root / safe_rel - if not file_path.exists(): - continue # _check_deployed_files_present owns this signal - if file_path.is_symlink(): + else: + target = KNOWN_TARGETS.get(locator.target) + if target is None: continue - if not file_path.is_file(): + try: + resolved = DeploymentLedgerCodec.resolve_locator( + locator, + project_root=project_root, + target=target, + ) + except (OSError, RuntimeError, ValueError): + continue + if isinstance(resolved, str): continue - actual_hash = compute_file_hash(file_path) - if actual_hash != expected_hash: - hash_mismatches.append((dep_key, rel_path, expected_hash, actual_hash)) + file_path = resolved + if not file_path.exists(): + continue # _check_deployed_files_present owns this signal + if file_path.is_symlink() or not file_path.is_file(): + continue + actual_hash = compute_file_hash(file_path) + if actual_hash != expected_hash: + hash_mismatches.append((record.active_owner, locator.value, expected_hash, actual_hash)) - if not critical_files and not hash_mismatches: + if not critical_files and not hash_mismatches and not missing_ownership: return CheckResult( name="content-integrity", passed=True, @@ -352,6 +378,8 @@ def _check_content_integrity( details: list[str] = [] for rel_path in critical_files: details.append(f"unicode: {rel_path}") + for rel_path in missing_ownership: + details.append(f"missing-ownership: {rel_path}") for dep_key, rel_path, expected, actual in hash_mismatches: # Truncate hashes for terminal width; full hashes available via JSON output. exp_short = expected.split(":", 1)[-1][:12] if ":" in expected else expected[:12] @@ -371,6 +399,9 @@ def _check_content_integrity( if hash_mismatches: parts.append(f"{len(hash_mismatches)} file(s) with hash drift") remedies.append("'apm install' to restore drifted files") + if missing_ownership: + parts.append(f"{len(missing_ownership)} file(s) without deployment ownership") + remedies.append("'apm install' to repair ownership metadata") summary = "; ".join(parts) remedy = " and ".join(remedies) return CheckResult( diff --git a/src/apm_cli/policy/discovery.py b/src/apm_cli/policy/discovery.py index c88d42e7d..03df7f0e7 100644 --- a/src/apm_cli/policy/discovery.py +++ b/src/apm_cli/policy/discovery.py @@ -196,7 +196,7 @@ def _verify_hash_pin( POLICY_CACHE_DIR = ".policy-cache" DEFAULT_CACHE_TTL = 3600 # 1 hour MAX_STALE_TTL = 7 * 24 * 3600 # 7 days -- stale cache usable on refresh failure -CACHE_SCHEMA_VERSION = "3" # Bump when cache format changes to auto-invalidate +CACHE_SCHEMA_VERSION = "4" # Bump when cache format changes to auto-invalidate @dataclass @@ -229,6 +229,7 @@ class PolicyFetchResult: cache_stale: bool = False # True if cache was served past TTL fetch_error: str | None = None # Network/parse error on refresh attempt outcome: str = "" # See docstring for valid values + warnings: list[str] = field(default_factory=list) # -- Hash-pin fields (#827 supply-chain hardening) -- # raw_bytes_hash is the digest of the leaf policy bytes off the wire, @@ -246,6 +247,8 @@ def discover_policy_with_chain( project_root: Path, *, expected_hash: str | None = None, + policy_override: str | None = None, + no_cache: bool = False, ) -> PolicyFetchResult: """Discover policy with full inheritance chain resolution. @@ -306,7 +309,12 @@ def discover_policy_with_chain( expected_hash = pin.normalized # -- Base discovery ------------------------------------------------ - fetch_result = discover_policy(project_root, expected_hash=expected_hash) + fetch_result = discover_policy( + project_root, + policy_override=policy_override, + no_cache=no_cache, + expected_hash=expected_hash, + ) # -- Chain resolution if leaf has extends: ------------------------- if ( @@ -443,15 +451,13 @@ def _resolve_and_persist_chain( refs and ``MAX_CHAIN_DEPTH`` enforcement protect against runaway or self-referential chains. - Partial-chain policy: if any parent fetch fails, emit a warning via - ``_rich_warning`` and merge whatever was resolved so far -- never - silently drop ancestors. + If any parent fetch fails, mark the chain incomplete and clear the + partial policy so enforcement cannot proceed with weaker constraints. Mutates *fetch_result*.policy in-place with the merged effective policy. Called by :func:`discover_policy_with_chain` -- not intended for direct use. """ - from ..utils.console import _rich_warning from . import inheritance as _inheritance_mod leaf_policy = fetch_result.policy @@ -473,7 +479,7 @@ def _resolve_and_persist_chain( visited: list[str] = [_strip_source_prefix(leaf_source)] if leaf_source else [] current = leaf_policy - partial_warning: tuple[str, int, int] | None = None + incomplete_chain: tuple[str, int, int] | None = None while current.extends: next_ref = current.extends @@ -502,12 +508,13 @@ def _resolve_and_persist_chain( policy_override=next_ref, no_cache=False, ) + fetch_result.warnings.extend(parent_result.warnings) if parent_result.policy is None: - # Parent fetch failed -- merge what we have so far and warn. + # Parent fetch failed -- never enforce the weaker partial chain. attempted = len(chain_policies) + 1 resolved = len(chain_policies) - partial_warning = (next_ref, resolved, attempted) + incomplete_chain = (next_ref, resolved, attempted) break chain_policies.append(parent_result.policy) @@ -517,13 +524,14 @@ def _resolve_and_persist_chain( # No actual ancestors fetched -- nothing to merge or re-cache. if len(chain_policies) == 1: - if partial_warning is not None: - ref, resolved, attempted = partial_warning - _rich_warning( - f"Policy chain incomplete: {ref} unreachable, " - f"using {resolved} of {attempted} policies", - symbol="warning", + if incomplete_chain is not None: + ref, resolved, attempted = incomplete_chain + fetch_result.outcome = "incomplete_chain" + fetch_result.error = ( + f"Policy chain incomplete: {ref} unreachable " + f"({resolved} of {attempted} policies resolved)" ) + fetch_result.policy = None return # Merge in [root, ..., leaf] order. We collected leaf-first, so reverse. @@ -540,17 +548,26 @@ def _resolve_and_persist_chain( chain_refs: list[str] = [_strip_source_prefix(src) for src in ordered_sources if src] cache_key = _strip_source_prefix(leaf_source) if leaf_source else "" - if cache_key: - _write_cache(cache_key, merged, project_root, chain_refs=chain_refs) - - fetch_result.policy = merged + if cache_key and incomplete_chain is None: + _write_cache( + cache_key, + merged, + project_root, + chain_refs=chain_refs, + warnings=fetch_result.warnings, + ) - if partial_warning is not None: - ref, resolved, attempted = partial_warning - _rich_warning( - f"Policy chain incomplete: {ref} unreachable, using {resolved} of {attempted} policies", - symbol="warning", + if incomplete_chain is not None: + ref, resolved, attempted = incomplete_chain + fetch_result.outcome = "incomplete_chain" + fetch_result.error = ( + f"Policy chain incomplete: {ref} unreachable " + f"({resolved} of {attempted} policies resolved)" ) + fetch_result.policy = None + return + + fetch_result.policy = merged def discover_policy( @@ -628,7 +645,7 @@ def _load_from_file(path: Path, *, expected_hash: str | None = None) -> PolicyFe return mismatch try: - policy, _warnings = load_policy(content) + policy, warnings = load_policy(content) outcome = "empty" if _is_policy_empty(policy) else "found" actual_hash = ( _compute_hash_normalized(content, expected_hash) if expected_hash is not None else None @@ -639,9 +656,14 @@ def _load_from_file(path: Path, *, expected_hash: str | None = None) -> PolicyFe outcome=outcome, raw_bytes_hash=actual_hash, expected_hash=expected_hash, + warnings=warnings, ) except PolicyValidationError as e: - return PolicyFetchResult(error=f"Invalid policy file {path}: {e}", outcome="malformed") + return PolicyFetchResult( + error=f"Invalid policy file {path}: {e}", + outcome="malformed", + warnings=e.warnings, + ) def _auto_discover( @@ -811,6 +833,7 @@ def _fetch_from_url( outcome=outcome, raw_bytes_hash=cache_entry.raw_bytes_hash or None, expected_hash=expected_hash, + warnings=cache_entry.warnings, ) fetch_error: str | None = None @@ -860,12 +883,13 @@ def _fetch_from_url( return mismatch try: - policy, _warnings = load_policy(content) + policy, warnings = load_policy(content) except PolicyValidationError as e: return PolicyFetchResult( error=f"Invalid policy from {url}: {e}", source=source_label, outcome="malformed", + warnings=e.warnings, ) chain_refs = [url] @@ -876,6 +900,7 @@ def _fetch_from_url( project_root, chain_refs=chain_refs, raw_bytes_hash=actual_hash, + warnings=warnings, ) outcome = "empty" if _is_policy_empty(policy) else "found" return PolicyFetchResult( @@ -884,6 +909,7 @@ def _fetch_from_url( outcome=outcome, raw_bytes_hash=actual_hash, expected_hash=expected_hash, + warnings=warnings, ) @@ -913,6 +939,7 @@ def _fetch_from_repo( outcome=outcome, raw_bytes_hash=cache_entry.raw_bytes_hash or None, expected_hash=expected_hash, + warnings=cache_entry.warnings, ) content, error = _fetch_github_contents(repo_ref, "apm-policy.yml") @@ -938,12 +965,13 @@ def _fetch_from_repo( return mismatch try: - policy, _warnings = load_policy(content) + policy, warnings = load_policy(content) except PolicyValidationError as e: return PolicyFetchResult( error=f"Invalid policy in {repo_ref}: {e}", source=source_label, outcome="malformed", + warnings=e.warnings, ) chain_refs = [repo_ref] @@ -954,6 +982,7 @@ def _fetch_from_repo( project_root, chain_refs=chain_refs, raw_bytes_hash=actual_hash, + warnings=warnings, ) outcome = "empty" if _is_policy_empty(policy) else "found" return PolicyFetchResult( @@ -962,6 +991,7 @@ def _fetch_from_repo( outcome=outcome, raw_bytes_hash=actual_hash, expected_hash=expected_hash, + warnings=warnings, ) @@ -1063,6 +1093,7 @@ def _fetch_from_ado_repo( outcome=outcome, raw_bytes_hash=cache_entry.raw_bytes_hash or None, expected_hash=expected_hash, + warnings=cache_entry.warnings, ) content, error = _fetch_ado_contents(org, project, repo, "apm-policy.yml", host=host) @@ -1084,12 +1115,13 @@ def _fetch_from_ado_repo( return mismatch try: - policy, _warnings = load_policy(content) + policy, warnings = load_policy(content) except PolicyValidationError as e: return PolicyFetchResult( error=f"Invalid policy in {repo_ref}: {e}", source=source_label, outcome="malformed", + warnings=e.warnings, ) chain_refs = [repo_ref] @@ -1100,6 +1132,7 @@ def _fetch_from_ado_repo( project_root, chain_refs=chain_refs, raw_bytes_hash=actual_hash, + warnings=warnings, ) outcome = "empty" if _is_policy_empty(policy) else "found" return PolicyFetchResult( @@ -1108,6 +1141,7 @@ def _fetch_from_ado_repo( outcome=outcome, raw_bytes_hash=actual_hash, expected_hash=expected_hash, + warnings=warnings, ) @@ -1211,6 +1245,7 @@ class _CacheEntry: age_seconds: int stale: bool # True if past TTL (but within MAX_STALE_TTL) chain_refs: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) fingerprint: str = "" raw_bytes_hash: str = "" # ":" of leaf bytes off wire (#827) @@ -1350,6 +1385,7 @@ def _stale_fallback_or_error( cache_age_seconds=cache_entry.age_seconds, fetch_error=fetch_error_msg, outcome="cached_stale", + warnings=cache_entry.warnings, ) return PolicyFetchResult( error=fetch_error_msg, @@ -1386,6 +1422,7 @@ def _detect_garbage( cache_age_seconds=cache_entry.age_seconds, fetch_error=msg, outcome="cached_stale", + warnings=cache_entry.warnings, ) return PolicyFetchResult( error=msg + " (possible captive portal or redirect)", @@ -1405,6 +1442,7 @@ def _detect_garbage( cache_age_seconds=cache_entry.age_seconds, fetch_error=msg, outcome="cached_stale", + warnings=cache_entry.warnings, ) return PolicyFetchResult( error=msg, @@ -1473,6 +1511,11 @@ def _read_cache_entry( return None policy, _warnings = load_policy(policy_file) + cached_warnings = meta.get("warnings", []) + if not isinstance(cached_warnings, list): + cached_warnings = [] + else: + cached_warnings = [str(warning) for warning in cached_warnings] # Determine source label if repo_ref.startswith("http://") or repo_ref.startswith("https://"): @@ -1486,6 +1529,7 @@ def _read_cache_entry( age_seconds=age, stale=age > ttl, chain_refs=meta.get("chain_refs", [repo_ref]), + warnings=cached_warnings, fingerprint=meta.get("fingerprint", ""), raw_bytes_hash=raw_bytes_hash, ) @@ -1513,6 +1557,7 @@ def _read_cache( cached=True, cache_age_seconds=entry.age_seconds, outcome=outcome, + warnings=entry.warnings, ) @@ -1523,6 +1568,7 @@ def _write_cache( *, chain_refs: list[str] | None = None, raw_bytes_hash: str | None = None, + warnings: list[str] | None = None, ) -> None: """Write merged effective policy and metadata to cache atomically. @@ -1566,6 +1612,7 @@ def _write_cache( "repo_ref": repo_ref, "cached_at": time.time(), "chain_refs": chain_refs if chain_refs is not None else [repo_ref], + "warnings": warnings or [], "schema_version": CACHE_SCHEMA_VERSION, "fingerprint": fingerprint, "raw_bytes_hash": raw_bytes_hash or "", diff --git a/src/apm_cli/policy/inheritance.py b/src/apm_cli/policy/inheritance.py index e0e0fc60e..17a11cb46 100644 --- a/src/apm_cli/policy/inheritance.py +++ b/src/apm_cli/policy/inheritance.py @@ -258,6 +258,8 @@ def _merge_manifest(parent: ManifestPolicy, child: ManifestPolicy) -> ManifestPo required_fields=_union(parent.required_fields, child.required_fields), scripts=_escalate(_SCRIPTS_LEVELS, parent.scripts, child.scripts), content_types=merged_content_types, + require_explicit_includes=parent.require_explicit_includes + or child.require_explicit_includes, ) diff --git a/src/apm_cli/policy/outcome_routing.py b/src/apm_cli/policy/outcome_routing.py index 4e75bc53b..5e666bb2b 100644 --- a/src/apm_cli/policy/outcome_routing.py +++ b/src/apm_cli/policy/outcome_routing.py @@ -60,6 +60,7 @@ "malformed", "cache_miss_fetch_fail", "garbage_response", + "incomplete_chain", ) @@ -127,6 +128,22 @@ def route_discovery_outcome( ) return None + if outcome == "incomplete_chain": + if logger is not None: + logger.policy_discovery_miss( + outcome=outcome, + source=source, + error=fetch_result.error or fetch_result.fetch_error, + ) + if raise_blocking_errors: + raise PolicyViolationError( + "Install blocked: org policy inheritance chain is incomplete " + f"(source={source or 'unknown'}). Restore the unreachable parent " + "policy before retrying.", + policy_source=source or "unknown", + ) + return None + # 6 of 9 non-found / non-disabled outcomes route through the # canonical logger helper for consistent wording (Logging C1/C2, # UX F1/F2/F4). diff --git a/src/apm_cli/policy/parser.py b/src/apm_cli/policy/parser.py index e4f38b80b..7e30ade83 100644 --- a/src/apm_cli/policy/parser.py +++ b/src/apm_cli/policy/parser.py @@ -54,6 +54,7 @@ "manifest", "unmanaged_files", "security", + "registry_source", "bin_deploy", "executables", } @@ -62,8 +63,9 @@ class PolicyValidationError(Exception): """Raised when policy YAML is malformed or violates schema constraints.""" - def __init__(self, errors: list[str]): + def __init__(self, errors: list[str], warnings: list[str] | None = None): self.errors = errors + self.warnings = warnings or [] super().__init__(f"Policy validation failed: {'; '.join(errors)}") @@ -84,6 +86,11 @@ def validate_policy(data: dict) -> tuple[list[str], list[str]]: for key in sorted(unknown): warnings.append(f"Unknown top-level policy key: '{key}'") + for key in ("mcp", "manifest", "compilation", "registry_source", "bin_deploy"): + value = data.get(key) + if value is not None and not isinstance(value, dict): + errors.append(f"{key} must be a YAML mapping") + # enforcement (coerce YAML booleans: off → "off") enforcement = data.get("enforcement") if isinstance(enforcement, bool): @@ -107,7 +114,9 @@ def validate_policy(data: dict) -> tuple[list[str], list[str]]: # cache.ttl cache = data.get("cache") - if isinstance(cache, dict): + if cache is not None and not isinstance(cache, dict): + errors.append("cache must be a YAML mapping") + elif isinstance(cache, dict): ttl = cache.get("ttl") if ttl is not None: if not isinstance(ttl, int) or isinstance(ttl, bool): @@ -117,7 +126,15 @@ def validate_policy(data: dict) -> tuple[list[str], list[str]]: # dependencies deps = data.get("dependencies") - if isinstance(deps, dict): + if deps is not None and not isinstance(deps, dict): + errors.append("dependencies must be a YAML mapping") + elif isinstance(deps, dict): + for key in ("allow", "deny", "require"): + value = deps.get(key) + if value is not None and ( + not isinstance(value, list) or not all(isinstance(item, str) for item in value) + ): + errors.append(f"dependencies.{key} must be a YAML list of package patterns") rr = deps.get("require_resolution") if rr is not None and rr not in _VALID_REQUIRE_RESOLUTION: errors.append( @@ -452,7 +469,7 @@ def load_policy(source: str | Path) -> tuple[ApmPolicy, list[str]]: errors, warnings = validate_policy(data) if errors: - raise PolicyValidationError(errors) + raise PolicyValidationError(errors, warnings) return _build_policy(data), warnings diff --git a/src/apm_cli/runtime/base.py b/src/apm_cli/runtime/base.py index ed8d6433e..3e1f1ca92 100644 --- a/src/apm_cli/runtime/base.py +++ b/src/apm_cli/runtime/base.py @@ -1,11 +1,44 @@ """Base runtime adapter interface for APM.""" +import os +import queue +import signal import subprocess +import threading +import time from abc import ABC, abstractmethod +from contextlib import suppress from typing import Any -def _stream_subprocess_output(cmd: list, timeout: int | None = None) -> tuple[list, int]: +def _terminate_and_reap(process: subprocess.Popen) -> None: + """Terminate a runtime process group and always reap the parent.""" + pid = getattr(process, "pid", None) + try: + if os.name != "nt" and isinstance(pid, int) and pid > 0: + os.killpg(pid, signal.SIGTERM) + else: + process.terminate() + process.wait(timeout=1) + except (OSError, TypeError, subprocess.TimeoutExpired): + try: + if os.name != "nt" and isinstance(pid, int) and pid > 0: + os.killpg(pid, signal.SIGKILL) + else: + process.kill() + except (OSError, TypeError): + pass + with suppress(subprocess.TimeoutExpired): + process.wait(timeout=1) + finally: + if process.stdout is not None: + process.stdout.close() + + +def _stream_subprocess_output( + cmd: list, + timeout: float | None = None, +) -> tuple[list, int]: """Run *cmd* as a subprocess, stream stdout in real-time, and return output. Args: @@ -25,22 +58,62 @@ def _stream_subprocess_output(cmd: list, timeout: int | None = None) -> tuple[li text=True, encoding="utf-8", bufsize=1, # Line buffered + start_new_session=os.name != "nt", ) - output_lines = [] - - for line in iter(process.stdout.readline, ""): - print(line, end="", flush=True) - output_lines.append(line) - + output_lines: list[str] = [] + stream_queue: queue.Queue[str | None] = queue.Queue(maxsize=1024) + cancelled = threading.Event() + + def _queue_item(item: str | None) -> None: + while not cancelled.is_set(): + try: + stream_queue.put(item, timeout=0.1) + return + except queue.Full: + continue + + def _read_output() -> None: + try: + if process.stdout is not None: + for line in iter(process.stdout.readline, ""): + _queue_item(line) + finally: + _queue_item(None) + + reader = threading.Thread(target=_read_output, daemon=True) + reader.start() + + def _expire() -> None: + cancelled.set() + _terminate_and_reap(process) + reader.join(timeout=1) + + deadline = time.monotonic() + timeout if timeout is not None else None + stream_closed = False + while not stream_closed: + remaining = None if deadline is None else deadline - time.monotonic() + if remaining is not None and remaining <= 0: + _expire() + raise subprocess.TimeoutExpired(cmd, timeout, output="".join(output_lines)) + try: + item = stream_queue.get(timeout=remaining) + except queue.Empty: + _expire() + raise subprocess.TimeoutExpired(cmd, timeout, output="".join(output_lines)) from None + if item is None: + stream_closed = True + continue + print(item, end="", flush=True) + output_lines.append(item) + + reader.join(timeout=1) + remaining = None if deadline is None else max(0.0, deadline - time.monotonic()) try: - return_code = process.wait(timeout=timeout) + return_code = process.wait(timeout=remaining) except subprocess.TimeoutExpired: - process.kill() - for line in iter(process.stdout.readline, ""): - output_lines.append(line) - process.stdout.close() - raise + _expire() + raise subprocess.TimeoutExpired(cmd, timeout, output="".join(output_lines)) from None return output_lines, return_code diff --git a/src/apm_cli/runtime/codex_runtime.py b/src/apm_cli/runtime/codex_runtime.py index 44fbb6c99..77d81b822 100644 --- a/src/apm_cli/runtime/codex_runtime.py +++ b/src/apm_cli/runtime/codex_runtime.py @@ -3,7 +3,7 @@ import subprocess from typing import Any -from .base import RuntimeAdapter +from .base import RuntimeAdapter, _stream_subprocess_output from .utils import find_runtime_binary @@ -38,26 +38,11 @@ def execute_prompt(self, prompt_content: str, **kwargs) -> str: # Use codex exec to execute the prompt with real-time streaming # Always skip git repo check when running from APM codex_binary = find_runtime_binary("codex") or "codex" - process = subprocess.Popen( + output_lines, return_code = _stream_subprocess_output( [codex_binary, "exec", "--skip-git-repo-check", prompt_content], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, # Merge stderr into stdout for streaming - text=True, - encoding="utf-8", - bufsize=1, # Line buffered + timeout=300, ) - output_lines = [] - - # Stream output in real-time - for line in iter(process.stdout.readline, ""): - # Print to terminal in real-time - print(line, end="", flush=True) - output_lines.append(line) - - # Wait for process to complete - return_code = process.wait(timeout=300) # 5 minute timeout - if return_code != 0: full_output = "".join(output_lines) # Check for common API key issues @@ -71,8 +56,6 @@ def execute_prompt(self, prompt_content: str, **kwargs) -> str: return "".join(output_lines).strip() except subprocess.TimeoutExpired: - if "process" in locals(): - process.kill() raise RuntimeError("Codex execution timed out after 5 minutes") # noqa: B904 except FileNotFoundError: raise RuntimeError("Codex CLI not found. Install with: npm i -g @openai/codex@native") # noqa: B904 diff --git a/src/apm_cli/runtime/factory.py b/src/apm_cli/runtime/factory.py index 391f2db13..854f493af 100644 --- a/src/apm_cli/runtime/factory.py +++ b/src/apm_cli/runtime/factory.py @@ -3,21 +3,23 @@ from typing import Any from .base import RuntimeAdapter -from .codex_runtime import CodexRuntime -from .copilot_runtime import CopilotRuntime -from .llm_runtime import LLMRuntime +from .registry import adapter_descriptors class RuntimeFactory: """Factory for creating runtime adapters with auto-detection.""" - # Registry of available runtime adapters in order of preference + # Compatibility projection for callers that patch the old test seam. + # The canonical authority is runtime.registry.RUNTIME_DESCRIPTORS. _RUNTIME_ADAPTERS: list[type[RuntimeAdapter]] = [ # noqa: RUF012 - CopilotRuntime, # Prefer Copilot CLI for its native MCP and advanced features - CodexRuntime, # Fallback to Codex for its native MCP support - LLMRuntime, # Final fallback to LLM library + descriptor.adapter for descriptor in adapter_descriptors() if descriptor.adapter is not None ] + @classmethod + def adapter_classes(cls) -> tuple[type[RuntimeAdapter], ...]: + """Return runtime adapters in canonical preference order.""" + return tuple(cls._RUNTIME_ADAPTERS) + @classmethod def get_available_runtimes(cls) -> list[dict[str, Any]]: """Get list of available runtimes on the system. @@ -27,7 +29,7 @@ def get_available_runtimes(cls) -> list[dict[str, Any]]: """ available = [] - for adapter_class in cls._RUNTIME_ADAPTERS: + for adapter_class in cls.adapter_classes(): if adapter_class.is_available(): try: # Create a temporary instance to get runtime info @@ -63,7 +65,7 @@ def get_runtime_by_name( Raises: ValueError: If runtime not found or not available """ - for adapter_class in cls._RUNTIME_ADAPTERS: + for adapter_class in cls.adapter_classes(): if adapter_class.get_runtime_name() == runtime_name: if not adapter_class.is_available(): raise ValueError(f"Runtime '{runtime_name}' is not available on this system") @@ -88,7 +90,7 @@ def get_best_available_runtime(cls, model_name: str | None = None) -> RuntimeAda Raises: RuntimeError: If no runtimes are available """ - for adapter_class in cls._RUNTIME_ADAPTERS: + for adapter_class in cls.adapter_classes(): if adapter_class.is_available(): try: if model_name: diff --git a/src/apm_cli/runtime/manager.py b/src/apm_cli/runtime/manager.py index 4ad505bdc..df34c610e 100644 --- a/src/apm_cli/runtime/manager.py +++ b/src/apm_cli/runtime/manager.py @@ -14,6 +14,7 @@ from colorama import Fore, Style from ..core.token_manager import setup_runtime_environment +from .registry import get_runtime_descriptor, runtime_descriptors class RuntimeManager: @@ -27,26 +28,12 @@ def __init__(self): self.runtime_dir = Path.home() / ".apm" / "runtimes" ext = ".ps1" if sys.platform == "win32" else ".sh" self.supported_runtimes = { - "copilot": { - "script": f"setup-copilot{ext}", - "description": "GitHub Copilot CLI with native MCP integration", - "binary": "copilot", - }, - "codex": { - "script": f"setup-codex{ext}", - "description": "OpenAI Codex CLI with GitHub Models support", - "binary": "codex", - }, - "llm": { - "script": f"setup-llm{ext}", - "description": "Simon Willison's LLM library with multiple providers", - "binary": "llm", - }, - "gemini": { - "script": f"setup-gemini{ext}", - "description": "Google Gemini CLI with MCP integration", - "binary": "gemini", - }, + descriptor.name: { + "script": f"{descriptor.setup_script}{ext}", + "description": descriptor.description, + "binary": descriptor.binary, + } + for descriptor in runtime_descriptors() } def get_embedded_script(self, script_name: str) -> str: @@ -327,15 +314,11 @@ def remove_runtime(self, runtime_name: str) -> bool: click.echo(f"{Fore.RED}[x] Unknown runtime: {runtime_name}{Style.RESET_ALL}", err=True) return False - # Handle npm-based runtimes (copilot, gemini) - _npm_packages = { - "copilot": "@github/copilot", - "gemini": "@google/gemini-cli", - } - if runtime_name in _npm_packages: + descriptor = get_runtime_descriptor(runtime_name) + if descriptor.npm_package: try: result = subprocess.run( - ["npm", "uninstall", "-g", _npm_packages[runtime_name]], + ["npm", "uninstall", "-g", descriptor.npm_package], capture_output=True, text=True, encoding="utf-8", @@ -392,7 +375,7 @@ def remove_runtime(self, runtime_name: str) -> bool: def get_runtime_preference(self) -> list[str]: """Get the runtime preference order.""" - return ["copilot", "codex", "gemini", "llm"] + return [descriptor.name for descriptor in runtime_descriptors()] def get_available_runtime(self) -> str | None: """Get the first available runtime based on preference.""" diff --git a/src/apm_cli/runtime/registry.py b/src/apm_cli/runtime/registry.py new file mode 100644 index 000000000..05901e3ae --- /dev/null +++ b/src/apm_cli/runtime/registry.py @@ -0,0 +1,126 @@ +"""Canonical runtime descriptors consumed by every runtime surface.""" + +from __future__ import annotations + +from dataclasses import dataclass +from types import MappingProxyType +from typing import TYPE_CHECKING + +from .codex_runtime import CodexRuntime +from .copilot_runtime import CopilotRuntime +from .llm_runtime import LLMRuntime + +if TYPE_CHECKING: + from .base import RuntimeAdapter + + +@dataclass(frozen=True) +class RuntimeDescriptor: + """Describe one runtime adapter, installer, and command capability.""" + + name: str + binary: str + description: str + preference: int + setup_script: str + adapter: type[RuntimeAdapter] | None = None + npm_package: str | None = None + script_builder: str | None = None + content_argument: str = "positional" + default_command: str | None = None + + +def _build_registry( + descriptors: tuple[RuntimeDescriptor, ...], +) -> MappingProxyType[str, RuntimeDescriptor]: + """Validate and freeze runtime descriptors.""" + registry: dict[str, RuntimeDescriptor] = {} + preferences: set[int] = set() + for descriptor in descriptors: + if not descriptor.name or descriptor.name in registry: + raise ValueError(f"Duplicate or empty runtime name: {descriptor.name!r}") + if descriptor.preference in preferences: + raise ValueError(f"Duplicate runtime preference: {descriptor.preference}") + if descriptor.adapter is not None: + adapter_name = descriptor.adapter.get_runtime_name() + if adapter_name != descriptor.name: + raise ValueError( + f"Runtime adapter {adapter_name!r} does not match descriptor " + f"{descriptor.name!r}" + ) + registry[descriptor.name] = descriptor + preferences.add(descriptor.preference) + return MappingProxyType(registry) + + +RUNTIME_DESCRIPTORS = _build_registry( + ( + RuntimeDescriptor( + name="copilot", + binary="copilot", + description="GitHub Copilot CLI with native MCP integration", + preference=10, + setup_script="setup-copilot", + adapter=CopilotRuntime, + npm_package="@github/copilot", + script_builder="_build_copilot_command", + content_argument="prompt_flag", + default_command=( + "copilot --log-level all --log-dir copilot-logs --allow-all-tools -p {prompt_file}" + ), + ), + RuntimeDescriptor( + name="codex", + binary="codex", + description="OpenAI Codex CLI with GitHub Models support", + preference=20, + setup_script="setup-codex", + adapter=CodexRuntime, + script_builder="_build_codex_command", + default_command=("codex -s workspace-write --skip-git-repo-check {prompt_file}"), + ), + RuntimeDescriptor( + name="gemini", + binary="gemini", + description="Google Gemini CLI with MCP integration", + preference=30, + setup_script="setup-gemini", + npm_package="@google/gemini-cli", + script_builder="_build_gemini_command", + content_argument="prompt_flag", + default_command="gemini -p {prompt_file}", + ), + RuntimeDescriptor( + name="llm", + binary="llm", + description="Simon Willison's LLM library with multiple providers", + preference=40, + setup_script="setup-llm", + adapter=LLMRuntime, + script_builder="_build_llm_command", + ), + ) +) + + +def runtime_descriptors() -> tuple[RuntimeDescriptor, ...]: + """Return descriptors in canonical preference order.""" + return tuple(sorted(RUNTIME_DESCRIPTORS.values(), key=lambda item: item.preference)) + + +def runtime_names() -> tuple[str, ...]: + """Return every managed runtime name in preference order.""" + return tuple(descriptor.name for descriptor in runtime_descriptors()) + + +def adapter_descriptors() -> tuple[RuntimeDescriptor, ...]: + """Return descriptors that expose a programmatic runtime adapter.""" + return tuple(descriptor for descriptor in runtime_descriptors() if descriptor.adapter) + + +def get_runtime_descriptor(name: str) -> RuntimeDescriptor: + """Return one descriptor or raise a stable unknown-runtime error.""" + try: + return RUNTIME_DESCRIPTORS[name] + except KeyError: + raise ValueError(f"Unknown runtime: {name}") from None diff --git a/src/apm_cli/security/gate.py b/src/apm_cli/security/gate.py index 84101395b..e158dcf4a 100644 --- a/src/apm_cli/security/gate.py +++ b/src/apm_cli/security/gate.py @@ -119,6 +119,25 @@ def scan_text( findings_by_file[filename] = file_findings return SecurityGate._build_verdict(findings_by_file, 1, policy, force=False) + @staticmethod + def scan_texts( + contents: dict[str, str], + *, + policy: ScanPolicy = BLOCK_POLICY, + ) -> ScanVerdict: + """Scan a complete in-memory output batch with one policy decision.""" + findings_by_file: dict[str, list[ScanFinding]] = {} + for filename, content in contents.items(): + file_findings = ContentScanner.scan_text(content, filename=filename) + if file_findings: + findings_by_file[filename] = file_findings + return SecurityGate._build_verdict( + findings_by_file, + len(contents), + policy, + force=False, + ) + @staticmethod def report( verdict: ScanVerdict, diff --git a/src/apm_cli/workflow/runner.py b/src/apm_cli/workflow/runner.py index 63f04ea2f..0cc788755 100644 --- a/src/apm_cli/workflow/runner.py +++ b/src/apm_cli/workflow/runner.py @@ -154,7 +154,7 @@ def run_workflow(workflow_name, params=None, base_dir=None): # Invalid runtime name - fail with clear error message available_runtimes = [ adapter.get_runtime_name() - for adapter in RuntimeFactory._RUNTIME_ADAPTERS + for adapter in RuntimeFactory.adapter_classes() if adapter.is_available() ] return ( diff --git a/tests/benchmarks/test_scaling_guards.py b/tests/benchmarks/test_scaling_guards.py index 4b6f669c7..aca24886f 100644 --- a/tests/benchmarks/test_scaling_guards.py +++ b/tests/benchmarks/test_scaling_guards.py @@ -19,6 +19,7 @@ from dataclasses import dataclass, field # noqa: F401 from pathlib import Path from typing import Dict, List, Optional # noqa: F401, UP035 +from unittest.mock import patch import pytest @@ -294,7 +295,46 @@ def test_scaling_ratio(self): # --------------------------------------------------------------------------- -# 6. should_exclude scaling with ** patterns +# 6. Deployment manifest reconciliation scaling +# --------------------------------------------------------------------------- + + +def _reconcile_manifest_size(n: int) -> None: + from apm_cli.install.manifest_reconcile import union_preserving + + current = [f"custom/current-{i}.json" for i in range(n // 2)] + prior = [*current, *(f"custom/prior-{i}.json" for i in range(n // 2))] + hashes = {path: f"sha256:{'ab' * 32}" for path in prior} + with patch("apm_cli.integration.targets.KNOWN_TARGETS", {}): + union_preserving( + current, + {path: hashes[path] for path in current}, + prior, + hashes, + targets=[], + ) + + +class TestDeploymentManifestReconcileScaling: + """Manifest union must stay linear in the number of deployed paths.""" + + def test_scaling_ratio(self): + t_small = _median_time(lambda: _reconcile_manifest_size(1000), repeats=3) + t_large = _median_time(lambda: _reconcile_manifest_size(10000), repeats=3) + + if t_small < 1e-7: + pytest.skip("below measurement threshold -- too fast to measure reliably") + + ratio = t_large / t_small + assert ratio < 25, ( + f"Scaling ratio {ratio:.1f}x for 10x input suggests " + f"O(n^2) regression (t_small={t_small:.6f}s, " + f"t_large={t_large:.6f}s)" + ) + + +# --------------------------------------------------------------------------- +# 7. should_exclude scaling with ** patterns # --------------------------------------------------------------------------- diff --git a/tests/integration/test_ado_ref_resolution_fallback.py b/tests/integration/test_ado_ref_resolution_fallback.py new file mode 100644 index 000000000..bc850985d --- /dev/null +++ b/tests/integration/test_ado_ref_resolution_fallback.py @@ -0,0 +1,131 @@ +"""Hermetic integration coverage for ADO ref-resolution credential retry.""" + +from types import SimpleNamespace +from unittest.mock import patch + +from apm_cli.core.auth import AuthResolver +from apm_cli.install.phases.resolve import _maybe_resolve_git_semver +from apm_cli.models.dependency.reference import DependencyReference + + +def test_ado_semver_ref_resolution_retries_stale_pat_with_cli_bearer( + monkeypatch, +) -> None: + """Tag resolution succeeds when ADO rejects PAT and accepts az bearer.""" + monkeypatch.setenv("ADO_APM_PAT", "stale-test-pat") + provider = SimpleNamespace( + is_available=lambda: True, + get_bearer_token=lambda: "fresh-test-bearer", + ) + attempts = [] + + def _run(args, **kwargs): + auth_values = { + value for key, value in kwargs["env"].items() if key.startswith("GIT_CONFIG_VALUE_") + } + scheme = "bearer" if any("Bearer " in value for value in auth_values) else "basic" + attempts.append(scheme) + if scheme == "bearer": + return SimpleNamespace( + returncode=0, + stdout=f"{'b' * 40}\trefs/tags/v2.0.0\n", + stderr="", + ) + return SimpleNamespace( + returncode=128, + stdout="", + stderr="fatal: The requested URL returned error: 401", + ) + + dep = DependencyReference( + host="dev.azure.com", + repo_url="example/project/_git/package", + reference="^2.0.0", + source="git", + explicit_scheme="https", + ) + with ( + patch("apm_cli.core.azure_cli.get_bearer_provider", return_value=provider), + patch("apm_cli.marketplace.ref_resolver.subprocess.run", side_effect=_run), + ): + resolution = _maybe_resolve_git_semver( + dep_ref=dep, + existing_lockfile=None, + update_refs=True, + auth_resolver=AuthResolver(), + ) + + assert resolution.resolved_tag == "v2.0.0" + assert attempts == ["basic", "bearer"] + + +def test_bearer_fallback_scrubs_inherited_authorization_header( + monkeypatch, +) -> None: + """Fallback keeps the scrubbed context and exactly one auth header.""" + sentinel = "Basic AAAA_INHERITED_SENTINEL_AAAA" + monkeypatch.setenv("GIT_CONFIG_COUNT", "2") + monkeypatch.setenv("GIT_CONFIG_KEY_0", "core.autocrlf") + monkeypatch.setenv("GIT_CONFIG_VALUE_0", "false") + monkeypatch.setenv("GIT_CONFIG_KEY_1", "http.extraheader") + monkeypatch.setenv("GIT_CONFIG_VALUE_1", f"Authorization: {sentinel}") + monkeypatch.setenv("ADO_APM_PAT", "stale-test-pat") + monkeypatch.setenv("SAFE_CONTEXT_SENTINEL", "preserved") + + provider = SimpleNamespace( + is_available=lambda: True, + get_bearer_token=lambda: "fresh-test-bearer", + ) + + fallback_envs: list[dict[str, str]] = [] + + def _run(args, **kwargs): + env = kwargs["env"] + auth_values = [ + value + for key, value in env.items() + if key.startswith("GIT_CONFIG_VALUE_") and "Authorization:" in value + ] + if any("Bearer " in value for value in auth_values): + fallback_envs.append(dict(env)) + return SimpleNamespace( + returncode=0, + stdout=f"{'a' * 40}\trefs/tags/v3.0.0\n", + stderr="", + ) + return SimpleNamespace( + returncode=128, + stdout="", + stderr="fatal: The requested URL returned error: 401", + ) + + dep = DependencyReference( + host="dev.azure.com", + repo_url="example/project/_git/package", + reference="^3.0.0", + source="git", + explicit_scheme="https", + ) + with ( + patch("apm_cli.core.azure_cli.get_bearer_provider", return_value=provider), + patch("apm_cli.marketplace.ref_resolver.subprocess.run", side_effect=_run), + ): + resolution = _maybe_resolve_git_semver( + dep_ref=dep, + existing_lockfile=None, + update_refs=True, + auth_resolver=AuthResolver(), + ) + + assert resolution.resolved_tag == "v3.0.0" + assert len(fallback_envs) == 1 + + env = fallback_envs[0] + auth_headers = [ + value + for key, value in env.items() + if key.startswith("GIT_CONFIG_VALUE_") and "Authorization:" in value + ] + assert not any(sentinel in header for header in auth_headers) + assert len(auth_headers) == 1 + assert env["SAFE_CONTEXT_SENTINEL"] == "preserved" diff --git a/tests/integration/test_architecture_authorities.py b/tests/integration/test_architecture_authorities.py new file mode 100644 index 000000000..d84579796 --- /dev/null +++ b/tests/integration/test_architecture_authorities.py @@ -0,0 +1,201 @@ +"""Integration guardrails for canonical architecture authorities.""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path + +import pytest + + +def test_plural_targets_drive_bundle_filtering(tmp_path: Path) -> None: + """The canonical manifest target list must control bundle packing.""" + from apm_cli.bundle.packer import pack_bundle + from apm_cli.deps.lockfile import LockedDependency, LockFile + + (tmp_path / "apm.yml").write_text( + "name: target-authority\nversion: 1.0.0\ntargets:\n - claude\n", + encoding="utf-8", + ) + claude_file = ".claude/commands/keep.md" + copilot_file = ".github/prompts/drop.prompt.md" + for relative in (claude_file, copilot_file): + path = tmp_path / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("content", encoding="utf-8") + lockfile = LockFile( + dependencies={ + "owner/dep": LockedDependency( + repo_url="https://github.com/owner/dep", + deployed_files=[claude_file, copilot_file], + ) + } + ) + (tmp_path / "apm.lock.yaml").write_text(lockfile.to_yaml(), encoding="utf-8") + + result = pack_bundle(tmp_path, tmp_path / "out", dry_run=True) + + assert result.files == [claude_file] + + +def test_target_catalog_matches_native_profiles() -> None: + """Every deployable target capability must have one native profile.""" + from apm_cli.core.target_catalog import TARGET_CAPABILITIES + from apm_cli.integration.targets import KNOWN_TARGETS + + expected = { + capability.name + for capability in TARGET_CAPABILITIES.values() + if capability.primitive_profile is not None and not capability.mcp_only + } + assert set(KNOWN_TARGETS) == expected + + +@pytest.mark.parametrize( + ("target_flag", "expected_targets"), + ( + ("claude,copilot", ["claude", "copilot"]), + ("agents", ["copilot"]), + ), +) +def test_init_persists_only_install_accepted_catalog_targets( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + target_flag: str, + expected_targets: list[str], +) -> None: + """Every target accepted by init must produce an installable manifest.""" + from click.testing import CliRunner + + from apm_cli.cli import cli + from apm_cli.models.apm_package import APMPackage + from apm_cli.utils.yaml_io import load_yaml + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("apm_cli.cli._check_and_notify_updates", lambda: None) + runner = CliRunner() + + initialized = runner.invoke(cli, ["init", "--yes", "--target", target_flag]) + + assert initialized.exit_code == 0, initialized.output + manifest = load_yaml(tmp_path / "apm.yml") + assert manifest["targets"] == expected_targets + assert APMPackage.from_apm_yml(tmp_path / "apm.yml").canonical_targets == tuple( + expected_targets + ) + + installed = runner.invoke(cli, ["install"]) + assert installed.exit_code == 0, installed.output + + +def test_host_provider_registry_drives_auth_and_backends() -> None: + """Auth classification and native backends must cover one provider set.""" + from apm_cli.core.auth import AuthResolver + from apm_cli.core.host_providers import ( + HOST_PROVIDERS, + host_backend_factory, + ) + + samples = { + "github": ("github.com", None), + "ghe_cloud": ("tenant.ghe.com", None), + "ado": ("dev.azure.com", None), + "gitlab": ("code.example.test", "gitlab"), + "generic": ("git.example.test", None), + } + for kind, (host, host_type) in samples.items(): + info = AuthResolver.classify_host(host, host_type=host_type) + assert info.kind == kind + assert host_backend_factory(kind)(host_info=info).kind == kind + assert set(samples).issubset(HOST_PROVIDERS) + + +def test_host_type_hint_cannot_override_recognized_provider() -> None: + """Manifest hints must not redirect credentials across known hosts.""" + from apm_cli.core.auth import AuthResolver + + for host in ("github.com", "tenant.ghe.com", "dev.azure.com"): + try: + AuthResolver.classify_host(host, host_type="gitlab") + except ValueError as exc: + assert "conflicts" in str(exc) + else: + raise AssertionError(f"host type override unexpectedly accepted for {host}") + + +def test_runtime_registry_drives_factory_manager_cli_and_runner() -> None: + """Every runtime consumer must project the canonical descriptors.""" + from apm_cli.commands.runtime import setup + from apm_cli.core.script_runner import ScriptRunner + from apm_cli.runtime.factory import RuntimeFactory + from apm_cli.runtime.manager import RuntimeManager + from apm_cli.runtime.registry import adapter_descriptors, runtime_names + + names = runtime_names() + manager = RuntimeManager() + runtime_argument = next(param for param in setup.params if param.name == "runtime_name") + cli_choices = tuple(runtime_argument.type.choices) + adapter_classes = tuple( + descriptor.adapter for descriptor in adapter_descriptors() if descriptor.adapter is not None + ) + + assert tuple(manager.supported_runtimes) == names + assert manager.get_runtime_preference() == list(names) + assert set(cli_choices) == set(names) + assert RuntimeFactory.adapter_classes() == adapter_classes + runner = ScriptRunner() + assert all(runner._detect_runtime(f"{name} run") == name for name in names) + + +def test_target_profile_owns_external_locator_encoding(tmp_path: Path) -> None: + """Install helpers must use target locator metadata without name branches.""" + from apm_cli.install.deployed_paths import deployed_path_entry + from apm_cli.install.manifest_reconcile import install_governance + from apm_cli.integration.targets import KNOWN_TARGETS + + deploy_root = tmp_path / "OneDrive" / "Documents" / "Cowork" / "skills" + target = replace( + KNOWN_TARGETS["copilot-cowork"], + resolved_deploy_root=deploy_root, + ) + deployed = deploy_root / "demo" / "SKILL.md" + + assert ( + deployed_path_entry(deployed, tmp_path / "project", [target]) + == "cowork://skills/demo/SKILL.md" + ) + _, schemes = install_governance([target]) + assert schemes == {"cowork://"} + + +def test_lockfile_builder_delegates_package_claim_policy() -> None: + """Lockfile assembly must consume the deployment owner's decision.""" + root = Path(__file__).parents[2] + source = (root / "src/apm_cli/install/phases/lockfile.py").read_text() + guard = (root / "scripts/lint-architecture-boundaries.sh").read_text() + + assert "DeploymentReconciler.reconcile_package_claims" in source + assert "Deployment claim handoff belongs to DeploymentReconciler" in guard + for duplicate in ( + "def reconcile_cross_package_deployed_files", + "all_current_deployed", + "other_current", + ): + assert duplicate not in source + + +def test_dependency_winner_selection_has_one_algorithm() -> None: + """Dispatch and flattening must consume one deterministic selector.""" + root = Path(__file__).parents[2] + source = (root / "src/apm_cli/deps/apm_resolver.py").read_text() + guard = (root / "scripts/lint-architecture-boundaries.sh").read_text() + + assert source.count("_select_dependency_winners(") == 3 + assert "Dependency ref winner selection must use one helper" in guard + for duplicate in ( + "download_winners", + "level_winners", + "seen_keys", + "nodes_at_depth.sort", + ): + assert duplicate not in source diff --git a/tests/integration/test_architecture_concurrency_guards.py b/tests/integration/test_architecture_concurrency_guards.py new file mode 100644 index 000000000..5576bdc49 --- /dev/null +++ b/tests/integration/test_architecture_concurrency_guards.py @@ -0,0 +1,77 @@ +"""Integration guardrails for runtime deadlines and registry concurrency.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +import pytest + + +def test_streaming_runtime_is_killed_at_wall_clock_deadline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A process holding stdout open must not defer timeout enforcement.""" + from apm_cli.runtime import base + + real_popen = subprocess.Popen + captured: list[subprocess.Popen] = [] + + def recording_popen(*args, **kwargs): + process = real_popen(*args, **kwargs) + captured.append(process) + return process + + monkeypatch.setattr(base.subprocess, "Popen", recording_popen) + started = time.monotonic() + with pytest.raises(subprocess.TimeoutExpired): + base._stream_subprocess_output( + [ + sys.executable, + "-c", + "import sys,time; print('ready', flush=True); time.sleep(30)", + ], + timeout=0.2, + ) + elapsed = time.monotonic() - started + + assert elapsed < 2 + assert len(captured) == 1 + assert captured[0].poll() is not None + + +def test_concurrent_marketplace_writers_preserve_every_entry(tmp_path: Path) -> None: + """Independent processes must serialize registry load-modify-save.""" + code = ( + "import sys;" + "from apm_cli.marketplace.models import MarketplaceSource;" + "from apm_cli.marketplace.registry import add_marketplace;" + "name=sys.argv[1];" + "add_marketplace(MarketplaceSource(name=name,url='https://example.test/'+name+'.git'))" + ) + env = os.environ.copy() + env["HOME"] = str(tmp_path) + names = [f"catalog-{index}" for index in range(8)] + processes = [ + subprocess.Popen( + [sys.executable, "-c", code, name], + cwd=Path(__file__).parents[2], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + for name in names + ] + for process in processes: + stdout, stderr = process.communicate(timeout=20) + assert process.returncode == 0, f"{stdout}\n{stderr}" + + registry_path = tmp_path / ".apm" / "marketplaces.json" + data = json.loads(registry_path.read_text(encoding="utf-8")) + recorded = {entry["name"] for entry in data["marketplaces"]} + assert recorded == set(names) diff --git a/tests/integration/test_architecture_contract_guards.py b/tests/integration/test_architecture_contract_guards.py new file mode 100644 index 000000000..e8ec8064a --- /dev/null +++ b/tests/integration/test_architecture_contract_guards.py @@ -0,0 +1,193 @@ +"""Integration guardrails for neutral IR and explicit schema contracts.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +def _write_portable_hook_package(tmp_path: Path) -> object: + """Create one package carrying the flat portable hook shape.""" + from apm_cli.models.apm_package import APMPackage, PackageInfo + + package_root = tmp_path / "apm_modules" / "owner" / "portable-hooks" + hooks_dir = package_root / ".apm" / "hooks" + hooks_dir.mkdir(parents=True) + (hooks_dir / "check.json").write_text( + json.dumps( + { + "hooks": { + "PreToolUse": [ + { + "type": "command", + "command": "echo check", + "timeout": 10, + "matcher": "fs_write|str_replace", + } + ] + } + } + ), + encoding="utf-8", + ) + return PackageInfo( + package=APMPackage(name="portable-hooks", version="1.0.0"), + install_path=package_root, + ) + + +def test_neutral_hook_intent_translates_at_native_edges() -> None: + """One portable timeout must render in each target's native unit.""" + from apm_cli.integration.hook_native_formats import ( + _to_antigravity_hook_entries, + _to_gemini_hook_entries, + ) + + source = [{"command": "echo ok", "timeoutSec": 3}] + + gemini = _to_gemini_hook_entries(source) + antigravity = _to_antigravity_hook_entries(source, "PreInvocation") + + assert gemini[0]["hooks"][0]["timeout"] == 3000 + assert antigravity[0]["timeout"] == 3 + + +def test_claude_edge_nests_flat_portable_hook_entries(tmp_path: Path) -> None: + """Claude output must wrap portable handlers in matcher/hooks entries.""" + from apm_cli.integration.hook_integrator import HookIntegrator + from apm_cli.integration.targets import KNOWN_TARGETS + + (tmp_path / ".claude").mkdir() + package_info = _write_portable_hook_package(tmp_path) + + HookIntegrator().integrate_hooks_for_target( + KNOWN_TARGETS["claude"], + package_info, + tmp_path, + ) + + settings = json.loads((tmp_path / ".claude" / "settings.json").read_text(encoding="utf-8")) + assert settings["hooks"]["PreToolUse"] == [ + { + "matcher": "fs_write|str_replace", + "hooks": [ + { + "type": "command", + "command": "echo check", + "timeout": 10, + } + ], + } + ] + + +def test_kiro_edge_emits_current_v1_hook_schema(tmp_path: Path) -> None: + """Kiro output must use v1 trigger/matcher/action documents.""" + from apm_cli.integration.hook_integrator import HookIntegrator + from apm_cli.integration.targets import KNOWN_TARGETS + + (tmp_path / ".kiro").mkdir() + package_info = _write_portable_hook_package(tmp_path) + + result = HookIntegrator().integrate_hooks_for_target( + KNOWN_TARGETS["kiro"], + package_info, + tmp_path, + ) + + assert len(result.target_paths) == 1 + payload = json.loads(result.target_paths[0].read_text(encoding="utf-8")) + assert payload == { + "version": "v1", + "hooks": [ + { + "name": "portable-hooks PreToolUse 1", + "trigger": "PreToolUse", + "matcher": "fs_write|str_replace", + "action": { + "type": "command", + "command": "echo check", + "timeout": 10, + }, + } + ], + } + + +def test_neutral_hook_ir_snapshots_metadata() -> None: + """Frozen portable intent must not alias mutable native dictionaries.""" + from apm_cli.integration.hook_ir import HookHandler + + metadata = {"args": ["one"]} + handler = HookHandler(command="echo ok", metadata=metadata) + metadata["args"].append("two") + + assert handler.metadata["args"] == ("one",) + with pytest.raises(TypeError): + handler.metadata["new"] = "value" + + +def test_manifest_schema_negotiates_normative_v01_registry_shape( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Explicit v0.1 identity must select its normative registry parser.""" + from apm_cli.models.apm_package import APMPackage + from apm_cli.models.manifest_contract import OPENAPM_V01_SCHEMA_URI + + monkeypatch.setattr( + "apm_cli.deps.registry.feature_gate.require_package_registry_enabled", + lambda _feature: None, + ) + manifest = tmp_path / "apm.yml" + manifest.write_text( + "\n".join( + ( + f"$schema: {OPENAPM_V01_SCHEMA_URI}", + "name: demo", + "version: 1.0.0", + "registries:", + " default: internal", + " internal: https://registry.example.test/apm", + ) + ) + + "\n", + encoding="utf-8", + ) + + package = APMPackage.from_apm_yml(manifest) + + assert package.manifest_contract == "openapm-v0.1" + assert package.registries == {"internal": "https://registry.example.test/apm"} + assert package.default_registry == "internal" + + +def test_unknown_manifest_schema_identity_fails_closed(tmp_path: Path) -> None: + """A future schema cannot be silently interpreted as the working draft.""" + from apm_cli.models.apm_package import APMPackage + from apm_cli.models.manifest_contract import UnsupportedManifestContractError + + manifest = tmp_path / "apm.yml" + manifest.write_text( + "$schema: https://example.test/openapm-v9.json\nname: demo\nversion: 1.0.0\n", + encoding="utf-8", + ) + + with pytest.raises(UnsupportedManifestContractError): + APMPackage.from_apm_yml(manifest) + + +def test_lifecycle_docs_match_explicit_compilation_contract() -> None: + """The lifecycle page must state the same install/compile ownership.""" + lifecycle = ( + Path(__file__).parents[2] + / "docs" + / "src" + / "content" + / "docs" + / "concepts" + / "lifecycle.md" + ).read_text(encoding="utf-8") + + assert "does not run aggregate\ncompilation" in lifecycle diff --git a/tests/integration/test_architecture_intent_guards.py b/tests/integration/test_architecture_intent_guards.py new file mode 100644 index 000000000..419cce678 --- /dev/null +++ b/tests/integration/test_architecture_intent_guards.py @@ -0,0 +1,129 @@ +"""Integration guardrails for preserving declared dependency intent.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + + +def test_incompatible_refs_survive_to_conflict_selection(tmp_path: Path) -> None: + """Two constraints for one package must be reported, not queue-deduped.""" + from apm_cli.deps.apm_resolver import APMDependencyResolver + + (tmp_path / "apm.yml").write_text( + "\n".join( + ( + "name: root", + "version: 1.0.0", + "dependencies:", + " apm:", + " - owner/shared#v1", + " - owner/shared#v2", + ) + ) + + "\n", + encoding="utf-8", + ) + + graph = APMDependencyResolver(max_parallel=1).resolve_dependencies(tmp_path) + + assert graph.has_conflicts() + conflict = graph.flattened_dependencies.conflicts[0] + assert conflict.winner.reference == "v1" + assert [item.reference for item in conflict.conflicts] == ["v2"] + + +def test_transitive_local_identity_includes_parent_and_anchor(tmp_path: Path) -> None: + """Equal relative paths from different parents must have distinct identity.""" + from apm_cli.models.dependency.reference import DependencyReference + + first = DependencyReference( + repo_url="_local/shared", + is_local=True, + local_path="../shared", + source="local", + declaring_parent="owner/parent-a#main", + anchored_local_path=str(tmp_path / "a" / "shared"), + ) + second = DependencyReference( + repo_url="_local/shared", + is_local=True, + local_path="../shared", + source="local", + declaring_parent="owner/parent-b#main", + anchored_local_path=str(tmp_path / "b" / "shared"), + ) + same_physical = DependencyReference( + repo_url="_local/shared", + is_local=True, + local_path="../shared", + source="local", + declaring_parent="owner/parent-c#main", + anchored_local_path=str(tmp_path / "a" / "shared"), + ) + + assert first.get_unique_key() != second.get_unique_key() + assert first.get_install_path(tmp_path / "apm_modules") != second.get_install_path( + tmp_path / "apm_modules" + ) + assert first.get_unique_key() == same_physical.get_unique_key() + assert first.get_install_path(tmp_path / "apm_modules") == same_physical.get_install_path( + tmp_path / "apm_modules" + ) + + +def test_configured_mcp_registry_url_is_used(monkeypatch) -> None: + """The URL shown by the command must be the URL passed to its client.""" + from apm_cli.commands import mcp + + captured: list[str | None] = [] + + class FakeRegistry: + def __init__(self, registry_url=None): + captured.append(registry_url) + self.client = MagicMock(registry_url=registry_url) + + monkeypatch.delenv(mcp.MCP_REGISTRY_ENV, raising=False) + monkeypatch.setattr("apm_cli.config.get_mcp_registry_url", lambda: "https://registry.test/v0") + monkeypatch.setattr("apm_cli.registry.integration.RegistryIntegration", FakeRegistry) + + registry = mcp._build_registry_with_diag(None, MagicMock()) + + assert captured == ["https://registry.test/v0"] + assert registry.client.registry_url == captured[0] + + +def test_marketplace_registry_routing_returns_registry_dependency(monkeypatch) -> None: + """Registry intent must reach the package-registry resolver contract.""" + from apm_cli.marketplace.models import ( + MarketplaceManifest, + MarketplacePlugin, + MarketplaceSource, + ) + from apm_cli.marketplace.resolver import resolve_marketplace_plugin + + source = MarketplaceSource(name="catalog", url="https://example.test/catalog.git") + manifest = MarketplaceManifest( + name="catalog", + plugins=( + MarketplacePlugin( + name="owner/tool", + source={"type": "github", "repo": "owner/registry-tool"}, + version="^1.2.0", + registry="internal", + ), + ), + ) + monkeypatch.setattr( + "apm_cli.marketplace.resolver.get_marketplace_by_name", lambda _name: source + ) + monkeypatch.setattr("apm_cli.marketplace.resolver.fetch_or_cache", lambda *_a, **_k: manifest) + + resolution = resolve_marketplace_plugin("owner/tool", "catalog") + + dep = resolution.dependency_reference + assert dep is not None + assert dep.source == "registry" + assert dep.repo_url == "owner/registry-tool" + assert dep.registry_name == "internal" + assert dep.reference == "^1.2.0" diff --git a/tests/integration/test_architecture_io_guards.py b/tests/integration/test_architecture_io_guards.py new file mode 100644 index 000000000..09d407785 --- /dev/null +++ b/tests/integration/test_architecture_io_guards.py @@ -0,0 +1,125 @@ +"""Integration guardrails for process-wide output and credential I/O.""" + +from __future__ import annotations + +import io +import json +import logging +from pathlib import Path + + +def test_sbom_stdout_stays_machine_pure_when_update_is_available( + tmp_path: Path, monkeypatch +) -> None: + """Root output mode must route pre-command notifications off stdout.""" + from click.testing import CliRunner + + from apm_cli.cli import cli + from apm_cli.deps.lockfile import LockFile + from apm_cli.utils.console import _reset_console, _rich_warning + + (tmp_path / "apm.yml").write_text("name: demo\nversion: 1.0.0\n", encoding="utf-8") + LockFile().write(tmp_path / "apm.lock.yaml") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + "apm_cli.cli._check_and_notify_updates", + lambda: _rich_warning("A new version is available", symbol="warning"), + ) + _reset_console() + + result = CliRunner().invoke(cli, ["lock", "export"]) + + assert result.exit_code == 0, result.output + document = json.loads(result.stdout) + assert result.stdout.startswith("{") + assert document["bomFormat"] == "CycloneDX" + assert "A new version is available" not in result.stdout + assert "A new version is available" in result.stderr + + +def test_descendant_logger_records_are_redacted(monkeypatch) -> None: + """Handler-level redaction must cover every apm_cli descendant logger.""" + from apm_cli.cli import _configure_logging + + root = logging.getLogger() + previous_handlers = list(root.handlers) + stream = io.StringIO() + handler = logging.StreamHandler(stream) + monkeypatch.setattr(root, "handlers", [handler]) + + _configure_logging(verbose=True) + logging.getLogger("apm_cli.integration.mcp_integrator").warning("authorization: secret-value") + try: + raise RuntimeError("authorization: traceback-secret") + except RuntimeError: + logging.getLogger("apm_cli.core.auth").exception("request failed") + + rendered = stream.getvalue() + assert "secret-value" not in rendered + assert "traceback-secret" not in rendered + assert "[REDACTED]" in rendered + root.handlers = previous_handlers + + +def test_unauthenticated_retry_receives_credential_free_environment( + monkeypatch, +) -> None: + """Authenticated state must not leak into a plain retry.""" + from apm_cli.core.auth import AuthContext, AuthResolver + + monkeypatch.setenv("GIT_TOKEN", "stale-token") + monkeypatch.setenv("GIT_HTTP_EXTRAHEADER", "Authorization: secret") + monkeypatch.setenv("GIT_CONFIG_COUNT", "2") + monkeypatch.setenv("GIT_CONFIG_KEY_0", "http.extraHeader") + monkeypatch.setenv("GIT_CONFIG_VALUE_0", "Authorization: secret") + monkeypatch.setenv("GIT_CONFIG_KEY_1", "http.sslCAInfo") + monkeypatch.setenv("GIT_CONFIG_VALUE_1", "/corporate/ca.pem") + monkeypatch.setenv( + "GIT_CONFIG_PARAMETERS", + "'http.extraHeader=Authorization: inherited-secret'", + ) + resolver = AuthResolver() + host_info = resolver.classify_host("github.com") + auth_env = resolver._build_git_env("active-token", host_kind=host_info.kind) + monkeypatch.setattr( + resolver, + "resolve", + lambda *_a, **_k: AuthContext( + token="active-token", + source="test", + token_type="classic", + host_info=host_info, + git_env=auth_env, + ), + ) + attempts: list[tuple[str | None, dict]] = [] + + def operation(token, env): + attempts.append((token, env)) + if token is not None: + raise RuntimeError("retry anonymously") + return "ok" + + assert resolver.try_with_fallback("github.com", operation) == "ok" + token, retry_env = attempts[-1] + assert token is None + assert "GIT_TOKEN" not in retry_env + assert "GIT_HTTP_EXTRAHEADER" not in retry_env + assert "GIT_CONFIG_PARAMETERS" not in retry_env + assert retry_env["GIT_CONFIG_COUNT"] == "1" + assert retry_env["GIT_CONFIG_KEY_0"] == "http.sslCAInfo" + assert retry_env["GIT_CONFIG_VALUE_0"] == "/corporate/ca.pem" + + +def test_machine_output_aliases_are_detected() -> None: + """Documented JSON and SARIF spellings must reserve stdout at the root.""" + from apm_cli.core.output_mode import detect_output_mode + + machine_argv = ( + ("policy", "status", "-o", "json"), + ("policy", "status", "--output", "json"), + ("audit", "-f", "json"), + ("audit", "--format", "sarif"), + ) + + assert all(detect_output_mode(argv).machine_readable for argv in machine_argv) diff --git a/tests/integration/test_architecture_mutation_guards.py b/tests/integration/test_architecture_mutation_guards.py new file mode 100644 index 000000000..7a7a9bd62 --- /dev/null +++ b/tests/integration/test_architecture_mutation_guards.py @@ -0,0 +1,136 @@ +"""Integration guardrails for validate-before-mutate architecture.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +def test_compiled_output_batch_scans_once_before_writing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A complete compile batch must cross one blocking scan chokepoint.""" + from apm_cli.compilation.output_writer import CompiledOutputWriter + from apm_cli.security.gate import SecurityGate + + calls = 0 + real_scan = SecurityGate.scan_texts + + def counting_scan(contents, *, policy): + nonlocal calls + calls += 1 + return real_scan(contents, policy=policy) + + monkeypatch.setattr(SecurityGate, "scan_texts", counting_scan) + outputs = { + tmp_path / "AGENTS.md": "# agents\n", + tmp_path / "nested" / "CLAUDE.md": "# claude\n", + } + + CompiledOutputWriter().write_many(outputs) + + assert calls == 1 + assert all(path.read_text(encoding="utf-8") == content for path, content in outputs.items()) + + +@pytest.mark.parametrize( + "payload", + ( + {"version": 2, "hooks": {}}, + {"version": 1, "hooks": {"PreToolUse": "not-a-list"}}, + ), +) +def test_invalid_hook_payload_writes_no_files( + tmp_path: Path, + payload: dict, +) -> None: + """Native hook validation must fail before payload or script mutation.""" + from apm_cli.core.deployment_state import MaterializationStatus + from apm_cli.integration.hook_integrator import HookIntegrator + from apm_cli.models.apm_package import APMPackage, PackageInfo + + package_root = tmp_path / "apm_modules" / "owner" / "hooks" + source_dir = package_root / "hooks" + source_dir.mkdir(parents=True) + (source_dir / "hooks.json").write_text( + json.dumps(payload), + encoding="utf-8", + ) + (tmp_path / ".github").mkdir() + package = APMPackage(name="invalid-hooks", version="1.0.0", package_path=package_root) + + result = HookIntegrator().integrate_package_hooks( + PackageInfo(package=package, install_path=package_root), + tmp_path, + ) + + hooks_dir = tmp_path / ".github" / "hooks" + assert not list(hooks_dir.rglob("*")) + assert result.files_integrated == 0 + assert len(result.materializations) == 1 + assert result.materializations[0].status is MaterializationStatus.FAILED + + +@pytest.mark.parametrize( + "payload", + [ + "lockfile_version: '99'\ndependencies: []\n", + "lockfile_version: '2'\ndependencies: {}\n", + "lockfile_version: '2'\ndependencies:\n - not-a-mapping\n", + ( + "lockfile_version: '1'\n" + "dependencies: []\n" + "deployments:\n" + " - kind: project-relative\n" + " target: copilot\n" + " value: .github/demo.md\n" + " scope: project\n" + " owners: []\n" + " active_owner: ''\n" + ), + ], +) +def test_lockfile_loader_fails_closed_on_unsupported_or_malformed_shape( + payload: str, +) -> None: + """Unknown versions and malformed containers must never downgrade.""" + from apm_cli.deps.lockfile import LockFile, LockfileFormatError + + with pytest.raises(LockfileFormatError): + LockFile.from_yaml(payload) + + +def test_optional_null_lockfile_mappings_remain_backward_compatible() -> None: + """Strict validation must normalize legacy null optional mappings.""" + from apm_cli.deps.lockfile import LockFile + + lockfile = LockFile.from_yaml( + "lockfile_version: '1'\ndependencies: []\nmcp_configs: null\nlsp_configs: null\n" + ) + + assert lockfile.mcp_configs == {} + assert lockfile.lsp_configs == {} + + +def test_transitive_local_identity_round_trips_through_lockfile(tmp_path: Path) -> None: + """Parent and anchored path provenance must survive persistence.""" + from apm_cli.deps.lockfile import LockedDependency, LockFile + + dependency = LockedDependency( + repo_url="_local/shared", + source="local", + local_path="../shared", + declaring_parent="owner/parent#main", + anchored_local_path=str(tmp_path / "workspace" / "shared"), + ) + lock_path = tmp_path / "apm.lock.yaml" + LockFile(dependencies={dependency.get_unique_key(): dependency}).write(lock_path) + + loaded = LockFile.read(lock_path) + + assert loaded is not None + restored = loaded.get_all_dependencies()[0] + assert restored.declaring_parent == "owner/parent#main" + assert restored.anchored_local_path == str(tmp_path / "workspace" / "shared") diff --git a/tests/integration/test_architecture_outcome_guards.py b/tests/integration/test_architecture_outcome_guards.py new file mode 100644 index 000000000..224328c5e --- /dev/null +++ b/tests/integration/test_architecture_outcome_guards.py @@ -0,0 +1,309 @@ +"""Integration guardrails for install and policy outcome authorities.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + + +def _write_plugin_consumer(tmp_path: Path, plugin_manifest: dict) -> tuple[Path, Path]: + """Create a local plugin and a consumer that installs it.""" + import json + + plugin = tmp_path / "plugin" + manifest_dir = plugin / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text( + json.dumps(plugin_manifest), + encoding="utf-8", + ) + consumer = tmp_path / "consumer" + consumer.mkdir() + (consumer / ".claude").mkdir() + (consumer / "apm.yml").write_text( + "name: consumer\n" + "version: 1.0.0\n" + "targets: [claude]\n" + "dependencies:\n" + " apm:\n" + " - path: ../plugin\n", + encoding="utf-8", + ) + return plugin, consumer + + +def test_install_result_disposition_owns_cli_exit_code() -> None: + """Service classification and adapter exit translation must agree.""" + from apm_cli.install.outcome import finalize_install_result + from apm_cli.models.results import InstallDisposition, InstallResult + + diagnostics = MagicMock(error_count=1, has_critical_security=False) + result = finalize_install_result( + InstallResult(diagnostics=diagnostics), + force=False, + ) + + assert result.disposition is InstallDisposition.FAILED + assert result.exit_code == 1 + + +def test_missing_declared_plugin_component_fails_before_commit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An explicit missing plugin path is an unsatisfied install requirement.""" + from click.testing import CliRunner + + from apm_cli.cli import cli + + plugin, consumer = _write_plugin_consumer( + tmp_path, + { + "name": "missing-components", + "version": "1.0.0", + "agents": ["./agents/does-not-exist.agent.md"], + "skills": ["./skills/does-not-exist"], + }, + ) + monkeypatch.chdir(consumer) + monkeypatch.setattr("apm_cli.cli._check_and_notify_updates", lambda: None) + + result = CliRunner().invoke(cli, ["install"]) + + assert result.exit_code != 0, result.output + assert "missing-components" in result.output + assert "agents" in result.output + assert "./agents/does-not-exist.agent.md" in result.output + assert "plugin root" in result.output + assert "remove the declaration" in result.output + assert not (consumer / "apm.lock.yaml").exists() + assert not (consumer / ".claude" / "agents").exists() + assert plugin.is_dir() + + +def test_requested_plugin_skill_with_no_match_fails_closed( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A named --skill miss must not become a successful no-op.""" + from click.testing import CliRunner + + from apm_cli.cli import cli + + plugin, consumer = _write_plugin_consumer( + tmp_path, + { + "name": "selective-skills", + "version": "1.0.0", + "skills": ["./skills/engineering/tdd"], + }, + ) + for name in ("tdd", "resolving-merge-conflicts"): + skill = plugin / "skills" / "engineering" / name + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text(f"# {name}\n", encoding="utf-8") + monkeypatch.chdir(consumer) + monkeypatch.setattr("apm_cli.cli._check_and_notify_updates", lambda: None) + + result = CliRunner().invoke( + cli, + ["install", "--skill", "engineering/resolving-merge-conflicts"], + ) + + assert result.exit_code != 0, result.output + assert "engineering/resolving-merge-conflicts" in result.output + assert "matched no declared skills" in result.output + assert "tdd" in result.output + assert "update the package manifest" in result.output + assert "then reinstall" in result.output + assert not (consumer / "apm.lock.yaml").exists() + assert not (consumer / ".claude" / "skills" / "resolving-merge-conflicts").exists() + + +def test_pipeline_diagnostics_make_install_exit_one( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The real CLI adapter must use the service-owned disposition.""" + from click.testing import CliRunner + + from apm_cli.cli import cli + from apm_cli.utils.diagnostics import DiagnosticCollector + + (tmp_path / "apm.yml").write_text( + "name: demo\nversion: 1.0.0\ntargets: [copilot]\n", + encoding="utf-8", + ) + (tmp_path / ".github").mkdir() + diagnostics = DiagnosticCollector() + diagnostics.error("integration failed") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("apm_cli.cli._check_and_notify_updates", lambda: None) + monkeypatch.setattr( + "apm_cli.commands.install._install_apm_packages", + lambda *_a, **_k: (0, 0, 0, diagnostics), + ) + + result = CliRunner().invoke(cli, ["install"]) + + assert result.exit_code == 1 + assert "Installation failed" in result.output + assert "Install interrupted" not in result.output + + +def test_handled_install_error_uses_failure_completion( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Handled errors must not be mislabeled as interruptions.""" + from click.testing import CliRunner + + from apm_cli.cli import cli + + (tmp_path / "apm.yml").write_text("name: demo\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("apm_cli.cli._check_and_notify_updates", lambda: None) + + result = CliRunner().invoke(cli, ["install"]) + + assert result.exit_code == 1 + assert "Install failed after" in result.output + assert "Install interrupted" not in result.output + + +def test_failed_install_does_not_fire_post_install_hook( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Service lifecycle hooks must observe the classified result.""" + from apm_cli.install.request import InstallRequest + from apm_cli.install.service import InstallService + from apm_cli.models.apm_package import APMPackage + from apm_cli.models.results import InstallDisposition, InstallResult + + runner = MagicMock() + monkeypatch.setattr( + InstallService, + "_build_script_runner", + staticmethod(lambda _request: runner), + ) + monkeypatch.setattr( + "apm_cli.install.pipeline.run_install_pipeline", + lambda *_a, **_k: InstallResult( + disposition=InstallDisposition.FAILED, + exit_code=1, + ), + ) + + result = InstallService().run( + InstallRequest(apm_package=APMPackage(name="demo", version="1.0.0")) + ) + + assert result.disposition is InstallDisposition.FAILED + assert [call.args[0] for call in runner.fire.call_args_list] == ["pre-install"] + + +def test_cancelled_install_skips_mcp_and_lsp( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Declining a plan must terminate all downstream mutation phases.""" + from click.testing import CliRunner + + from apm_cli.cli import cli + from apm_cli.models.results import InstallDisposition, InstallResult + + (tmp_path / "apm.yml").write_text( + "name: demo\nversion: 1.0.0\ntargets: [copilot]\ndependencies:\n apm:\n - owner/repo\n", + encoding="utf-8", + ) + (tmp_path / ".github").mkdir() + mcp_install = MagicMock() + lsp_install = MagicMock() + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("apm_cli.cli._check_and_notify_updates", lambda: None) + monkeypatch.setattr( + "apm_cli.commands.install._install_apm_dependencies", + lambda *_a, **_k: InstallResult(disposition=InstallDisposition.CANCELLED), + ) + monkeypatch.setattr("apm_cli.install.mcp.run_mcp_integration", mcp_install) + monkeypatch.setattr("apm_cli.install.lsp.run_lsp_integration", lsp_install) + + result = CliRunner().invoke(cli, ["install"]) + + assert result.exit_code == 0, result.output + mcp_install.assert_not_called() + lsp_install.assert_not_called() + + +def test_manifest_inheritance_cannot_relax_explicit_includes() -> None: + """Either ancestor or child may tighten explicit-include enforcement.""" + from apm_cli.policy.inheritance import merge_policies + from apm_cli.policy.schema import ApmPolicy, ManifestPolicy + + parent_true = ApmPolicy(manifest=ManifestPolicy(require_explicit_includes=True)) + child_false = ApmPolicy(manifest=ManifestPolicy(require_explicit_includes=False)) + parent_false = ApmPolicy(manifest=ManifestPolicy(require_explicit_includes=False)) + child_true = ApmPolicy(manifest=ManifestPolicy(require_explicit_includes=True)) + + assert merge_policies(parent_true, child_false).manifest.require_explicit_includes + assert merge_policies(parent_false, child_true).manifest.require_explicit_includes + + +def test_explicit_policy_uses_chain_aware_discovery( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Explicit leaves must resolve ancestors through the shared entry point.""" + from apm_cli.policy import discovery + from apm_cli.policy.schema import ApmPolicy + + calls: list[str | None] = [] + leaf = discovery.PolicyFetchResult( + policy=ApmPolicy(extends="owner/parent"), + source="org:owner/leaf", + outcome="found", + ) + missing_parent = discovery.PolicyFetchResult( + policy=None, + source="org:owner/parent", + outcome="cache_miss_fetch_fail", + error="unreachable", + ) + + def fake_discover(_root, *, policy_override=None, **_kwargs): + calls.append(policy_override) + return leaf if len(calls) == 1 else missing_parent + + monkeypatch.setattr(discovery, "discover_policy", fake_discover) + + result = discovery.discover_policy_with_chain( + tmp_path, + policy_override="owner/leaf", + no_cache=True, + ) + + assert calls == ["owner/leaf", "owner/parent"] + assert result.outcome == "incomplete_chain" + assert result.policy is None + + +def test_incomplete_policy_chain_always_fails_closed() -> None: + """A partial ancestor set must never become an enforceable policy.""" + from apm_cli.install.errors import PolicyViolationError + from apm_cli.policy.discovery import PolicyFetchResult + from apm_cli.policy.outcome_routing import route_discovery_outcome + + result = PolicyFetchResult( + policy=None, + source="org:owner/leaf", + outcome="incomplete_chain", + error="parent unreachable", + ) + + with pytest.raises(PolicyViolationError): + route_discovery_outcome( + result, + logger=MagicMock(), + fetch_failure_default="warn", + ) diff --git a/tests/integration/test_audit_root_local_hook_replay_e2e.py b/tests/integration/test_audit_root_local_hook_replay_e2e.py index 54b7efb7b..a69ef1791 100644 --- a/tests/integration/test_audit_root_local_hook_replay_e2e.py +++ b/tests/integration/test_audit_root_local_hook_replay_e2e.py @@ -69,12 +69,14 @@ def _install(project: Path, monkeypatch: pytest.MonkeyPatch) -> None: def _codex_marker(root: Path) -> str: - """Return the first APM ownership marker from Codex hooks.json.""" + """Return the first APM ownership marker from the Codex sidecar.""" hooks_path = root / ".codex" / "hooks.json" data = json.loads(hooks_path.read_text(encoding="utf-8")) entries = data["hooks"]["PreToolUse"] assert len(entries) == 1 - marker = entries[0]["_apm_source"] + assert "_apm_source" not in entries[0] + sidecar = json.loads((root / ".codex" / "apm-hooks.json").read_text(encoding="utf-8")) + marker = sidecar["PreToolUse"][0]["_apm_source"] assert isinstance(marker, str) return marker diff --git a/tests/integration/test_audit_silent_skip_e2e.py b/tests/integration/test_audit_silent_skip_e2e.py index d8ad9cf06..70c87f67f 100644 --- a/tests/integration/test_audit_silent_skip_e2e.py +++ b/tests/integration/test_audit_silent_skip_e2e.py @@ -85,6 +85,12 @@ def _setup_clean_project(project: Path, *, fetch_failure_default: str | None = N - .github/prompts/test.md """) (project / "apm.lock.yaml").write_text(lockfile, encoding="utf-8") + package = project / "apm_modules" / "owner" / "repo" + package.mkdir(parents=True) + (package / "apm.yml").write_text( + "name: repo\nversion: 1.0.0\n", + encoding="utf-8", + ) prompts_dir = project / ".github" / "prompts" prompts_dir.mkdir(parents=True) (prompts_dir / "test.md").write_text("Clean content\n", encoding="utf-8") diff --git a/tests/integration/test_commands_auth_flow.py b/tests/integration/test_commands_auth_flow.py index 11c8f87fc..e8a89b777 100644 --- a/tests/integration/test_commands_auth_flow.py +++ b/tests/integration/test_commands_auth_flow.py @@ -20,8 +20,6 @@ # --------------------------------------------------------------------------- from apm_cli.commands.install import ( _check_package_conflicts, - _maybe_rollback_manifest, - _restore_manifest_from_snapshot, _split_argv_at_double_dash, install, ) @@ -47,6 +45,10 @@ synthesize_plugin_json_from_apm_yml, validate_plugin_package, ) +from apm_cli.install.transaction import ( + _maybe_rollback_manifest, + _restore_manifest_from_snapshot, +) from apm_cli.models.apm_package import clear_apm_yml_cache # --------------------------------------------------------------------------- diff --git a/tests/integration/test_commands_auth_phase3b.py b/tests/integration/test_commands_auth_phase3b.py index b8bbed761..f2e5d2493 100644 --- a/tests/integration/test_commands_auth_phase3b.py +++ b/tests/integration/test_commands_auth_phase3b.py @@ -20,8 +20,6 @@ # --------------------------------------------------------------------------- from apm_cli.commands.install import ( _check_package_conflicts, - _maybe_rollback_manifest, - _restore_manifest_from_snapshot, _split_argv_at_double_dash, install, ) @@ -47,6 +45,10 @@ synthesize_plugin_json_from_apm_yml, validate_plugin_package, ) +from apm_cli.install.transaction import ( + _maybe_rollback_manifest, + _restore_manifest_from_snapshot, +) from apm_cli.models.apm_package import clear_apm_yml_cache # --------------------------------------------------------------------------- diff --git a/tests/integration/test_commands_mcp_flow.py b/tests/integration/test_commands_mcp_flow.py index a5e49590b..eb50b589b 100644 --- a/tests/integration/test_commands_mcp_flow.py +++ b/tests/integration/test_commands_mcp_flow.py @@ -823,7 +823,7 @@ class TestRestoreManifestFromSnapshot: """_restore_manifest_from_snapshot — atomic write.""" def test_restore_writes_original_bytes(self, tmp_path: Path) -> None: - from apm_cli.commands.install import _restore_manifest_from_snapshot + from apm_cli.install.transaction import _restore_manifest_from_snapshot apm_yml = tmp_path / "apm.yml" original = b"name: original\n" @@ -834,7 +834,7 @@ def test_restore_writes_original_bytes(self, tmp_path: Path) -> None: assert apm_yml.read_bytes() == original def test_restore_creates_file_if_missing(self, tmp_path: Path) -> None: - from apm_cli.commands.install import _restore_manifest_from_snapshot + from apm_cli.install.transaction import _restore_manifest_from_snapshot apm_yml = tmp_path / "apm.yml" original = b"name: fresh\n" @@ -848,8 +848,8 @@ class TestMaybeRollbackManifest: """_maybe_rollback_manifest — no-op when snapshot is None.""" def test_noop_when_snapshot_is_none(self, tmp_path: Path) -> None: - from apm_cli.commands.install import _maybe_rollback_manifest from apm_cli.core.command_logger import InstallLogger + from apm_cli.install.transaction import _maybe_rollback_manifest apm_yml = tmp_path / "apm.yml" apm_yml.write_bytes(b"name: current\n") @@ -862,8 +862,8 @@ def test_noop_when_snapshot_is_none(self, tmp_path: Path) -> None: logger.progress.assert_not_called() def test_restores_when_snapshot_provided(self, tmp_path: Path) -> None: - from apm_cli.commands.install import _maybe_rollback_manifest from apm_cli.core.command_logger import InstallLogger + from apm_cli.install.transaction import _maybe_rollback_manifest apm_yml = tmp_path / "apm.yml" original = b"name: original\n" diff --git a/tests/integration/test_commands_mcp_phase3c.py b/tests/integration/test_commands_mcp_phase3c.py index a5e49590b..eb50b589b 100644 --- a/tests/integration/test_commands_mcp_phase3c.py +++ b/tests/integration/test_commands_mcp_phase3c.py @@ -823,7 +823,7 @@ class TestRestoreManifestFromSnapshot: """_restore_manifest_from_snapshot — atomic write.""" def test_restore_writes_original_bytes(self, tmp_path: Path) -> None: - from apm_cli.commands.install import _restore_manifest_from_snapshot + from apm_cli.install.transaction import _restore_manifest_from_snapshot apm_yml = tmp_path / "apm.yml" original = b"name: original\n" @@ -834,7 +834,7 @@ def test_restore_writes_original_bytes(self, tmp_path: Path) -> None: assert apm_yml.read_bytes() == original def test_restore_creates_file_if_missing(self, tmp_path: Path) -> None: - from apm_cli.commands.install import _restore_manifest_from_snapshot + from apm_cli.install.transaction import _restore_manifest_from_snapshot apm_yml = tmp_path / "apm.yml" original = b"name: fresh\n" @@ -848,8 +848,8 @@ class TestMaybeRollbackManifest: """_maybe_rollback_manifest — no-op when snapshot is None.""" def test_noop_when_snapshot_is_none(self, tmp_path: Path) -> None: - from apm_cli.commands.install import _maybe_rollback_manifest from apm_cli.core.command_logger import InstallLogger + from apm_cli.install.transaction import _maybe_rollback_manifest apm_yml = tmp_path / "apm.yml" apm_yml.write_bytes(b"name: current\n") @@ -862,8 +862,8 @@ def test_noop_when_snapshot_is_none(self, tmp_path: Path) -> None: logger.progress.assert_not_called() def test_restores_when_snapshot_provided(self, tmp_path: Path) -> None: - from apm_cli.commands.install import _maybe_rollback_manifest from apm_cli.core.command_logger import InstallLogger + from apm_cli.install.transaction import _maybe_rollback_manifest apm_yml = tmp_path / "apm.yml" original = b"name: original\n" diff --git a/tests/integration/test_compile_global.py b/tests/integration/test_compile_global.py index 52519a5fe..7e6ac5a0c 100644 --- a/tests/integration/test_compile_global.py +++ b/tests/integration/test_compile_global.py @@ -42,6 +42,37 @@ def test_install_global_hints_compile_and_writes_no_root_file(tmp_path, monkeypa assert "apm compile -g" in result.output +def test_install_global_supports_claude_config_dir_outside_home(tmp_path, monkeypatch): + """Global Claude installs support an absolute config root outside HOME.""" + from apm_cli.commands.install import install as install_cmd + from apm_cli.primitives.discovery import clear_discovery_cache + + home = tmp_path / "home" + package_dir = tmp_path / "global-package" + instruction_dir = package_dir / ".apm" / "instructions" + instruction_dir.mkdir(parents=True) + (package_dir / "apm.yml").write_text( + "name: global-package\nversion: 0.1.0\n", + encoding="utf-8", + ) + (instruction_dir / "global.instructions.md").write_text( + "---\ndescription: Global install instructions\n---\nUse integration tests.\n", + encoding="utf-8", + ) + + claude_config = tmp_path / "outside-home" / "claude-config" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude_config)) + clear_discovery_cache() + + result = CliRunner().invoke(install_cmd, ["-g", str(package_dir), "--target", "claude"]) + + clear_discovery_cache() + assert result.exit_code == 0, result.output + assert (claude_config / "rules" / "global.md").is_file() + assert "[x]" not in result.output + + def test_compile_global_writes_claude_md_from_real_fixtures(tmp_path, monkeypatch): """Run apm compile --global through real discovery and filesystem writes.""" from apm_cli.commands.compile.cli import compile as compile_cmd diff --git a/tests/integration/test_copilot_app_targets_coverage.py b/tests/integration/test_copilot_app_targets_coverage.py index a0ae2cdc9..c0b063cc2 100644 --- a/tests/integration/test_copilot_app_targets_coverage.py +++ b/tests/integration/test_copilot_app_targets_coverage.py @@ -12,11 +12,14 @@ from __future__ import annotations +from dataclasses import replace from pathlib import Path from unittest.mock import patch import pytest +from apm_cli.core.target_catalog import TARGET_CAPABILITIES + # =========================================================================== # copilot_app_db -- constants # =========================================================================== @@ -518,7 +521,12 @@ def _make_profile(self, **kwargs): from apm_cli.integration.targets import PrimitiveMapping, TargetProfile defaults = dict( - name="test", + capability=replace( + TARGET_CAPABILITIES["copilot"], + name="test", + aliases=(), + runtimes=(), + ), root_dir=".test", primitives={ "instructions": PrimitiveMapping("rules", ".md", "test_rules"), @@ -547,7 +555,7 @@ def test_effective_pack_prefixes_uses_override_when_set(self) -> None: from apm_cli.integration.targets import TargetProfile profile = TargetProfile( - name="codex", + capability=TARGET_CAPABILITIES["codex"], root_dir=".codex", primitives={}, pack_prefixes=(".codex/", ".agents/"), diff --git a/tests/integration/test_cross_package_collision_file_survival.py b/tests/integration/test_cross_package_collision_file_survival.py new file mode 100644 index 000000000..94fdbdf4f --- /dev/null +++ b/tests/integration/test_cross_package_collision_file_survival.py @@ -0,0 +1,48 @@ +"""Cross-package deployed-file ownership regression.""" + +from pathlib import Path +from types import SimpleNamespace + +from apm_cli.deps.lockfile import LockedDependency, LockFile +from apm_cli.install.phases.lockfile import LockfileBuilder +from apm_cli.utils.content_hash import compute_file_hash + + +def test_collision_loser_does_not_delete_winner_file(tmp_path: Path) -> None: + loser = "orga/loser-skill" + winner = "orgb/winner-skill" + shared = ".claude/skills/shared-topic/SKILL.md" + shared_path = tmp_path / shared + shared_path.parent.mkdir(parents=True) + shared_path.write_text("# Shared skill\n", encoding="utf-8") + + prior = LockFile() + prior.add_dependency( + LockedDependency( + repo_url=loser, + deployed_files=[shared], + deployed_file_hashes={shared: compute_file_hash(shared_path)}, + ) + ) + current = LockFile() + current.add_dependency(LockedDependency(repo_url=loser)) + current.add_dependency(LockedDependency(repo_url=winner)) + context = SimpleNamespace( + package_deployed_files={loser: [shared], winner: [shared]}, + existing_lockfile=prior, + targets=[SimpleNamespace(name="claude", root_dir=".claude", primitives={})], + project_root=tmp_path, + diagnostics=SimpleNamespace( + count_for_package=lambda *args, **kwargs: 0, + warning=lambda *args, **kwargs: None, + error=lambda *args, **kwargs: None, + ), + logger=None, + verbose=False, + ) + + LockfileBuilder(context)._attach_deployed_files(current) + + assert shared_path.exists() + assert current.get_dependency(winner).deployed_files == [shared] + assert current.get_dependency(loser).deployed_files == [] diff --git a/tests/integration/test_drift_check.py b/tests/integration/test_drift_check.py index fec48af71..18318a784 100644 --- a/tests/integration/test_drift_check.py +++ b/tests/integration/test_drift_check.py @@ -449,19 +449,14 @@ def test_c2_no_lockfile_ci_audit_passes_baseline( def test_c3_corrupt_lockfile_yaml_skips_drift( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Bad YAML in lockfile -> ``LockFile.read`` returns ``None``. - - The audit command then skips drift silently. Pinning this so a - future ``raise`` doesn't surprise users. - """ + """Bad YAML fails closed with a stable audit diagnostic.""" project = _make_apm_project(tmp_path) _install(project, monkeypatch) (project / "apm.lock.yaml").write_bytes(b"!!!{not valid yaml: [\n") result = _audit(project, monkeypatch) - # Either passes silently (no parseable lockfile -> nothing to - # scan) or emits a warning -- both acceptable, but no traceback. - assert result.exit_code in {0, 1} + assert result.exit_code == 1 + assert "Cannot audit invalid apm.lock.yaml" in result.output assert "Traceback" not in result.stdout assert "Traceback" not in result.stderr diff --git a/tests/integration/test_gemini_integration.py b/tests/integration/test_gemini_integration.py index 593d9f4ae..96777bdaf 100644 --- a/tests/integration/test_gemini_integration.py +++ b/tests/integration/test_gemini_integration.py @@ -369,7 +369,7 @@ def _setup_hook_package(self, name: str = "test-hooks") -> PackageInfo: return _make_package_info(pkg, name) def test_hooks_merge_into_settings_json(self): - """Hooks are merged into .gemini/settings.json with _apm_source.""" + """Hooks use Gemini fields while ownership stays in the APM sidecar.""" from apm_cli.integration.hook_integrator import HookIntegrator info = self._setup_hook_package() @@ -382,7 +382,9 @@ def test_hooks_merge_into_settings_json(self): settings = json.loads((self.root / ".gemini" / "settings.json").read_text()) assert "hooks" in settings assert "preCommit" in settings["hooks"] - assert settings["hooks"]["preCommit"][0]["_apm_source"] == "test-hooks" + assert "_apm_source" not in settings["hooks"]["preCommit"][0] + sidecar = json.loads((self.root / ".gemini" / "apm-hooks.json").read_text()) + assert sidecar["preCommit"][0]["_apm_source"] == "test-hooks" def test_hooks_preserve_existing_mcp_servers(self): """Hook merge must not clobber existing mcpServers in settings.json.""" diff --git a/tests/integration/test_hook_integrator_copilot_casing_e2e.py b/tests/integration/test_hook_integrator_copilot_casing_e2e.py index 8fcf85fba..0f995f741 100644 --- a/tests/integration/test_hook_integrator_copilot_casing_e2e.py +++ b/tests/integration/test_hook_integrator_copilot_casing_e2e.py @@ -80,7 +80,9 @@ def _read_copilot_hooks_config(project_root: Path, package_name: str) -> dict[st """Read the actual Copilot hook JSON written under .github/hooks.""" config_path = project_root / ".github" / "hooks" / f"{package_name}-hooks.json" assert config_path.exists() - return json.loads(config_path.read_text(encoding="utf-8")) + config = json.loads(config_path.read_text(encoding="utf-8")) + assert config["version"] == 1 + return config def test_copilot_install_writes_only_camel_case_hook_events(tmp_path: Path) -> None: diff --git a/tests/integration/test_hook_root_source_drift_e2e.py b/tests/integration/test_hook_root_source_drift_e2e.py index b29543333..44d0c30db 100644 --- a/tests/integration/test_hook_root_source_drift_e2e.py +++ b/tests/integration/test_hook_root_source_drift_e2e.py @@ -24,11 +24,10 @@ def _run(cwd: Path, *args: str) -> subprocess.CompletedProcess: # Per-target on-disk layout descriptors: # - settings_rel: path (relative to project root) of the merged hook config -# - sidecar_rel: path of the apm-hooks.json sidecar (schema-strict targets only) +# - sidecar_rel: path of the APM-owned apm-hooks.json ownership sidecar # - event_key: event key under which PreToolUse entries land for the target # -# Sidecar-bearing targets (currently Claude) keep _apm_source in the sidecar -# rather than in settings.json; the heal assertion reads from there. +# Every merged target keeps ownership outside its native configuration. _HARNESS_CASES = [ pytest.param( "claude", @@ -40,28 +39,28 @@ def _run(cwd: Path, *args: str) -> subprocess.CompletedProcess: pytest.param( "codex", ".codex/hooks.json", - None, + ".codex/apm-hooks.json", "PreToolUse", id="codex", ), pytest.param( "cursor", ".cursor/hooks.json", - None, + ".cursor/apm-hooks.json", "PreToolUse", id="cursor", ), pytest.param( "gemini", ".gemini/settings.json", - None, + ".gemini/apm-hooks.json", "BeforeTool", id="gemini", ), pytest.param( "windsurf", ".windsurf/hooks.json", - None, + ".windsurf/apm-hooks.json", "PreToolUse", id="windsurf", ), diff --git a/tests/integration/test_inactive_target_ghost_e2e.py b/tests/integration/test_inactive_target_ghost_e2e.py index 95b11ff91..66f3edcd1 100644 --- a/tests/integration/test_inactive_target_ghost_e2e.py +++ b/tests/integration/test_inactive_target_ghost_e2e.py @@ -8,6 +8,7 @@ from typing import Any from unittest.mock import patch +import pytest import yaml from click.testing import CliRunner @@ -154,3 +155,88 @@ def test_install_repairs_ghost_before_fresh_checkout_audit(tmp_path: Path, monke runner, checkout, monkeypatch, "audit", "--ci", "--no-policy", "--no-fail-fast" ) assert clean_audit.exit_code == 0, clean_audit.output + + +@pytest.mark.parametrize( + ("command", "args"), + [ + ("update", ("update", "--yes", "--target", "copilot")), + ("compile", ("compile", "--target", "copilot")), + ], +) +def test_materializing_command_reconciles_contracted_target( + tmp_path: Path, + monkeypatch, + command: str, + args: tuple[str, ...], +) -> None: + """Update and compile remove artifacts owned by an undeclared target.""" + project = _write_fixture(tmp_path / command) + + from apm_cli.deps import github_downloader + + downloader = _HermeticDownloader() + monkeypatch.setattr( + github_downloader.GitHubPackageDownloader, + "download_package", + downloader.download_package, + ) + runner = CliRunner() + + initial = _invoke(runner, project, monkeypatch, "install", "--target", "copilot,windsurf") + assert initial.exit_code == 0, initial.output + ghost_path = project / GHOST + assert ghost_path.is_file() + initial_lock = yaml.safe_load((project / "apm.lock.yaml").read_text(encoding="utf-8")) + initial_dep = (initial_lock.get("dependencies") or [])[0] + assert GHOST in (initial_dep.get("deployed_files") or []) + assert GHOST in (initial_dep.get("deployed_file_hashes") or {}) + + (project / "apm.yml").write_text( + (project / "apm.yml").read_text(encoding="utf-8").replace(" - windsurf\n", ""), + encoding="utf-8", + ) + + result = _invoke(runner, project, monkeypatch, *args) + assert result.exit_code == 0, result.output + assert not ghost_path.exists() + reconciled_lock = yaml.safe_load((project / "apm.lock.yaml").read_text(encoding="utf-8")) + reconciled_dep = (reconciled_lock.get("dependencies") or [])[0] + assert GHOST not in (reconciled_dep.get("deployed_files") or []) + assert GHOST not in (reconciled_dep.get("deployed_file_hashes") or {}) + + +def test_compile_preserves_sibling_when_declared_targets_conflict( + tmp_path: Path, + monkeypatch, +) -> None: + """Post-compile reconciliation falls back safely for malformed targets.""" + project = _write_fixture(tmp_path / "compile-conflicting-targets") + + from apm_cli.deps import github_downloader + + downloader = _HermeticDownloader() + monkeypatch.setattr( + github_downloader.GitHubPackageDownloader, + "download_package", + downloader.download_package, + ) + runner = CliRunner() + + initial = _invoke(runner, project, monkeypatch, "install", "--target", "copilot,windsurf") + assert initial.exit_code == 0, initial.output + sibling_path = project / GHOST + assert sibling_path.is_file() + + with (project / "apm.yml").open("a", encoding="utf-8") as manifest: + manifest.write("target: copilot\n") + + monkeypatch.chdir(project) + with patch(_PATCH_UPDATES, return_value=None): + result = runner.invoke(cli, ["compile", "--target", "copilot"]) + + assert result.exit_code == 0, result.output + assert sibling_path.is_file() + lockfile = yaml.safe_load((project / "apm.lock.yaml").read_text(encoding="utf-8")) + dependency = (lockfile.get("dependencies") or [])[0] + assert GHOST in (dependency.get("deployed_files") or []) diff --git a/tests/integration/test_install_marketplace_phase4w3.py b/tests/integration/test_install_marketplace_phase4w3.py index a417dc2db..65c13afc5 100644 --- a/tests/integration/test_install_marketplace_phase4w3.py +++ b/tests/integration/test_install_marketplace_phase4w3.py @@ -427,8 +427,9 @@ def test_no_diagnostics_calls_blank_line(self) -> None: ) mock_blank.assert_called_once() - def test_critical_security_exits_without_force(self) -> None: + def test_critical_security_returns_failed_result_without_force(self) -> None: from apm_cli.install.summary import render_post_install_summary + from apm_cli.models.results import InstallDisposition logger = self._make_logger() diag = MagicMock() @@ -436,18 +437,16 @@ def test_critical_security_exits_without_force(self) -> None: diag.has_critical_security = True diag.error_count = 0 - with ( - patch("apm_cli.install.summary._rich_blank_line"), - pytest.raises(SystemExit) as exc_info, - ): - render_post_install_summary( + with patch("apm_cli.install.summary._rich_blank_line"): + result = render_post_install_summary( logger=logger, apm_count=0, mcp_count=0, apm_diagnostics=diag, force=False, ) - assert exc_info.value.code == 1 + assert result.disposition is InstallDisposition.FAILED + assert result.exit_code == 1 def test_critical_security_no_exit_with_force(self) -> None: from apm_cli.install.summary import render_post_install_summary diff --git a/tests/integration/test_install_services_orchestration.py b/tests/integration/test_install_services_orchestration.py index 9c387005b..4db9c6557 100644 --- a/tests/integration/test_install_services_orchestration.py +++ b/tests/integration/test_install_services_orchestration.py @@ -34,6 +34,11 @@ def make_target( target.auto_create = True target.hooks_config_display = hooks_config_display target.resolved_deploy_root = resolved_deploy_root + if resolved_deploy_root is not None: + target.managed_deploy_root = resolved_deploy_root + else: + _root = Path(root_dir) + target.managed_deploy_root = _root if _root.is_absolute() else None return target diff --git a/tests/integration/test_install_services_phase3w5.py b/tests/integration/test_install_services_phase3w5.py index 70fc898a7..49057c27d 100644 --- a/tests/integration/test_install_services_phase3w5.py +++ b/tests/integration/test_install_services_phase3w5.py @@ -34,6 +34,11 @@ def make_target( target.auto_create = True target.hooks_config_display = hooks_config_display target.resolved_deploy_root = resolved_deploy_root + if resolved_deploy_root is not None: + target.managed_deploy_root = resolved_deploy_root + else: + _root = Path(root_dir) + target.managed_deploy_root = _root if _root.is_absolute() else None return target diff --git a/tests/integration/test_integration_runtime_coverage.py b/tests/integration/test_integration_runtime_coverage.py index 51d3c525c..01320f8e7 100644 --- a/tests/integration/test_integration_runtime_coverage.py +++ b/tests/integration/test_integration_runtime_coverage.py @@ -75,163 +75,140 @@ def test_dots_and_dashes_preserved(self) -> None: assert _safe_hook_slug("pre-tool.use") == "pre-tool.use" -class TestKiroPatternsFromMatcher: - """Tests for _kiro_patterns_from_matcher().""" +class TestKiroMatcher: + """Tests for neutral-IR to Kiro matcher rendering.""" def test_string_patterns(self) -> None: - """Single string pattern is returned as a list.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_patterns_from_matcher + """Single string pattern is preserved.""" + from apm_cli.integration.hook_ir import HookBinding + from apm_cli.integration.kiro_hook_integrator import _kiro_matcher - result = _kiro_patterns_from_matcher({"patterns": "**/*.py"}) - assert result == ["**/*.py"] + binding = HookBinding(event="PreToolUse", handlers=(), metadata={"patterns": "**/*.py"}) + assert _kiro_matcher(binding) == "**/*.py" def test_list_patterns(self) -> None: - """List of patterns is returned correctly.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_patterns_from_matcher - - result = _kiro_patterns_from_matcher({"patterns": ["*.ts", "*.js"]}) - assert result == ["*.ts", "*.js"] + """Multiple legacy patterns become one Kiro matcher expression.""" + from apm_cli.integration.hook_ir import HookBinding + from apm_cli.integration.kiro_hook_integrator import _kiro_matcher + + binding = HookBinding( + event="PreToolUse", + handlers=(), + metadata={"patterns": ["*.ts", "*.js"]}, + ) + assert _kiro_matcher(binding) == "*.ts|*.js" def test_list_patterns_filters_empty(self) -> None: """Empty strings in list patterns are filtered out.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_patterns_from_matcher - - result = _kiro_patterns_from_matcher({"patterns": ["*.ts", "", "*.js"]}) - assert result == ["*.ts", "*.js"] - - def test_matcher_key_fallback(self) -> None: - """Falls back to 'matcher' key when 'patterns' is absent.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_patterns_from_matcher - - result = _kiro_patterns_from_matcher({"matcher": "src/**"}) - assert result == ["src/**"] + from apm_cli.integration.hook_ir import HookBinding + from apm_cli.integration.kiro_hook_integrator import _kiro_matcher - def test_no_patterns_returns_empty(self) -> None: - """Empty matcher dict returns empty list.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_patterns_from_matcher + binding = HookBinding( + event="PreToolUse", + handlers=(), + metadata={"patterns": ["*.ts", "", "*.js"]}, + ) + assert _kiro_matcher(binding) == "*.ts|*.js" - result = _kiro_patterns_from_matcher({}) - assert result == [] + def test_neutral_matcher_takes_precedence(self) -> None: + """The neutral matcher field is rendered directly.""" + from apm_cli.integration.hook_ir import HookBinding + from apm_cli.integration.kiro_hook_integrator import _kiro_matcher - def test_empty_string_patterns_returns_empty(self) -> None: - """Empty-string pattern field returns empty list.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_patterns_from_matcher + binding = HookBinding(event="PreToolUse", handlers=(), matcher="src/**") + assert _kiro_matcher(binding) == "src/**" - result = _kiro_patterns_from_matcher({"patterns": " "}) - assert result == [] + def test_no_patterns_returns_none(self) -> None: + """An unscoped binding omits the native matcher.""" + from apm_cli.integration.hook_ir import HookBinding + from apm_cli.integration.kiro_hook_integrator import _kiro_matcher + assert _kiro_matcher(HookBinding(event="Stop", handlers=())) is None -class TestKiroThenFromAction: - """Tests for _kiro_then_from_action().""" + def test_empty_string_patterns_returns_none(self) -> None: + """An empty legacy pattern does not create a native matcher.""" + from apm_cli.integration.hook_ir import HookBinding + from apm_cli.integration.kiro_hook_integrator import _kiro_matcher - def test_ask_agent_type(self) -> None: - """type=askAgent returns askAgent then object with prompt.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_then_from_action + binding = HookBinding(event="Stop", handlers=(), metadata={"patterns": " "}) + assert _kiro_matcher(binding) is None - result = _kiro_then_from_action( - {"type": "askAgent", "prompt": "Do something"}, - command_keys=("bash", "command"), - ) - assert result == {"type": "askAgent", "prompt": "Do something"} - def test_prompt_string_shorthand(self) -> None: - """Dict with 'prompt' key (no type) maps to askAgent.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_then_from_action +class TestKiroAction: + """Tests for neutral-IR to Kiro v1 action rendering.""" - result = _kiro_then_from_action( - {"prompt": "Run tests"}, - command_keys=("bash", "command"), - ) - assert result == {"type": "askAgent", "prompt": "Run tests"} + def test_prompt_handler(self) -> None: + """Portable prompt metadata becomes a Kiro agent action.""" + from apm_cli.integration.hook_ir import HookHandler + from apm_cli.integration.kiro_hook_integrator import _kiro_action - def test_bash_command_key(self) -> None: - """Dict with 'bash' key maps to runCommand.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_then_from_action - - result = _kiro_then_from_action( - {"bash": "npm test"}, - command_keys=("bash", "command"), + result = _kiro_action( + HookHandler(command=None, metadata={"type": "askAgent", "prompt": "Do something"}) ) - assert result == {"type": "runCommand", "command": "npm test"} + assert result == {"type": "agent", "prompt": "Do something"} - def test_command_key(self) -> None: - """Dict with 'command' key maps to runCommand.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_then_from_action + def test_command_handler(self) -> None: + """Portable command intent becomes a Kiro command action.""" + from apm_cli.integration.hook_ir import HookHandler + from apm_cli.integration.kiro_hook_integrator import _kiro_action - result = _kiro_then_from_action( - {"command": "make lint"}, - command_keys=("bash", "command"), - ) - assert result == {"type": "runCommand", "command": "make lint"} + result = _kiro_action(HookHandler(command="npm test", timeout_seconds=5)) + assert result == {"type": "command", "command": "npm test", "timeout": 5} def test_empty_prompt_returns_none(self) -> None: - """askAgent with blank prompt returns None.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_then_from_action + """A blank prompt cannot produce a Kiro action.""" + from apm_cli.integration.hook_ir import HookHandler + from apm_cli.integration.kiro_hook_integrator import _kiro_action - result = _kiro_then_from_action( - {"type": "askAgent", "prompt": " "}, - command_keys=("bash",), + result = _kiro_action( + HookHandler(command=None, metadata={"type": "askAgent", "prompt": " "}) ) assert result is None - def test_no_matching_key_returns_none(self) -> None: - """Dict with no matching command key returns None.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_then_from_action + def test_empty_handler_returns_none(self) -> None: + """A handler without command or prompt has no Kiro action.""" + from apm_cli.integration.hook_ir import HookHandler + from apm_cli.integration.kiro_hook_integrator import _kiro_action - result = _kiro_then_from_action( - {"unknown_key": "something"}, - command_keys=("bash", "command"), - ) - assert result is None + assert _kiro_action(HookHandler(command=None)) is None class TestKiroHookDocument: """Tests for _kiro_hook_document().""" - def test_builds_document_with_patterns(self) -> None: - """Document includes patterns in 'when' when provided.""" + def test_builds_v1_document_with_matcher(self) -> None: + """Document uses the current Kiro v1 hook container.""" from apm_cli.integration.kiro_hook_integrator import _kiro_hook_document doc = _kiro_hook_document( - name="my-pkg preToolUse 1", - description="A description", - event_name="preToolUse", - patterns=["**/*.py"], - then={"type": "runCommand", "command": "echo hi"}, + name="my-pkg PreToolUse 1", + event_name="PreToolUse", + matcher="**/*.py", + action={"type": "command", "command": "echo hi"}, ) - assert doc["name"] == "my-pkg preToolUse 1" - assert doc["version"] == "1.0.0" - assert doc["when"]["type"] == "preToolUse" - assert doc["when"]["patterns"] == ["**/*.py"] - assert doc["then"] == {"type": "runCommand", "command": "echo hi"} - assert doc["description"] == "A description" - - def test_builds_document_without_patterns(self) -> None: - """Document omits 'patterns' from 'when' when list is empty.""" - from apm_cli.integration.kiro_hook_integrator import _kiro_hook_document - - doc = _kiro_hook_document( - name="pkg hook 1", - description=None, - event_name="postToolUse", - patterns=[], - then={"type": "askAgent", "prompt": "Check"}, - ) - assert "patterns" not in doc["when"] - assert "description" not in doc + assert doc == { + "version": "v1", + "hooks": [ + { + "name": "my-pkg PreToolUse 1", + "trigger": "PreToolUse", + "matcher": "**/*.py", + "action": {"type": "command", "command": "echo hi"}, + } + ], + } - def test_no_description_omitted(self) -> None: - """None description is omitted from the document.""" + def test_builds_document_without_matcher(self) -> None: + """Document omits matcher when the neutral binding is unscoped.""" from apm_cli.integration.kiro_hook_integrator import _kiro_hook_document doc = _kiro_hook_document( - name="x", - description=None, - event_name="ev", - patterns=[], - then={"type": "runCommand", "command": "ls"}, + name="pkg hook 1", + event_name="Stop", + matcher=None, + action={"type": "agent", "prompt": "Check"}, ) - assert "description" not in doc + assert "matcher" not in doc["hooks"][0] # --------------------------------------------------------------------------- @@ -316,7 +293,7 @@ def test_writes_hook_document(self, tmp_path: Path) -> None: json_files = list(hooks_dir.glob("*.json")) assert len(json_files) == 1 doc = json.loads(json_files[0].read_text(encoding="utf-8")) - assert doc["then"]["type"] == "runCommand" + assert doc["hooks"][0]["action"]["type"] == "command" def test_adopts_identical_existing_file(self, tmp_path: Path) -> None: """When file already has identical content, it is adopted not re-written.""" @@ -347,15 +324,14 @@ def test_adopts_identical_existing_file(self, tmp_path: Path) -> None: from apm_cli.integration.kiro_hook_integrator import _kiro_hook_document, _safe_hook_slug doc = _kiro_hook_document( - name="my-pkg preToolUse 1", - description=None, - event_name="preToolUse", - patterns=[], - then={"type": "runCommand", "command": "make test"}, + name="my-pkg PreToolUse 1", + event_name="PreToolUse", + matcher=None, + action={"type": "command", "command": "make test"}, ) expected_filename = ( f"{_safe_hook_slug('my-pkg')}-{_safe_hook_slug('hooks')}-" - f"{_safe_hook_slug('preToolUse')}-1.json" + f"{_safe_hook_slug('PreToolUse')}-1.json" ) target = hooks_dir / expected_filename target.write_text(json.dumps(doc, indent=2) + "\n", encoding="utf-8") diff --git a/tests/integration/test_integrators_hooks_execution.py b/tests/integration/test_integrators_hooks_execution.py index 0aa43a702..e2b4f2c5f 100644 --- a/tests/integration/test_integrators_hooks_execution.py +++ b/tests/integration/test_integrators_hooks_execution.py @@ -34,9 +34,11 @@ _HOOK_EVENT_MAP, HookIntegrationResult, HookIntegrator, - _copilot_keys_to_gemini, _filter_hook_files_for_target, _reinject_apm_source_from_sidecar, +) +from apm_cli.integration.hook_native_formats import ( + _copilot_keys_to_gemini, _to_gemini_hook_entries, ) from apm_cli.integration.mcp_integrator import MCPIntegrator @@ -1047,6 +1049,67 @@ def test_integrates_when_codex_dir_exists(self, tmp_path: Path) -> None: result = integrator.integrate_package_hooks_codex(pkg_info, project_root) assert result.hooks_integrated >= 1 + def test_native_config_stays_pure_while_sidecar_owns_reconciliation( + self, + tmp_path: Path, + ) -> None: + """Reinstall and cleanup use external ownership without native fields.""" + project_root = _make_copilot_project(tmp_path) + codex_dir = project_root / ".codex" + codex_dir.mkdir() + native_path = codex_dir / "hooks.json" + native_path.write_text( + json.dumps( + { + "hooks": { + "PreToolUse": [ + {"type": "command", "command": "echo user"}, + ] + } + } + ), + encoding="ascii", + ) + pkg_path = project_root / "apm_modules" / "codex-hooks-pkg" + hooks_dir = pkg_path / ".apm" / "hooks" + hooks_dir.mkdir(parents=True) + (hooks_dir / "codex-hooks.json").write_text( + json.dumps( + { + "hooks": { + "PreToolUse": [ + {"type": "command", "command": "echo apm"}, + ] + } + } + ), + encoding="ascii", + ) + pkg_info = _make_package_info(pkg_path, "codex-hooks-pkg") + integrator = HookIntegrator() + + integrator.integrate_package_hooks_codex(pkg_info, project_root) + integrator.integrate_package_hooks_codex(pkg_info, project_root) + + native = json.loads(native_path.read_text(encoding="ascii")) + entries = native["hooks"]["PreToolUse"] + assert [entry["command"] for entry in entries] == ["echo user", "echo apm"] + assert "_apm_source" not in json.dumps(native) + sidecar_path = codex_dir / "apm-hooks.json" + assert sidecar_path.exists() + + integrator.sync_integration( + _make_apm_package(), + project_root, + managed_files=set(), + ) + + cleaned = json.loads(native_path.read_text(encoding="ascii")) + assert cleaned["hooks"]["PreToolUse"] == [ + {"type": "command", "command": "echo user"}, + ] + assert not sidecar_path.exists() + class TestIntegrateHooksForTarget: """integrate_hooks_for_target: dispatch to copilot vs. merge targets.""" diff --git a/tests/integration/test_integrators_hooks_phase3c.py b/tests/integration/test_integrators_hooks_phase3c.py index 44ec36266..83871cc77 100644 --- a/tests/integration/test_integrators_hooks_phase3c.py +++ b/tests/integration/test_integrators_hooks_phase3c.py @@ -34,9 +34,11 @@ _HOOK_EVENT_MAP, HookIntegrationResult, HookIntegrator, - _copilot_keys_to_gemini, _filter_hook_files_for_target, _reinject_apm_source_from_sidecar, +) +from apm_cli.integration.hook_native_formats import ( + _copilot_keys_to_gemini, _to_gemini_hook_entries, ) from apm_cli.integration.mcp_integrator import MCPIntegrator diff --git a/tests/integration/test_lifecycle_reconciliation.py b/tests/integration/test_lifecycle_reconciliation.py new file mode 100644 index 000000000..57f0d08c9 --- /dev/null +++ b/tests/integration/test_lifecycle_reconciliation.py @@ -0,0 +1,101 @@ +"""Lifecycle-level checks for the canonical deployment owner.""" + +from pathlib import Path + +from apm_cli.core.deployment_state import ( + DeploymentIntent, + DeploymentLedger, + DeploymentLocator, + DeploymentReconciler, + LocatorKind, + MaterializationResult, + MaterializationStatus, + NativePayloadValidation, +) +from apm_cli.core.target_catalog import TARGET_CAPABILITIES +from apm_cli.integration.targets import TargetProfile +from apm_cli.policy.ci_checks import _check_content_integrity +from apm_cli.utils.diagnostics import DiagnosticCollector + + +def test_install_update_compile_uninstall_share_one_owner(tmp_path: Path) -> None: + target = TargetProfile( + capability=TARGET_CAPABILITIES["copilot"], + root_dir=".github", + primitives={}, + ) + reconciler = DeploymentReconciler( + tmp_path, + {"copilot": target}, + diagnostics=DiagnosticCollector(), + ) + locator = DeploymentLocator( + kind=LocatorKind.PROJECT_RELATIVE, + target="copilot", + value=".github/agents/demo.agent.md", + runtime=None, + scope="project", + ) + intent = DeploymentIntent( + active_targets=frozenset({"copilot"}), + declared_targets=frozenset({"copilot"}), + desired_owners=frozenset({"owner/package"}), + authoritative_targets=True, + ) + + ledger = DeploymentLedger(records={}) + for status in ( + MaterializationStatus.WRITTEN, + MaterializationStatus.UNCHANGED, + MaterializationStatus.WRITTEN, + ): + result = MaterializationResult( + locator=locator, + owners=frozenset({"owner/package"}), + status=status, + content_hash="sha256:demo", + validation=NativePayloadValidation(valid=True, contract="file"), + ) + ledger = reconciler.reconcile(ledger, [result], intent).ledger + + uninstall = reconciler.reconcile( + ledger, + [ + MaterializationResult( + locator=locator, + owners=frozenset({"owner/package"}), + status=MaterializationStatus.REMOVED, + content_hash=None, + validation=NativePayloadValidation(valid=True, contract="file"), + ) + ], + DeploymentIntent( + active_targets=frozenset({"copilot"}), + declared_targets=frozenset({"copilot"}), + desired_owners=frozenset(), + authoritative_targets=True, + ), + ) + + assert uninstall.ledger.records == {} + assert uninstall.removed == (locator,) + + +def test_content_integrity_fails_when_ownership_row_is_absent( + tmp_path: Path, +) -> None: + from apm_cli.deps.lockfile import LockFile + + path = tmp_path / ".github" / "agents" / "demo.agent.md" + path.parent.mkdir(parents=True) + path.write_text("demo", encoding="utf-8") + lockfile = LockFile( + local_deployed_files=[".github/agents/demo.agent.md"], + local_deployed_file_hashes={".github/agents/demo.agent.md": "sha256:missing"}, + ) + lockfile._deployments_present = True + + result = _check_content_integrity(tmp_path, lockfile) + + assert result.passed is False + assert result.details == ["missing-ownership: .github/agents/demo.agent.md"] diff --git a/tests/integration/test_mcp_install_flow.py b/tests/integration/test_mcp_install_flow.py index 183800686..cf7555243 100644 --- a/tests/integration/test_mcp_install_flow.py +++ b/tests/integration/test_mcp_install_flow.py @@ -12,6 +12,7 @@ from unittest.mock import MagicMock, patch import pytest +import tomlkit import yaml from click.testing import CliRunner @@ -94,6 +95,123 @@ def test_install_restores_dev_mcp_dependencies_to_lockfile_and_config(tmp_path, assert lockfile.mcp_configs["dev-server"]["command"] == "python" +def test_install_target_contraction_removes_only_apm_managed_mcp_servers(tmp_path, monkeypatch): + """Reinstalling with fewer targets purges APM-owned entries from dropped targets.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + LockFile().write(tmp_path / "apm.lock.yaml") + manifest = { + "name": "mcp-target-contraction", + "version": "0.0.1", + "targets": ["copilot", "codex"], + "dependencies": { + "mcp": [ + { + "name": "apm-managed", + "registry": False, + "transport": "stdio", + "command": "echo", + "args": ["managed"], + } + ] + }, + } + (tmp_path / "apm.yml").write_text(yaml.safe_dump(manifest), encoding="utf-8") + codex_config = tmp_path / ".codex" / "config.toml" + codex_config.parent.mkdir() + codex_config.write_text( + "[projects.'c:\\src\\project']\n" + 'trust_level = "trusted"\n' + "\n" + "[mcp_servers.user-authored]\n" + 'command = "user-command"\n', + encoding="utf-8", + ) + + broad = CliRunner().invoke( + cli, + ["install", "--target", "copilot,codex", "--no-policy"], + ) + assert broad.exit_code == 0, broad.output + broad_config = tomlkit.parse(codex_config.read_text(encoding="utf-8")) + assert broad_config["mcp_servers"]["apm-managed"]["command"] == "echo" + broad_lock = LockFile.read(tmp_path / "apm.lock.yaml") + assert broad_lock is not None + assert broad_lock.mcp_target_servers == { + "codex": ["apm-managed"], + "vscode": ["apm-managed"], + } + + manifest["targets"] = ["copilot"] + (tmp_path / "apm.yml").write_text(yaml.safe_dump(manifest), encoding="utf-8") + contracted = CliRunner().invoke( + cli, + ["install", "--target", "copilot", "--no-policy"], + ) + + assert contracted.exit_code == 0, contracted.output + updated_text = codex_config.read_text(encoding="utf-8") + updated = tomlkit.parse(updated_text) + assert "apm-managed" not in updated["mcp_servers"] + assert updated["mcp_servers"]["user-authored"]["command"] == "user-command" + assert updated["projects"][r"c:\src\project"]["trust_level"] == "trusted" + contracted_lock = LockFile.read(tmp_path / "apm.lock.yaml") + assert contracted_lock is not None + assert contracted_lock.mcp_target_servers == {"copilot": ["apm-managed"]} + + +def test_legacy_lockfile_adopts_exact_mcp_baseline_before_target_contraction( + tmp_path, + monkeypatch, +) -> None: + """A pre-ownership lock adopts exact native entries, then removes dropped targets.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + LockFile().write(tmp_path / "apm.lock.yaml") + manifest = { + "name": "legacy-mcp-target-contraction", + "version": "0.0.1", + "targets": ["copilot", "codex"], + "dependencies": { + "mcp": [ + { + "name": "apm-managed", + "registry": False, + "transport": "stdio", + "command": "echo", + "args": ["managed"], + } + ] + }, + } + (tmp_path / "apm.yml").write_text(yaml.safe_dump(manifest), encoding="utf-8") + broad = CliRunner().invoke( + cli, + ["install", "--target", "copilot,codex", "--no-policy"], + ) + assert broad.exit_code == 0, broad.output + + lock_path = tmp_path / "apm.lock.yaml" + legacy_data = yaml.safe_load(lock_path.read_text(encoding="utf-8")) + legacy_data.pop("mcp_target_servers", None) + legacy_data.pop("deployments", None) + lock_path.write_text(yaml.safe_dump(legacy_data), encoding="utf-8") + + manifest["targets"] = ["copilot"] + (tmp_path / "apm.yml").write_text(yaml.safe_dump(manifest), encoding="utf-8") + contracted = CliRunner().invoke( + cli, + ["install", "--target", "copilot", "--no-policy"], + ) + + assert contracted.exit_code == 0, contracted.output + codex_config = tomlkit.parse((tmp_path / ".codex" / "config.toml").read_text(encoding="utf-8")) + assert "apm-managed" not in codex_config.get("mcp_servers", {}) + migrated = LockFile.read(lock_path) + assert migrated is not None + assert migrated.mcp_target_servers == {"copilot": ["apm-managed"]} + + # --------------------------------------------------------------------------- # Early-return and no-deps branches -- line 70 # --------------------------------------------------------------------------- diff --git a/tests/integration/test_mcp_integrator_characterisation.py b/tests/integration/test_mcp_integrator_characterisation.py index a9f13fab9..70b9d8f79 100644 --- a/tests/integration/test_mcp_integrator_characterisation.py +++ b/tests/integration/test_mcp_integrator_characterisation.py @@ -84,263 +84,33 @@ def test_uses_current_working_directory_when_project_root_missing(self, tmp_path class TestCollectTransitive: - def test_returns_empty_when_modules_dir_missing(self, tmp_path: Path) -> None: - result = MCPIntegrator.collect_transitive(tmp_path / "apm_modules") - - assert result == [] - - def test_falls_back_to_scan_when_no_lockfile(self, tmp_path: Path) -> None: - apm_modules = tmp_path / "apm_modules" - package_dir = apm_modules / "owner" / "repo" - (package_dir / "apm.yml").parent.mkdir(parents=True) - (package_dir / "apm.yml").write_text("name: pkg\n", encoding="utf-8") - dep = _make_dep(name="scan-server") - pkg = MagicMock() - pkg.name = "pkg" - pkg.get_mcp_dependencies.return_value = [dep] - - with patch("apm_cli.models.apm_package.APMPackage") as mock_pkg_cls: - mock_pkg_cls.from_apm_yml.return_value = pkg - result = MCPIntegrator.collect_transitive(apm_modules) - - assert result == [dep] - mock_pkg_cls.from_apm_yml.assert_called_once_with(package_dir / "apm.yml") - - def test_falls_back_to_scan_when_lock_read_returns_none(self, tmp_path: Path) -> None: - apm_modules = tmp_path / "apm_modules" - package_dir = apm_modules / "owner" / "repo" - (package_dir / "apm.yml").parent.mkdir(parents=True) - (package_dir / "apm.yml").write_text("name: pkg\n", encoding="utf-8") + def test_delegates_to_current_config_owner(self, tmp_path: Path) -> None: + expected = [_make_dep(name="server")] + modules_root = tmp_path / "apm_modules" lock_path = tmp_path / "apm.lock.yaml" - lock_path.write_text("lock_version: '1'\n", encoding="utf-8") - dep = _make_dep(name="scan-server") - pkg = MagicMock() - pkg.name = "pkg" - pkg.get_mcp_dependencies.return_value = [dep] - - with ( - patch("apm_cli.integration.mcp_integrator.LockFile.read", return_value=None), - patch("apm_cli.models.apm_package.APMPackage") as mock_pkg_cls, - ): - mock_pkg_cls.from_apm_yml.return_value = pkg - result = MCPIntegrator.collect_transitive(apm_modules, lock_path) - - assert result == [dep] - mock_pkg_cls.from_apm_yml.assert_called_once_with(package_dir / "apm.yml") - - def test_uses_lockfile_paths_and_skips_missing_apm_yml(self, tmp_path: Path) -> None: - apm_modules = tmp_path / "apm_modules" - existing_dir = apm_modules / "owner" / "repo" - (existing_dir / "apm.yml").parent.mkdir(parents=True) - (existing_dir / "apm.yml").write_text("name: pkg\n", encoding="utf-8") - lock_path = tmp_path / "apm.lock.yaml" - lock_path.write_text("lock_version: '1'\n", encoding="utf-8") - lockfile = MagicMock() - lockfile.get_package_dependencies.return_value = [ - _make_lock_dep("owner/repo", depth=1), - _make_lock_dep("owner/missing", depth=1), - ] - dep = _make_dep(name="locked-server") - pkg = MagicMock() - pkg.name = "pkg" - pkg.get_mcp_dependencies.return_value = [dep] - - with ( - patch("apm_cli.integration.mcp_integrator.LockFile.read", return_value=lockfile), - patch("apm_cli.models.apm_package.APMPackage") as mock_pkg_cls, - ): - mock_pkg_cls.from_apm_yml.return_value = pkg - result = MCPIntegrator.collect_transitive(apm_modules, lock_path) - - assert result == [dep] - mock_pkg_cls.from_apm_yml.assert_called_once_with(existing_dir / "apm.yml") - - def test_trusts_direct_self_defined_dependency(self, tmp_path: Path) -> None: - apm_modules = tmp_path / "apm_modules" - package_dir = apm_modules / "owner" / "repo" - (package_dir / "apm.yml").parent.mkdir(parents=True) - yml_path = package_dir / "apm.yml" - yml_path.write_text("name: pkg\n", encoding="utf-8") - lock_path = tmp_path / "apm.lock.yaml" - lock_path.write_text("lock_version: '1'\n", encoding="utf-8") - lockfile = MagicMock() - lockfile.get_package_dependencies.return_value = [_make_lock_dep("owner/repo", depth=1)] - dep = _make_dep(name="direct-private", is_self_defined=True) - pkg = MagicMock() - pkg.name = "pkg" - pkg.get_mcp_dependencies.return_value = [dep] logger = MagicMock() - - with ( - patch("apm_cli.integration.mcp_integrator.LockFile.read", return_value=lockfile), - patch("apm_cli.models.apm_package.APMPackage") as mock_pkg_cls, - ): - mock_pkg_cls.from_apm_yml.return_value = pkg - result = MCPIntegrator.collect_transitive(apm_modules, lock_path, logger=logger) - - assert result == [dep] - logger.progress.assert_called_once() - assert "Trusting direct dependency MCP" in logger.progress.call_args.args[0] - mock_pkg_cls.from_apm_yml.assert_called_once_with(yml_path) - - def test_skips_untrusted_transitive_self_defined_dependency_with_diagnostics( - self, tmp_path: Path - ) -> None: - apm_modules = tmp_path / "apm_modules" - package_dir = apm_modules / "owner" / "repo" - (package_dir / "apm.yml").parent.mkdir(parents=True) - (package_dir / "apm.yml").write_text("name: pkg\n", encoding="utf-8") - lock_path = tmp_path / "apm.lock.yaml" - lock_path.write_text("lock_version: '1'\n", encoding="utf-8") - lockfile = MagicMock() - lockfile.get_package_dependencies.return_value = [_make_lock_dep("owner/repo", depth=2)] - dep = _make_dep(name="transitive-private", is_self_defined=True) - pkg = MagicMock() - pkg.name = "pkg" - pkg.get_mcp_dependencies.return_value = [dep] diagnostics = MagicMock() - logger = MagicMock() - - with ( - patch("apm_cli.integration.mcp_integrator.LockFile.read", return_value=lockfile), - patch("apm_cli.models.apm_package.APMPackage") as mock_pkg_cls, - ): - mock_pkg_cls.from_apm_yml.return_value = pkg - result = MCPIntegrator.collect_transitive( - apm_modules, - lock_path, - trust_private=False, - logger=logger, - diagnostics=diagnostics, - ) - - assert result == [] - diagnostics.warn.assert_called_once() - logger.warning.assert_not_called() - assert "Re-declare it in your apm.yml" in diagnostics.warn.call_args.args[0] - - def test_skips_untrusted_transitive_self_defined_dependency_with_logger( - self, tmp_path: Path - ) -> None: - apm_modules = tmp_path / "apm_modules" - package_dir = apm_modules / "owner" / "repo" - (package_dir / "apm.yml").parent.mkdir(parents=True) - (package_dir / "apm.yml").write_text("name: pkg\n", encoding="utf-8") - lock_path = tmp_path / "apm.lock.yaml" - lock_path.write_text("lock_version: '1'\n", encoding="utf-8") - lockfile = MagicMock() - lockfile.get_package_dependencies.return_value = [_make_lock_dep("owner/repo", depth=2)] - dep = _make_dep(name="transitive-private", is_self_defined=True) - pkg = MagicMock() - pkg.name = "pkg" - pkg.get_mcp_dependencies.return_value = [dep] - logger = MagicMock() - - with ( - patch("apm_cli.integration.mcp_integrator.LockFile.read", return_value=lockfile), - patch("apm_cli.models.apm_package.APMPackage") as mock_pkg_cls, - ): - mock_pkg_cls.from_apm_yml.return_value = pkg - result = MCPIntegrator.collect_transitive( - apm_modules, - lock_path, - trust_private=False, - logger=logger, - ) - - assert result == [] - logger.warning.assert_called_once() - assert "registry: false" in logger.warning.call_args.args[0] - - def test_trusts_transitive_self_defined_dependency_when_enabled(self, tmp_path: Path) -> None: - apm_modules = tmp_path / "apm_modules" - package_dir = apm_modules / "owner" / "repo" - (package_dir / "apm.yml").parent.mkdir(parents=True) - (package_dir / "apm.yml").write_text("name: pkg\n", encoding="utf-8") - lock_path = tmp_path / "apm.lock.yaml" - lock_path.write_text("lock_version: '1'\n", encoding="utf-8") - lockfile = MagicMock() - lockfile.get_package_dependencies.return_value = [_make_lock_dep("owner/repo", depth=2)] - dep = _make_dep(name="transitive-private", is_self_defined=True) - pkg = MagicMock() - pkg.name = "pkg" - pkg.get_mcp_dependencies.return_value = [dep] - logger = MagicMock() - with ( - patch("apm_cli.integration.mcp_integrator.LockFile.read", return_value=lockfile), - patch("apm_cli.models.apm_package.APMPackage") as mock_pkg_cls, - ): - mock_pkg_cls.from_apm_yml.return_value = pkg + with patch( + "apm_cli.integration.mcp_integrator._collect_transitive_compat", + return_value=expected, + ) as collect: result = MCPIntegrator.collect_transitive( - apm_modules, + modules_root, lock_path, trust_private=True, logger=logger, + diagnostics=diagnostics, ) - assert result == [dep] - logger.progress.assert_called_once() - assert "--trust-transitive-mcp" in logger.progress.call_args.args[0] - - def test_skips_parse_errors_and_continues(self, tmp_path: Path) -> None: - apm_modules = tmp_path / "apm_modules" - broken_dir = apm_modules / "owner" / "broken" - good_dir = apm_modules / "owner" / "good" - for package_dir in (broken_dir, good_dir): - (package_dir / "apm.yml").parent.mkdir(parents=True) - (package_dir / "apm.yml").write_text("name: pkg\n", encoding="utf-8") - first = broken_dir / "apm.yml" - second = good_dir / "apm.yml" - dep = _make_dep(name="good-server") - pkg = MagicMock() - pkg.name = "good" - pkg.get_mcp_dependencies.return_value = [dep] - - # Side-effect order must follow the filesystem-discovery order of - # apm.yml files (which Path.iterdir does not guarantee to be - # alphabetic across OSes/filesystems). Determine the order - # lazily so the bad-then-good (ValueError-then-pkg) sequence - # always lines up with whichever directory is enumerated first. - def _from_apm_yml(path, *_args, **_kw): - if path == first: - raise ValueError("bad") - return pkg - - with patch("apm_cli.models.apm_package.APMPackage") as mock_pkg_cls: - mock_pkg_cls.from_apm_yml.side_effect = _from_apm_yml - result = MCPIntegrator.collect_transitive(apm_modules) - - assert result == [dep] - called_paths = {call.args[0] for call in mock_pkg_cls.from_apm_yml.call_args_list} - assert called_paths == {first, second} - - def test_supports_lock_entries_with_virtual_path(self, tmp_path: Path) -> None: - apm_modules = tmp_path / "apm_modules" - package_dir = apm_modules / "owner" / "repo" / "subpkg" - (package_dir / "apm.yml").parent.mkdir(parents=True) - yml_path = package_dir / "apm.yml" - yml_path.write_text("name: pkg\n", encoding="utf-8") - lock_path = tmp_path / "apm.lock.yaml" - lock_path.write_text("lock_version: '1'\n", encoding="utf-8") - lockfile = MagicMock() - lockfile.get_package_dependencies.return_value = [ - _make_lock_dep("owner/repo", depth=1, virtual_path="subpkg") - ] - dep = _make_dep(name="virtual-server") - pkg = MagicMock() - pkg.name = "pkg" - pkg.get_mcp_dependencies.return_value = [dep] - - with ( - patch("apm_cli.integration.mcp_integrator.LockFile.read", return_value=lockfile), - patch("apm_cli.models.apm_package.APMPackage") as mock_pkg_cls, - ): - mock_pkg_cls.from_apm_yml.return_value = pkg - result = MCPIntegrator.collect_transitive(apm_modules, lock_path) - - assert result == [dep] - mock_pkg_cls.from_apm_yml.assert_called_once_with(yml_path) + assert result == expected + collect.assert_called_once_with( + modules_root, + lock_path, + True, + logger=logger, + diagnostics=diagnostics, + ) class TestDeduplicate: diff --git a/tests/integration/test_mcp_integrator_phase3w5.py b/tests/integration/test_mcp_integrator_phase3w5.py index a9f13fab9..70b9d8f79 100644 --- a/tests/integration/test_mcp_integrator_phase3w5.py +++ b/tests/integration/test_mcp_integrator_phase3w5.py @@ -84,263 +84,33 @@ def test_uses_current_working_directory_when_project_root_missing(self, tmp_path class TestCollectTransitive: - def test_returns_empty_when_modules_dir_missing(self, tmp_path: Path) -> None: - result = MCPIntegrator.collect_transitive(tmp_path / "apm_modules") - - assert result == [] - - def test_falls_back_to_scan_when_no_lockfile(self, tmp_path: Path) -> None: - apm_modules = tmp_path / "apm_modules" - package_dir = apm_modules / "owner" / "repo" - (package_dir / "apm.yml").parent.mkdir(parents=True) - (package_dir / "apm.yml").write_text("name: pkg\n", encoding="utf-8") - dep = _make_dep(name="scan-server") - pkg = MagicMock() - pkg.name = "pkg" - pkg.get_mcp_dependencies.return_value = [dep] - - with patch("apm_cli.models.apm_package.APMPackage") as mock_pkg_cls: - mock_pkg_cls.from_apm_yml.return_value = pkg - result = MCPIntegrator.collect_transitive(apm_modules) - - assert result == [dep] - mock_pkg_cls.from_apm_yml.assert_called_once_with(package_dir / "apm.yml") - - def test_falls_back_to_scan_when_lock_read_returns_none(self, tmp_path: Path) -> None: - apm_modules = tmp_path / "apm_modules" - package_dir = apm_modules / "owner" / "repo" - (package_dir / "apm.yml").parent.mkdir(parents=True) - (package_dir / "apm.yml").write_text("name: pkg\n", encoding="utf-8") + def test_delegates_to_current_config_owner(self, tmp_path: Path) -> None: + expected = [_make_dep(name="server")] + modules_root = tmp_path / "apm_modules" lock_path = tmp_path / "apm.lock.yaml" - lock_path.write_text("lock_version: '1'\n", encoding="utf-8") - dep = _make_dep(name="scan-server") - pkg = MagicMock() - pkg.name = "pkg" - pkg.get_mcp_dependencies.return_value = [dep] - - with ( - patch("apm_cli.integration.mcp_integrator.LockFile.read", return_value=None), - patch("apm_cli.models.apm_package.APMPackage") as mock_pkg_cls, - ): - mock_pkg_cls.from_apm_yml.return_value = pkg - result = MCPIntegrator.collect_transitive(apm_modules, lock_path) - - assert result == [dep] - mock_pkg_cls.from_apm_yml.assert_called_once_with(package_dir / "apm.yml") - - def test_uses_lockfile_paths_and_skips_missing_apm_yml(self, tmp_path: Path) -> None: - apm_modules = tmp_path / "apm_modules" - existing_dir = apm_modules / "owner" / "repo" - (existing_dir / "apm.yml").parent.mkdir(parents=True) - (existing_dir / "apm.yml").write_text("name: pkg\n", encoding="utf-8") - lock_path = tmp_path / "apm.lock.yaml" - lock_path.write_text("lock_version: '1'\n", encoding="utf-8") - lockfile = MagicMock() - lockfile.get_package_dependencies.return_value = [ - _make_lock_dep("owner/repo", depth=1), - _make_lock_dep("owner/missing", depth=1), - ] - dep = _make_dep(name="locked-server") - pkg = MagicMock() - pkg.name = "pkg" - pkg.get_mcp_dependencies.return_value = [dep] - - with ( - patch("apm_cli.integration.mcp_integrator.LockFile.read", return_value=lockfile), - patch("apm_cli.models.apm_package.APMPackage") as mock_pkg_cls, - ): - mock_pkg_cls.from_apm_yml.return_value = pkg - result = MCPIntegrator.collect_transitive(apm_modules, lock_path) - - assert result == [dep] - mock_pkg_cls.from_apm_yml.assert_called_once_with(existing_dir / "apm.yml") - - def test_trusts_direct_self_defined_dependency(self, tmp_path: Path) -> None: - apm_modules = tmp_path / "apm_modules" - package_dir = apm_modules / "owner" / "repo" - (package_dir / "apm.yml").parent.mkdir(parents=True) - yml_path = package_dir / "apm.yml" - yml_path.write_text("name: pkg\n", encoding="utf-8") - lock_path = tmp_path / "apm.lock.yaml" - lock_path.write_text("lock_version: '1'\n", encoding="utf-8") - lockfile = MagicMock() - lockfile.get_package_dependencies.return_value = [_make_lock_dep("owner/repo", depth=1)] - dep = _make_dep(name="direct-private", is_self_defined=True) - pkg = MagicMock() - pkg.name = "pkg" - pkg.get_mcp_dependencies.return_value = [dep] logger = MagicMock() - - with ( - patch("apm_cli.integration.mcp_integrator.LockFile.read", return_value=lockfile), - patch("apm_cli.models.apm_package.APMPackage") as mock_pkg_cls, - ): - mock_pkg_cls.from_apm_yml.return_value = pkg - result = MCPIntegrator.collect_transitive(apm_modules, lock_path, logger=logger) - - assert result == [dep] - logger.progress.assert_called_once() - assert "Trusting direct dependency MCP" in logger.progress.call_args.args[0] - mock_pkg_cls.from_apm_yml.assert_called_once_with(yml_path) - - def test_skips_untrusted_transitive_self_defined_dependency_with_diagnostics( - self, tmp_path: Path - ) -> None: - apm_modules = tmp_path / "apm_modules" - package_dir = apm_modules / "owner" / "repo" - (package_dir / "apm.yml").parent.mkdir(parents=True) - (package_dir / "apm.yml").write_text("name: pkg\n", encoding="utf-8") - lock_path = tmp_path / "apm.lock.yaml" - lock_path.write_text("lock_version: '1'\n", encoding="utf-8") - lockfile = MagicMock() - lockfile.get_package_dependencies.return_value = [_make_lock_dep("owner/repo", depth=2)] - dep = _make_dep(name="transitive-private", is_self_defined=True) - pkg = MagicMock() - pkg.name = "pkg" - pkg.get_mcp_dependencies.return_value = [dep] diagnostics = MagicMock() - logger = MagicMock() - - with ( - patch("apm_cli.integration.mcp_integrator.LockFile.read", return_value=lockfile), - patch("apm_cli.models.apm_package.APMPackage") as mock_pkg_cls, - ): - mock_pkg_cls.from_apm_yml.return_value = pkg - result = MCPIntegrator.collect_transitive( - apm_modules, - lock_path, - trust_private=False, - logger=logger, - diagnostics=diagnostics, - ) - - assert result == [] - diagnostics.warn.assert_called_once() - logger.warning.assert_not_called() - assert "Re-declare it in your apm.yml" in diagnostics.warn.call_args.args[0] - - def test_skips_untrusted_transitive_self_defined_dependency_with_logger( - self, tmp_path: Path - ) -> None: - apm_modules = tmp_path / "apm_modules" - package_dir = apm_modules / "owner" / "repo" - (package_dir / "apm.yml").parent.mkdir(parents=True) - (package_dir / "apm.yml").write_text("name: pkg\n", encoding="utf-8") - lock_path = tmp_path / "apm.lock.yaml" - lock_path.write_text("lock_version: '1'\n", encoding="utf-8") - lockfile = MagicMock() - lockfile.get_package_dependencies.return_value = [_make_lock_dep("owner/repo", depth=2)] - dep = _make_dep(name="transitive-private", is_self_defined=True) - pkg = MagicMock() - pkg.name = "pkg" - pkg.get_mcp_dependencies.return_value = [dep] - logger = MagicMock() - - with ( - patch("apm_cli.integration.mcp_integrator.LockFile.read", return_value=lockfile), - patch("apm_cli.models.apm_package.APMPackage") as mock_pkg_cls, - ): - mock_pkg_cls.from_apm_yml.return_value = pkg - result = MCPIntegrator.collect_transitive( - apm_modules, - lock_path, - trust_private=False, - logger=logger, - ) - - assert result == [] - logger.warning.assert_called_once() - assert "registry: false" in logger.warning.call_args.args[0] - - def test_trusts_transitive_self_defined_dependency_when_enabled(self, tmp_path: Path) -> None: - apm_modules = tmp_path / "apm_modules" - package_dir = apm_modules / "owner" / "repo" - (package_dir / "apm.yml").parent.mkdir(parents=True) - (package_dir / "apm.yml").write_text("name: pkg\n", encoding="utf-8") - lock_path = tmp_path / "apm.lock.yaml" - lock_path.write_text("lock_version: '1'\n", encoding="utf-8") - lockfile = MagicMock() - lockfile.get_package_dependencies.return_value = [_make_lock_dep("owner/repo", depth=2)] - dep = _make_dep(name="transitive-private", is_self_defined=True) - pkg = MagicMock() - pkg.name = "pkg" - pkg.get_mcp_dependencies.return_value = [dep] - logger = MagicMock() - with ( - patch("apm_cli.integration.mcp_integrator.LockFile.read", return_value=lockfile), - patch("apm_cli.models.apm_package.APMPackage") as mock_pkg_cls, - ): - mock_pkg_cls.from_apm_yml.return_value = pkg + with patch( + "apm_cli.integration.mcp_integrator._collect_transitive_compat", + return_value=expected, + ) as collect: result = MCPIntegrator.collect_transitive( - apm_modules, + modules_root, lock_path, trust_private=True, logger=logger, + diagnostics=diagnostics, ) - assert result == [dep] - logger.progress.assert_called_once() - assert "--trust-transitive-mcp" in logger.progress.call_args.args[0] - - def test_skips_parse_errors_and_continues(self, tmp_path: Path) -> None: - apm_modules = tmp_path / "apm_modules" - broken_dir = apm_modules / "owner" / "broken" - good_dir = apm_modules / "owner" / "good" - for package_dir in (broken_dir, good_dir): - (package_dir / "apm.yml").parent.mkdir(parents=True) - (package_dir / "apm.yml").write_text("name: pkg\n", encoding="utf-8") - first = broken_dir / "apm.yml" - second = good_dir / "apm.yml" - dep = _make_dep(name="good-server") - pkg = MagicMock() - pkg.name = "good" - pkg.get_mcp_dependencies.return_value = [dep] - - # Side-effect order must follow the filesystem-discovery order of - # apm.yml files (which Path.iterdir does not guarantee to be - # alphabetic across OSes/filesystems). Determine the order - # lazily so the bad-then-good (ValueError-then-pkg) sequence - # always lines up with whichever directory is enumerated first. - def _from_apm_yml(path, *_args, **_kw): - if path == first: - raise ValueError("bad") - return pkg - - with patch("apm_cli.models.apm_package.APMPackage") as mock_pkg_cls: - mock_pkg_cls.from_apm_yml.side_effect = _from_apm_yml - result = MCPIntegrator.collect_transitive(apm_modules) - - assert result == [dep] - called_paths = {call.args[0] for call in mock_pkg_cls.from_apm_yml.call_args_list} - assert called_paths == {first, second} - - def test_supports_lock_entries_with_virtual_path(self, tmp_path: Path) -> None: - apm_modules = tmp_path / "apm_modules" - package_dir = apm_modules / "owner" / "repo" / "subpkg" - (package_dir / "apm.yml").parent.mkdir(parents=True) - yml_path = package_dir / "apm.yml" - yml_path.write_text("name: pkg\n", encoding="utf-8") - lock_path = tmp_path / "apm.lock.yaml" - lock_path.write_text("lock_version: '1'\n", encoding="utf-8") - lockfile = MagicMock() - lockfile.get_package_dependencies.return_value = [ - _make_lock_dep("owner/repo", depth=1, virtual_path="subpkg") - ] - dep = _make_dep(name="virtual-server") - pkg = MagicMock() - pkg.name = "pkg" - pkg.get_mcp_dependencies.return_value = [dep] - - with ( - patch("apm_cli.integration.mcp_integrator.LockFile.read", return_value=lockfile), - patch("apm_cli.models.apm_package.APMPackage") as mock_pkg_cls, - ): - mock_pkg_cls.from_apm_yml.return_value = pkg - result = MCPIntegrator.collect_transitive(apm_modules, lock_path) - - assert result == [dep] - mock_pkg_cls.from_apm_yml.assert_called_once_with(yml_path) + assert result == expected + collect.assert_called_once_with( + modules_root, + lock_path, + True, + logger=logger, + diagnostics=diagnostics, + ) class TestDeduplicate: diff --git a/tests/integration/test_pack_install_flow.py b/tests/integration/test_pack_install_flow.py index 83fa81fda..f67041915 100644 --- a/tests/integration/test_pack_install_flow.py +++ b/tests/integration/test_pack_install_flow.py @@ -14,8 +14,6 @@ from apm_cli.commands.install import ( _check_package_conflicts, - _maybe_rollback_manifest, - _restore_manifest_from_snapshot, _split_argv_at_double_dash, _validate_and_add_packages_to_apm_yml, ) @@ -26,6 +24,10 @@ _render_marketplace_result, pack_cmd, ) +from apm_cli.install.transaction import ( + _maybe_rollback_manifest, + _restore_manifest_from_snapshot, +) # --------------------------------------------------------------------------- # Shared fixtures / helpers diff --git a/tests/integration/test_pack_install_phase3w4.py b/tests/integration/test_pack_install_phase3w4.py index d634912e6..2c3f578f5 100644 --- a/tests/integration/test_pack_install_phase3w4.py +++ b/tests/integration/test_pack_install_phase3w4.py @@ -14,8 +14,6 @@ from apm_cli.commands.install import ( _check_package_conflicts, - _maybe_rollback_manifest, - _restore_manifest_from_snapshot, _split_argv_at_double_dash, _validate_and_add_packages_to_apm_yml, ) @@ -26,6 +24,10 @@ _render_marketplace_result, pack_cmd, ) +from apm_cli.install.transaction import ( + _maybe_rollback_manifest, + _restore_manifest_from_snapshot, +) # --------------------------------------------------------------------------- # Shared fixtures / helpers diff --git a/tests/integration/test_policy_install_e2e.py b/tests/integration/test_policy_install_e2e.py index c7a15481f..4239ecb71 100644 --- a/tests/integration/test_policy_install_e2e.py +++ b/tests/integration/test_policy_install_e2e.py @@ -131,7 +131,7 @@ def _write_apm_yml( deps: list | None = None, mcp: list | None = None, target: str | None = None, -) -> None: +) -> Path: """Write a minimal apm.yml.""" data: dict = {"name": name, "version": "1.0.0", "dependencies": {}} if deps: @@ -162,6 +162,7 @@ def _make_pkg( deps=apm_deps, mcp=mcp, ) + return pkg_dir def _seed_lockfile(path: Path, locked_deps: list, mcp_servers: list | None = None): @@ -510,7 +511,7 @@ def test_transitive_mcp_blocked( # Root depends on carrier-pkg, which has a denied MCP dep _write_apm_yml(project_dir / "apm.yml", deps=["acme/carrier-pkg"]) - _make_pkg( + carrier_dir = _make_pkg( apm_modules, "acme/carrier-pkg", mcp=["io.github.untrusted/evil-mcp-server"], @@ -535,6 +536,17 @@ def test_transitive_mcp_blocked( fetch = _make_fetch_result("found", policy=policy) mock_gate.return_value = fetch mock_preflight.return_value = fetch + downloaded = mock_dl.return_value.download_package.return_value + downloaded.resolved_reference.resolved_commit = "0" * 40 + downloaded.resolved_reference.ref_name = "main" + downloaded.resolved_reference.is_branch = True + downloaded.resolved_reference.is_tag = False + downloaded.resolved_reference.is_sha = False + downloaded.package_type.value = "apm_package" + from apm_cli.models.apm_package import APMPackage + + downloaded.package = APMPackage.from_apm_yml(carrier_dir / "apm.yml") + downloaded.install_path = carrier_dir result = _invoke_install(runner, ["--trust-transitive-mcp"]) diff --git a/tests/integration/test_transitive_chain_e2e.py b/tests/integration/test_transitive_chain_e2e.py index f2348085a..2a9c4c984 100644 --- a/tests/integration/test_transitive_chain_e2e.py +++ b/tests/integration/test_transitive_chain_e2e.py @@ -103,9 +103,11 @@ def test_three_level_apm_chain_resolves_all_levels(chain_workspace, apm_command) assert result.returncode == 0, f"Install failed: {result.stderr}\n{result.stdout}" modules_local = consumer / "apm_modules" / "_local" - for name in ("pkg-a", "pkg-b", "pkg-c"): - assert (modules_local / name / "apm.yml").exists(), ( - f"Transitive package {name} not materialised under apm_modules/_local/" + assert (modules_local / "pkg-a" / "apm.yml").exists() + for name in ("pkg-b", "pkg-c"): + matches = list(modules_local.glob(f"*/{name}/apm.yml")) + assert len(matches) == 1, ( + f"Transitive package {name} not materialised in its parent-scoped slot" ) deps = _deps_by_name(_load_lockfile(consumer)) @@ -155,7 +157,7 @@ def test_three_level_chain_uninstall_root_cascades(chain_workspace, apm_command) modules_local = consumer / "apm_modules" / "_local" for name in ("pkg-a", "pkg-b", "pkg-c"): - assert not (modules_local / name).exists(), ( + assert not list(modules_local.rglob(name)), ( f"Transitive orphan {name} not cleaned from apm_modules/_local/" ) @@ -224,7 +226,7 @@ def test_asymmetric_layout_anchors_on_declaring_pkg(tmp_path, apm_command): # anchor is on specialized/, not on consumer/. Install path uses the # source-dir basename (NOT the apm.yml `name` field). assert (consumer / "apm_modules" / "_local" / "specialized").exists() - assert (consumer / "apm_modules" / "_local" / "base").exists() + assert len(list((consumer / "apm_modules" / "_local").glob("*/base"))) == 1 # No "outside the project root" rejection should appear in either stream. combined = result.stdout + result.stderr assert "outside the project root" not in combined, combined diff --git a/tests/integration/test_transitive_mcp_audit_e2e.py b/tests/integration/test_transitive_mcp_audit_e2e.py index eff5afcf2..19ecc626d 100644 --- a/tests/integration/test_transitive_mcp_audit_e2e.py +++ b/tests/integration/test_transitive_mcp_audit_e2e.py @@ -62,8 +62,8 @@ def _write_workspace(root: Path) -> Path: return project -def test_force_install_then_ci_audit_accepts_transitive_mcp(tmp_path: Path) -> None: - """A clean forced install must not report the transitive server as orphaned.""" +def test_ci_audit_tracks_current_transitive_mcp_source(tmp_path: Path) -> None: + """Audit accepts installed transitive MCP, then rejects its removal.""" project = _write_workspace(tmp_path) install = _run( @@ -88,3 +88,30 @@ def test_force_install_then_ci_audit_accepts_transitive_mcp(tmp_path: Path) -> N check for check in payload["checks"] if check["name"] == "config-consistency" ) assert config_check["passed"] is True, config_check + + package_manifest = project / "packages" / "agent-config" / "apm.yml" + package_manifest.write_text( + "name: agent-config\nversion: 1.0.0\n", + encoding="utf-8", + ) + + changed_audit = _run( + project, + "audit", + "--ci", + "--no-policy", + "--no-fail-fast", + "-f", + "json", + ) + assert changed_audit.returncode == 1, ( + f"audit stdout:\n{changed_audit.stdout}\naudit stderr:\n{changed_audit.stderr}" + ) + changed_payload = json.loads(changed_audit.stdout) + changed_check = next( + check for check in changed_payload["checks"] if check["name"] == "config-consistency" + ) + assert changed_check["passed"] is False + assert changed_check["details"] == [ + "shadcn: in lockfile but not in manifest (declared by agent-config)" + ] diff --git a/tests/integration/test_uninstall_policy_pack_flow.py b/tests/integration/test_uninstall_policy_pack_flow.py index 039aa2b2f..0530cc653 100644 --- a/tests/integration/test_uninstall_policy_pack_flow.py +++ b/tests/integration/test_uninstall_policy_pack_flow.py @@ -525,18 +525,16 @@ def test_orphan_path_removed(self, tmp_path: Path) -> None: # Create lockfile where owner/orphan is resolved by owner/repo. # repo_url on DependencyReference.parse("owner/repo") is "owner/repo", # so resolved_by must also be "owner/repo" for the children_index to match. - child_dep = MagicMock() - child_dep.resolved_by = "owner/repo" - child_dep.repo_url = "owner/orphan" - child_dep.get_unique_key.return_value = "owner/orphan" + from apm_cli.deps.lockfile import LockedDependency - # The orphan_dep returned by lockfile.get_dependency - orphan_dep_full = MagicMock() - orphan_dep_full.get_unique_key.return_value = "owner/orphan" + child_dep = LockedDependency( + repo_url="owner/orphan", + resolved_by="owner/repo", + ) lockfile = MagicMock() lockfile.get_package_dependencies.return_value = [child_dep] - lockfile.get_dependency.return_value = orphan_dep_full + lockfile.get_dependency.return_value = child_dep logger = MagicMock() diff --git a/tests/integration/test_uninstall_policy_pack_phase3.py b/tests/integration/test_uninstall_policy_pack_phase3.py index 039aa2b2f..0530cc653 100644 --- a/tests/integration/test_uninstall_policy_pack_phase3.py +++ b/tests/integration/test_uninstall_policy_pack_phase3.py @@ -525,18 +525,16 @@ def test_orphan_path_removed(self, tmp_path: Path) -> None: # Create lockfile where owner/orphan is resolved by owner/repo. # repo_url on DependencyReference.parse("owner/repo") is "owner/repo", # so resolved_by must also be "owner/repo" for the children_index to match. - child_dep = MagicMock() - child_dep.resolved_by = "owner/repo" - child_dep.repo_url = "owner/orphan" - child_dep.get_unique_key.return_value = "owner/orphan" + from apm_cli.deps.lockfile import LockedDependency - # The orphan_dep returned by lockfile.get_dependency - orphan_dep_full = MagicMock() - orphan_dep_full.get_unique_key.return_value = "owner/orphan" + child_dep = LockedDependency( + repo_url="owner/orphan", + resolved_by="owner/repo", + ) lockfile = MagicMock() lockfile.get_package_dependencies.return_value = [child_dep] - lockfile.get_dependency.return_value = orphan_dep_full + lockfile.get_dependency.return_value = child_dep logger = MagicMock() diff --git a/tests/integration/test_wave5_e2e_coverage.py b/tests/integration/test_wave5_e2e_coverage.py index 6a31346f9..56d97ec77 100644 --- a/tests/integration/test_wave5_e2e_coverage.py +++ b/tests/integration/test_wave5_e2e_coverage.py @@ -333,6 +333,7 @@ def test_compile_cursor_target(self, tmp_path: Path, monkeypatch: pytest.MonkeyP runner = CliRunner() result = runner.invoke(self._cli(), ["compile"], catch_exceptions=False) assert result.exit_code == 0 + assert (tmp_path / "AGENTS.md").is_file() def test_compile_single_agents_mode( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch diff --git a/tests/integration/test_wave6_formatters_lockfile_coverage.py b/tests/integration/test_wave6_formatters_lockfile_coverage.py index 286a2f0b7..20df41905 100644 --- a/tests/integration/test_wave6_formatters_lockfile_coverage.py +++ b/tests/integration/test_wave6_formatters_lockfile_coverage.py @@ -183,13 +183,12 @@ def test_read_nonexistent(self, tmp_path: Path) -> None: assert result is None def test_read_empty_file(self, tmp_path: Path) -> None: - from apm_cli.deps.lockfile import LockFile + from apm_cli.deps.lockfile import LockFile, LockfileFormatError f = tmp_path / "apm.lock.yaml" f.write_text("") - result = LockFile.read(f) - # May return None or empty lockfile - assert result is None or isinstance(result, LockFile) + with pytest.raises(LockfileFormatError): + LockFile.read(f) def test_read_valid_lockfile(self, tmp_path: Path) -> None: from apm_cli.deps.lockfile import LockFile diff --git a/tests/integration/test_wave6_integrators_coverage.py b/tests/integration/test_wave6_integrators_coverage.py index 27c15c993..5bcfbfdd7 100644 --- a/tests/integration/test_wave6_integrators_coverage.py +++ b/tests/integration/test_wave6_integrators_coverage.py @@ -15,14 +15,18 @@ from __future__ import annotations import json +from dataclasses import replace from pathlib import Path +from apm_cli.core.target_catalog import TARGET_CAPABILITIES from apm_cli.integration.hook_integrator import ( HookIntegrationResult, HookIntegrator, - _copilot_keys_to_gemini, _filter_hook_files_for_target, _reinject_apm_source_from_sidecar, +) +from apm_cli.integration.hook_native_formats import ( + _copilot_keys_to_gemini, _to_gemini_hook_entries, ) from apm_cli.integration.mcp_integrator import MCPIntegrator, _is_vscode_available @@ -606,7 +610,8 @@ def test_wraps_flat_entry(self): def test_passes_through_already_nested_entry(self): entry = {"hooks": [{"type": "command", "command": "echo bye"}]} result = _to_gemini_hook_entries([entry]) - assert result[0] is entry + assert result[0] == entry + assert result[0] is not entry def test_propagates_apm_source(self): entry = {"type": "command", "bash": "x", "_apm_source": "my-pkg"} @@ -992,7 +997,12 @@ def test_returns_empty_for_unknown_target_name(self, tmp_path): # Create a target with a name not in _MERGE_HOOK_TARGETS dummy_target = TargetProfile( - name="unknown-target", + capability=replace( + TARGET_CAPABILITIES["copilot"], + name="unknown-target", + aliases=(), + runtimes=(), + ), root_dir=".unknown", primitives={"hooks": PrimitiveMapping("hooks", ".json", "hooks")}, ) diff --git a/tests/integration/test_wave7_marketplace_install_coverage.py b/tests/integration/test_wave7_marketplace_install_coverage.py index d8ec7718b..a6b942e19 100644 --- a/tests/integration/test_wave7_marketplace_install_coverage.py +++ b/tests/integration/test_wave7_marketplace_install_coverage.py @@ -1056,8 +1056,8 @@ def test_install_package_validation_failure(self, runner: CliRunner, tmp_path: P ), ): result = runner.invoke(cli, ["install", "owner/nonexistent"]) - # All validations failed → exit - assert result.exit_code != 0 or "not accessible" in result.output + # All validations failed, so the command must report failure. + assert result.exit_code == 1 # =========================================================================== @@ -1230,7 +1230,7 @@ def test_split_argv_at_double_dash_with_separator(self) -> None: def test_restore_manifest_from_snapshot(self, tmp_path: Path) -> None: """_restore_manifest_from_snapshot writes bytes atomically.""" - from apm_cli.commands.install import _restore_manifest_from_snapshot + from apm_cli.install.transaction import _restore_manifest_from_snapshot target = tmp_path / "apm.yml" target.write_bytes(b"original content") @@ -1240,7 +1240,7 @@ def test_restore_manifest_from_snapshot(self, tmp_path: Path) -> None: def test_maybe_rollback_manifest_no_snapshot(self) -> None: """_maybe_rollback_manifest is a no-op when snapshot is None.""" - from apm_cli.commands.install import _maybe_rollback_manifest + from apm_cli.install.transaction import _maybe_rollback_manifest logger = MagicMock() _maybe_rollback_manifest(Path("/does/not/exist"), None, logger) diff --git a/tests/test_apm_package_models.py b/tests/test_apm_package_models.py index 3a8b40c61..32ad47e60 100644 --- a/tests/test_apm_package_models.py +++ b/tests/test_apm_package_models.py @@ -617,6 +617,24 @@ def test_from_apm_yml_missing_required_fields(self): Path(f.name).unlink() + @pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("name", "", "'name' must be a non-empty string"), + ("name", ["a", "b"], "'name' must be a non-empty string"), + ("version", 123, "'version' must be a non-empty string"), + ], + ) + def test_from_apm_yml_rejects_invalid_identity(self, tmp_path, field, value, message): + """Manifest identity fields reject empty and wrong-typed native values.""" + apm_yml = tmp_path / "apm.yml" + manifest = {"name": "valid-pkg", "version": "1.0.0"} + manifest[field] = value + apm_yml.write_text(yaml.safe_dump(manifest), encoding="utf-8") + + with pytest.raises(ValueError, match=message): + APMPackage.from_apm_yml(apm_yml) + def test_from_apm_yml_invalid_yaml(self): """Test loading invalid YAML.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f: diff --git a/tests/test_github_downloader.py b/tests/test_github_downloader.py index 4edc3b8a9..4640fe577 100644 --- a/tests/test_github_downloader.py +++ b/tests/test_github_downloader.py @@ -12,6 +12,7 @@ import pytest import requests as requests_lib +from apm_cli.core.auth import AuthResolver from apm_cli.deps.github_downloader import GitHubPackageDownloader from apm_cli.models.apm_package import ( APMPackage, @@ -2494,7 +2495,7 @@ def setup_method(self): with patch.dict(os.environ, {}, clear=True), _CRED_FILL_PATCH: self.downloader = GitHubPackageDownloader() - def test_object_form_type_gitlab_routes_bespoke_host_to_gitlab_api(self): + def test_object_form_type_gitlab_routes_untrusted_host_without_global_pat(self): dep_ref = DependencyReference.parse_from_dict( {"git": "https://code.acme.com/group/sub/repo.git", "type": "gitlab"} ) @@ -2502,8 +2503,51 @@ def test_object_form_type_gitlab_routes_bespoke_host_to_gitlab_api(self): response = _make_resp(200, expected) with patch.dict(os.environ, {"GITLAB_APM_PAT": "glpat-bespoke"}, clear=True): - downloader = GitHubPackageDownloader() - with patch.object(downloader, "_resilient_get", return_value=response) as mock_get: + downloader = GitHubPackageDownloader( + auth_resolver=AuthResolver(allow_external_fallback=False) + ) + with ( + patch.object( + downloader._strategies, + "_download_gitlab_file_via_git", + side_effect=RuntimeError("force REST fallback"), + ), + patch.object(downloader, "_resilient_get", return_value=response) as mock_get, + ): + result = downloader._download_github_file(dep_ref, "SKILL.md", "main") + + assert result == expected + request_url = mock_get.call_args[0][0] + parsed = urlparse(request_url) + assert parsed.hostname == "code.acme.com" + assert parsed.path.endswith("/repository/files/SKILL.md/raw") + headers = mock_get.call_args[1]["headers"] + assert "PRIVATE-TOKEN" not in headers + assert dep_ref.host_type == "gitlab" + + def test_object_form_type_gitlab_routes_trusted_host_with_token(self): + dep_ref = DependencyReference.parse_from_dict( + {"git": "https://code.acme.com/group/sub/repo.git", "type": "gitlab"} + ) + expected = b"gitlab raw" + response = _make_resp(200, expected) + env = { + "APM_GITLAB_HOSTS": "code.acme.com", + "GITLAB_APM_PAT": "glpat-bespoke", + } + + with patch.dict(os.environ, env, clear=True): + downloader = GitHubPackageDownloader( + auth_resolver=AuthResolver(allow_external_fallback=False) + ) + with ( + patch.object( + downloader._strategies, + "_download_gitlab_file_via_git", + side_effect=RuntimeError("force REST fallback"), + ), + patch.object(downloader, "_resilient_get", return_value=response) as mock_get, + ): result = downloader._download_github_file(dep_ref, "SKILL.md", "main") assert result == expected diff --git a/tests/unit/commands/test_audit_error_handling.py b/tests/unit/commands/test_audit_error_handling.py index 8ec1c5f4e..ff0d1f2a6 100644 --- a/tests/unit/commands/test_audit_error_handling.py +++ b/tests/unit/commands/test_audit_error_handling.py @@ -482,7 +482,7 @@ def test_text_format_with_output_path_exits(self, tmp_path: Path) -> None: # Create the lockfile so the scan proceeds past the "no lockfile" early-exit lock_file = tmp_path / "apm.lock.yaml" - lock_file.write_text("dependencies: {}\n") + lock_file.write_text("lockfile_version: '1'\ndependencies: []\n") with pytest.raises(SystemExit) as exc_info: with patch("apm_cli.commands.audit.get_lockfile_path") as mock_lf: @@ -499,7 +499,7 @@ def test_text_format_with_output_path_exits(self, tmp_path: Path) -> None: def test_package_not_found_warning_and_exit(self, tmp_path: Path) -> None: logger = _make_logger() lock_file = tmp_path / "apm.lock.yaml" - lock_file.write_text("dependencies: {}\n") + lock_file.write_text("lockfile_version: '1'\ndependencies: []\n") cfg = _AuditConfig( project_root=tmp_path, logger=logger, @@ -521,7 +521,7 @@ def test_package_not_found_warning_and_exit(self, tmp_path: Path) -> None: def test_dry_run_without_strip_warns(self, tmp_path: Path) -> None: logger = _make_logger() lock_file = tmp_path / "apm.lock.yaml" - lock_file.write_text("dependencies: {}\n") + lock_file.write_text("lockfile_version: '1'\ndependencies: []\n") cfg = _AuditConfig( project_root=tmp_path, logger=logger, diff --git a/tests/unit/commands/test_audit_phase3.py b/tests/unit/commands/test_audit_phase3.py index 8dc7ce353..6c021d0d1 100644 --- a/tests/unit/commands/test_audit_phase3.py +++ b/tests/unit/commands/test_audit_phase3.py @@ -482,7 +482,7 @@ def test_text_format_with_output_path_exits(self, tmp_path: Path) -> None: # Create the lockfile so the scan proceeds past the "no lockfile" early-exit lock_file = tmp_path / "apm.lock.yaml" - lock_file.write_text("dependencies: {}\n") + lock_file.write_text("lockfile_version: '1'\ndependencies: []\n") with pytest.raises(SystemExit) as exc_info: with patch("apm_cli.commands.audit.get_lockfile_path") as mock_lf: @@ -499,7 +499,7 @@ def test_text_format_with_output_path_exits(self, tmp_path: Path) -> None: def test_package_not_found_warning_and_exit(self, tmp_path: Path) -> None: logger = _make_logger() lock_file = tmp_path / "apm.lock.yaml" - lock_file.write_text("dependencies: {}\n") + lock_file.write_text("lockfile_version: '1'\ndependencies: []\n") cfg = _AuditConfig( project_root=tmp_path, logger=logger, @@ -521,7 +521,7 @@ def test_package_not_found_warning_and_exit(self, tmp_path: Path) -> None: def test_dry_run_without_strip_warns(self, tmp_path: Path) -> None: logger = _make_logger() lock_file = tmp_path / "apm.lock.yaml" - lock_file.write_text("dependencies: {}\n") + lock_file.write_text("lockfile_version: '1'\ndependencies: []\n") cfg = _AuditConfig( project_root=tmp_path, logger=logger, diff --git a/tests/unit/commands/test_install_context.py b/tests/unit/commands/test_install_context.py index fc2cac878..14fccaf3d 100644 --- a/tests/unit/commands/test_install_context.py +++ b/tests/unit/commands/test_install_context.py @@ -29,7 +29,7 @@ def test_is_dataclass(self): ) def test_is_not_frozen(self): - """The CLI context is mutable (snapshot fields are set after construction).""" + """The CLI context remains a mutable command parameter bundle.""" assert not InstallContext.__dataclass_params__.frozen @@ -61,12 +61,11 @@ class TestInstallContextFields: "no_policy", "install_mode", "packages", + "transaction", "refresh", "legacy_skill_paths", # optional (default=None) "only_packages", - "manifest_snapshot", - "snapshot_manifest_path", # issue #1203: --frozen + plan-callback for `apm update` "frozen", "plan_callback", @@ -74,6 +73,7 @@ class TestInstallContextFields: "skill_subset", "skill_subset_from_cli", "audit_override", + "install_result", ) def test_all_required_fields_present(self): @@ -124,19 +124,14 @@ def _build_minimal(self, **overrides): no_policy=False, install_mode=sentinel.MODE, packages=(), + transaction=sentinel.TRANSACTION, ) defaults.update(overrides) return InstallContext(**defaults) - ctx = self._build_minimal() - assert ctx.only_packages is None - def test_manifest_snapshot_defaults_to_none(self): + def test_only_packages_defaults_to_none(self): ctx = self._build_minimal() - assert ctx.manifest_snapshot is None - - def test_snapshot_manifest_path_defaults_to_none(self): - ctx = self._build_minimal() - assert ctx.snapshot_manifest_path is None + assert ctx.only_packages is None class TestInstallContextRoundTrip: @@ -168,6 +163,7 @@ def test_round_trip_required_fields(self): no_policy=True, install_mode=sentinel.MODE, packages=("owner/repo",), + transaction=sentinel.TRANSACTION, ) assert ctx.scope is sentinel.SCOPE @@ -194,6 +190,7 @@ def test_round_trip_required_fields(self): assert ctx.no_policy is True assert ctx.install_mode is sentinel.MODE assert ctx.packages == ("owner/repo",) + assert ctx.transaction is sentinel.TRANSACTION def test_round_trip_optional_fields(self): ctx = InstallContext( @@ -221,11 +218,8 @@ def test_round_trip_optional_fields(self): no_policy=False, install_mode=sentinel.MODE, packages=(), + transaction=sentinel.TRANSACTION, only_packages=["pkg-a"], - manifest_snapshot=b"raw-yml-bytes", - snapshot_manifest_path=Path("/proj/apm.yml"), ) assert ctx.only_packages == ["pkg-a"] - assert ctx.manifest_snapshot == b"raw-yml-bytes" - assert ctx.snapshot_manifest_path == Path("/proj/apm.yml") diff --git a/tests/unit/commands/test_install_context_and_resolution.py b/tests/unit/commands/test_install_context_and_resolution.py index 42e452648..c326c888f 100644 --- a/tests/unit/commands/test_install_context_and_resolution.py +++ b/tests/unit/commands/test_install_context_and_resolution.py @@ -1,8 +1,6 @@ """Comprehensive unit tests for apm_cli.commands.install. Targets the uncovered branches in: -- _restore_manifest_from_snapshot -- _maybe_rollback_manifest - _split_argv_at_double_dash - _get_invocation_argv - _check_package_conflicts @@ -21,9 +19,7 @@ from apm_cli.commands.install import ( _check_package_conflicts, _get_invocation_argv, - _maybe_rollback_manifest, _merge_packages_into_yml, - _restore_manifest_from_snapshot, _split_argv_at_double_dash, ) @@ -112,85 +108,6 @@ def test_empty_argv_returns_empty(self) -> None: assert cmd == () -# --------------------------------------------------------------------------- -# _restore_manifest_from_snapshot -# --------------------------------------------------------------------------- - - -class TestRestoreManifestFromSnapshot: - def test_writes_snapshot_bytes_to_path(self, tmp_path: Path) -> None: - manifest = tmp_path / "apm.yml" - original = b"name: original\n" - manifest.write_bytes(original) - - snapshot = b"name: snapshot\n" - _restore_manifest_from_snapshot(manifest, snapshot) - - assert manifest.read_bytes() == snapshot - - def test_uses_atomic_replace(self, tmp_path: Path) -> None: - """The operation should be atomic (temp file + os.replace).""" - manifest = tmp_path / "apm.yml" - manifest.write_bytes(b"name: original\n") - - snapshot = b"name: restored\n" - with patch("os.replace") as mock_replace: - _restore_manifest_from_snapshot(manifest, snapshot) - mock_replace.assert_called_once() - - def test_cleans_up_temp_file_on_error(self, tmp_path: Path) -> None: - """Temp file should be removed if os.replace raises.""" - manifest = tmp_path / "apm.yml" - snapshot = b"name: restored\n" - - with patch("os.replace", side_effect=OSError("replace failed")): - with pytest.raises(OSError): - _restore_manifest_from_snapshot(manifest, snapshot) - - -# --------------------------------------------------------------------------- -# _maybe_rollback_manifest -# --------------------------------------------------------------------------- - - -class TestMaybeRollbackManifest: - def test_none_snapshot_is_noop(self, tmp_path: Path) -> None: - logger = _make_install_logger() - manifest = tmp_path / "apm.yml" - manifest.write_bytes(b"name: current\n") - - _maybe_rollback_manifest(manifest, None, logger) - - # File must be untouched and no progress logged - assert manifest.read_bytes() == b"name: current\n" - logger.progress.assert_not_called() - - def test_valid_snapshot_restores_file(self, tmp_path: Path) -> None: - logger = _make_install_logger() - manifest = tmp_path / "apm.yml" - snapshot = b"name: original\n" - manifest.write_bytes(b"name: mutated\n") - - _maybe_rollback_manifest(manifest, snapshot, logger) - - assert manifest.read_bytes() == snapshot - logger.progress.assert_called_once() - - def test_restore_failure_logs_warning_not_crash(self, tmp_path: Path) -> None: - """If restore itself fails, a warning is logged but no exception propagates.""" - logger = _make_install_logger() - manifest = tmp_path / "apm.yml" - snapshot = b"name: original\n" - - with patch( - "apm_cli.commands.install._restore_manifest_from_snapshot", - side_effect=OSError("disk full"), - ): - _maybe_rollback_manifest(manifest, snapshot, logger) - - logger.warning.assert_called_once() - - # --------------------------------------------------------------------------- # _check_package_conflicts # --------------------------------------------------------------------------- @@ -611,8 +528,7 @@ def test_refresh_default_is_false(self) -> None: ) assert ctx.refresh is False assert ctx.only_packages is None - assert ctx.manifest_snapshot is None - assert ctx.snapshot_manifest_path is None + assert ctx.transaction is None assert ctx.legacy_skill_paths is False assert ctx.frozen is False assert ctx.plan_callback is None diff --git a/tests/unit/commands/test_install_error_handling.py b/tests/unit/commands/test_install_error_handling.py index da6a79b19..b0a4d3e22 100644 --- a/tests/unit/commands/test_install_error_handling.py +++ b/tests/unit/commands/test_install_error_handling.py @@ -123,11 +123,11 @@ def test_non_user_scope_delegates_to_super(self) -> None: class TestValidateAndAddPackagesReadError: """read apm.yml fails (no logger) -> _rich_error + sys.exit.""" - def test_read_error_no_logger_calls_rich_error_and_exits(self, tmp_path: Path) -> None: + def test_read_error_no_logger_uses_command_logger_and_exits(self, tmp_path: Path) -> None: from apm_cli.commands.install import _validate_and_add_packages_to_apm_yml with ( - patch("apm_cli.commands.install._rich_error") as mock_err, + patch("apm_cli.core.command_logger._rich_error") as mock_err, patch("apm_cli.utils.yaml_io.load_yaml", side_effect=OSError("disk full")), ): with pytest.raises(SystemExit) as exc_info: @@ -163,13 +163,13 @@ def _make_valid_apm_yml(self, tmp_path: Path) -> Path: p.write_text("name: test\ndependencies:\n apm: []\n", encoding="utf-8") return p - def test_write_error_no_logger_calls_rich_error(self, tmp_path: Path) -> None: + def test_write_error_no_logger_uses_command_logger(self, tmp_path: Path) -> None: from apm_cli.commands.install import _validate_and_add_packages_to_apm_yml manifest = self._make_valid_apm_yml(tmp_path) with ( - patch("apm_cli.commands.install._rich_error") as mock_err, + patch("apm_cli.core.command_logger._rich_error") as mock_err, patch( "apm_cli.commands.install._resolve_package_references", return_value=( @@ -296,9 +296,10 @@ def _make_ctx(self, tmp_path: Path): ctx.trust_transitive_mcp = False return ctx - def test_apm_unavailable_exits(self, tmp_path: Path) -> None: - """APM_DEPS_AVAILABLE=False -> logger.error + sys.exit(1).""" + def test_apm_unavailable_raises_structured_failure(self, tmp_path: Path) -> None: + """An unavailable dependency engine raises after rendering the error.""" from apm_cli.commands.install import _install_apm_packages + from apm_cli.install.errors import InstallFailureAlreadyRendered ctx = self._make_ctx(tmp_path) (tmp_path / "apm.yml").write_text("name: test\n", encoding="utf-8") @@ -325,10 +326,9 @@ def test_apm_unavailable_exits(self, tmp_path: Path) -> None: mock_apm.from_apm_yml.return_value = mock_pkg mock_lf.read.return_value = None - with pytest.raises(SystemExit) as exc_info: + with pytest.raises(InstallFailureAlreadyRendered): _install_apm_packages(ctx, None) - assert exc_info.value.code == 1 ctx.logger.error.assert_called() @@ -338,7 +338,7 @@ def test_apm_unavailable_exits(self, tmp_path: Path) -> None: class TestInstallErrorHandlers: - """install() catches specific exceptions and exits with code 1.""" + """Install helpers propagate typed failures to the command boundary.""" def _make_ctx(self, tmp_path: Path): from apm_cli.constants import InstallMode @@ -386,8 +386,8 @@ def _setup_common_patches(self, tmp_path: Path): mock_pkg.target = None return mock_pkg - def test_insecure_policy_error_exits_1(self, tmp_path: Path) -> None: - """InsecureDependencyPolicyError during install triggers rollback + exit(1).""" + def test_insecure_policy_error_propagates(self, tmp_path: Path) -> None: + """InsecureDependencyPolicyError propagates to the transaction boundary.""" from apm_cli.commands.install import _install_apm_packages from apm_cli.install.insecure_policy import InsecureDependencyPolicyError @@ -408,19 +408,17 @@ def test_insecure_policy_error_exits_1(self, tmp_path: Path) -> None: "apm_cli.commands.install._install_apm_dependencies", side_effect=InsecureDependencyPolicyError("blocked"), ), - patch("apm_cli.commands.install._maybe_rollback_manifest"), ): mock_apm.from_apm_yml.return_value = mock_pkg mock_lf.read.return_value = None - with pytest.raises(SystemExit) as exc_info: + with pytest.raises(InsecureDependencyPolicyError, match="blocked"): _install_apm_packages(ctx, None) - assert exc_info.value.code == 1 - def test_authentication_error_with_diagnostic_context(self, tmp_path: Path) -> None: - """AuthenticationError with diagnostic_context emits it.""" + def test_authentication_error_with_diagnostic_context_propagates(self, tmp_path: Path) -> None: + """AuthenticationError retains diagnostic context at the command boundary.""" from apm_cli.commands.install import _install_apm_packages - from apm_cli.install.errors import AuthenticationError + from apm_cli.install.errors import AuthenticationError, InstallFailureAlreadyRendered ctx = self._make_ctx(tmp_path) (tmp_path / "apm.yml").write_text("name: test\n", encoding="utf-8") @@ -439,22 +437,21 @@ def test_authentication_error_with_diagnostic_context(self, tmp_path: Path) -> N patch("apm_cli.commands.install._project_has_root_primitives", return_value=False), patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True), patch("apm_cli.commands.install._install_apm_dependencies", side_effect=err), - patch("apm_cli.commands.install._maybe_rollback_manifest"), - patch("apm_cli.commands.install._rich_error"), - patch("apm_cli.commands.install._rich_echo") as mock_echo, ): mock_apm.from_apm_yml.return_value = mock_pkg mock_lf.read.return_value = None - with pytest.raises(SystemExit) as exc_info: + with pytest.raises(InstallFailureAlreadyRendered) as exc_info: _install_apm_packages(ctx, None) - assert exc_info.value.code == 1 - mock_echo.assert_called_once_with("hint: check token") + assert exc_info.value.__cause__ is err + assert err.diagnostic_context == "hint: check token" - def test_authentication_error_no_diagnostic_context(self, tmp_path: Path) -> None: - """AuthenticationError without diagnostic_context exits without echo.""" + def test_authentication_error_without_diagnostic_context_propagates( + self, tmp_path: Path + ) -> None: + """AuthenticationError without diagnostic context propagates unchanged.""" from apm_cli.commands.install import _install_apm_packages - from apm_cli.install.errors import AuthenticationError + from apm_cli.install.errors import AuthenticationError, InstallFailureAlreadyRendered ctx = self._make_ctx(tmp_path) (tmp_path / "apm.yml").write_text("name: test\n", encoding="utf-8") @@ -473,22 +470,18 @@ def test_authentication_error_no_diagnostic_context(self, tmp_path: Path) -> Non patch("apm_cli.commands.install._project_has_root_primitives", return_value=False), patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True), patch("apm_cli.commands.install._install_apm_dependencies", side_effect=err), - patch("apm_cli.commands.install._maybe_rollback_manifest"), - patch("apm_cli.commands.install._rich_error"), - patch("apm_cli.commands.install._rich_echo") as mock_echo, ): mock_apm.from_apm_yml.return_value = mock_pkg mock_lf.read.return_value = None - with pytest.raises(SystemExit) as exc_info: + with pytest.raises(InstallFailureAlreadyRendered) as exc_info: _install_apm_packages(ctx, None) - assert exc_info.value.code == 1 - mock_echo.assert_not_called() + assert exc_info.value.__cause__ is err - def test_frozen_install_error_echoes_reasons(self, tmp_path: Path) -> None: - """FrozenInstallError emits each reason line.""" + def test_frozen_install_error_propagates_reasons(self, tmp_path: Path) -> None: + """FrozenInstallError retains reasons for command-boundary rendering.""" from apm_cli.commands.install import _install_apm_packages - from apm_cli.install.errors import FrozenInstallError + from apm_cli.install.errors import FrozenInstallError, InstallFailureAlreadyRendered ctx = self._make_ctx(tmp_path) (tmp_path / "apm.yml").write_text("name: test\n", encoding="utf-8") @@ -507,17 +500,14 @@ def test_frozen_install_error_echoes_reasons(self, tmp_path: Path) -> None: patch("apm_cli.commands.install._project_has_root_primitives", return_value=False), patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True), patch("apm_cli.commands.install._install_apm_dependencies", side_effect=err), - patch("apm_cli.commands.install._maybe_rollback_manifest"), - patch("apm_cli.commands.install._rich_error"), - patch("apm_cli.commands.install._rich_echo") as mock_echo, ): mock_apm.from_apm_yml.return_value = mock_pkg mock_lf.read.return_value = None - with pytest.raises(SystemExit) as exc_info: + with pytest.raises(InstallFailureAlreadyRendered) as exc_info: _install_apm_packages(ctx, None) - assert exc_info.value.code == 1 - assert mock_echo.call_count == 2 + assert exc_info.value.__cause__ is err + assert err.reasons == ["reason A", "reason B"] # --------------------------------------------------------------------------- diff --git a/tests/unit/commands/test_install_phase3.py b/tests/unit/commands/test_install_phase3.py index 0deec45ff..cb96a3461 100644 --- a/tests/unit/commands/test_install_phase3.py +++ b/tests/unit/commands/test_install_phase3.py @@ -1,8 +1,6 @@ """Comprehensive unit tests for apm_cli.commands.install -- phase 3. Targets the uncovered branches in: -- _restore_manifest_from_snapshot -- _maybe_rollback_manifest - _split_argv_at_double_dash - _get_invocation_argv - _check_package_conflicts @@ -22,9 +20,7 @@ from apm_cli.commands.install import ( _check_package_conflicts, _get_invocation_argv, - _maybe_rollback_manifest, _merge_packages_into_yml, - _restore_manifest_from_snapshot, _split_argv_at_double_dash, ) @@ -113,85 +109,6 @@ def test_empty_argv_returns_empty(self) -> None: assert cmd == () -# --------------------------------------------------------------------------- -# _restore_manifest_from_snapshot -# --------------------------------------------------------------------------- - - -class TestRestoreManifestFromSnapshot: - def test_writes_snapshot_bytes_to_path(self, tmp_path: Path) -> None: - manifest = tmp_path / "apm.yml" - original = b"name: original\n" - manifest.write_bytes(original) - - snapshot = b"name: snapshot\n" - _restore_manifest_from_snapshot(manifest, snapshot) - - assert manifest.read_bytes() == snapshot - - def test_uses_atomic_replace(self, tmp_path: Path) -> None: - """The operation should be atomic (temp file + os.replace).""" - manifest = tmp_path / "apm.yml" - manifest.write_bytes(b"name: original\n") - - snapshot = b"name: restored\n" - with patch("os.replace") as mock_replace: - _restore_manifest_from_snapshot(manifest, snapshot) - mock_replace.assert_called_once() - - def test_cleans_up_temp_file_on_error(self, tmp_path: Path) -> None: - """Temp file should be removed if os.replace raises.""" - manifest = tmp_path / "apm.yml" - snapshot = b"name: restored\n" - - with patch("os.replace", side_effect=OSError("replace failed")): - with pytest.raises(OSError): - _restore_manifest_from_snapshot(manifest, snapshot) - - -# --------------------------------------------------------------------------- -# _maybe_rollback_manifest -# --------------------------------------------------------------------------- - - -class TestMaybeRollbackManifest: - def test_none_snapshot_is_noop(self, tmp_path: Path) -> None: - logger = _make_install_logger() - manifest = tmp_path / "apm.yml" - manifest.write_bytes(b"name: current\n") - - _maybe_rollback_manifest(manifest, None, logger) - - # File must be untouched and no progress logged - assert manifest.read_bytes() == b"name: current\n" - logger.progress.assert_not_called() - - def test_valid_snapshot_restores_file(self, tmp_path: Path) -> None: - logger = _make_install_logger() - manifest = tmp_path / "apm.yml" - snapshot = b"name: original\n" - manifest.write_bytes(b"name: mutated\n") - - _maybe_rollback_manifest(manifest, snapshot, logger) - - assert manifest.read_bytes() == snapshot - logger.progress.assert_called_once() - - def test_restore_failure_logs_warning_not_crash(self, tmp_path: Path) -> None: - """If restore itself fails, a warning is logged but no exception propagates.""" - logger = _make_install_logger() - manifest = tmp_path / "apm.yml" - snapshot = b"name: original\n" - - with patch( - "apm_cli.commands.install._restore_manifest_from_snapshot", - side_effect=OSError("disk full"), - ): - _maybe_rollback_manifest(manifest, snapshot, logger) - - logger.warning.assert_called_once() - - # --------------------------------------------------------------------------- # _check_package_conflicts # --------------------------------------------------------------------------- @@ -570,8 +487,7 @@ def test_refresh_default_is_false(self) -> None: ) assert ctx.refresh is False assert ctx.only_packages is None - assert ctx.manifest_snapshot is None - assert ctx.snapshot_manifest_path is None + assert ctx.transaction is None assert ctx.legacy_skill_paths is False assert ctx.frozen is False assert ctx.plan_callback is None diff --git a/tests/unit/commands/test_install_phase3w4.py b/tests/unit/commands/test_install_phase3w4.py index 53ae38e91..8b31ddd72 100644 --- a/tests/unit/commands/test_install_phase3w4.py +++ b/tests/unit/commands/test_install_phase3w4.py @@ -123,11 +123,11 @@ def test_non_user_scope_delegates_to_super(self) -> None: class TestValidateAndAddPackagesReadError: """read apm.yml fails (no logger) -> _rich_error + sys.exit.""" - def test_read_error_no_logger_calls_rich_error_and_exits(self, tmp_path: Path) -> None: + def test_read_error_no_logger_uses_command_logger_and_exits(self, tmp_path: Path) -> None: from apm_cli.commands.install import _validate_and_add_packages_to_apm_yml with ( - patch("apm_cli.commands.install._rich_error") as mock_err, + patch("apm_cli.core.command_logger._rich_error") as mock_err, patch("apm_cli.utils.yaml_io.load_yaml", side_effect=OSError("disk full")), ): with pytest.raises(SystemExit) as exc_info: @@ -163,13 +163,13 @@ def _make_valid_apm_yml(self, tmp_path: Path) -> Path: p.write_text("name: test\ndependencies:\n apm: []\n", encoding="utf-8") return p - def test_write_error_no_logger_calls_rich_error(self, tmp_path: Path) -> None: + def test_write_error_no_logger_uses_command_logger(self, tmp_path: Path) -> None: from apm_cli.commands.install import _validate_and_add_packages_to_apm_yml manifest = self._make_valid_apm_yml(tmp_path) with ( - patch("apm_cli.commands.install._rich_error") as mock_err, + patch("apm_cli.core.command_logger._rich_error") as mock_err, patch( "apm_cli.commands.install._resolve_package_references", return_value=( @@ -270,7 +270,7 @@ def test_dry_run_no_new_packages_logs_progress(self, tmp_path: Path) -> None: class TestInstallApmPackagesUnavailable: - """APM_DEPS_AVAILABLE=False logs error and exits.""" + """APM_DEPS_AVAILABLE=False logs and raises a structured failure.""" def _make_ctx(self, tmp_path: Path): from apm_cli.constants import InstallMode @@ -296,9 +296,10 @@ def _make_ctx(self, tmp_path: Path): ctx.trust_transitive_mcp = False return ctx - def test_apm_unavailable_exits(self, tmp_path: Path) -> None: - """APM_DEPS_AVAILABLE=False -> logger.error + sys.exit(1).""" + def test_apm_unavailable_raises_structured_failure(self, tmp_path: Path) -> None: + """An unavailable dependency engine raises after rendering the error.""" from apm_cli.commands.install import _install_apm_packages + from apm_cli.install.errors import InstallFailureAlreadyRendered ctx = self._make_ctx(tmp_path) (tmp_path / "apm.yml").write_text("name: test\n", encoding="utf-8") @@ -325,10 +326,9 @@ def test_apm_unavailable_exits(self, tmp_path: Path) -> None: mock_apm.from_apm_yml.return_value = mock_pkg mock_lf.read.return_value = None - with pytest.raises(SystemExit) as exc_info: + with pytest.raises(InstallFailureAlreadyRendered): _install_apm_packages(ctx, None) - assert exc_info.value.code == 1 ctx.logger.error.assert_called() @@ -338,7 +338,7 @@ def test_apm_unavailable_exits(self, tmp_path: Path) -> None: class TestInstallErrorHandlers: - """install() catches specific exceptions and exits with code 1.""" + """Install helpers propagate typed failures to the command boundary.""" def _make_ctx(self, tmp_path: Path): from apm_cli.constants import InstallMode @@ -386,8 +386,8 @@ def _setup_common_patches(self, tmp_path: Path): mock_pkg.target = None return mock_pkg - def test_insecure_policy_error_exits_1(self, tmp_path: Path) -> None: - """InsecureDependencyPolicyError during install triggers rollback + exit(1).""" + def test_insecure_policy_error_propagates(self, tmp_path: Path) -> None: + """InsecureDependencyPolicyError propagates to the transaction boundary.""" from apm_cli.commands.install import _install_apm_packages from apm_cli.install.insecure_policy import InsecureDependencyPolicyError @@ -408,19 +408,17 @@ def test_insecure_policy_error_exits_1(self, tmp_path: Path) -> None: "apm_cli.commands.install._install_apm_dependencies", side_effect=InsecureDependencyPolicyError("blocked"), ), - patch("apm_cli.commands.install._maybe_rollback_manifest"), ): mock_apm.from_apm_yml.return_value = mock_pkg mock_lf.read.return_value = None - with pytest.raises(SystemExit) as exc_info: + with pytest.raises(InsecureDependencyPolicyError, match="blocked"): _install_apm_packages(ctx, None) - assert exc_info.value.code == 1 - def test_authentication_error_with_diagnostic_context(self, tmp_path: Path) -> None: - """AuthenticationError with diagnostic_context emits it.""" + def test_authentication_error_with_diagnostic_context_propagates(self, tmp_path: Path) -> None: + """AuthenticationError retains diagnostic context at the command boundary.""" from apm_cli.commands.install import _install_apm_packages - from apm_cli.install.errors import AuthenticationError + from apm_cli.install.errors import AuthenticationError, InstallFailureAlreadyRendered ctx = self._make_ctx(tmp_path) (tmp_path / "apm.yml").write_text("name: test\n", encoding="utf-8") @@ -439,22 +437,21 @@ def test_authentication_error_with_diagnostic_context(self, tmp_path: Path) -> N patch("apm_cli.commands.install._project_has_root_primitives", return_value=False), patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True), patch("apm_cli.commands.install._install_apm_dependencies", side_effect=err), - patch("apm_cli.commands.install._maybe_rollback_manifest"), - patch("apm_cli.commands.install._rich_error"), - patch("apm_cli.commands.install._rich_echo") as mock_echo, ): mock_apm.from_apm_yml.return_value = mock_pkg mock_lf.read.return_value = None - with pytest.raises(SystemExit) as exc_info: + with pytest.raises(InstallFailureAlreadyRendered) as exc_info: _install_apm_packages(ctx, None) - assert exc_info.value.code == 1 - mock_echo.assert_called_once_with("hint: check token") + assert exc_info.value.__cause__ is err + assert err.diagnostic_context == "hint: check token" - def test_authentication_error_no_diagnostic_context(self, tmp_path: Path) -> None: - """AuthenticationError without diagnostic_context exits without echo.""" + def test_authentication_error_without_diagnostic_context_propagates( + self, tmp_path: Path + ) -> None: + """AuthenticationError without diagnostic context propagates unchanged.""" from apm_cli.commands.install import _install_apm_packages - from apm_cli.install.errors import AuthenticationError + from apm_cli.install.errors import AuthenticationError, InstallFailureAlreadyRendered ctx = self._make_ctx(tmp_path) (tmp_path / "apm.yml").write_text("name: test\n", encoding="utf-8") @@ -473,22 +470,18 @@ def test_authentication_error_no_diagnostic_context(self, tmp_path: Path) -> Non patch("apm_cli.commands.install._project_has_root_primitives", return_value=False), patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True), patch("apm_cli.commands.install._install_apm_dependencies", side_effect=err), - patch("apm_cli.commands.install._maybe_rollback_manifest"), - patch("apm_cli.commands.install._rich_error"), - patch("apm_cli.commands.install._rich_echo") as mock_echo, ): mock_apm.from_apm_yml.return_value = mock_pkg mock_lf.read.return_value = None - with pytest.raises(SystemExit) as exc_info: + with pytest.raises(InstallFailureAlreadyRendered) as exc_info: _install_apm_packages(ctx, None) - assert exc_info.value.code == 1 - mock_echo.assert_not_called() + assert exc_info.value.__cause__ is err - def test_frozen_install_error_echoes_reasons(self, tmp_path: Path) -> None: - """FrozenInstallError emits each reason line.""" + def test_frozen_install_error_propagates_reasons(self, tmp_path: Path) -> None: + """FrozenInstallError retains reasons for command-boundary rendering.""" from apm_cli.commands.install import _install_apm_packages - from apm_cli.install.errors import FrozenInstallError + from apm_cli.install.errors import FrozenInstallError, InstallFailureAlreadyRendered ctx = self._make_ctx(tmp_path) (tmp_path / "apm.yml").write_text("name: test\n", encoding="utf-8") @@ -507,17 +500,14 @@ def test_frozen_install_error_echoes_reasons(self, tmp_path: Path) -> None: patch("apm_cli.commands.install._project_has_root_primitives", return_value=False), patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True), patch("apm_cli.commands.install._install_apm_dependencies", side_effect=err), - patch("apm_cli.commands.install._maybe_rollback_manifest"), - patch("apm_cli.commands.install._rich_error"), - patch("apm_cli.commands.install._rich_echo") as mock_echo, ): mock_apm.from_apm_yml.return_value = mock_pkg mock_lf.read.return_value = None - with pytest.raises(SystemExit) as exc_info: + with pytest.raises(InstallFailureAlreadyRendered) as exc_info: _install_apm_packages(ctx, None) - assert exc_info.value.code == 1 - assert mock_echo.call_count == 2 + assert exc_info.value.__cause__ is err + assert err.reasons == ["reason A", "reason B"] # --------------------------------------------------------------------------- diff --git a/tests/unit/commands/test_policy_status.py b/tests/unit/commands/test_policy_status.py index 1a7d2200f..7866439f9 100644 --- a/tests/unit/commands/test_policy_status.py +++ b/tests/unit/commands/test_policy_status.py @@ -316,6 +316,41 @@ def test_policy_source_override(self, runner, tmp_path): assert data["source"].startswith("file:") assert str(policy_file) in data["source"] + def test_json_surfaces_unknown_top_level_key_warning(self, runner, tmp_path): + policy_file = tmp_path / "apm-policy.yml" + policy_file.write_text( + ("version: '1.0'\nenforcment: true\ndependencies:\n deny: [blocked/package]\n"), + encoding="utf-8", + ) + + result = runner.invoke( + policy_group, + ["status", "--check", "--policy-source", str(policy_file), "--json"], + ) + + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["warnings"] == ["Unknown top-level policy key: 'enforcment'"] + + def test_json_preserves_warning_when_policy_has_schema_errors(self, runner, tmp_path): + policy_file = tmp_path / "apm-policy.yml" + policy_file.write_text( + ("version: '1.0'\nenforcment: true\ncache: []\ndependencies:\n allow: ''\n"), + encoding="utf-8", + ) + + result = runner.invoke( + policy_group, + ["status", "--check", "--policy-source", str(policy_file), "--json"], + ) + + assert result.exit_code == 1, result.output + data = json.loads(result.output) + assert data["error"] is not None + assert "cache must be a YAML mapping" in data["error"] + assert "dependencies.allow must be a YAML list" in data["error"] + assert data["warnings"] == ["Unknown top-level policy key: 'enforcment'"] + def test_policy_source_routes_through_discover_policy(self, runner): result_obj = PolicyFetchResult( policy=_rich_policy(), diff --git a/tests/unit/commands/test_uninstall_lockfile_state.py b/tests/unit/commands/test_uninstall_lockfile_state.py new file mode 100644 index 000000000..69b2e8d60 --- /dev/null +++ b/tests/unit/commands/test_uninstall_lockfile_state.py @@ -0,0 +1,53 @@ +"""Tests for atomic uninstall lockfile reconciliation.""" + +from apm_cli.commands.uninstall.lockfile_state import reconcile_uninstall_deployment_state +from apm_cli.core.deployment_state import ( + DeploymentLedger, + DeploymentLocator, + DeploymentRecord, + LocatorKind, +) +from apm_cli.deps.lockfile import LockedDependency, LockFile + + +def test_duplicate_locator_values_update_each_target_record_hash(tmp_path) -> None: + """Shared paths keep distinct target records without index collisions.""" + deployed_path = ".agents/skills/shared/SKILL.md" + target_file = tmp_path / deployed_path + target_file.parent.mkdir(parents=True) + target_file.write_text("# current\n", encoding="ascii") + records = {} + for target in ("codex", "cursor"): + locator = DeploymentLocator( + kind=LocatorKind.PROJECT_RELATIVE, + target=target, + value=deployed_path, + runtime=None, + scope="project", + ) + records[locator.key] = DeploymentRecord( + locator=locator, + owners=("removed", "survivor"), + active_owner="removed", + content_hash="sha256:old", + ) + lockfile = LockFile( + dependencies={ + "survivor": LockedDependency(repo_url="survivor"), + }, + deployment_ledger=DeploymentLedger(records=records), + _deployments_present=True, + ) + + changed = reconcile_uninstall_deployment_state( + lockfile, + deploy_root=tmp_path, + all_deployed_files={deployed_path}, + surviving_deployed_files={"survivor": {deployed_path}}, + ) + + assert changed is True + hashes = {record.content_hash for record in lockfile.deployment_ledger.records.values()} + assert len(hashes) == 1 + assert next(iter(hashes)).startswith("sha256:") + assert hashes != {"sha256:old"} diff --git a/tests/unit/compilation/test_agents_compiler_branches.py b/tests/unit/compilation/test_agents_compiler_branches.py index a22e4a9d1..d0f857808 100644 --- a/tests/unit/compilation/test_agents_compiler_branches.py +++ b/tests/unit/compilation/test_agents_compiler_branches.py @@ -4,7 +4,7 @@ - CompilationConfig.__post_init__ (single_agents=True triggers strategy="single-file") - AgentsCompiler.compile() unknown frozenset target families - AgentsCompiler.compile() unknown string target -- AgentsCompiler.compile() no-results (cursor, agent-skills, windsurf) +- AgentsCompiler.compile() no-results (agent-skills, windsurf) - AgentsCompiler.compile() top-level exception handler - AgentsCompiler._compile_gemini_md() dry_run + non-dry_run - AgentsCompiler._maybe_emit_copilot_root_instructions() all branches: @@ -176,13 +176,13 @@ def tearDown(self) -> None: shutil.rmtree(self.tmp, ignore_errors=True) - def test_cursor_target_returns_empty_success(self) -> None: + def test_cursor_target_routes_through_agents_compiler(self) -> None: compiler = AgentsCompiler(self.tmp) config = CompilationConfig(target="cursor") primitives = _make_primitives() result = compiler.compile(config, primitives) self.assertTrue(result.success) - self.assertEqual(result.output_path, "") + self.assertTrue(result.output_path.startswith("Distributed:")) def test_agent_skills_target_logs_skip(self) -> None: compiler = AgentsCompiler(self.tmp) @@ -294,7 +294,9 @@ def test_non_dry_run_writes_files(self) -> None: patch( "apm_cli.compilation.gemini_formatter.GeminiFormatter", return_value=mock_formatter ), - patch("apm_cli.compilation.output_writer.CompiledOutputWriter.write") as mock_write, + patch( + "apm_cli.compilation.output_writer.CompiledOutputWriter.write_many" + ) as mock_write, ): result = compiler._compile_gemini_md(config, primitives) @@ -316,7 +318,7 @@ def test_write_oserror_adds_error(self) -> None: "apm_cli.compilation.gemini_formatter.GeminiFormatter", return_value=mock_formatter ), patch( - "apm_cli.compilation.output_writer.CompiledOutputWriter.write", + "apm_cli.compilation.output_writer.CompiledOutputWriter.write_many", side_effect=OSError("write denied"), ), ): @@ -572,7 +574,10 @@ def test_oserror_writing_file_adds_error(self) -> None: primitives = _make_primitives(inst) base_result = _make_result(stats={}) - with patch("pathlib.Path.write_text", side_effect=OSError("write denied")): + with patch( + "apm_cli.compilation.output_writer.CompiledOutputWriter.write", + side_effect=OSError("write denied"), + ): result = compiler._maybe_emit_copilot_root_instructions(config, primitives, base_result) self.assertFalse(result.success) diff --git a/tests/unit/compilation/test_agents_compiler_phase3.py b/tests/unit/compilation/test_agents_compiler_phase3.py index 3d1e13a49..0fabbb32c 100644 --- a/tests/unit/compilation/test_agents_compiler_phase3.py +++ b/tests/unit/compilation/test_agents_compiler_phase3.py @@ -4,7 +4,7 @@ - CompilationConfig.__post_init__ (single_agents=True triggers strategy="single-file") - AgentsCompiler.compile() unknown frozenset target families - AgentsCompiler.compile() unknown string target -- AgentsCompiler.compile() no-results (cursor, agent-skills, windsurf) +- AgentsCompiler.compile() no-results (agent-skills, windsurf) - AgentsCompiler.compile() top-level exception handler - AgentsCompiler._compile_gemini_md() dry_run + non-dry_run - AgentsCompiler._maybe_emit_copilot_root_instructions() all branches: @@ -176,13 +176,13 @@ def tearDown(self) -> None: shutil.rmtree(self.tmp, ignore_errors=True) - def test_cursor_target_returns_empty_success(self) -> None: + def test_cursor_target_routes_through_agents_compiler(self) -> None: compiler = AgentsCompiler(self.tmp) config = CompilationConfig(target="cursor") primitives = _make_primitives() result = compiler.compile(config, primitives) self.assertTrue(result.success) - self.assertEqual(result.output_path, "") + self.assertTrue(result.output_path.startswith("Distributed:")) def test_agent_skills_target_logs_skip(self) -> None: compiler = AgentsCompiler(self.tmp) @@ -294,7 +294,9 @@ def test_non_dry_run_writes_files(self) -> None: patch( "apm_cli.compilation.gemini_formatter.GeminiFormatter", return_value=mock_formatter ), - patch("apm_cli.compilation.output_writer.CompiledOutputWriter.write") as mock_write, + patch( + "apm_cli.compilation.output_writer.CompiledOutputWriter.write_many" + ) as mock_write, ): result = compiler._compile_gemini_md(config, primitives) @@ -316,7 +318,7 @@ def test_write_oserror_adds_error(self) -> None: "apm_cli.compilation.gemini_formatter.GeminiFormatter", return_value=mock_formatter ), patch( - "apm_cli.compilation.output_writer.CompiledOutputWriter.write", + "apm_cli.compilation.output_writer.CompiledOutputWriter.write_many", side_effect=OSError("write denied"), ), ): @@ -572,7 +574,10 @@ def test_oserror_writing_file_adds_error(self) -> None: primitives = _make_primitives(inst) base_result = _make_result(stats={}) - with patch("pathlib.Path.write_text", side_effect=OSError("write denied")): + with patch( + "apm_cli.compilation.output_writer.CompiledOutputWriter.write", + side_effect=OSError("write denied"), + ): result = compiler._maybe_emit_copilot_root_instructions(config, primitives, base_result) self.assertFalse(result.success) diff --git a/tests/unit/compilation/test_compile_clean_last_primitive_2130.py b/tests/unit/compilation/test_compile_clean_last_primitive_2130.py new file mode 100644 index 000000000..dffabe87a --- /dev/null +++ b/tests/unit/compilation/test_compile_clean_last_primitive_2130.py @@ -0,0 +1,78 @@ +"""Regression test for orphan cleanup after the last primitive is removed.""" + +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from apm_cli.cli import cli + + +def test_clean_removes_claude_md_after_last_primitive_is_removed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """--clean reaches orphan removal when the project has no primitives left.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "apm.yml").write_text( + "name: clean-last-primitive\nversion: 1.0.0\ntargets:\n - claude\n", + encoding="utf-8", + ) + instructions_dir = tmp_path / ".apm" / "instructions" + instructions_dir.mkdir(parents=True) + instruction = instructions_dir / "base.instructions.md" + instruction.write_text( + "---\ndescription: Test instruction\n---\nKeep responses concise.\n", + encoding="utf-8", + ) + + runner = CliRunner() + initial = runner.invoke(cli, ["compile", "--target", "claude"]) + assert initial.exit_code == 0, initial.output + claude_md = tmp_path / "CLAUDE.md" + assert claude_md.is_file() + + instruction.unlink() + cleaned = runner.invoke(cli, ["compile", "--target", "claude", "--clean"]) + + assert cleaned.exit_code == 0, cleaned.output + assert not claude_md.exists() + assert "no source primitives remain" in cleaned.output + assert "produced no output files" not in cleaned.output + + +def test_clean_validate_still_rejects_project_without_primitives( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """--validate keeps requiring project content when combined with --clean.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "apm.yml").write_text( + "name: clean-validate-empty\nversion: 1.0.0\ntargets:\n - claude\n", + encoding="utf-8", + ) + (tmp_path / ".apm" / "instructions").mkdir(parents=True) + + result = CliRunner().invoke(cli, ["compile", "--target", "claude", "--clean", "--validate"]) + + assert result.exit_code == 1 + assert "No instruction files found in .apm/ directory" in result.output + + +def test_clean_preserves_hand_authored_claude_md_without_duplicate_guidance( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Empty-project cleanup preserves user content without irrelevant advice.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "apm.yml").write_text( + "name: clean-hand-authored\nversion: 1.0.0\ntargets:\n - claude\n", + encoding="utf-8", + ) + (tmp_path / ".apm" / "instructions").mkdir(parents=True) + claude_md = tmp_path / "CLAUDE.md" + claude_md.write_text("# User-authored context\n", encoding="utf-8") + + result = CliRunner().invoke(cli, ["compile", "--target", "claude", "--clean"]) + + assert result.exit_code == 0, result.output + assert claude_md.read_text(encoding="utf-8") == "# User-authored context\n" + assert "hand-authored file will not be deleted" in result.output + assert "duplicate context" not in result.output diff --git a/tests/unit/compilation/test_compile_target_flag.py b/tests/unit/compilation/test_compile_target_flag.py index beaefec6e..d75516335 100644 --- a/tests/unit/compilation/test_compile_target_flag.py +++ b/tests/unit/compilation/test_compile_target_flag.py @@ -501,8 +501,8 @@ def test_multi_target_claude_copilot_emits_copilot_root_instructions(self, temp_ ) assert result.stats["copilot_root_instructions_generated"] == 1 - def test_frozenset_cursor_opencode_codex_does_not_emit_copilot_instructions(self, temp_project): - """Round-3 regression: -t cursor,opencode,codex (no copilot family) + def test_agents_only_targets_do_not_emit_copilot_instructions(self, temp_project): + """Round-3 regression: -t opencode,codex (no copilot family) must NOT emit .github/copilot-instructions.md. Previously the 'agents' family token was overloaded for AGENTS.md AND @@ -523,34 +523,32 @@ def test_frozenset_cursor_opencode_codex_does_not_emit_copilot_instructions(self ) compiler = AgentsCompiler(str(temp_project)) - # Simulate what _resolve_compile_target(["cursor","opencode","codex"]) returns + # Simulate what _resolve_compile_target(["opencode","codex"]) returns # under the new semantics: a single 'agents'-only family collapses to - # the bare cursor target, which routes correctly via the string path. + # the bare opencode target, which routes correctly via the string path. result = compiler.compile( - CompilationConfig(target="cursor", dry_run=False, single_agents=True), + CompilationConfig(target="opencode", dry_run=False, single_agents=True), primitives, ) assert result.success root_file = temp_project / ".github" / "copilot-instructions.md" assert not root_file.exists(), ( - "copilot-instructions.md must NOT be generated for cursor/opencode/codex" + "copilot-instructions.md must NOT be generated for opencode/codex" ) assert result.stats.get("copilot_root_instructions_generated", 0) == 0 - def test_resolve_compile_target_cursor_opencode_codex_does_not_route_to_vscode(self): - """Round-3 regression at the resolver: cursor/opencode/codex multi-target + def test_resolve_compile_target_opencode_codex_does_not_route_to_vscode(self): + """Round-3 regression at the resolver: opencode/codex multi-target list must not collapse to 'vscode' (which would wrongly route copilot-instructions.md generation).""" from apm_cli.commands.compile.cli import _resolve_compile_target from apm_cli.core.target_detection import should_compile_copilot_instructions_md for target_list in ( - ["cursor"], ["opencode"], ["codex"], - ["cursor", "opencode"], - ["cursor", "opencode", "codex"], + ["opencode", "codex"], ): resolved = _resolve_compile_target(target_list) assert resolved != "vscode", ( @@ -573,7 +571,7 @@ def test_resolve_compile_target_copilot_family_combos_do_route(self): ["claude", "copilot"], ["claude", "vscode"], ["gemini", "vscode"], - ["cursor", "vscode"], + ["codex", "vscode"], ["claude", "vscode", "gemini"], ): resolved = _resolve_compile_target(target_list) @@ -989,6 +987,34 @@ def temp_project(self): yield temp_path shutil.rmtree(temp_dir, ignore_errors=True) + def test_help_advertises_intellij_target(self, runner): + """Compile help lists every accepted target, including IntelliJ.""" + result = runner.invoke(cli, ["compile", "--help"]) + + assert result.exit_code == 0 + assert "intellij" in result.output.lower() + + def test_invalid_target_error_uses_structured_rendering(self, runner): + """Unknown-target guidance keeps the three-section rendering.""" + result = runner.invoke(cli, ["compile", "--target", "definitely-bogus"]) + + assert result.exit_code == 2 + assert "[x] Unknown target 'definitely-bogus'" in result.output + assert "Valid targets:" in result.output + assert "Fix with one of:" in result.output + + def test_target_flag_accepts_intellij(self, runner, temp_project): + """Compile accepts IntelliJ and generates its native context artifact.""" + original_dir = os.getcwd() + try: + os.chdir(temp_project) + result = runner.invoke(cli, ["compile", "--target", "intellij"]) + + assert result.exit_code == 0, result.output + assert (temp_project / "AGENTS.md").is_file() + finally: + os.chdir(original_dir) + def test_target_flag_accepts_vscode(self, runner, temp_project): """Test that --target vscode is accepted.""" original_dir = os.getcwd() @@ -1541,7 +1567,7 @@ def test_single_string_passthrough(self): assert _resolve_compile_target("claude") == "claude" assert _resolve_compile_target("vscode") == "vscode" assert _resolve_compile_target("all") == "all" - assert _resolve_compile_target("copilot") == "copilot" + assert _resolve_compile_target("copilot") == "vscode" def test_list_claude_and_copilot_returns_full_set(self): from apm_cli.commands.compile.cli import _resolve_compile_target @@ -1567,24 +1593,42 @@ def test_list_copilot_only_returns_vscode(self): assert _resolve_compile_target(["copilot"]) == "vscode" def test_list_agents_md_only_family_preserves_bare_target(self): - """cursor/opencode/codex/windsurf must NOT collapse to 'vscode' (which would + """opencode/codex/windsurf must NOT collapse to 'vscode' (which would wrongly route copilot-instructions.md). Single-element lists keep the bare target name; multi-element lists pick the first present.""" from apm_cli.commands.compile.cli import _resolve_compile_target - assert _resolve_compile_target(["cursor"]) == "cursor" assert _resolve_compile_target(["opencode"]) == "opencode" assert _resolve_compile_target(["codex"]) == "codex" assert _resolve_compile_target(["windsurf"]) == "windsurf" # Multi-element AGENTS.md-only list collapses to a representative bare - # target (cursor wins by deterministic ordering). - assert _resolve_compile_target(["cursor", "opencode"]) == "cursor" + # target according to deterministic catalog ordering. assert _resolve_compile_target(["opencode", "codex"]) == "opencode" assert _resolve_compile_target(["codex", "windsurf"]) == "codex" + @pytest.mark.parametrize( + "target", + [ + "cursor", + ["cursor"], + "agent-skills", + ["agent-skills"], + "intellij", + ["intellij"], + "hermes", + ["hermes"], + ], + ) + def test_accepted_target_scalar_and_list_forms_resolve_identically(self, target): + """Every accepted target uses the same resolver path for both input shapes.""" + from apm_cli.commands.compile.cli import _resolve_compile_target + + target_name = target if isinstance(target, str) else target[0] + assert _resolve_compile_target(target) == _resolve_compile_target(target_name) + def test_windsurf_routes_via_agents_family(self): """Regression: windsurf must route through the 'agents' compile_family - the same way cursor/opencode/codex do. Before the registry-driven + the same way opencode/codex do. Before the registry-driven refactor, windsurf was missing from agents_md_family and would have silently collapsed to the 'vscode' fallback (emitting copilot- instructions.md by mistake).""" @@ -1598,12 +1642,11 @@ def test_windsurf_routes_via_agents_family(self): # AGENTS.md (issue #1678 -- dedup must not fire for mixed targets). assert _resolve_compile_target(["windsurf", "copilot"]) == frozenset({"agents", "vscode"}) - def test_list_cursor_and_claude_returns_agents_claude_set(self): + def test_list_codex_and_claude_returns_agents_claude_set(self): from apm_cli.commands.compile.cli import _resolve_compile_target # No 'vscode' family because copilot/vscode/agents was not requested; - # cursor/codex contribute only the 'agents' (AGENTS.md) family. - assert _resolve_compile_target(["cursor", "claude"]) == frozenset({"agents", "claude"}) + # codex contributes only the 'agents' (AGENTS.md) family. assert _resolve_compile_target(["codex", "claude"]) == frozenset({"agents", "claude"}) def test_list_gemini_only_returns_gemini(self): @@ -1629,10 +1672,21 @@ def test_list_all_three_families_returns_full_set(self): assert _resolve_compile_target(["claude", "vscode", "gemini"]) == frozenset( {"vscode", "agents", "claude", "gemini"} ) - assert _resolve_compile_target(["claude", "vscode", "cursor"]) == frozenset( + assert _resolve_compile_target(["claude", "vscode", "codex"]) == frozenset( {"vscode", "agents", "claude"} ) + def test_all_plus_explicit_compile_target_excludes_non_compile_expansion(self): + """Generic all expansion must not inject install-only targets into compile.""" + from apm_cli.commands.compile.cli import _resolve_compile_target + from apm_cli.core.target_detection import parse_target_field + + parsed = parse_target_field("all,antigravity") + + assert _resolve_compile_target(parsed) == frozenset( + {"vscode", "agents", "claude", "gemini"} + ) + class TestMultiTargetDoesNotGenerateUnrequestedFiles: """Regression tests: multi-target lists must not generate files for families not requested.""" @@ -1650,19 +1704,6 @@ def test_claude_codex_does_not_compile_gemini(self): assert should_compile_claude_md(resolved) is True assert should_compile_gemini_md(resolved) is False - def test_claude_cursor_does_not_compile_gemini(self): - from apm_cli.commands.compile.cli import _resolve_compile_target - from apm_cli.core.target_detection import ( - should_compile_agents_md, - should_compile_claude_md, - should_compile_gemini_md, - ) - - resolved = _resolve_compile_target(["claude", "cursor"]) - assert should_compile_agents_md(resolved) is True - assert should_compile_claude_md(resolved) is True - assert should_compile_gemini_md(resolved) is False - def test_gemini_codex_does_not_compile_claude(self): from apm_cli.commands.compile.cli import _resolve_compile_target from apm_cli.core.target_detection import ( diff --git a/tests/unit/compilation/test_user_root_context.py b/tests/unit/compilation/test_user_root_context.py index 48360f98a..408bce4f2 100644 --- a/tests/unit/compilation/test_user_root_context.py +++ b/tests/unit/compilation/test_user_root_context.py @@ -642,7 +642,10 @@ def test_oserror_on_write_new_file(self, tmp_path): "apm_cli.primitives.discovery.discover_primitives", return_value=primitives, ), - patch.object(Path, "write_text", side_effect=OSError("disk full")), + patch( + "apm_cli.compilation.output_writer.CompiledOutputWriter.write_many", + side_effect=OSError("disk full"), + ), ): result = compile_user_root_contexts([target], source_root) diff --git a/tests/unit/core/test_deployment_owner_invariant.py b/tests/unit/core/test_deployment_owner_invariant.py new file mode 100644 index 000000000..6e5f7f175 --- /dev/null +++ b/tests/unit/core/test_deployment_owner_invariant.py @@ -0,0 +1,90 @@ +"""Source invariant for canonical deployment-state mutation.""" + +import ast +from pathlib import Path + +import pytest + +_OWNED_FIELDS = frozenset( + { + "deployed_files", + "deployed_file_hashes", + "local_deployed_files", + "local_deployed_file_hashes", + "mcp_target_servers", + } +) +_MUTATORS = frozenset({"append", "remove", "pop", "extend", "clear", "update", "insert"}) +_ALLOWED = { + Path("core/deployment_state.py"), + Path("core/deployment_ledger.py"), + Path("deps/lockfile.py"), +} + + +def _owned_attribute(node: ast.AST) -> bool: + return isinstance(node, ast.Attribute) and node.attr in _OWNED_FIELDS + + +def _mutation_lines(source: str) -> list[int]: + tree = ast.parse(source) + violations: set[int] = set() + for node in ast.walk(tree): + if isinstance(node, (ast.Assign, ast.AugAssign)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + for target in targets: + if _owned_attribute(target) or ( + isinstance(target, ast.Subscript) and _owned_attribute(target.value) + ): + violations.add(node.lineno) + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in _MUTATORS + and _owned_attribute(node.func.value) + ): + violations.add(node.lineno) + return sorted(violations) + + +@pytest.mark.parametrize( + "source", + [ + "lock.deployed_files = []", + "lock.deployed_files += ['new']", + "lock.deployed_file_hashes['path'] = 'sha256:value'", + "lock.local_deployed_files.append('path')", + "lock.local_deployed_files.remove('path')", + "lock.local_deployed_file_hashes.pop('path')", + "lock.local_deployed_files.extend(['path'])", + "lock.local_deployed_files.clear()", + "lock.local_deployed_file_hashes.update({'path': 'hash'})", + "lock.local_deployed_files.insert(0, 'path')", + ], +) +def test_mutation_detector_catches_negative_controls(source: str) -> None: + assert _mutation_lines(source) == [1] + + +@pytest.mark.parametrize( + "source", + [ + "files = lock.local_deployed_files", + "files = [*lock.local_deployed_files]", + "value = lock.local_deployed_file_hashes.get('path')", + ], +) +def test_mutation_detector_allows_owned_field_reads(source: str) -> None: + assert _mutation_lines(source) == [] + + +def test_only_canonical_owner_mutates_legacy_deployment_fields() -> None: + root = Path(__file__).resolve().parents[3] / "src" / "apm_cli" + violations: list[str] = [] + for source in root.rglob("*.py"): + relative = source.relative_to(root) + if relative in _ALLOWED: + continue + for line_number in _mutation_lines(source.read_text(encoding="utf-8")): + violations.append(f"{relative.as_posix()}:{line_number}") + assert violations == [] diff --git a/tests/unit/core/test_deployment_state.py b/tests/unit/core/test_deployment_state.py new file mode 100644 index 000000000..fdbb31ab5 --- /dev/null +++ b/tests/unit/core/test_deployment_state.py @@ -0,0 +1,388 @@ +"""Tests for canonical deployment state and lockfile encoding.""" + +from dataclasses import replace +from pathlib import Path + +import pytest + +from apm_cli.core.deployment_ledger import DeploymentLedgerCodec +from apm_cli.core.deployment_state import ( + DeploymentIntent, + DeploymentLedger, + DeploymentLocator, + DeploymentReconciler, + DeploymentRecord, + LocatorKind, + MaterializationResult, + MaterializationStatus, + NativePayloadValidation, +) +from apm_cli.core.scope import InstallScope +from apm_cli.core.target_catalog import TARGET_CAPABILITIES +from apm_cli.deps.lockfile import LockedDependency, LockFile +from apm_cli.integration.targets import TargetProfile +from apm_cli.utils.diagnostics import DiagnosticCollector + +VALID = NativePayloadValidation(valid=True, contract="file") + + +def _target(name: str, root: str, managed: Path | None = None) -> TargetProfile: + capability = TARGET_CAPABILITIES.get(name) + if capability is None: + capability = replace( + TARGET_CAPABILITIES["copilot"], + name=name, + aliases=(), + runtimes=(), + ) + return TargetProfile( + capability=capability, + root_dir=str(managed or root), + primitives={}, + resolved_deploy_root=managed, + ) + + +def _locator( + value: str = ".github/agents/demo.agent.md", + *, + target: str = "copilot", + runtime: str | None = None, +) -> DeploymentLocator: + return DeploymentLocator( + kind=LocatorKind.PROJECT_RELATIVE, + target=target, + value=value, + runtime=runtime, + scope="project", + ) + + +def _materialization( + owner: str, + locator: DeploymentLocator, + *, + status: MaterializationStatus = MaterializationStatus.WRITTEN, + valid: bool = True, + content_hash: str | None = "sha256:new", +) -> MaterializationResult: + return MaterializationResult( + locator=locator, + owners=frozenset({owner}), + status=status, + content_hash=content_hash, + validation=NativePayloadValidation(valid=valid, contract="native-v1"), + ) + + +def _record(locator: DeploymentLocator, owners=("old",), active="old") -> DeploymentRecord: + return DeploymentRecord( + locator=locator, + owners=owners, + active_owner=active, + content_hash="sha256:old", + ) + + +def _reconciler(tmp_path: Path) -> DeploymentReconciler: + profiles = { + "copilot": _target("copilot", ".github"), + "claude": _target("claude", ".claude"), + } + return DeploymentReconciler( + tmp_path, + profiles, + diagnostics=DiagnosticCollector(), + ) + + +def _intent( + *, + active=frozenset({"copilot"}), + declared=None, + owners=None, + authoritative=True, +) -> DeploymentIntent: + return DeploymentIntent( + active_targets=active, + declared_targets=declared, + desired_owners=owners, + authoritative_targets=authoritative, + ) + + +def test_legacy_package_claims_share_one_handoff_decision() -> None: + """Current ownership and prior eligibility must come from one owner.""" + shared = ".claude/skills/shared/SKILL.md" + unique = ".claude/skills/loser-only/SKILL.md" + reconcile = getattr(DeploymentReconciler, "reconcile_package_claims", None) + + assert callable(reconcile) + claims = reconcile( + package_keys=("loser", "winner"), + current_claims={"loser": [shared, unique], "winner": [shared]}, + prior_files={"loser": [shared, unique], "winner": []}, + prior_hashes={ + "loser": {shared: "sha256:shared", unique: "sha256:unique"}, + "winner": {}, + }, + ) + + assert claims["loser"].current_files == (unique,) + assert claims["loser"].prior_files == (unique,) + assert claims["loser"].prior_hashes == {unique: "sha256:unique"} + assert claims["winner"].current_files == (shared,) + + +def test_last_successful_writer_is_active_and_order_is_deterministic(tmp_path: Path) -> None: + locator = _locator() + results = [ + _materialization("alpha", locator, content_hash="sha256:a"), + _materialization("beta", locator, content_hash="sha256:b"), + _materialization("alpha", locator, content_hash="sha256:c"), + ] + + reconciled = _reconciler(tmp_path).reconcile( + DeploymentLedger(records={}), + results, + _intent(owners=frozenset({"alpha", "beta"})), + ) + + record = reconciled.ledger.records[locator.key] + assert record.owners == ("beta", "alpha") + assert record.active_owner == "alpha" + assert record.content_hash == "sha256:c" + + +def test_uninstall_handoff_requires_successful_survivor(tmp_path: Path) -> None: + locator = _locator() + prior = DeploymentLedger( + records={locator.key: _record(locator, owners=("survivor", "removed"), active="removed")} + ) + + reconciled = _reconciler(tmp_path).reconcile( + prior, + [_materialization("survivor", locator)], + _intent(owners=frozenset({"survivor"})), + ) + + record = reconciled.ledger.records[locator.key] + assert record.active_owner == "survivor" + assert reconciled.owner_handoffs == ((locator.key, "removed", "survivor"),) + + +def test_uninstall_without_new_proof_transfers_active_owner_to_survivor( + tmp_path: Path, +) -> None: + locator = _locator() + prior = DeploymentLedger( + records={ + locator.key: _record( + locator, + owners=("zeta", "alpha", "removed"), + active="removed", + ) + } + ) + + reconciled = _reconciler(tmp_path).reconcile( + prior, + [], + _intent( + owners=frozenset({"alpha", "zeta"}), + authoritative=False, + ), + ) + + record = reconciled.ledger.records[locator.key] + assert record.owners == ("zeta", "alpha") + assert record.active_owner == "zeta" + assert record.active_owner in record.owners + + +def test_deployment_record_rejects_active_owner_outside_owners() -> None: + with pytest.raises(ValueError, match="active_owner must be present in owners"): + _record(_locator(), owners=("survivor",), active="removed") + + +def test_no_handoff_without_successful_survivor(tmp_path: Path) -> None: + locator = _locator() + prior = DeploymentLedger( + records={locator.key: _record(locator, owners=("survivor", "removed"), active="removed")} + ) + + reconciled = _reconciler(tmp_path).reconcile( + prior, + [_materialization("survivor", locator, status=MaterializationStatus.FAILED)], + _intent(owners=frozenset({"survivor"})), + ) + + assert reconciled.ledger.records[locator.key] == prior.records[locator.key] + assert reconciled.owner_handoffs == () + + +def test_same_target_stale_and_undeclared_target_are_removed(tmp_path: Path) -> None: + active = _locator("active.md") + ghost = _locator(".claude/rules/ghost.md", target="claude") + prior = DeploymentLedger( + records={ + active.key: _record(active), + ghost.key: _record(ghost), + } + ) + + reconciled = _reconciler(tmp_path).reconcile( + prior, + [], + _intent(declared=frozenset({"copilot"})), + ) + + assert reconciled.ledger.records == {} + assert set(reconciled.removed) == {active, ghost} + + +def test_unknown_declared_universe_preserves_sibling_target(tmp_path: Path) -> None: + sibling = _locator(".claude/rules/kept.md", target="claude") + prior = DeploymentLedger(records={sibling.key: _record(sibling)}) + + reconciled = _reconciler(tmp_path).reconcile(prior, [], _intent()) + + assert reconciled.ledger.records[sibling.key] == prior.records[sibling.key] + + +def test_failed_cleanup_retains_prior_row(tmp_path: Path) -> None: + locator = _locator() + prior = DeploymentLedger(records={locator.key: _record(locator)}) + + reconciled = _reconciler(tmp_path).reconcile( + prior, + [_materialization("old", locator, status=MaterializationStatus.FAILED)], + _intent(), + ) + + assert reconciled.ledger == prior + assert reconciled.failed == (locator,) + + +def test_invalid_native_payload_aborts_the_ledger_transition(tmp_path: Path) -> None: + prior_locator = _locator("prior.md") + new_locator = _locator("new.md") + prior = DeploymentLedger(records={prior_locator.key: _record(prior_locator)}) + + reconciled = _reconciler(tmp_path).reconcile( + prior, + [ + _materialization("new", new_locator), + _materialization("bad", _locator("bad.md"), valid=False), + ], + _intent(), + ) + + assert reconciled.ledger is prior + assert reconciled.changed is False + + +def test_locator_round_trips_project_external_uri_and_mcp( + tmp_path: Path, +) -> None: + project = tmp_path / "project" + external = tmp_path / "external" + project.mkdir() + external.mkdir() + copilot = _target("copilot", ".github") + cowork = _target("copilot-cowork", ".copilot", external) + + project_locator = DeploymentLedgerCodec.locator_for_path( + project / ".github" / "agents" / "demo.agent.md", + project_root=project, + target=copilot, + scope=InstallScope.PROJECT, + ) + external_locator = DeploymentLedgerCodec.locator_for_path( + external / "skills" / "demo" / "SKILL.md", + project_root=project, + target=cowork, + scope=InstallScope.USER, + ) + uri_locator = DeploymentLedgerCodec.locator_for_path( + "copilot-app-db://workflows/demo", + project_root=project, + target=copilot, + scope=InstallScope.PROJECT, + ) + mcp_locator = DeploymentLedgerCodec.locator_for_path( + "mcp://demo", + project_root=project, + target=_target("mcp", ".mcp"), + runtime="vscode", + scope=InstallScope.PROJECT, + ) + + assert project_locator.kind == LocatorKind.PROJECT_RELATIVE + assert ( + DeploymentLedgerCodec.resolve_locator(project_locator, project_root=project, target=copilot) + == (project / project_locator.value).resolve() + ) + assert external_locator.kind == LocatorKind.TARGET_RELATIVE + assert external_locator.value == "skills/demo/SKILL.md" + assert ( + DeploymentLedgerCodec.resolve_locator(external_locator, project_root=project, target=cowork) + == (external / external_locator.value).resolve() + ) + assert ( + DeploymentLedgerCodec.resolve_locator(uri_locator, project_root=project, target=copilot) + == "copilot-app-db://workflows/demo" + ) + assert mcp_locator.runtime == "vscode" + + +def test_legacy_import_and_dual_write_are_semantically_equivalent() -> None: + dependency = LockedDependency( + repo_url="owner/package", + deployed_files=[".github/agents/demo.agent.md"], + deployed_file_hashes={".github/agents/demo.agent.md": "sha256:demo"}, + ) + lockfile = LockFile() + lockfile.add_dependency(dependency) + lockfile.local_deployed_files = [".claude/rules/local.md"] + lockfile.local_deployed_file_hashes = {".claude/rules/local.md": "sha256:local"} + lockfile.mcp_target_servers = {"vscode": ["demo"]} + + ledger = DeploymentLedgerCodec.from_lockfile(lockfile) + DeploymentLedgerCodec.apply_to_lockfile(ledger, lockfile) + rebuilt = LockFile.from_yaml(lockfile.to_yaml()) + + assert rebuilt.deployment_ledger == ledger + assert rebuilt.is_semantically_equivalent(lockfile) + + +def test_from_rows_preserves_hashless_legacy_ownership_row() -> None: + rows = [ + { + "kind": "project-relative", + "target": "copilot", + "value": ".github/agents/verified.agent.md", + "runtime": None, + "scope": "project", + "owners": ["verified"], + "active_owner": "verified", + "content_hash": "sha256:verified", + }, + { + "kind": "project-relative", + "target": "copilot", + "value": ".github/agents/unverified.agent.md", + "runtime": None, + "scope": "project", + "owners": ["unverified"], + "active_owner": "unverified", + "content_hash": None, + }, + ] + + ledger = DeploymentLedgerCodec.from_rows(rows) + + assert tuple(record.locator.value for record in ledger.records.values()) == ( + ".github/agents/verified.agent.md", + ".github/agents/unverified.agent.md", + ) diff --git a/tests/unit/core/test_error_renderer.py b/tests/unit/core/test_error_renderer.py index 73a0d0d3e..d02ab2e74 100644 --- a/tests/unit/core/test_error_renderer.py +++ b/tests/unit/core/test_error_renderer.py @@ -71,6 +71,19 @@ def test_unknown_target_error_lists_valid(): assert "claude" in text +def test_unknown_target_error_uses_compile_recovery_commands(): + text = render_unknown_target_error( + "foo", + ["claude", "copilot"], + command="compile", + ) + + assert "apm compile --target copilot" in text + assert "apm compile --dry-run" in text + assert "apm install" not in text + assert "Or declare in apm.yml:" not in text + + def test_unknown_target_error_suggests_copilot_not_first_alphabetical(): """Suggestion must be a sensible default (#1188), not sorted-first.""" valid = ["agent-skills", "claude", "copilot", "cursor"] @@ -102,39 +115,18 @@ def test_unknown_target_error_falls_back_when_strip_empties_value(): assert "Unknown target '" in headline -def test_unknown_target_error_hides_agent_skills_meta_target(): - """agent-skills is a meta-target; do not advertise it as a recovery - path here when ``apm targets`` won't list it (#1208). - - The canonical set still ACCEPTS ``agent-skills`` via --target or - apm.yml; this test only pins that the unknown-target error message - does not steer users to it. Discoverability lives in - ``apm targets --json --all``. - """ +def test_unknown_target_error_advertises_agent_skills_meta_target(): + """Every accepted target is visible in unknown-target recovery.""" valid = ["agent-skills", "claude", "copilot", "cursor"] text = render_unknown_target_error("foo", valid) - # 'agent-skills' must not appear in the rendered "Valid targets:" CSV - # nor in any of the three suggested commands or the apm.yml snippet. - assert "agent-skills" not in text, ( - f"agent-skills meta-target leaked into unknown-target suggestions:\n{text}" - ) - # Sanity: the surviving harness targets are still listed. - assert "claude" in text and "copilot" in text and "cursor" in text + assert "Valid targets: agent-skills, claude, copilot, cursor" in text -def test_unknown_target_error_falls_back_when_only_meta_target_visible(): - """If the caller filters to only agent-skills, the renderer must - not crash and must emit a sane default suggestion (claude) -- and - the 'Valid targets:' line must not render as a bare colon.""" +def test_unknown_target_error_uses_meta_target_when_it_is_only_value(): + """A sole accepted meta-target remains actionable.""" text = render_unknown_target_error("foo", ["agent-skills"]) - # No agent-skills in suggestions... - assert "--target agent-skills" not in text - assert " - agent-skills" not in text - # ...and the safety-net default 'claude' surfaces instead. - assert "--target claude" in text - # The 'Valid targets:' line must have non-empty content (#1215 review). - assert "Valid targets: \n" not in text - assert "Valid targets: claude" in text + assert "Valid targets: agent-skills" in text + assert "--target agent-skills" in text def test_conflicting_schema_error_has_three_parts(): diff --git a/tests/unit/core/test_host_providers.py b/tests/unit/core/test_host_providers.py new file mode 100644 index 000000000..9de35db36 --- /dev/null +++ b/tests/unit/core/test_host_providers.py @@ -0,0 +1,46 @@ +"""Credential-scope regressions for manifest host-type hints.""" + +from __future__ import annotations + +import os +from unittest.mock import patch + +from apm_cli.core.auth import AuthResolver + + +def test_gitlab_hint_does_not_route_global_pat_to_untrusted_host() -> None: + sentinel = "gitlab-global-sentinel" + with patch.dict(os.environ, {"GITLAB_APM_PAT": sentinel}, clear=True): + context = AuthResolver(allow_external_fallback=False).resolve( + "packages.attacker.example", + host_type="gitlab", + ) + + assert context.host_info.kind == "gitlab" + assert context.token is None + assert context.source == "none" + + +def test_canonical_gitlab_host_still_uses_global_pat() -> None: + sentinel = "gitlab-global-sentinel" + with patch.dict(os.environ, {"GITLAB_APM_PAT": sentinel}, clear=True): + context = AuthResolver(allow_external_fallback=False).resolve("gitlab.com") + + assert context.token == sentinel + assert context.source == "GITLAB_APM_PAT" + + +def test_user_trusted_gitlab_host_uses_global_pat() -> None: + sentinel = "gitlab-global-sentinel" + env = { + "APM_GITLAB_HOSTS": "gitlab.corp.example", + "GITLAB_APM_PAT": sentinel, + } + with patch.dict(os.environ, env, clear=True): + context = AuthResolver(allow_external_fallback=False).resolve( + "gitlab.corp.example", + host_type="gitlab", + ) + + assert context.token == sentinel + assert context.source == "GITLAB_APM_PAT" diff --git a/tests/unit/core/test_output_mode.py b/tests/unit/core/test_output_mode.py new file mode 100644 index 000000000..213608406 --- /dev/null +++ b/tests/unit/core/test_output_mode.py @@ -0,0 +1,49 @@ +"""Regression tests for machine-output detection at the root CLI.""" + +from __future__ import annotations + +import json +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from apm_cli.policy.discovery import PolicyFetchResult + + +@pytest.mark.parametrize( + "args", + [ + ["policy", "status", "--output=json"], + ["policy", "status", "-ojson"], + ["audit", "--ci", "--no-drift", "--no-policy", "--format=json"], + ["audit", "--ci", "--no-drift", "--no-policy", "-fjson"], + ["--verbose", "policy", "status", "--output", "json"], + ], +) +def test_machine_output_keeps_update_notice_off_stdout(args: list[str]) -> None: + """Every Click spelling must leave stdout as one parseable JSON document.""" + from apm_cli.cli import cli + + with ( + patch("apm_cli.commands._helpers.is_self_update_enabled", return_value=True), + patch("apm_cli.commands._helpers.get_version", return_value="1.0.0"), + patch("apm_cli.commands._helpers.check_for_updates", return_value="2.0.0"), + patch( + "apm_cli.commands.policy.discover_policy_with_chain", + return_value=PolicyFetchResult(outcome="absent"), + ), + CliRunner().isolated_filesystem(), + ): + result = CliRunner().invoke(cli, args) + + assert result.exception is None, result.output + json.loads(result.stdout) + assert "A new version of APM is available" in result.stderr + assert "A new version of APM is available" not in result.stdout + + +def test_human_output_is_not_machine_readable() -> None: + from apm_cli.core.output_mode import detect_output_mode + + assert not detect_output_mode(("policy", "status", "--output", "table")).machine_readable diff --git a/tests/unit/core/test_target_catalog.py b/tests/unit/core/test_target_catalog.py new file mode 100644 index 000000000..821759c73 --- /dev/null +++ b/tests/unit/core/test_target_catalog.py @@ -0,0 +1,382 @@ +"""Characterization tests for target capability metadata.""" + +from contextlib import nullcontext +from dataclasses import FrozenInstanceError, astuple +from types import MappingProxyType + +import pytest +from click.testing import CliRunner + +from apm_cli.cli import cli +from apm_cli.core.apm_yml import CANONICAL_TARGETS +from apm_cli.core.target_catalog import ( + TARGET_CAPABILITIES, + TargetCapability, + _build_target_catalog, + accepted_target_values, + expand_all, + get_target_capability, + normalize_target_name, + target_error_values, + target_help_fragment, +) +from apm_cli.core.target_detection import ( + ALL_CANONICAL_TARGETS, + EXPERIMENTAL_TARGETS, + EXPLICIT_ONLY_TARGETS, + MCP_ONLY_TARGETS, + TARGET_ALIASES, + VALID_TARGET_VALUES, + parse_target_field, +) +from apm_cli.integration.targets import KNOWN_TARGETS, RUNTIME_TO_CANONICAL_TARGET + +COMMANDS = ("compile", "install", "update") + + +def test_current_target_sets_and_aliases_are_characterized() -> None: + """Lock the accepted target contract before moving its owner.""" + assert ( + frozenset({"claude", "codex", "cursor", "gemini", "kiro", "opencode", "vscode", "windsurf"}) + == ALL_CANONICAL_TARGETS + ) + assert ( + frozenset({"copilot-app", "copilot-cowork", "hermes", "openclaw"}) == EXPERIMENTAL_TARGETS + ) + assert frozenset({"agent-skills", "antigravity"}) == EXPLICIT_ONLY_TARGETS + assert frozenset({"intellij"}) == MCP_ONLY_TARGETS + assert TARGET_ALIASES == { + "agy": "antigravity", + "agents": "vscode", + "copilot": "vscode", + "vscode": "vscode", + } + assert ( + frozenset( + { + "agent-skills", + "agents", + "agy", + "all", + "antigravity", + "claude", + "codex", + "copilot", + "copilot-app", + "copilot-cowork", + "cursor", + "gemini", + "hermes", + "intellij", + "kiro", + "openclaw", + "opencode", + "vscode", + "windsurf", + } + ) + == VALID_TARGET_VALUES + ) + + +def test_current_runtime_mapping_is_characterized() -> None: + """Lock runtime-to-native-profile routing before moving its owner.""" + assert RUNTIME_TO_CANONICAL_TARGET == { + "agents": "copilot", + "intellij": "copilot", + "vscode": "copilot", + } + + +def test_current_native_profiles_are_characterized() -> None: + """Lock native roots, primitive mappings, flags, and compile families.""" + actual = { + name: ( + profile.root_dir, + {primitive: astuple(mapping) for primitive, mapping in profile.primitives.items()}, + profile.compile_family, + profile.requires_flag, + ) + for name, profile in KNOWN_TARGETS.items() + } + assert actual == { + "copilot": ( + ".github", + { + "instructions": ( + "instructions", + ".instructions.md", + "github_instructions", + None, + False, + ), + "prompts": ("prompts", ".prompt.md", "github_prompt", None, False), + "agents": ("agents", ".agent.md", "github_agent", None, False), + "skills": ("skills", "/SKILL.md", "skill_standard", ".agents", False), + "hooks": ("hooks", ".json", "github_hooks", None, False), + "canvas": ("extensions", "", "copilot_canvas", None, False), + }, + "vscode", + None, + ), + "claude": ( + ".claude", + { + "instructions": ("rules", ".md", "claude_rules", None, True), + "agents": ("agents", ".md", "claude_agent", None, False), + "commands": ("commands", ".md", "claude_command", None, False), + "skills": ("skills", "/SKILL.md", "skill_standard", None, False), + "hooks": ("hooks", ".json", "claude_hooks", None, False), + }, + "claude", + None, + ), + "cursor": ( + ".cursor", + { + "instructions": ("rules", ".mdc", "cursor_rules", None, True), + "agents": ("agents", ".md", "cursor_agent", None, False), + "commands": ("commands", ".md", "claude_command", None, False), + "skills": ("skills", "/SKILL.md", "skill_standard", ".agents", False), + "hooks": ("hooks", ".json", "cursor_hooks", None, False), + }, + "agents", + None, + ), + "kiro": ( + ".kiro", + { + "instructions": ("steering", ".md", "kiro_steering", None, True), + "skills": ("skills", "/SKILL.md", "skill_standard", None, False), + "hooks": ("hooks", ".json", "kiro_hooks", None, False), + }, + "agents", + None, + ), + "opencode": ( + ".opencode", + { + "agents": ("agents", ".md", "opencode_agent", None, False), + "commands": ("commands", ".md", "opencode_command", None, False), + "skills": ("skills", "/SKILL.md", "skill_standard", ".agents", False), + }, + "agents", + None, + ), + "gemini": ( + ".gemini", + { + "commands": ("commands", ".toml", "gemini_command", None, False), + "skills": ("skills", "/SKILL.md", "skill_standard", ".agents", False), + "hooks": ("hooks", ".json", "gemini_hooks", None, False), + }, + "gemini", + None, + ), + "antigravity": ( + ".agents", + { + "instructions": ("rules", ".md", "antigravity_rules", None, True), + "skills": ("skills", "/SKILL.md", "skill_standard", None, False), + "hooks": ("", "hooks.json", "antigravity_hooks", None, False), + }, + "agents", + None, + ), + "codex": ( + ".codex", + { + "agents": ("agents", ".toml", "codex_agent", None, False), + "skills": ("skills", "/SKILL.md", "skill_standard", ".agents", False), + "hooks": ("", "hooks.json", "codex_hooks", None, False), + }, + "agents", + None, + ), + "windsurf": ( + ".windsurf", + { + "instructions": ("rules", ".md", "windsurf_rules", None, True), + "skills": ("skills", "/SKILL.md", "skill_standard", ".agents", False), + "commands": ("workflows", ".md", "windsurf_workflow", None, False), + "hooks": ("", "hooks.json", "windsurf_hooks", None, False), + }, + "agents", + None, + ), + "agent-skills": ( + ".agents", + {"skills": ("skills", "/SKILL.md", "skill_standard", None, False)}, + None, + None, + ), + "openclaw": ( + ".agents", + {"skills": ("skills", "/SKILL.md", "skill_standard", None, False)}, + None, + "openclaw", + ), + "hermes": ( + ".agents", + {"skills": ("skills", "/SKILL.md", "skill_standard", None, False)}, + "agents", + "hermes", + ), + "copilot-cowork": ( + "copilot-cowork", + {"skills": ("skills", "/SKILL.md", "skill_standard", None, False)}, + None, + "copilot_cowork", + ), + "copilot-app": ( + "copilot-app", + {"prompts": ("workflows", ".prompt.md", "prompt_standard", None, False)}, + None, + "copilot_app", + ), + } + + +def test_catalog_is_immutable_and_projects_accepted_values() -> None: + """The catalog owns every accepted target value.""" + assert isinstance(TARGET_CAPABILITIES, MappingProxyType) + assert accepted_target_values() == VALID_TARGET_VALUES + assert accepted_target_values("install") == VALID_TARGET_VALUES + assert accepted_target_values("update") == VALID_TARGET_VALUES + assert accepted_target_values("compile") == VALID_TARGET_VALUES + + +def test_every_accepted_value_is_advertised_and_parses() -> None: + """Help and error advertising cannot drift from parser acceptance.""" + for command in COMMANDS: + help_fragment = target_help_fragment(command) + error_values = target_error_values(command) + assert frozenset(error_values) == accepted_target_values(command) + for value in accepted_target_values(command): + assert value in help_fragment + with pytest.warns(Warning) if value == "agents" else nullcontext(): + assert parse_target_field(value) is not None + + +@pytest.mark.parametrize("command", COMMANDS) +def test_command_help_and_errors_advertise_every_accepted_value(command: str) -> None: + """Real Click surfaces advertise the same values accepted by parsing.""" + runner = CliRunner() + help_result = runner.invoke(cli, [command, "--help"], terminal_width=1000) + error_result = runner.invoke( + cli, + [command, "--target", "definitely-bogus"], + terminal_width=1000, + ) + + assert help_result.exit_code == 0 + assert error_result.exit_code == 2 + valid_line = next( + line for line in error_result.output.splitlines() if line.startswith("Valid targets:") + ) + error_values = {target.strip() for target in valid_line.partition(":")[2].split(",")} + assert error_values == set(accepted_target_values(command)) + for value in accepted_target_values(command): + assert value in help_result.output + + +def test_alias_runtime_profile_and_compile_projections_match_catalog() -> None: + """Catalog metadata reproduces every legacy projection.""" + assert normalize_target_name("vscode") == "copilot" + assert normalize_target_name("agents") == "copilot" + assert normalize_target_name("agy") == "antigravity" + assert get_target_capability("vscode") is TARGET_CAPABILITIES["copilot"] + + catalog_runtime_map = { + runtime: capability.primitive_profile + for capability in TARGET_CAPABILITIES.values() + for runtime in capability.runtimes + } + assert catalog_runtime_map == RUNTIME_TO_CANONICAL_TARGET + for name, profile in KNOWN_TARGETS.items(): + capability = TARGET_CAPABILITIES[name] + assert profile.capability is capability + assert profile.name == capability.name + assert capability.primitive_profile == name + assert capability.compile_family == profile.compile_family + assert capability.experimental_flag == profile.requires_flag + with pytest.raises(FrozenInstanceError): + profile.name = "changed" + + +def test_all_excludes_explicit_experimental_and_mcp_only_targets() -> None: + """The all expansion preserves the stable implicit target set.""" + assert frozenset(expand_all("install")) == ALL_CANONICAL_TARGETS + assert frozenset(expand_all("update")) == ALL_CANONICAL_TARGETS + assert frozenset(expand_all("compile")) == ALL_CANONICAL_TARGETS + for command in COMMANDS: + expanded = frozenset(expand_all(command)) + assert not expanded & EXPERIMENTAL_TARGETS + assert not expanded & EXPLICIT_ONLY_TARGETS + assert not expanded & MCP_ONLY_TARGETS + + +def test_apm_yml_canonical_targets_project_catalog_profiles() -> None: + """Manifest validation accepts stable native deployment profiles.""" + assert ( + frozenset( + capability.name + for capability in TARGET_CAPABILITIES.values() + if capability.experimental_flag is None and not capability.mcp_only + ) + == CANONICAL_TARGETS + ) + + +def test_catalog_validation_rejects_duplicate_aliases() -> None: + """No accepted value may resolve to multiple capabilities.""" + first = _capability("one", aliases=("shared",)) + second = _capability("two", aliases=("shared",)) + with pytest.raises(ValueError, match=r"Duplicate target value 'shared'"): + _build_target_catalog((first, second)) + + +def test_catalog_validation_rejects_missing_mcp_primitive_profile() -> None: + """MCP-only capabilities must name their native primitive profile.""" + invalid = _capability("mcp-only", mcp_only=True, primitive_profile=None) + with pytest.raises(ValueError, match=r"MCP-only target 'mcp-only'.*primitive_profile"): + _build_target_catalog((invalid,)) + + +def test_catalog_validation_rejects_duplicate_runtime_and_invalid_all_membership() -> None: + """Runtimes are unique and gated targets cannot participate in all.""" + with pytest.raises(ValueError, match=r"Runtime 'same'.*multiple targets"): + _build_target_catalog( + ( + _capability("one", runtimes=("same",)), + _capability("two", runtimes=("same",)), + ) + ) + with pytest.raises(ValueError, match=r"Target 'gated'.*in_all"): + _build_target_catalog((_capability("gated", in_all=True, experimental_flag="flag"),)) + + +def _capability( + name: str, + *, + aliases: tuple[str, ...] = (), + in_all: bool = False, + experimental_flag: str | None = None, + mcp_only: bool = False, + primitive_profile: str | None = "profile", + runtimes: tuple[str, ...] = (), +) -> TargetCapability: + """Build compact validation fixtures with otherwise valid metadata.""" + return TargetCapability( + name=name, + aliases=aliases, + description=f"{name} target", + in_all=in_all, + explicit_only=False, + experimental_flag=experimental_flag, + mcp_only=mcp_only, + primitive_profile=primitive_profile, + compile_family=None, + runtimes=runtimes, + commands=frozenset(COMMANDS), + ) diff --git a/tests/unit/core/test_target_catalog_owner_invariant.py b/tests/unit/core/test_target_catalog_owner_invariant.py new file mode 100644 index 000000000..288556309 --- /dev/null +++ b/tests/unit/core/test_target_catalog_owner_invariant.py @@ -0,0 +1,163 @@ +"""Source invariants for the canonical target capability owner.""" + +import ast +from pathlib import Path + +TARGET_CATALOG = Path("src/apm_cli/core/target_catalog.py") +SOURCE_ROOT = Path("src/apm_cli") +TARGET_NAMES = frozenset( + { + "agent-skills", + "antigravity", + "claude", + "codex", + "copilot", + "copilot-app", + "copilot-cowork", + "cursor", + "gemini", + "hermes", + "intellij", + "kiro", + "openclaw", + "opencode", + "vscode", + "windsurf", + } +) +COMMAND_NAMES = frozenset({"compile", "install", "update"}) +CAPABILITY_WORDS = ("capabil", "command", "compil") + + +def _string_literals(node: ast.AST) -> set[str]: + return { + child.value + for child in ast.walk(node) + if isinstance(child, ast.Constant) and isinstance(child.value, str) + } + + +def _assigned_names(node: ast.Assign | ast.AnnAssign) -> tuple[str, ...]: + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + return tuple(target.id for target in targets if isinstance(target, ast.Name)) + + +def _foreign_capability_definitions(tree: ast.AST) -> list[tuple[int, str]]: + """Find target-to-command capability data declared outside its owner.""" + findings: list[tuple[int, str]] = [] + for node in ast.walk(tree): + if not isinstance(node, (ast.Assign, ast.AnnAssign)) or node.value is None: + continue + + names = _assigned_names(node) + lowered_names = tuple(name.lower() for name in names) + literals = _string_literals(node.value) + target_literals = literals & TARGET_NAMES + command_literals = literals & COMMAND_NAMES + + if isinstance(node.value, ast.Dict): + key_targets = { + key.value + for key in node.value.keys + if isinstance(key, ast.Constant) + and isinstance(key.value, str) + and key.value in TARGET_NAMES + } + capability_values = [ + value + for key, value in zip(node.value.keys, node.value.values, strict=True) + if isinstance(key, ast.Constant) + and isinstance(key.value, str) + and key.value in TARGET_NAMES + and isinstance(value, (ast.Dict, ast.Set, ast.List, ast.Tuple)) + and bool(_string_literals(value) & (COMMAND_NAMES | {"commands", "compilable"})) + ] + if len(key_targets) >= 2 and len(capability_values) >= 2: + findings.append((node.lineno, names[0] if names else "")) + continue + + if ( + isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Name) + and node.value.func.id == "dict" + ): + capability_keywords = [ + keyword + for keyword in node.value.keywords + if keyword.arg is not None + and keyword.arg.replace("_", "-") in TARGET_NAMES + and isinstance(keyword.value, (ast.Dict, ast.Set, ast.List, ast.Tuple)) + and bool( + _string_literals(keyword.value) & (COMMAND_NAMES | {"commands", "compilable"}) + ) + ] + if len(capability_keywords) >= 2: + findings.append((node.lineno, names[0] if names else "")) + continue + + capability_named = any( + "target" in name + and any(word in name for word in CAPABILITY_WORDS) + and "family" not in name + for name in lowered_names + ) + if capability_named and len(target_literals) >= 2: + findings.append((node.lineno, names[0] if names else "")) + continue + + per_target_commands = any( + "command" in name and any(target.replace("-", "_") in name for target in TARGET_NAMES) + for name in lowered_names + ) + if per_target_commands and command_literals: + findings.append((node.lineno, names[0] if names else "")) + + return findings + + +def test_foreign_capability_map_fixture_is_detected() -> None: + """Prove a short duplicate capability map cannot evade the guard.""" + tree = ast.parse( + """ +TARGET_SUPPORT = { + "copilot": {"compile": True, "install": True}, + "intellij": {"compile": False, "install": True}, +} +""" + ) + + assert _foreign_capability_definitions(tree) == [(2, "TARGET_SUPPORT")] + + +def test_alias_and_dict_constructor_capability_maps_are_detected() -> None: + """Alternate literal spellings cannot bypass the capability owner.""" + alias_tree = ast.parse( + """ +COMPILE_TARGETS = {"vscode", "cursor"} +""" + ) + constructor_tree = ast.parse( + """ +TARGET_CAPABILITIES = dict( + copilot={"compile", "install"}, + intellij={"install"}, +) +""" + ) + + assert _foreign_capability_definitions(alias_tree) == [(2, "COMPILE_TARGETS")] + assert _foreign_capability_definitions(constructor_tree) == [(2, "TARGET_CAPABILITIES")] + + +def test_target_catalog_is_only_per_target_command_capability_owner() -> None: + """No Python module outside target_catalog may redeclare command support.""" + findings: list[str] = [] + for source_path in SOURCE_ROOT.rglob("*.py"): + if source_path == TARGET_CATALOG: + continue + tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path)) + findings.extend( + f"{source_path}:{line} ({name})" for line, name in _foreign_capability_definitions(tree) + ) + + assert findings == [] diff --git a/tests/unit/core/test_target_detection.py b/tests/unit/core/test_target_detection.py index f7a903835..090439cf7 100644 --- a/tests/unit/core/test_target_detection.py +++ b/tests/unit/core/test_target_detection.py @@ -433,9 +433,9 @@ def test_auto_detect_cursor_plus_github(self, tmp_path): ) assert target == "all" - def test_cursor_no_compile_agents_md(self): - """Cursor target should NOT compile AGENTS.md (uses .cursor/agents/).""" - assert should_compile_agents_md("cursor") is False + def test_cursor_compiles_agents_md(self): + """Cursor's agents compile family emits the shared root context.""" + assert should_compile_agents_md("cursor") is True def test_cursor_no_compile_claude_md(self): """Cursor target should NOT compile CLAUDE.md.""" diff --git a/tests/unit/deps/test_apm_resolver_parallel.py b/tests/unit/deps/test_apm_resolver_parallel.py index 0ffe476f8..d23211909 100644 --- a/tests/unit/deps/test_apm_resolver_parallel.py +++ b/tests/unit/deps/test_apm_resolver_parallel.py @@ -26,7 +26,10 @@ import yaml +from apm_cli.deps import apm_resolver from apm_cli.deps.apm_resolver import APMDependencyResolver +from apm_cli.deps.dependency_graph import DependencyNode +from apm_cli.models.apm_package import APMPackage, DependencyReference def _write_pkg(root: Path, name: str, deps: list[str] | None = None) -> Path: @@ -89,6 +92,38 @@ def _resolved_node_keys(graph) -> list[str]: return list(graph.dependency_tree.nodes.keys()) +def test_dependency_winner_selector_matches_flattening_precedence() -> None: + """Earliest depth and lowest node ID must select one shared winner.""" + selector = getattr(apm_resolver, "_select_dependency_winners", None) + nodes = [ + DependencyNode( + package=APMPackage(name="shared", version="v0"), + dependency_ref=DependencyReference(repo_url="org/shared", reference="v0"), + depth=2, + ), + DependencyNode( + package=APMPackage(name="shared", version="v2"), + dependency_ref=DependencyReference(repo_url="org/shared", reference="v2"), + depth=1, + ), + DependencyNode( + package=APMPackage(name="shared", version="v1"), + dependency_ref=DependencyReference(repo_url="org/shared", reference="v1"), + depth=1, + ), + ] + + assert callable(selector) + ordered, winner_ids = selector(nodes) + + assert [node.get_id() for node in ordered] == [ + "org/shared#v1", + "org/shared#v2", + "org/shared#v0", + ] + assert winner_ids == {"org/shared": "org/shared#v1"} + + def test_max_parallel_one_matches_default_resolver(tmp_path): """``max_parallel=1`` must produce the exact same tree as the default.""" project = _make_tree(tmp_path) @@ -138,6 +173,69 @@ def test_parallel_resolution_is_deterministic_under_jitter(tmp_path): assert all(r == runs[0] for r in runs), runs +def test_conflicting_refs_select_winner_before_parallel_download(tmp_path): + """The flattened winner, callback, loaded metadata, and disk must agree.""" + modules = tmp_path / "apm_modules" + modules.mkdir() + (tmp_path / "apm.yml").write_text( + yaml.safe_dump( + { + "name": "root", + "version": "0.0.1", + "dependencies": { + "apm": ["org/shared#v2", "org/shared#v1", "org/other"], + "mcp": [], + }, + } + ) + ) + + callback_refs: list[tuple[str, str | None]] = [] + callback_lock = threading.Lock() + two_workers_active = threading.Event() + active_workers = 0 + max_active_workers = 0 + + def download(dep_ref, mods_dir, parent_chain="", parent_pkg=None): + nonlocal active_workers, max_active_workers + with callback_lock: + active_workers += 1 + max_active_workers = max(max_active_workers, active_workers) + if active_workers == 2: + two_workers_active.set() + assert two_workers_active.wait(timeout=2) + try: + with callback_lock: + callback_refs.append((dep_ref.repo_url, dep_ref.reference)) + package_dir = dep_ref.get_install_path(mods_dir) + package_dir.mkdir(parents=True, exist_ok=True) + version = dep_ref.reference or "unversioned" + (package_dir / "apm.yml").write_text( + yaml.safe_dump({"name": dep_ref.repo_url.rsplit("/", 1)[-1], "version": version}) + ) + return package_dir + finally: + with callback_lock: + active_workers -= 1 + + graph = APMDependencyResolver( + apm_modules_dir=modules, + download_callback=download, + max_parallel=2, + ).resolve_dependencies(tmp_path) + + winner = graph.flattened_dependencies.get_dependency("org/shared") + winner_node = graph.dependency_tree.nodes["org/shared#v1"] + disk_manifest = yaml.safe_load((modules / "org" / "shared" / "apm.yml").read_text()) + + assert max_active_workers == 2 + assert [ref for repo, ref in callback_refs if repo == "org/shared"] == ["v1"] + assert winner is not None and winner.reference == "v1" + assert winner_node.package.version == "v1" + assert disk_manifest["version"] == "v1" + assert graph.flattened_dependencies.has_conflicts() + + def test_shared_transitive_dep_is_deduplicated(tmp_path): """A dep referenced by two siblings appears once in the tree, with both parents pointing at the same node.""" diff --git a/tests/unit/deps/test_apm_resolver_phase3.py b/tests/unit/deps/test_apm_resolver_phase3.py index c4170b7ff..e846febd0 100644 --- a/tests/unit/deps/test_apm_resolver_phase3.py +++ b/tests/unit/deps/test_apm_resolver_phase3.py @@ -20,6 +20,7 @@ from __future__ import annotations +import re import threading from pathlib import Path from unittest.mock import MagicMock, patch @@ -642,3 +643,80 @@ def legacy_cb(dep_ref, modules_dir, parent_chain=""): resolver = APMDependencyResolver(apm_modules_dir=mods, download_callback=legacy_cb) resolver._try_load_dependency_package(ref) assert len(call_log) == 1 + + +# --------------------------------------------------------------------------- +# Anchored-local-path portability (committable, cross-machine lockfile) +# --------------------------------------------------------------------------- + + +class TestPortableAnchorIdentity: + """`_portable_anchor_identity` is the single owner of the identity string + persisted for a resolved transitive local dependency. That identity feeds + the lockfile `dependencies[].anchored_local_path` field and, via the + `local:` owner identity, the deployment-ledger owner rows. A lockfile is a + committed cross-machine artifact, so an in-project package MUST serialize + to a project-root-relative POSIX path -- never an absolute path carrying a + developer home directory, which would make a regenerated lockfile + non-portable and non-deterministic across machines and CI. + """ + + def test_in_project_dep_is_root_relative_posix(self, tmp_path: Path) -> None: + base = tmp_path / "proj" + anchored = base / "packages" / "child" + identity = APMDependencyResolver._portable_anchor_identity(anchored, base) + assert identity == "packages/child" + assert not Path(identity).is_absolute() + assert str(tmp_path) not in identity + assert "\\" not in identity + + def test_nested_transitive_dep_is_root_relative(self, tmp_path: Path) -> None: + base = tmp_path / "proj" + anchored = base / "child" / "grandchild" + identity = APMDependencyResolver._portable_anchor_identity(anchored, base) + assert identity == "child/grandchild" + + def test_out_of_project_dep_falls_back_to_absolute_posix(self, tmp_path: Path) -> None: + base = tmp_path / "proj" + base.mkdir() + outside = tmp_path / "elsewhere" / "pkg" + identity = APMDependencyResolver._portable_anchor_identity(outside, base) + # Out-of-project local deps cannot be committed portably; a stable + # absolute POSIX identity is the honest fallback, not a fabricated one. + assert identity == outside.resolve().as_posix() + + def test_unknown_base_falls_back_to_absolute_posix(self, tmp_path: Path) -> None: + anchored = tmp_path / "child" + identity = APMDependencyResolver._portable_anchor_identity(anchored, None) + assert identity == anchored.resolve().as_posix() + + +def _repo_root() -> Path: + here = Path(__file__).resolve() + for parent in here.parents: + if (parent / "apm.lock.yaml").exists() and (parent / "pyproject.toml").exists(): + return parent + raise AssertionError("could not locate repo root with apm.lock.yaml") + + +class TestCommittedLockfilePortability: + """The committed `apm.lock.yaml` is a cross-machine artifact. This guard + fails if any identity field ever regenerates with an absolute machine path + -- the exact regression the resolver anchoring fix prevents. It scans the + real committed lockfile, so it bites regardless of who regenerates it. + """ + + def test_no_absolute_anchor_or_owner_identity(self) -> None: + text = (_repo_root() / "apm.lock.yaml").read_text(encoding="utf-8") + # Absolute anchored_local_path (POSIX or Windows drive form). + assert not re.search(r"anchored_local_path:\s*(/|[A-Za-z]:\\)", text), ( + "anchored_local_path leaked an absolute path into the committed lockfile" + ) + # Absolute local: owner identity (declaring_parent + ledger owner rows). + assert "local:/" not in text, ( + "an absolute local: owner identity leaked into the committed lockfile" + ) + # No developer home directory prefix anywhere in the artifact. + assert "/Users/" not in text and "/home/" not in text, ( + "a developer home directory leaked into the committed lockfile" + ) diff --git a/tests/unit/deps/test_github_downloader_gitlab_routing.py b/tests/unit/deps/test_github_downloader_gitlab_routing.py new file mode 100644 index 000000000..2817ac2f6 --- /dev/null +++ b/tests/unit/deps/test_github_downloader_gitlab_routing.py @@ -0,0 +1,60 @@ +"""GitLab routing and credential-scope contract tests.""" + +import os +from unittest.mock import Mock, patch +from urllib.parse import urlparse + +from apm_cli.core.auth import AuthResolver +from apm_cli.deps.github_downloader import GitHubPackageDownloader +from apm_cli.models.apm_package import DependencyReference + + +def _download_from_bespoke_gitlab_host(env: dict[str, str]) -> tuple[str, dict[str, str]]: + """Route an object-form GitLab dependency and return its HTTP request.""" + dep_ref = DependencyReference.parse_from_dict( + {"git": "https://code.acme.com/group/sub/repo.git", "type": "gitlab"} + ) + response = Mock(status_code=200, content=b"gitlab raw", headers={}) + + with patch.dict(os.environ, env, clear=True): + downloader = GitHubPackageDownloader( + auth_resolver=AuthResolver(allow_external_fallback=False) + ) + with ( + patch.object( + downloader._strategies, + "_download_gitlab_file_via_git", + side_effect=RuntimeError("force REST fallback"), + ), + patch.object(downloader, "_resilient_get", return_value=response) as mock_get, + ): + result = downloader._download_github_file(dep_ref, "SKILL.md", "main") + + assert result == b"gitlab raw" + assert dep_ref.host_type == "gitlab" + return mock_get.call_args[0][0], mock_get.call_args[1]["headers"] + + +def test_type_gitlab_routes_untrusted_bespoke_host_without_private_token() -> None: + """Explicit GitLab type selects its API without trusting the host for auth.""" + request_url, headers = _download_from_bespoke_gitlab_host({"GITLAB_APM_PAT": "glpat-bespoke"}) + + parsed = urlparse(request_url) + assert parsed.hostname == "code.acme.com" + assert parsed.path.endswith("/repository/files/SKILL.md/raw") + assert "PRIVATE-TOKEN" not in headers + + +def test_type_gitlab_routes_trusted_bespoke_host_with_private_token() -> None: + """APM_GITLAB_HOSTS opts the bespoke host into GitLab token delivery.""" + request_url, headers = _download_from_bespoke_gitlab_host( + { + "APM_GITLAB_HOSTS": "code.acme.com", + "GITLAB_APM_PAT": "glpat-bespoke", + } + ) + + parsed = urlparse(request_url) + assert parsed.hostname == "code.acme.com" + assert parsed.path.endswith("/repository/files/SKILL.md/raw") + assert headers.get("PRIVATE-TOKEN") == "glpat-bespoke" diff --git a/tests/unit/deps/test_lockfile_deployment_paths.py b/tests/unit/deps/test_lockfile_deployment_paths.py new file mode 100644 index 000000000..27a6c1600 --- /dev/null +++ b/tests/unit/deps/test_lockfile_deployment_paths.py @@ -0,0 +1,42 @@ +"""Tests for lockfile-owned deployment path mutations.""" + +from apm_cli.core.deployment_ledger import DeploymentLedgerCodec +from apm_cli.deps.lockfile import LockFile + + +def test_rename_local_deployed_path_moves_path_and_hash_without_duplicates() -> None: + lockfile = LockFile( + local_deployed_files=["old.md", "new.md", "old.md"], + local_deployed_file_hashes={"old.md": "sha256:old"}, + ) + + lockfile.rename_local_deployed_path("old.md", "new.md") + + assert lockfile.local_deployed_files == ["new.md"] + assert lockfile.local_deployed_file_hashes == {"new.md": "sha256:old"} + + +def test_rename_local_deployed_path_is_noop_when_old_path_is_absent() -> None: + lockfile = LockFile( + local_deployed_files=["kept.md"], + local_deployed_file_hashes={"old.md": "sha256:orphan"}, + ) + + lockfile.rename_local_deployed_path("missing.md", "new.md") + + assert lockfile.local_deployed_files == ["kept.md"] + assert lockfile.local_deployed_file_hashes == {"old.md": "sha256:orphan"} + + +def test_rename_local_deployed_path_invalidates_canonical_projection() -> None: + lockfile = LockFile( + local_deployed_files=["old.md"], + local_deployed_file_hashes={"old.md": "sha256:old"}, + ) + lockfile.deployment_ledger = DeploymentLedgerCodec.from_lockfile(lockfile) + lockfile._deployments_present = True + + lockfile.rename_local_deployed_path("old.md", "new.md") + + assert lockfile.deployment_ledger.records == {} + assert lockfile._deployments_present is False diff --git a/tests/unit/install/phases/test_lockfile_cross_package_reconcile.py b/tests/unit/install/phases/test_lockfile_cross_package_reconcile.py index d4aa6271c..559b9d373 100644 --- a/tests/unit/install/phases/test_lockfile_cross_package_reconcile.py +++ b/tests/unit/install/phases/test_lockfile_cross_package_reconcile.py @@ -9,13 +9,14 @@ end up claiming ``deployed_files`` for a path only one of them actually owns on disk -- a lockfile integrity bug: a future ``apm uninstall`` or ``apm audit`` on the "losing" package would act on a file it does not -control. See ``LockfileBuilder._reconcile_cross_package_deployed_files``. +control. The claim decision belongs to ``DeploymentReconciler``. """ from __future__ import annotations from types import SimpleNamespace +from apm_cli.core.deployment_state import DeploymentReconciler from apm_cli.deps.lockfile import LockedDependency, LockFile from apm_cli.install.phases.lockfile import LockfileBuilder @@ -33,37 +34,41 @@ def _ctx(*, package_deployed_files, existing_lockfile=None, targets=None, projec ) +def _reconciled_current(package_deployed_files: dict[str, list[str]]) -> dict[str, list[str]]: + claims = DeploymentReconciler.reconcile_package_claims( + package_keys=package_deployed_files, + current_claims=package_deployed_files, + prior_files={}, + prior_hashes={}, + ) + return {owner: list(claim.current_files) for owner, claim in claims.items()} + + class TestReconcileCrossPackageDeployedFiles: - def test_colliding_path_kept_only_on_last_writer(self, tmp_path) -> None: + def test_colliding_path_kept_only_on_last_writer(self) -> None: """Two dep_keys both report the same path; only the last (the actual on-disk owner, under sequential integration order) keeps it.""" package_deployed_files = { "orga/shared-skill": [".claude/skills/shared-topic/SKILL.md"], "orgb/shared-skill": [".claude/skills/shared-topic/SKILL.md"], } - ctx = _ctx(package_deployed_files=package_deployed_files, project_root=tmp_path) + reconciled = _reconciled_current(package_deployed_files) - LockfileBuilder(ctx)._reconcile_cross_package_deployed_files() + assert reconciled["orga/shared-skill"] == [] + assert reconciled["orgb/shared-skill"] == [".claude/skills/shared-topic/SKILL.md"] - assert package_deployed_files["orga/shared-skill"] == [] - assert package_deployed_files["orgb/shared-skill"] == [ - ".claude/skills/shared-topic/SKILL.md" - ] - - def test_non_colliding_paths_are_untouched(self, tmp_path) -> None: + def test_non_colliding_paths_are_untouched(self) -> None: """Normal case: no two dep_keys share a path -- nothing is stripped.""" package_deployed_files = { "orga/repo-a": [".claude/skills/topic-a/SKILL.md"], "orgb/repo-b": [".claude/skills/topic-b/SKILL.md"], } - ctx = _ctx(package_deployed_files=package_deployed_files, project_root=tmp_path) - - LockfileBuilder(ctx)._reconcile_cross_package_deployed_files() + reconciled = _reconciled_current(package_deployed_files) - assert package_deployed_files["orga/repo-a"] == [".claude/skills/topic-a/SKILL.md"] - assert package_deployed_files["orgb/repo-b"] == [".claude/skills/topic-b/SKILL.md"] + assert reconciled["orga/repo-a"] == [".claude/skills/topic-a/SKILL.md"] + assert reconciled["orgb/repo-b"] == [".claude/skills/topic-b/SKILL.md"] - def test_partial_collision_only_strips_the_shared_path(self, tmp_path) -> None: + def test_partial_collision_only_strips_the_shared_path(self) -> None: """A dep_key with multiple deployed files only loses the ONE path another dep_key also claims -- its other files are untouched.""" package_deployed_files = { @@ -73,16 +78,10 @@ def test_partial_collision_only_strips_the_shared_path(self, tmp_path) -> None: ], "orgb/shared-skill": [".claude/skills/shared-topic/SKILL.md"], } - ctx = _ctx(package_deployed_files=package_deployed_files, project_root=tmp_path) - - LockfileBuilder(ctx)._reconcile_cross_package_deployed_files() + reconciled = _reconciled_current(package_deployed_files) - assert package_deployed_files["orga/shared-skill"] == [ - ".claude/skills/unique-to-a/SKILL.md" - ] - assert package_deployed_files["orgb/shared-skill"] == [ - ".claude/skills/shared-topic/SKILL.md" - ] + assert reconciled["orga/shared-skill"] == [".claude/skills/unique-to-a/SKILL.md"] + assert reconciled["orgb/shared-skill"] == [".claude/skills/shared-topic/SKILL.md"] def test_attach_deployed_files_end_to_end_only_winner_recorded(self, tmp_path) -> None: """End-to-end through _attach_deployed_files: the lockfile entry for diff --git a/tests/unit/install/phases/test_lockfile_union.py b/tests/unit/install/phases/test_lockfile_union.py index 8782300ec..ceb82ad8d 100644 --- a/tests/unit/install/phases/test_lockfile_union.py +++ b/tests/unit/install/phases/test_lockfile_union.py @@ -357,6 +357,32 @@ def test_union_declared_universe_still_preserves_1716_sibling(self): assert "copilot-app-db://workflows/new" in files assert "copilot-app-db://workflows/old" not in files + def test_state_reconcile_without_declared_universe_preserves_sibling(self, tmp_path): + """Command entrypoints retain legacy multi-target state without targets.""" + from apm_cli.install.manifest_reconcile import reconcile_deployed_state + from apm_cli.utils.diagnostics import DiagnosticCollector + + sibling = ".windsurf/rules/keep.md" + lockfile = LockFile() + lockfile.add_dependency( + LockedDependency( + repo_url="owner/pkg", + deployed_files=[sibling], + deployed_file_hashes={sibling: "sha256:old"}, + ) + ) + + changed = reconcile_deployed_state( + project_root=tmp_path, + lockfile=lockfile, + active_targets=[_known("copilot")], + declared_targets=None, + diagnostics=DiagnosticCollector(), + ) + + assert not changed + assert lockfile.get_dependency("owner/pkg").deployed_files == [sibling] + def test_gated_dynamic_target_uri_never_treated_as_ghost(self, tmp_path): """A consumer that declares only canonical ``copilot`` but uses the gated ``copilot-app`` target (activated by flag/detection, NOT diff --git a/tests/unit/install/phases/test_resolve_ref_resolver_reuse.py b/tests/unit/install/phases/test_resolve_ref_resolver_reuse.py index e3b85d68e..35fd2cd8e 100644 --- a/tests/unit/install/phases/test_resolve_ref_resolver_reuse.py +++ b/tests/unit/install/phases/test_resolve_ref_resolver_reuse.py @@ -9,10 +9,13 @@ import os import sys +from types import SimpleNamespace from unittest.mock import MagicMock, patch +from urllib.parse import urlparse sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "src")) +from apm_cli.install.helpers.ref_reuse import resolve_dep_auth from apm_cli.install.phases.resolve import _maybe_resolve_git_semver from apm_cli.models.dependency.reference import DependencyReference @@ -32,8 +35,8 @@ def _patched_resolver_env(): made = [] class _FakeRefResolver: - def __init__(self, *, host=None, token=None): - made.append((host, token)) + def __init__(self, *, host=None, token=None, auth_scheme="basic"): + made.append((host, token, auth_scheme)) fake_semver = MagicMock() fake_semver.return_value.resolve.return_value = "RESOLUTION" @@ -102,12 +105,12 @@ def test_concurrent_same_repo_deps_share_one_resolver_under_lock(): errors_lock = threading.Lock() class _SlowFakeRefResolver: - def __init__(self, *, host=None, token=None): + def __init__(self, *, host=None, token=None, auth_scheme="basic"): # Small delay to widen the construction window. With a working # lock only one thread ever reaches here; without it, several # would slip in during this sleep and append multiple entries. time.sleep(0.02) - made.append((host, token)) + made.append((host, token, auth_scheme)) fake_semver = MagicMock() fake_semver.return_value.resolve.return_value = "R" @@ -187,8 +190,9 @@ def test_cache_key_does_not_contain_raw_token(): cache: dict = {} class _FakeRefResolver: - def __init__(self, *, host=None, token=None): + def __init__(self, *, host=None, token=None, auth_scheme="basic"): self.token = token + self.auth_scheme = auth_scheme with patch("apm_cli.marketplace.ref_resolver.RefResolver", _FakeRefResolver): resolver = get_shared_ref_resolver("github.com", secret, cache) @@ -202,3 +206,207 @@ def __init__(self, *, host=None, token=None): with patch("apm_cli.marketplace.ref_resolver.RefResolver", _FakeRefResolver): get_shared_ref_resolver("github.com", "ghp_a_different_token_000000", cache) assert len(cache) == 2 + + +def test_cache_separates_basic_and_bearer_for_same_host_and_token(): + """The scheme is part of resolver identity even when credentials match.""" + from apm_cli.install.helpers.ref_reuse import get_shared_ref_resolver + + class _FakeRefResolver: + def __init__(self, *, host=None, token=None, auth_scheme="basic"): + self.auth_scheme = auth_scheme + + cache: dict = {} + with patch("apm_cli.marketplace.ref_resolver.RefResolver", _FakeRefResolver): + basic = get_shared_ref_resolver("dev.azure.com", "dummy", cache) + bearer = get_shared_ref_resolver("dev.azure.com", "dummy", cache, auth_scheme="bearer") + + assert basic is not bearer + assert {basic.auth_scheme, bearer.auth_scheme} == {"basic", "bearer"} + assert len(cache) == 2 + + +def test_semver_resolution_preserves_bearer_and_basic_auth_schemes(): + """Semver tag listing must use the complete per-dependency auth context.""" + bearer_token = "dummy-ado-bearer" + basic_token = "dummy-github-basic" + calls = [] + + class _AuthResolver: + def resolve_for_dep(self, dep_ref): + if dep_ref.host == "dev.azure.com": + return SimpleNamespace(token=bearer_token, auth_scheme="bearer") + return SimpleNamespace(token=basic_token, auth_scheme="basic") + + def _run(args, **kwargs): + calls.append((args, kwargs)) + return SimpleNamespace( + returncode=0, + stdout=f"{'a' * 40}\trefs/tags/v1.0.0\n", + stderr="", + ) + + deps = [ + DependencyReference( + host="dev.azure.com", + repo_url="example/project/_git/package", + reference="^1.0.0", + source="git", + explicit_scheme="https", + ), + DependencyReference( + host="github.com", + repo_url="example/package", + reference="^1.0.0", + source="git", + explicit_scheme="https", + ), + ] + + cache = {} + with patch("apm_cli.marketplace.ref_resolver.subprocess.run", side_effect=_run): + for dep in deps: + _maybe_resolve_git_semver( + dep_ref=dep, + existing_lockfile=None, + update_refs=True, + auth_resolver=_AuthResolver(), + ref_resolver_cache=cache, + ) + + assert {resolver._auth_scheme for resolver in cache.values()} == {"basic", "bearer"} + ado_args, ado_kwargs = calls[0] + github_args, github_kwargs = calls[1] + ado_auth_values = { + value for key, value in ado_kwargs["env"].items() if key.startswith("GIT_CONFIG_VALUE_") + } + assert ado_auth_values == {f"Authorization: Bearer {bearer_token}"} + assert urlparse(ado_args[-1]).username is None + assert urlparse(github_args[-1]).password == basic_token + assert not { + value + for key, value in github_kwargs["env"].items() + if key.startswith("GIT_CONFIG_VALUE_") and value.startswith("Authorization:") + } + + +def test_resolve_dep_auth_falls_back_to_basic_when_token_missing(): + """A token-less context must not forward a bearer scheme. + + Forwarding ``auth_scheme="bearer"`` with an empty token would make + RefResolver attempt a bearer request on what is effectively the + unauthenticated public-repo path. The resolver must degrade to + ``(None, "basic", None)`` so the legacy best-effort behaviour is preserved. + """ + + class _NoTokenBearerResolver: + def resolve_for_dep(self, dep_ref): + return SimpleNamespace(token=None, auth_scheme="bearer") + + class _EmptyTokenBearerResolver: + def resolve_for_dep(self, dep_ref): + return SimpleNamespace(token="", auth_scheme="bearer") + + dep = DependencyReference( + host="dev.azure.com", + repo_url="example/project/_git/package", + reference="^1.0.0", + source="git", + explicit_scheme="https", + ) + + assert resolve_dep_auth(dep, _NoTokenBearerResolver()) == (None, "basic", None) + assert resolve_dep_auth(dep, _EmptyTokenBearerResolver()) == (None, "basic", None) + assert resolve_dep_auth(dep, None) == (None, "basic", None) + + +def test_resolve_dep_auth_preserves_sanitized_git_environment(): + sanitized = {"PATH": "/usr/bin", "GIT_TERMINAL_PROMPT": "0"} + + class _Resolver: + def resolve_for_dep(self, dep_ref): + return SimpleNamespace( + token="pat", + auth_scheme="basic", + git_env=sanitized, + ) + + dep = DependencyReference( + host="dev.azure.com", + repo_url="example/project/_git/package", + reference="^1.0.0", + source="git", + explicit_scheme="https", + ) + + assert resolve_dep_auth(dep, _Resolver()) == ("pat", "basic", sanitized) + + +def test_semver_ref_resolution_retries_rejected_ado_pat_with_bearer(): + """A stale ADO PAT retries tag listing with the canonical bearer scheme.""" + from apm_cli.core.auth import BearerFallbackOutcome + + calls = [] + + class _AuthResolver: + def resolve_for_dep(self, dep_ref): + return SimpleNamespace(token="stale-pat", auth_scheme="basic") + + def execute_with_bearer_fallback( + self, + dep_ref, + primary_op, + bearer_op, + is_auth_failure, + ): + primary = primary_op() + assert is_auth_failure(primary) + fallback = bearer_op("fresh-bearer") + return BearerFallbackOutcome(fallback, True) + + def _run(args, **kwargs): + calls.append((args, kwargs)) + auth_values = { + value for key, value in kwargs["env"].items() if key.startswith("GIT_CONFIG_VALUE_") + } + if any("Bearer " in value for value in auth_values): + return SimpleNamespace( + returncode=0, + stdout=f"{'a' * 40}\trefs/tags/v1.2.0\n", + stderr="", + ) + return SimpleNamespace( + returncode=128, + stdout="", + stderr="fatal: The requested URL returned error: 401", + ) + + dep = DependencyReference( + host="dev.azure.com", + repo_url="example/project/_git/package", + reference="^1.0.0", + source="git", + explicit_scheme="https", + ) + + with patch("apm_cli.marketplace.ref_resolver.subprocess.run", side_effect=_run): + resolution = _maybe_resolve_git_semver( + dep_ref=dep, + existing_lockfile=None, + update_refs=True, + auth_resolver=_AuthResolver(), + ) + + assert resolution.resolved_tag == "v1.2.0" + assert len(calls) == 2 + auth_headers = [ + {value for key, value in call_kwargs["env"].items() if key.startswith("GIT_CONFIG_VALUE_")} + for _call_args, call_kwargs in calls + ] + assert len(auth_headers[0]) == 1 + assert next(iter(auth_headers[0])).startswith("Authorization: Basic ") + assert len(auth_headers[1]) == 1 + assert next(iter(auth_headers[1])).split(maxsplit=2)[:2] == [ + "Authorization:", + "Bearer", + ] diff --git a/tests/unit/install/test_install_cmd_auth_rendering.py b/tests/unit/install/test_install_cmd_auth_rendering.py index 9be446afc..fd79238b5 100644 --- a/tests/unit/install/test_install_cmd_auth_rendering.py +++ b/tests/unit/install/test_install_cmd_auth_rendering.py @@ -6,6 +6,9 @@ APM dependencies: Failed to resolve..." string. """ +import ast +from pathlib import Path + from apm_cli.install.errors import AuthenticationError @@ -73,3 +76,18 @@ def test_auth_error_bypasses_generic_runtime_wrap(self): assert caught_by_auth assert not caught_by_generic + + +def test_install_exception_handlers_do_not_render_directly() -> None: + """Exception handlers declare events through the command logger owner.""" + source_path = Path("src/apm_cli/commands/install.py") + tree = ast.parse(source_path.read_text(encoding="utf-8")) + offenders = [] + for handler in (node for node in ast.walk(tree) if isinstance(node, ast.ExceptHandler)): + for child in ast.walk(handler): + if not isinstance(child, ast.Call) or not isinstance(child.func, ast.Name): + continue + if child.func.id.startswith("_rich_"): + offenders.append((child.lineno, child.func.id)) + + assert offenders == [] diff --git a/tests/unit/install/test_install_exit_owner_invariant.py b/tests/unit/install/test_install_exit_owner_invariant.py new file mode 100644 index 000000000..dabaa3156 --- /dev/null +++ b/tests/unit/install/test_install_exit_owner_invariant.py @@ -0,0 +1,99 @@ +"""Source invariants for install exit-code ownership.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +SRC_ROOT = Path(__file__).resolve().parents[3] / "src" / "apm_cli" +INSTALL_ROOT = SRC_ROOT / "install" + + +def _tree(path: Path) -> ast.AST: + return ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + + +def _is_exit_code(node: ast.AST) -> bool: + return isinstance(node, ast.Attribute) and node.attr == "exit_code" + + +def _is_exit_sink(function: ast.AST) -> bool: + if isinstance(function, ast.Name): + return function.id == "SystemExit" + if not isinstance(function, ast.Attribute): + return False + return function.attr in {"exit", "Exit", "SystemExit"} + + +def _qualified_name(node: ast.AST | None) -> str: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + prefix = _qualified_name(node.value) + return f"{prefix}.{node.attr}" if prefix else node.attr + return "" + + +def _click_exception_exit_calls(tree: ast.AST) -> set[tuple[int, int]]: + """Return exit calls that map ClickException, not InstallResult.""" + calls = set() + for node in ast.walk(tree): + if not isinstance(node, ast.ExceptHandler): + continue + if _qualified_name(node.type) != "click.ClickException": + continue + for statement in node.body: + for child in ast.walk(statement): + if isinstance(child, ast.Call) and _is_exit_sink(child.func): + calls.add((child.lineno, child.col_offset)) + return calls + + +def _result_exit_mappings(tree: ast.AST) -> list[ast.Call]: + mappings = [] + click_exception_calls = _click_exception_exit_calls(tree) + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not _is_exit_sink(node.func): + continue + if (node.lineno, node.col_offset) in click_exception_calls: + continue + arguments = [*node.args, *(keyword.value for keyword in node.keywords)] + if any(_is_exit_code(argument) for argument in arguments): + mappings.append(node) + return mappings + + +def test_only_install_command_maps_result_exit_code() -> None: + """Exactly one command boundary translates InstallResult.exit_code.""" + owners: list[str] = [] + for path in SRC_ROOT.rglob("*.py"): + relative_path = path.relative_to(SRC_ROOT).as_posix() + mappings = _result_exit_mappings(_tree(path)) + if mappings: + owners.append(relative_path) + + assert owners == ["commands/install.py"] + + +def test_install_engine_never_maps_result_exit_code() -> None: + """Install engine modules return InstallResult; only the command maps it.""" + offenders = [ + path.relative_to(SRC_ROOT).as_posix() + for path in INSTALL_ROOT.rglob("*.py") + if _result_exit_mappings(_tree(path)) + ] + + assert not offenders + + +def test_negative_control_exit_mappings_are_detected() -> None: + """Variable renames and each supported exit form must not bypass the guard.""" + snippets = ( + "ctx.exit(result.exit_code)", + "sys.exit(outcome.exit_code)", + "raise SystemExit(install_result.exit_code)", + "raise click.exceptions.Exit(value.exit_code)", + ) + + for snippet in snippets: + assert _result_exit_mappings(ast.parse(snippet)), snippet diff --git a/tests/unit/install/test_install_pkg_policy_rollback.py b/tests/unit/install/test_install_pkg_policy_rollback.py index e66efdab0..f57ed59a7 100644 --- a/tests/unit/install/test_install_pkg_policy_rollback.py +++ b/tests/unit/install/test_install_pkg_policy_rollback.py @@ -473,7 +473,7 @@ class TestRestoreManifestFromSnapshot: def test_atomic_restore_byte_exact(self, tmp_path): """Restored file is byte-identical to snapshot.""" - from apm_cli.commands.install import _restore_manifest_from_snapshot + from apm_cli.install.transaction import _restore_manifest_from_snapshot target = tmp_path / "apm.yml" original = b"name: test\nversion: 1.0.0\n" @@ -485,7 +485,7 @@ def test_atomic_restore_byte_exact(self, tmp_path): def test_atomic_restore_no_temp_file_left(self, tmp_path): """No temporary files remain after successful restore.""" - from apm_cli.commands.install import _restore_manifest_from_snapshot + from apm_cli.install.transaction import _restore_manifest_from_snapshot target = tmp_path / "apm.yml" target.write_bytes(b"mutated") @@ -498,7 +498,7 @@ def test_atomic_restore_no_temp_file_left(self, tmp_path): def test_atomic_restore_replaces_existing(self, tmp_path): """Restore replaces the mutated file, not appends.""" - from apm_cli.commands.install import _restore_manifest_from_snapshot + from apm_cli.install.transaction import _restore_manifest_from_snapshot target = tmp_path / "apm.yml" original = b"short" @@ -514,7 +514,7 @@ class TestMaybeRollbackManifest: def test_noop_when_snapshot_is_none(self, tmp_path): """No-op when snapshot is None (not an ``install `` invocation).""" - from apm_cli.commands.install import _maybe_rollback_manifest + from apm_cli.install.transaction import _maybe_rollback_manifest target = tmp_path / "apm.yml" target.write_bytes(b"should not change") @@ -528,7 +528,7 @@ def test_noop_when_snapshot_is_none(self, tmp_path): def test_restores_and_logs_when_snapshot_present(self, tmp_path): """Restores apm.yml and emits info message.""" - from apm_cli.commands.install import _maybe_rollback_manifest + from apm_cli.install.transaction import _maybe_rollback_manifest target = tmp_path / "apm.yml" target.write_bytes(b"mutated") @@ -539,9 +539,9 @@ def test_restores_and_logs_when_snapshot_present(self, tmp_path): assert target.read_bytes() == b"original" logger.progress.assert_called_once_with("apm.yml restored to its previous state.") - def test_warns_on_restore_failure(self, tmp_path): - """If restore fails, warn but don't mask the original error.""" - from apm_cli.commands.install import _maybe_rollback_manifest + def test_reports_actionable_warning_on_restore_failure(self, tmp_path): + """Restore failures name the file, next action, and verbose detail.""" + from apm_cli.install.transaction import _maybe_rollback_manifest # Point at a non-existent directory so tempfile.mkstemp fails bad_path = tmp_path / "nonexistent" / "apm.yml" @@ -550,8 +550,12 @@ def test_warns_on_restore_failure(self, tmp_path): # Should not raise -- best-effort _maybe_rollback_manifest(bad_path, b"data", logger) - logger.warning.assert_called_once() - assert "Failed to restore" in logger.warning.call_args[0][0] + logger.warning.assert_called_once_with( + "Failed to restore apm.yml. Inspect apm.yml before retrying." + ) + logger.verbose_detail.assert_called_once() + assert "Manifest rollback error:" in logger.verbose_detail.call_args[0][0] + logger.error.assert_not_called() # --------------------------------------------------------------------------- diff --git a/tests/unit/install/test_install_target_copilot_cowork_e2e.py b/tests/unit/install/test_install_target_copilot_cowork_e2e.py index ed33ba1f2..0c424ab46 100644 --- a/tests/unit/install/test_install_target_copilot_cowork_e2e.py +++ b/tests/unit/install/test_install_target_copilot_cowork_e2e.py @@ -37,6 +37,7 @@ from click.testing import CliRunner from apm_cli.cli import cli +from apm_cli.core.target_catalog import TARGET_CAPABILITIES # --------------------------------------------------------------------------- # Shared helpers @@ -454,7 +455,7 @@ def test_uninstall_deletes_cowork_skill_directory(self, tmp_path: Path) -> None: # -- setup: cowork target profile --- cowork_target = TargetProfile( - name="copilot-cowork", + capability=TARGET_CAPABILITIES["copilot-cowork"], root_dir="copilot-cowork", primitives={ "skills": PrimitiveMapping("skills", "/SKILL.md", "skill_standard"), @@ -503,7 +504,7 @@ def test_uninstall_cowork_with_resolver_none_skips_gracefully(self, tmp_path: Pa project_root.mkdir() cowork_target = TargetProfile( - name="copilot-cowork", + capability=TARGET_CAPABILITIES["copilot-cowork"], root_dir="copilot-cowork", primitives={ "skills": PrimitiveMapping("skills", "/SKILL.md", "skill_standard"), diff --git a/tests/unit/install/test_install_transaction.py b/tests/unit/install/test_install_transaction.py new file mode 100644 index 000000000..4811fa30b --- /dev/null +++ b/tests/unit/install/test_install_transaction.py @@ -0,0 +1,321 @@ +"""Unit coverage for the canonical install transaction owner.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from apm_cli.cli import cli +from apm_cli.core.command_logger import _ValidationOutcome +from apm_cli.install.transaction import InstallTransaction +from apm_cli.models.results import InstallDisposition, InstallResult +from apm_cli.utils.path_security import PathTraversalError + + +def _transaction( + tmp_path: Path, + validation: _ValidationOutcome | None = None, +) -> InstallTransaction: + manifest = tmp_path / "apm.yml" + manifest.write_bytes(b"name: fixture\r\nversion: 1.0.0\r\n") + modules = tmp_path / "apm_modules" + modules.mkdir() + return InstallTransaction( + manifest_path=manifest, + apm_modules_dir=modules, + validation=validation, + logger=MagicMock(), + ) + + +def test_total_validation_failure_rolls_back(tmp_path: Path) -> None: + """An all-invalid batch is a failed validation, not a successful no-op.""" + transaction = _transaction( + tmp_path, + _ValidationOutcome(valid=[], invalid=[("bad", "not found")]), + ) + transaction.manifest_path.write_text("changed\n", encoding="ascii") + + result = transaction.validation_result() + + assert result is not None + assert result.disposition is InstallDisposition.VALIDATION_FAILED + assert result.exit_code == 1 + assert result.committed is False + assert transaction.manifest_path.read_bytes() == b"name: fixture\r\nversion: 1.0.0\r\n" + + +def test_mixed_validation_commits_as_partial_success(tmp_path: Path) -> None: + """A batch with survivors commits and keeps the existing warning policy.""" + transaction = _transaction( + tmp_path, + _ValidationOutcome( + valid=[("good", False)], + invalid=[("bad", "not found")], + ), + ) + + result = transaction.commit(InstallResult(installed_count=1)) + + assert result.disposition is InstallDisposition.PARTIAL_SUCCESS + assert result.exit_code == 0 + assert result.committed is True + + +@pytest.mark.parametrize( + "disposition", + [InstallDisposition.CANCELLED], +) +def test_non_mutating_dispositions_remain_uncommitted( + tmp_path: Path, + disposition: InstallDisposition, +) -> None: + """Cancellation and dry-run results remain non-mutating and uncommitted.""" + transaction = _transaction(tmp_path) + result = InstallResult(disposition=disposition) + + transaction.rollback() + + assert result.exit_code == 0 + assert result.committed is False + + +def test_dry_run_completion_preserves_auto_created_manifest(tmp_path: Path) -> None: + """A successful dry-run keeps bootstrap configuration but rolls back modules.""" + manifest = tmp_path / "apm.yml" + modules = tmp_path / "apm_modules" + modules.mkdir() + transaction = InstallTransaction( + manifest_path=manifest, + apm_modules_dir=modules, + validation=None, + logger=MagicMock(), + ) + package = modules / "new-package" + transaction.resolution.prepare_path(package) + package.mkdir() + manifest.write_text("name: created\n", encoding="ascii") + result = InstallResult(disposition=InstallDisposition.DRY_RUN) + + completed = transaction.complete(result) + transaction.__exit__(None, None, None) + + assert completed is result + assert completed.committed is False + assert manifest.read_text(encoding="ascii") == "name: created\n" + assert not package.exists() + + +def test_success_commit_finalizes_resolution(tmp_path: Path) -> None: + """A successful install finalizes the resolution journal.""" + transaction = _transaction(tmp_path) + package = transaction.apm_modules_dir / "new-package" + transaction.resolution.prepare_path(package) + package.mkdir() + + result = transaction.commit(InstallResult(installed_count=1)) + + assert result.disposition is InstallDisposition.SUCCESS + assert result.committed is True + assert package.is_dir() + assert not (transaction.apm_modules_dir / ".apm-resolution-staging").exists() + + +def test_manifest_restore_is_byte_exact(tmp_path: Path) -> None: + """Rollback restores the exact bytes captured before validation.""" + transaction = _transaction(tmp_path) + original = transaction.manifest_path.read_bytes() + transaction.manifest_path.write_bytes(b"name: changed\n") + + transaction.rollback() + + assert transaction.manifest_path.exists() + assert transaction.manifest_path.read_bytes() == original + + +def test_rollback_removes_manifest_created_by_first_install(tmp_path: Path) -> None: + """Rollback removes apm.yml when this attempt created it.""" + manifest = tmp_path / "apm.yml" + modules = tmp_path / "apm_modules" + modules.mkdir() + transaction = InstallTransaction( + manifest_path=manifest, + apm_modules_dir=modules, + validation=None, + logger=MagicMock(), + ) + manifest.write_text("name: created\n", encoding="ascii") + + transaction.rollback() + + assert not manifest.exists() + + +def test_rollback_reports_action_when_created_manifest_cannot_be_removed( + tmp_path: Path, +) -> None: + """A failed removal tells the user how to complete rollback.""" + manifest = tmp_path / "apm.yml" + modules = tmp_path / "apm_modules" + modules.mkdir() + logger = MagicMock() + transaction = InstallTransaction( + manifest_path=manifest, + apm_modules_dir=modules, + validation=None, + logger=logger, + ) + manifest.write_text("name: created\n", encoding="ascii") + + with patch.object(Path, "unlink", side_effect=OSError("permission denied")): + transaction.rollback() + + logger.warning.assert_called_once_with( + "Failed to remove apm.yml created by this install. Delete apm.yml manually before retrying." + ) + assert "permission denied" in logger.verbose_detail.call_args[0][0] + + +def test_rollback_removes_new_path_and_restores_existing_path(tmp_path: Path) -> None: + """Rollback affects only paths prepared by this resolution attempt.""" + transaction = _transaction(tmp_path) + new_path = transaction.apm_modules_dir / "new" + existing_path = transaction.apm_modules_dir / "existing" + existing_path.mkdir() + (existing_path / "marker").write_text("original", encoding="ascii") + + transaction.resolution.prepare_path(new_path) + new_path.mkdir() + transaction.resolution.prepare_path(existing_path) + existing_path.mkdir() + (existing_path / "marker").write_text("replacement", encoding="ascii") + transaction.rollback() + + assert not new_path.exists() + assert (existing_path / "marker").read_text(encoding="ascii") == "original" + + +def test_concurrent_duplicate_prepare_is_idempotent(tmp_path: Path) -> None: + """Concurrent duplicate prepares preserve one original snapshot.""" + transaction = _transaction(tmp_path) + package = transaction.apm_modules_dir / "package" + package.mkdir() + (package / "marker").write_text("original", encoding="ascii") + + with ThreadPoolExecutor(max_workers=8) as executor: + list(executor.map(transaction.resolution.prepare_path, [package] * 32)) + package.mkdir(exist_ok=True) + (package / "marker").write_text("replacement", encoding="ascii") + transaction.rollback() + + assert (package / "marker").read_text(encoding="ascii") == "original" + + +def test_commit_only_after_cycle_validation(tmp_path: Path) -> None: + """The resolution journal is finalized only after graph validation.""" + transaction = _transaction(tmp_path) + cycle_validated = False + original_commit = transaction.resolution.commit + + def checked_commit() -> None: + assert cycle_validated + original_commit() + + transaction.resolution.commit = checked_commit + cycle_validated = True + + transaction.commit(InstallResult()) + + +@pytest.mark.parametrize( + "error", + [RuntimeError("boom"), SystemExit(2), KeyboardInterrupt()], +) +def test_context_rolls_back_base_exceptions(tmp_path: Path, error: BaseException) -> None: + """Exceptions, process exits, and interruptions all restore staged paths.""" + transaction = _transaction(tmp_path) + package = transaction.apm_modules_dir / "package" + + with pytest.raises(type(error)): + with transaction: + transaction.resolution.prepare_path(package) + package.mkdir() + raise error + + assert not package.exists() + + +def test_fail_rolls_back_and_preserves_error(tmp_path: Path) -> None: + """Failure returns a structured non-zero result after rollback.""" + transaction = _transaction(tmp_path) + error = RuntimeError("failed") + + result = transaction.fail(error) + + assert result.disposition is InstallDisposition.FAILED + assert result.exit_code == 1 + assert result.error is error + assert result.committed is False + + +def test_no_cleanup_outside_apm_modules(tmp_path: Path) -> None: + """The resolution journal rejects paths outside its owned root.""" + transaction = _transaction(tmp_path) + outside = tmp_path / "outside" + outside.mkdir() + (outside / "marker").write_text("keep", encoding="ascii") + + with pytest.raises(PathTraversalError): + transaction.resolution.prepare_path(outside) + transaction.rollback() + + assert (outside / "marker").read_text(encoding="ascii") == "keep" + + +def test_positional_url_total_failure_exits_one(tmp_path: Path, monkeypatch) -> None: + """The Click boundary maps a structured total validation failure to 1.""" + (tmp_path / "apm.yml").write_text("name: test\nversion: 1.0.0\n", encoding="ascii") + monkeypatch.chdir(tmp_path) + outcome = _ValidationOutcome( + valid=[], + invalid=[("https://example.invalid/missing", "not found")], + ) + + with patch( + "apm_cli.commands.install._validate_and_add_packages_to_apm_yml", + return_value=([], outcome), + ): + result = CliRunner().invoke( + cli, + ["install", "https://example.invalid/missing"], + ) + + assert result.exit_code == 1 + + +def test_failed_first_install_removes_auto_created_manifest( + tmp_path: Path, + monkeypatch, +) -> None: + """The transaction observes absence before the command creates apm.yml.""" + monkeypatch.chdir(tmp_path) + outcome = _ValidationOutcome( + valid=[], + invalid=[("https://example.invalid/missing", "not found")], + ) + + with patch( + "apm_cli.commands.install._validate_and_add_packages_to_apm_yml", + return_value=([], outcome), + ): + result = CliRunner().invoke( + cli, + ["install", "https://example.invalid/missing"], + ) + + assert result.exit_code == 1 + assert not (tmp_path / "apm.yml").exists() diff --git a/tests/unit/install/test_mcp_integration_extraction.py b/tests/unit/install/test_mcp_integration_extraction.py index 4f7eac954..04c40fbc5 100644 --- a/tests/unit/install/test_mcp_integration_extraction.py +++ b/tests/unit/install/test_mcp_integration_extraction.py @@ -37,12 +37,16 @@ _PATCH_TARGET = "apm_cli.integration.mcp_integrator.MCPIntegrator" -def _make_apm_package(*, scripts=None, targets=None, target=None, allow_executables=None): +def _make_apm_package( + *, scripts=None, targets=None, target=None, allow_executables=None, mcp_deps=None +): pkg = MagicMock() pkg.scripts = scripts pkg.targets = targets pkg.target = target pkg.allow_executables = allow_executables + pkg.package_path = Path("/fake/project") + pkg.get_all_mcp_dependencies.return_value = list(mcp_deps or []) return pkg @@ -59,12 +63,17 @@ def _base_kwargs(**overrides): old_mcp_servers=set(), old_mcp_configs={}, old_mcp_provenance={}, + old_mcp_target_servers={}, project_root=Path("/fake/project"), user_scope=False, should_install=True, logger=_make_logger(), ) kwargs.update(overrides) + if "apm_package" not in overrides: + kwargs["apm_package"] = _make_apm_package(mcp_deps=kwargs["mcp_deps"]) + elif "mcp_deps" in overrides: + kwargs["apm_package"].get_all_mcp_dependencies.return_value = list(kwargs["mcp_deps"]) return kwargs @@ -77,10 +86,7 @@ def test_installs_and_updates_lockfile(self, mock_mcp, tmp_path: Path): mock_mcp.deduplicate.side_effect = lambda x: x mock_mcp.install.return_value = 1 mock_mcp.get_server_names.return_value = {"io.github.acme/server"} - mock_mcp.get_server_configs.return_value = {"io.github.acme/server": {"name": "x"}} - mock_mcp.get_server_provenance.return_value = { - "io.github.acme/server": "io.github.acme/package" - } + dep.resolved_by = "io.github.acme/package" count, apm_config = run_mcp_integration( **_base_kwargs(mcp_deps=[dep], project_root=tmp_path) @@ -94,7 +100,8 @@ def test_installs_and_updates_lockfile(self, mock_mcp, tmp_path: Path): mock_mcp.update_lockfile.assert_called_once_with( {"io.github.acme/server"}, Path("/nonexistent/apm.lock.yaml"), - mcp_configs={"io.github.acme/server": {"name": "x"}}, + mcp_configs={"io.github.acme/server": dep.to_dict()}, + mcp_target_servers={}, mcp_config_provenance={"io.github.acme/server": "io.github.acme/package"}, ) mock_mcp.remove_stale.assert_not_called() @@ -189,6 +196,7 @@ def test_no_deps_with_old_servers_prunes_them(self, mock_mcp): set(), Path("/nonexistent/apm.lock.yaml"), mcp_configs={}, + mcp_target_servers={}, mcp_config_provenance={}, ) @@ -205,6 +213,7 @@ def test_restores_old_servers_when_not_installing_mcp(self, mock_mcp): old_mcp_servers={"io.github.acme/kept"}, old_mcp_configs={"io.github.acme/kept": {"name": "kept"}}, old_mcp_provenance={"io.github.acme/kept": "io.github.acme/pkg"}, + old_mcp_target_servers={"copilot": ["io.github.acme/kept"]}, ) ) @@ -212,6 +221,7 @@ def test_restores_old_servers_when_not_installing_mcp(self, mock_mcp): {"io.github.acme/kept"}, Path("/nonexistent/apm.lock.yaml"), mcp_configs={"io.github.acme/kept": {"name": "kept"}}, + mcp_target_servers={"copilot": ["io.github.acme/kept"]}, mcp_config_provenance={"io.github.acme/kept": "io.github.acme/pkg"}, ) mock_mcp.install.assert_not_called() diff --git a/tests/unit/install/test_mcp_ownership.py b/tests/unit/install/test_mcp_ownership.py new file mode 100644 index 000000000..6349b99f7 --- /dev/null +++ b/tests/unit/install/test_mcp_ownership.py @@ -0,0 +1,52 @@ +"""Tests for conservative legacy MCP ownership adoption.""" + +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from apm_cli.install.mcp.ownership import adopt_legacy_mcp_target_servers + + +@pytest.mark.parametrize( + ("native_config", "expected"), + [ + ({"command": "echo", "args": ["managed"]}, {"codex": {"managed"}}), + ({"command": "user-edited", "args": ["managed"]}, {}), + ], +) +def test_legacy_adoption_requires_exact_native_baseline( + tmp_path, + native_config, + expected, +) -> None: + """User-edited native entries are never guessed to be APM-owned.""" + client = SimpleNamespace( + mcp_servers_key="mcp_servers", + get_current_config=lambda: {"mcp_servers": {"managed": native_config}}, + render_server_config=lambda _info: { + "command": "echo", + "args": ["managed"], + }, + ) + stored = { + "managed": { + "name": "managed", + "registry": False, + "transport": "stdio", + "command": "echo", + "args": ["managed"], + } + } + with ( + patch("apm_cli.factory.ClientFactory.supported_clients", return_value=["codex"]), + patch("apm_cli.factory.ClientFactory.create_client", return_value=client), + ): + adopted = adopt_legacy_mcp_target_servers( + server_names={"managed"}, + stored_configs=stored, + project_root=tmp_path, + user_scope=False, + ) + + assert adopted == expected diff --git a/tests/unit/install/test_no_policy_flag.py b/tests/unit/install/test_no_policy_flag.py index 4ac6a4a1e..71fe9cf46 100644 --- a/tests/unit/install/test_no_policy_flag.py +++ b/tests/unit/install/test_no_policy_flag.py @@ -121,7 +121,9 @@ def test_install_help_lists_kiro_runtime_and_global_scope(self): result = self.runner.invoke(cli, ["install", "--help"]) assert result.exit_code == 0 normalized = " ".join(result.output.split()) - assert "gemini, antigravity, intellij, kiro" in normalized + assert "Target a specific runtime only." in normalized + assert "intellij" in normalized + assert "kiro" in normalized assert "Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf" in normalized def test_help_text_is_plain_ascii(self): diff --git a/tests/unit/install/test_pipeline_auth_preflight_env_leak.py b/tests/unit/install/test_pipeline_auth_preflight_env_leak.py new file mode 100644 index 000000000..c6a5889cb --- /dev/null +++ b/tests/unit/install/test_pipeline_auth_preflight_env_leak.py @@ -0,0 +1,78 @@ +"""Real-git regressions for install preflight auth sanitization.""" + +from __future__ import annotations + +import subprocess +from types import SimpleNamespace + +import pytest + +from apm_cli.core.auth import AuthResolver +from apm_cli.install.pipeline import _preflight_auth_check +from apm_cli.models.apm_package import DependencyReference + +_SENTINEL = "INHERITED_AUTH_SENTINEL" + + +@pytest.mark.parametrize( + "inherited", + [ + { + "GIT_CONFIG_PARAMETERS": (f"'http.extraheader=Authorization: Basic {_SENTINEL}'"), + }, + { + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "http.extraheader", + "GIT_CONFIG_VALUE_0": f"Authorization: Basic {_SENTINEL}", + }, + ], +) +def test_preflight_child_uses_auth_resolver_sanitized_environment( + inherited: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A real git child must not observe inherited authorization channels.""" + for key, value in inherited.items(): + monkeypatch.setenv(key, value) + + dependency = DependencyReference( + host="git.example.com", + repo_url="example/package", + source="git", + explicit_scheme="https", + ) + context = SimpleNamespace( + deps_to_install=[dependency], + update_refs=True, + logger=None, + ) + real_run = subprocess.run + observed_envs: list[dict[str, str]] = [] + + def run_probe(args, **kwargs): + env = dict(kwargs["env"]) + observed_envs.append(env) + git_config = real_run( + ["git", "config", "--list"], + capture_output=True, + text=True, + env=env, + timeout=10, + check=False, + ) + assert git_config.returncode == 0, git_config.stderr + assert _SENTINEL not in git_config.stdout + return subprocess.CompletedProcess(args, 0, stdout="deadbeef\trefs/heads/main\n", stderr="") + + monkeypatch.setattr(subprocess, "run", run_probe) + + _preflight_auth_check( + context, + AuthResolver(allow_external_fallback=False), + verbose=False, + ) + + assert len(observed_envs) == 1 + child_env = observed_envs[0] + assert "GIT_CONFIG_PARAMETERS" not in child_env + assert _SENTINEL not in " ".join(child_env.values()) diff --git a/tests/unit/install/test_resolution_transactionality.py b/tests/unit/install/test_resolution_transactionality.py new file mode 100644 index 000000000..eea938871 --- /dev/null +++ b/tests/unit/install/test_resolution_transactionality.py @@ -0,0 +1,78 @@ +"""Regression coverage for transactional dependency resolution.""" + +from __future__ import annotations + +from pathlib import Path + +from click.testing import CliRunner + +from apm_cli.cli import cli +from apm_cli.models.apm_package import clear_apm_yml_cache + + +def _write_package(path: Path, name: str, dependency: str | None = None) -> None: + """Write a local package and one Copilot instruction.""" + path.mkdir(parents=True, exist_ok=True) + dependency_block = "" + if dependency is not None: + dependency_block = f"dependencies:\n apm:\n - path: {dependency}\n" + (path / "apm.yml").write_text( + f"name: {name}\nversion: 1.0.0\n{dependency_block}", + encoding="ascii", + ) + instructions = path / ".apm" / "instructions" + instructions.mkdir(parents=True, exist_ok=True) + (instructions / f"{name}.instructions.md").write_text( + f"# {name}\n", + encoding="ascii", + ) + + +def test_corrected_local_cycle_resumes_without_manual_cache_deletion( + tmp_path: Path, + monkeypatch, +) -> None: + """A rejected local cycle must not leave snapshots that poison the retry.""" + workspace = tmp_path / "workspace" + project = workspace / "project" + package_a = workspace / "package-a" + package_b = workspace / "package-b" + home = workspace / "home" + project.mkdir(parents=True) + home.mkdir() + (project / ".github").mkdir() + (project / "apm.yml").write_text( + "name: consumer\nversion: 1.0.0\ndependencies:\n apm:\n - path: ../package-a\n", + encoding="ascii", + ) + _write_package(package_a, "package-a", "../package-b") + _write_package(package_b, "package-b", "../package-a") + + monkeypatch.chdir(project) + monkeypatch.setenv("HOME", str(home)) + runner = CliRunner() + + rejected = runner.invoke( + cli, + ["install", "--target", "copilot", "--verbose"], + catch_exceptions=True, + ) + + assert rejected.exit_code != 0, rejected.output + assert "Circular dependencies detected" in rejected.output + modules = project / "apm_modules" + assert not (modules / "_local" / "package-a").exists() + assert not (modules / "_local" / "package-b").exists() + assert not (project / "apm.lock.yaml").exists() + + _write_package(package_b, "package-b") + clear_apm_yml_cache() + resumed = runner.invoke( + cli, + ["install", "--target", "copilot", "--verbose"], + catch_exceptions=True, + ) + + assert resumed.exit_code == 0, resumed.output + assert (project / "apm.lock.yaml").is_file() + assert (project / ".github" / "instructions" / "package-a.instructions.md").is_file() diff --git a/tests/unit/install/test_service.py b/tests/unit/install/test_service.py index 11b99df5b..8fceabe6d 100644 --- a/tests/unit/install/test_service.py +++ b/tests/unit/install/test_service.py @@ -83,10 +83,13 @@ def test_run_delegates_to_pipeline_with_request_fields(self, fake_apm_package): marketplace_provenance={"source": "test-marketplace"}, ) with patch("apm_cli.install.pipeline.run_install_pipeline") as mock_run: - mock_run.return_value = "result-sentinel" + from apm_cli.models.results import InstallResult + + expected = InstallResult() + mock_run.return_value = expected result = InstallService().run(request) - assert result == "result-sentinel" + assert result is expected mock_run.assert_called_once() args, kwargs = mock_run.call_args assert args[0] is fake_apm_package @@ -114,9 +117,11 @@ def test_run_passes_optional_collaborators(self, fake_apm_package): assert kwargs["scope"] is scope def test_service_is_reusable_across_invocations(self, fake_apm_package): + from apm_cli.models.results import InstallResult + service = InstallService() with patch("apm_cli.install.pipeline.run_install_pipeline") as mock_run: - mock_run.return_value = "ok" + mock_run.return_value = InstallResult() service.run(_make_request(fake_apm_package)) service.run(_make_request(fake_apm_package, force=True)) assert mock_run.call_count == 2 diff --git a/tests/unit/install/test_services.py b/tests/unit/install/test_services.py index 21cdd958f..883299870 100644 --- a/tests/unit/install/test_services.py +++ b/tests/unit/install/test_services.py @@ -11,6 +11,7 @@ from apm_cli.install.services import IntegratorBundle, _deployed_path_entry from apm_cli.integration.targets import KNOWN_TARGETS +from apm_cli.utils.paths import portable_relpath # --------------------------------------------------------------------------- # Helper: convert legacy integrators dict to IntegratorBundle @@ -155,6 +156,36 @@ def test_runtime_error_when_no_matching_target(self, tmp_path: Path) -> None: with pytest.raises(RuntimeError, match="This is a bug"): _deployed_path_entry(target_path, project_root, targets=[]) + def test_absolute_static_root_rejects_symlink_escape(self, tmp_path: Path) -> None: + from apm_cli.utils.path_security import PathTraversalError + + project_root = tmp_path / "home" + project_root.mkdir() + config_root = tmp_path / "claude-config" + config_root.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (config_root / "rules").symlink_to(outside, target_is_directory=True) + target_path = config_root / "rules" / "managed.md" + target_path.write_text("managed\n", encoding="utf-8") + claude_target = replace(KNOWN_TARGETS["claude"], root_dir=str(config_root)) + + with pytest.raises(PathTraversalError): + _deployed_path_entry(target_path, project_root, targets=[claude_target]) + + def test_absolute_static_root_returns_portable_relpath(self, tmp_path: Path) -> None: + project_root = tmp_path / "home" + project_root.mkdir() + config_root = tmp_path / "claude-config" + target_path = config_root / "rules" / "managed.md" + target_path.parent.mkdir(parents=True) + target_path.write_text("managed\n", encoding="utf-8") + claude_target = replace(KNOWN_TARGETS["claude"], root_dir=str(config_root)) + + result = _deployed_path_entry(target_path, project_root, targets=[claude_target]) + + assert result == portable_relpath(target_path, project_root) + def test_path_traversal_error_propagates_from_cowork_translation(self, tmp_path: Path) -> None: """PathTraversalError from to_lockfile_path must propagate, never be swallowed.""" from apm_cli.utils.path_security import PathTraversalError diff --git a/tests/unit/install/test_skill_path_migration.py b/tests/unit/install/test_skill_path_migration.py index 72db42dbc..bc1b6a26a 100644 --- a/tests/unit/install/test_skill_path_migration.py +++ b/tests/unit/install/test_skill_path_migration.py @@ -42,6 +42,18 @@ class _StubLockFile: def get_dependency(self, key: str) -> _StubDep | None: return self.dependencies.get(key) + def rename_local_deployed_path(self, old_value: str, new_value: str) -> None: + if old_value not in self.local_deployed_files: + return + self.local_deployed_files = [ + value for value in self.local_deployed_files if value != old_value + ] + if new_value not in self.local_deployed_files: + self.local_deployed_files.append(new_value) + if old_value in self.local_deployed_file_hashes: + old_hash = self.local_deployed_file_hashes.pop(old_value) + self.local_deployed_file_hashes.setdefault(new_value, old_hash) + # =================================================================== # _LEGACY_SKILL_PATTERN regex tests diff --git a/tests/unit/install/test_sources_materialisation.py b/tests/unit/install/test_sources_materialisation.py index 2bf076fa8..95047f02c 100644 --- a/tests/unit/install/test_sources_materialisation.py +++ b/tests/unit/install/test_sources_materialisation.py @@ -762,8 +762,10 @@ def test_unpinned_dep_adds_delta(self, tmp_path: Path) -> None: assert result is not None assert result.deltas.get("unpinned") == 1 - def test_hash_mismatch_calls_sys_exit(self, tmp_path: Path) -> None: - """Content hash mismatch → sys.exit(1).""" + def test_hash_mismatch_raises_direct_dependency_error(self, tmp_path: Path) -> None: + """Content hash mismatch returns to the transaction boundary.""" + from apm_cli.install.errors import DirectDependencyError + ctx = _make_ctx(targets=["copilot"], logger=None, tui=None, update_refs=False) dep_ref = _make_dep_ref(is_virtual=False, reference="main") install_path = tmp_path / "pkg" @@ -785,15 +787,12 @@ def test_hash_mismatch_calls_sys_exit(self, tmp_path: Path) -> None: return_value="actual_different_hash", ), patch("apm_cli.utils.path_security.safe_rmtree"), - patch("apm_cli.utils.console._rich_error"), patch("apm_cli.utils.console._rich_success"), - pytest.raises(SystemExit) as exc_info, + pytest.raises(DirectDependencyError), ): source = self._make_source(ctx, dep_ref, install_path, dep_locked_chk=locked_chk) source.acquire() - assert exc_info.value.code == 1 - def test_exception_in_download_records_error_returns_none(self, tmp_path: Path) -> None: """Exception in download → diagnostics.error + None returned.""" ctx = _make_ctx(targets=["copilot"], logger=None, tui=None) diff --git a/tests/unit/install/test_sources_phase3.py b/tests/unit/install/test_sources_phase3.py index 9af7725f9..7e5de4405 100644 --- a/tests/unit/install/test_sources_phase3.py +++ b/tests/unit/install/test_sources_phase3.py @@ -27,7 +27,7 @@ - success path with logger - success path with progress object - unpinned dep (reference=None → deltas["unpinned"]=1) - - hash mismatch → sys.exit(1) + - hash mismatch raises DirectDependencyError - exception in download → diagnostics.error + return None - no targets path * make_dependency_source factory @@ -792,8 +792,10 @@ def test_unpinned_dep_adds_delta(self, tmp_path: Path) -> None: assert result is not None assert result.deltas.get("unpinned") == 1 - def test_hash_mismatch_calls_sys_exit(self, tmp_path: Path) -> None: - """Content hash mismatch → sys.exit(1).""" + def test_hash_mismatch_raises_direct_dependency_error(self, tmp_path: Path) -> None: + """Content hash mismatch raises a structured install error.""" + from apm_cli.install.errors import DirectDependencyError + ctx = _make_ctx(targets=["copilot"], logger=None, tui=None, update_refs=False) dep_ref = _make_dep_ref(is_virtual=False, reference="main") install_path = tmp_path / "pkg" @@ -817,13 +819,11 @@ def test_hash_mismatch_calls_sys_exit(self, tmp_path: Path) -> None: patch("apm_cli.utils.path_security.safe_rmtree"), patch("apm_cli.utils.console._rich_error"), patch("apm_cli.utils.console._rich_success"), - pytest.raises(SystemExit) as exc_info, + pytest.raises(DirectDependencyError, match="Content hash mismatch"), ): source = self._make_source(ctx, dep_ref, install_path, dep_locked_chk=locked_chk) source.acquire() - assert exc_info.value.code == 1 - def test_exception_in_download_records_error_returns_none(self, tmp_path: Path) -> None: """Exception in download → diagnostics.error + None returned.""" ctx = _make_ctx(targets=["copilot"], logger=None, tui=None) diff --git a/tests/unit/install/test_summary.py b/tests/unit/install/test_summary.py index e32f120aa..5d356958e 100644 --- a/tests/unit/install/test_summary.py +++ b/tests/unit/install/test_summary.py @@ -6,7 +6,8 @@ import pytest -from apm_cli.install.summary import render_post_install_summary +from apm_cli.install.summary import classify_post_install_result, render_post_install_summary +from apm_cli.models.results import InstallDisposition # --------------------------------------------------------------------------- # Helpers @@ -57,6 +58,7 @@ def test_calls_install_summary_with_counts(self) -> None: errors=0, stale_cleaned=0, elapsed_seconds=None, + disposition=InstallDisposition.SUCCESS, ) def test_renders_diagnostics_when_present(self) -> None: @@ -102,21 +104,15 @@ def test_rich_blank_line_when_apm_diagnostics_is_none(self) -> None: def test_error_count_forwarded_to_install_summary(self) -> None: logger = _make_logger() diag = _make_diag(has_diagnostics=True, error_count=2) - # error_count > 0 hard-fails with exit 1 (npm/pip/cargo convention, - # Bug 2 fix on #1496). The install_summary call still fires before - # the SystemExit, so the forwarded counter assertion still holds. - with ( - patch("apm_cli.install.summary._rich_blank_line"), - pytest.raises(SystemExit) as exc_info, - ): - render_post_install_summary( + with patch("apm_cli.install.summary._rich_blank_line"): + result = render_post_install_summary( logger=logger, apm_count=1, mcp_count=0, apm_diagnostics=diag, force=False, ) - assert exc_info.value.code == 1 + assert result.exit_code == 1 call_kwargs = logger.install_summary.call_args[1] assert call_kwargs["errors"] == 2 @@ -130,18 +126,15 @@ def test_hard_fail_on_reported_errors_without_critical_security(self) -> None: """ logger = _make_logger() diag = _make_diag(has_diagnostics=True, error_count=1, has_critical_security=False) - with ( - patch("apm_cli.install.summary._rich_blank_line"), - pytest.raises(SystemExit) as exc_info, - ): - render_post_install_summary( + with patch("apm_cli.install.summary._rich_blank_line"): + result = render_post_install_summary( logger=logger, apm_count=0, mcp_count=0, apm_diagnostics=diag, force=False, ) - assert exc_info.value.code == 1 + assert result.exit_code == 1 def test_force_does_not_suppress_reported_errors(self) -> None: """``--force`` overrides only the critical-security hard-fail; a @@ -149,18 +142,15 @@ def test_force_does_not_suppress_reported_errors(self) -> None: cannot mask a "failed to download dep" by passing ``--force``.""" logger = _make_logger() diag = _make_diag(has_diagnostics=True, error_count=1, has_critical_security=False) - with ( - patch("apm_cli.install.summary._rich_blank_line"), - pytest.raises(SystemExit) as exc_info, - ): - render_post_install_summary( + with patch("apm_cli.install.summary._rich_blank_line"): + result = render_post_install_summary( logger=logger, apm_count=0, mcp_count=0, apm_diagnostics=diag, force=True, ) - assert exc_info.value.code == 1 + assert result.exit_code == 1 def test_elapsed_seconds_forwarded(self) -> None: logger = _make_logger() @@ -179,18 +169,15 @@ def test_elapsed_seconds_forwarded(self) -> None: def test_hard_fail_on_critical_security_without_force(self) -> None: logger = _make_logger() diag = _make_diag(has_diagnostics=True, has_critical_security=True) - with ( - patch("apm_cli.install.summary._rich_blank_line"), - pytest.raises(SystemExit) as exc_info, - ): - render_post_install_summary( + with patch("apm_cli.install.summary._rich_blank_line"): + result = render_post_install_summary( logger=logger, apm_count=1, mcp_count=0, apm_diagnostics=diag, force=False, ) - assert exc_info.value.code == 1 + assert result.exit_code == 1 def test_no_hard_fail_when_force_is_true(self) -> None: logger = _make_logger() @@ -245,3 +232,46 @@ def test_invalid_error_count_defaults_to_zero(self) -> None: ) call_kwargs = logger.install_summary.call_args[1] assert call_kwargs["errors"] == 0 + + +def test_classification_does_not_render_before_transaction_completion() -> None: + """Disposition classification is side-effect free so commit can run first.""" + logger = _make_logger() + diagnostics = _make_diag(has_diagnostics=True, error_count=1) + + result = classify_post_install_result( + apm_count=1, + apm_diagnostics=diagnostics, + force=False, + ) + + assert result.disposition is InstallDisposition.FAILED + logger.install_summary.assert_not_called() + diagnostics.render_summary.assert_not_called() + + +def test_renderer_receives_final_committed_disposition() -> None: + """The final summary receives the result after transaction promotion.""" + logger = _make_logger() + result = classify_post_install_result( + apm_count=1, + apm_diagnostics=None, + force=False, + ) + result.disposition = InstallDisposition.PARTIAL_SUCCESS + result.committed = True + + with patch("apm_cli.install.summary._rich_blank_line"): + rendered = render_post_install_summary( + logger=logger, + apm_count=1, + mcp_count=0, + apm_diagnostics=None, + force=False, + result=result, + ) + + assert rendered is result + assert logger.install_summary.call_args.kwargs["disposition"] is ( + InstallDisposition.PARTIAL_SUCCESS + ) diff --git a/tests/unit/integration/test_base_integrator.py b/tests/unit/integration/test_base_integrator.py index 73f1f1ed3..ceb61b8b9 100644 --- a/tests/unit/integration/test_base_integrator.py +++ b/tests/unit/integration/test_base_integrator.py @@ -190,6 +190,23 @@ def test_valid_github_prompt_path(self): def test_valid_claude_rules_path(self): assert BaseIntegrator.validate_deploy_path(".claude/rules/foo.mdc", self.root) is True + def test_absolute_claude_config_path_is_valid_only_under_config_root( + self, tmp_path, monkeypatch + ): + home = tmp_path / "home" + config_root = tmp_path / "outside-home" / "claude" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(config_root)) + + assert BaseIntegrator.validate_deploy_path(str(config_root / "rules" / "managed.md"), home) + assert not BaseIntegrator.validate_deploy_path(str(config_root), home) + assert not BaseIntegrator.validate_deploy_path( + str(config_root / "unmanaged" / "other.md"), home + ) + assert not BaseIntegrator.validate_deploy_path( + str(tmp_path / "unmanaged" / "rules" / "other.md"), home + ) + def test_traversal_rejected(self): assert BaseIntegrator.validate_deploy_path("../evil.md", self.root) is False @@ -398,6 +415,32 @@ def test_multiple_deleted_paths(self): assert not dir1.exists() assert not dir2.exists() + def test_external_claude_cleanup_stops_at_config_root(self, tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + config_root = tmp_path / "outside-home" / "claude" + rules_dir = config_root / "rules" + rules_dir.mkdir(parents=True) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(config_root)) + + original_resolve = Path.resolve + resolve_calls = 0 + + def bounded_resolve(path, *args, **kwargs): + nonlocal resolve_calls + resolve_calls += 1 + if resolve_calls > 50: + raise RuntimeError("parent walk escaped its cleanup boundary") + return original_resolve(path, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", bounded_resolve) + BaseIntegrator.cleanup_empty_parents([rules_dir / "deleted.md"], home) + + assert not rules_dir.exists() + assert config_root.exists() + assert config_root.parent.exists() + # --------------------------------------------------------------------------- # sync_remove_files diff --git a/tests/unit/integration/test_copilot_cowork_target.py b/tests/unit/integration/test_copilot_cowork_target.py index e7892b7fd..d9acef4bf 100644 --- a/tests/unit/integration/test_copilot_cowork_target.py +++ b/tests/unit/integration/test_copilot_cowork_target.py @@ -9,6 +9,7 @@ import pytest +from apm_cli.core.target_catalog import TARGET_CAPABILITIES from apm_cli.integration.targets import ( KNOWN_TARGETS, TargetProfile, @@ -96,7 +97,12 @@ def test_for_scope_non_resolver_user_unsupported_returns_none( self, ) -> None: unsupported = TargetProfile( - name="dummy", + capability=replace( + TARGET_CAPABILITIES["copilot"], + name="dummy", + aliases=(), + runtimes=(), + ), root_dir=".dummy", primitives={}, user_supported=False, diff --git a/tests/unit/integration/test_data_driven_dispatch.py b/tests/unit/integration/test_data_driven_dispatch.py index f0a5cbb48..335cf4366 100644 --- a/tests/unit/integration/test_data_driven_dispatch.py +++ b/tests/unit/integration/test_data_driven_dispatch.py @@ -9,10 +9,12 @@ import shutil import tempfile +from dataclasses import replace from pathlib import Path from unittest.mock import MagicMock from apm_cli.commands.install import _integrate_package_primitives +from apm_cli.core.target_catalog import TARGET_CAPABILITIES from apm_cli.install.services import IntegratorBundle from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult from apm_cli.integration.targets import KNOWN_TARGETS, PrimitiveMapping, TargetProfile @@ -357,7 +359,12 @@ def test_synthetic_target_integrates_successfully(self): from apm_cli.integration.command_integrator import CommandIntegrator synthetic = TargetProfile( - name="newcode", + capability=replace( + TARGET_CAPABILITIES["copilot"], + name="newcode", + aliases=(), + runtimes=(), + ), root_dir=".newcode", primitives={ "commands": PrimitiveMapping("cmds", ".md", "newcode_cmd"), @@ -405,7 +412,12 @@ def test_synthetic_target_sync_computes_correct_prefix(self): from apm_cli.integration.command_integrator import CommandIntegrator synthetic = TargetProfile( - name="newcode", + capability=replace( + TARGET_CAPABILITIES["copilot"], + name="newcode", + aliases=(), + runtimes=(), + ), root_dir=".newcode", primitives={ "commands": PrimitiveMapping("cmds", ".md", "newcode_cmd"), diff --git a/tests/unit/integration/test_hook_integrator.py b/tests/unit/integration/test_hook_integrator.py index 558496358..bff2a46b5 100644 --- a/tests/unit/integration/test_hook_integrator.py +++ b/tests/unit/integration/test_hook_integrator.py @@ -17,6 +17,7 @@ import pytest +from apm_cli.core.deployment_state import MaterializationStatus from apm_cli.integration.hook_integrator import ( HookIntegrationResult, # noqa: F401 HookIntegrator, @@ -281,6 +282,33 @@ def test_integrate_hookify_vscode(self, temp_project): assert (scripts_dir / "stop.py").exists() assert (scripts_dir / "userpromptsubmit.py").exists() + def test_copilot_version_emitted_on_fresh_install(self, temp_project): + """Fresh Copilot hook JSON must contain top-level "version": 1.""" + pkg_info = self._setup_hookify_package(temp_project) + integrator = HookIntegrator() + + integrator.integrate_package_hooks(pkg_info, temp_project) + + hooks_path = temp_project / ".github" / "hooks" / "hookify-hooks.json" + config = json.loads(hooks_path.read_text()) + assert config["version"] == 1 + + def test_copilot_unsupported_source_version_writes_nothing(self, temp_project): + """An unsupported native payload must fail before mutation.""" + pkg_info = self._setup_hookify_package(temp_project) + source_path = pkg_info.install_path / "hooks" / "hooks.json" + source_config = json.loads(source_path.read_text()) + source_config["version"] = 3 + source_path.write_text(json.dumps(source_config)) + + integrator = HookIntegrator() + result = integrator.integrate_package_hooks(pkg_info, temp_project) + + hooks_path = temp_project / ".github" / "hooks" / "hookify-hooks.json" + assert not hooks_path.exists() + assert result.files_integrated == 0 + assert result.materializations[0].status is MaterializationStatus.FAILED + def test_integrate_learning_output_style_vscode(self, temp_project): """Test VSCode integration of learning-output-style plugin (different script dir).""" pkg_dir = temp_project / "apm_modules" / "anthropics" / "learning-output-style" @@ -1269,8 +1297,10 @@ def test_integrate_hookify_cursor(self, temp_project): assert "Stop" in config["hooks"] assert "UserPromptSubmit" in config["hooks"] - # Check APM source marker for cleanup - assert config["hooks"]["PreToolUse"][0]["_apm_source"] == "hookify" + # Ownership stays in the external APM sidecar, not the native schema. + assert "_apm_source" not in config["hooks"]["PreToolUse"][0] + sidecar = json.loads((temp_project / ".cursor" / "apm-hooks.json").read_text()) + assert sidecar["PreToolUse"][0]["_apm_source"] == "hookify" # Verify rewritten paths point to .cursor/hooks/ (normalize separators) cmd = config["hooks"]["PreToolUse"][0]["hooks"][0]["command"] @@ -1314,7 +1344,9 @@ def test_merge_into_existing_hooks_json(self, temp_project): assert config["hooks"]["afterFileEdit"][0]["command"] == "echo user-hook" # New hook added assert "Stop" in config["hooks"] - assert config["hooks"]["Stop"][0]["_apm_source"] == "pkg" + assert "_apm_source" not in config["hooks"]["Stop"][0] + sidecar = json.loads((temp_project / ".cursor" / "apm-hooks.json").read_text()) + assert sidecar["Stop"][0]["_apm_source"] == "pkg" def test_additive_merge_same_event(self, temp_project): """Test that multiple packages can add hooks to the same event.""" @@ -1350,8 +1382,12 @@ def test_additive_merge_same_event(self, temp_project): config = json.loads((temp_project / ".cursor" / "hooks.json").read_text()) # Both entries present under Stop assert len(config["hooks"]["Stop"]) == 2 - assert config["hooks"]["Stop"][0]["_apm_source"] == "ralph-loop" - assert config["hooks"]["Stop"][1]["_apm_source"] == "other-pkg" + assert all("_apm_source" not in entry for entry in config["hooks"]["Stop"]) + sidecar = json.loads((temp_project / ".cursor" / "apm-hooks.json").read_text()) + assert [entry["_apm_source"] for entry in sidecar["Stop"]] == [ + "ralph-loop", + "other-pkg", + ] def test_scripts_copied_to_cursor_hooks_dir(self, temp_project): """Test that scripts are copied to .cursor/hooks//.""" @@ -2524,7 +2560,7 @@ def _make_package_info(self, name="test-pkg", hook_data=None): return pi def test_codex_hooks_merge_into_hooks_json(self): - """Hooks are merged into .codex/hooks.json with _apm_source markers.""" + """Hooks use native Codex fields while ownership stays in a sidecar.""" pi = self._make_package_info() integrator = HookIntegrator() result = integrator.integrate_package_hooks_codex(pi, self.root) @@ -2535,7 +2571,9 @@ def test_codex_hooks_merge_into_hooks_json(self): data = json.loads(hooks_json.read_text()) assert "SessionStart" in data["hooks"] entries = data["hooks"]["SessionStart"] - assert any(e.get("_apm_source") == "test-pkg" for e in entries) + assert all("_apm_source" not in entry for entry in entries) + sidecar = json.loads((self.root / ".codex" / "apm-hooks.json").read_text()) + assert sidecar["SessionStart"][0]["_apm_source"] == "test-pkg" def test_codex_hooks_preserve_user_hooks(self): """Existing user hooks in .codex/hooks.json are preserved.""" @@ -2640,7 +2678,9 @@ def test_integrate_hooks_gemini(self, temp_project): # "Stop" is mapped to "SessionEnd" for Gemini assert "SessionEnd" in settings["hooks"] assert "Stop" not in settings["hooks"] - assert settings["hooks"]["SessionEnd"][0]["_apm_source"] == "ralph-loop" + assert "_apm_source" not in settings["hooks"]["SessionEnd"][0] + sidecar = json.loads((temp_project / ".gemini" / "apm-hooks.json").read_text()) + assert sidecar["SessionEnd"][0]["_apm_source"] == "ralph-loop" def test_skips_when_no_gemini_dir(self, temp_project): """Gemini hooks are not deployed when .gemini/ does not exist.""" @@ -3957,7 +3997,9 @@ def test_root_local_heals_stale_source_in_codex_hooks( entries = self._read_codex_hooks(temp_project)["hooks"]["PreToolUse"] assert len(entries) == 1 - assert entries[0]["_apm_source"] == "_local/sample-project" + assert "_apm_source" not in entries[0] + sidecar = json.loads((temp_project / ".codex" / "apm-hooks.json").read_text()) + assert sidecar["PreToolUse"][0]["_apm_source"] == "_local/sample-project" def test_root_local_healer_preserves_dependency_source_entries( self, diff --git a/tests/unit/integration/test_kiro_target.py b/tests/unit/integration/test_kiro_target.py index 138b522e4..30c74ce5a 100644 --- a/tests/unit/integration/test_kiro_target.py +++ b/tests/unit/integration/test_kiro_target.py @@ -7,9 +7,6 @@ from datetime import datetime from pathlib import Path -from click.testing import CliRunner - -from apm_cli.cli import cli from apm_cli.integration.hook_integrator import HookIntegrator from apm_cli.integration.instruction_integrator import InstructionIntegrator from apm_cli.integration.skill_integrator import SkillIntegrator @@ -49,24 +46,6 @@ def _make_package_info( ) -def test_kiro_is_discoverable_in_target_help() -> None: - runner = CliRunner() - - install = runner.invoke(cli, ["install", "--help"]) - compile_result = runner.invoke(cli, ["compile", "--help"]) - - expected_all_targets = "copilot+claude+cursor+opencode+codex+gemini+windsurf+kiro" - install_help = "".join(install.output.split()) - compile_help = "".join(compile_result.output.split()) - - assert install.exit_code == 0 - assert compile_result.exit_code == 0 - assert expected_all_targets in install_help - assert expected_all_targets in compile_help - assert "ClaudeCode" in install_help - assert "Windsurf" in install_help - - def test_kiro_runtime_discovered_in_user_scope_without_project_dir(tmp_path: Path) -> None: from apm_cli.integration.mcp_integrator_install import _discover_installed_runtimes @@ -227,22 +206,32 @@ def test_kiro_hooks_expand_each_apm_hook_to_individual_json(tmp_path: Path) -> N assert result.scripts_copied == 2 pre_tool = tmp_path / ".kiro" / "hooks" / "hookify-hooks-pretooluse-1.json" - prompt_submit = tmp_path / ".kiro" / "hooks" / "hookify-hooks-promptsubmit-1.json" + prompt_submit = tmp_path / ".kiro" / "hooks" / "hookify-hooks-userpromptsubmit-1.json" assert pre_tool.exists() assert prompt_submit.exists() pre_data = json.loads(pre_tool.read_text(encoding="utf-8")) - assert pre_data["when"] == {"type": "preToolUse"} - assert pre_data["then"]["type"] == "runCommand" - assert pre_data["then"]["command"] == "python .kiro/hooks/hookify/hooks/check.py" - assert pre_data["description"] == "Validate before tool use" - assert "hooks" not in pre_data + assert pre_data == { + "version": "v1", + "hooks": [ + { + "name": "hookify PreToolUse 1", + "trigger": "PreToolUse", + "action": { + "type": "command", + "command": "python .kiro/hooks/hookify/hooks/check.py", + }, + } + ], + } if sys.platform != "win32": assert pre_tool.stat().st_mode & 0o777 == 0o600 prompt_data = json.loads(prompt_submit.read_text(encoding="utf-8")) - assert prompt_data["when"] == {"type": "promptSubmit"} - assert prompt_data["then"]["command"] == "python .kiro/hooks/hookify/hooks/prompt.py" + assert prompt_data["hooks"][0]["trigger"] == "UserPromptSubmit" + assert prompt_data["hooks"][0]["action"]["command"] == ( + "python .kiro/hooks/hookify/hooks/prompt.py" + ) if sys.platform != "win32": assert prompt_submit.stat().st_mode & 0o777 == 0o600 @@ -309,7 +298,7 @@ def test_kiro_deploys_hook_directory_siblings_and_package_module_type( assert deployed_package_json in result.target_paths -def test_kiro_hooks_convert_prompt_actions_to_ask_agent(tmp_path: Path) -> None: +def test_kiro_hooks_convert_prompt_actions_to_v1_agent(tmp_path: Path) -> None: (tmp_path / ".kiro").mkdir() package_dir = tmp_path / "prompt-hooks" hooks_dir = package_dir / "hooks" @@ -336,13 +325,21 @@ def test_kiro_hooks_convert_prompt_actions_to_ask_agent(tmp_path: Path) -> None: tmp_path, ) - target = tmp_path / ".kiro" / "hooks" / "prompt-hooks-hooks-promptsubmit-1.json" + target = tmp_path / ".kiro" / "hooks" / "prompt-hooks-hooks-userpromptsubmit-1.json" assert result.files_integrated == 1 data = json.loads(target.read_text(encoding="utf-8")) - assert data["when"] == {"type": "promptSubmit"} - assert data["then"] == { - "type": "askAgent", - "prompt": "Review the submitted prompt for policy drift.", + assert data == { + "version": "v1", + "hooks": [ + { + "name": "prompt-hooks UserPromptSubmit 1", + "trigger": "UserPromptSubmit", + "action": { + "type": "agent", + "prompt": "Review the submitted prompt for policy drift.", + }, + } + ], } if sys.platform != "win32": assert target.stat().st_mode & 0o777 == 0o600 diff --git a/tests/unit/integration/test_mcp_config_view.py b/tests/unit/integration/test_mcp_config_view.py new file mode 100644 index 000000000..a415144fb --- /dev/null +++ b/tests/unit/integration/test_mcp_config_view.py @@ -0,0 +1,388 @@ +"""Tests for the canonical current MCP configuration view.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +from apm_cli.deps.lockfile import LockedDependency, LockFile +from apm_cli.integration.mcp_config_view import CurrentMcpConfigView, McpConfigDiff +from apm_cli.models.apm_package import APMPackage, clear_apm_yml_cache +from apm_cli.models.dependency.mcp import MCPDependency + + +def _write_manifest( + directory: Path, + *, + name: str, + mcp: list[dict[str, object] | str] | None = None, + dev_mcp: list[dict[str, object] | str] | None = None, +) -> APMPackage: + """Write and parse a minimal APM package manifest.""" + directory.mkdir(parents=True, exist_ok=True) + data: dict[str, object] = {"name": name, "version": "1.0.0"} + if mcp is not None: + data["dependencies"] = {"mcp": mcp} + if dev_mcp is not None: + data["devDependencies"] = {"mcp": dev_mcp} + path = directory / "apm.yml" + path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + clear_apm_yml_cache() + return APMPackage.from_apm_yml(path) + + +def _self_defined(name: str, command: str) -> dict[str, object]: + """Return a valid self-defined stdio MCP declaration.""" + return { + "name": name, + "registry": False, + "transport": "stdio", + "command": command, + } + + +def _lock(*dependencies: LockedDependency) -> LockFile: + """Build a lockfile preserving the supplied dependency order.""" + return LockFile(dependencies={dep.get_unique_key(): dep for dep in dependencies}) + + +def _derive( + root: APMPackage, lockfile: LockFile | None, modules_root: Path +) -> CurrentMcpConfigView: + """Derive a view with transitive self-defined declarations trusted.""" + return CurrentMcpConfigView.derive( + root, + lockfile, + modules_root, + trust_transitive_self_defined=True, + ) + + +def test_derives_root_and_package_production_and_dev_mcp(tmp_path: Path) -> None: + """Root and locked package prod/dev declarations share manifest order.""" + root = _write_manifest( + tmp_path, + name="root", + mcp=["root-prod"], + dev_mcp=["root-dev"], + ) + package_dir = tmp_path / "packages" / "tools" + _write_manifest( + package_dir, + name="tools", + mcp=["package-prod"], + dev_mcp=["package-dev"], + ) + locked = LockedDependency( + repo_url="_local/tools", + source="local", + local_path="./packages/tools", + depth=1, + ) + + view = _derive(root, _lock(locked), tmp_path / "apm_modules") + + assert [dep.name for dep in view.dependencies] == [ + "root-prod", + "root-dev", + "package-prod", + "package-dev", + ] + assert view.provenance == { + "package-prod": "tools", + "package-dev": "tools", + } + + +def test_derives_local_and_installed_remote_lock_bounded_manifests(tmp_path: Path) -> None: + """Local sources and installed remote sources use their canonical paths.""" + root = _write_manifest(tmp_path, name="root") + local_dir = tmp_path / "packages" / "local-tools" + _write_manifest(local_dir, name="local-tools", mcp=["local-server"]) + + modules_root = tmp_path / "apm_modules" + remote = LockedDependency(repo_url="owner/remote-tools", depth=1) + remote_dir = remote.to_dependency_ref().get_install_path(modules_root) + _write_manifest(remote_dir, name="remote-tools", mcp=["remote-server"]) + local = LockedDependency( + repo_url="_local/local-tools", + source="local", + local_path="./packages/local-tools", + depth=1, + ) + + view = _derive(root, _lock(local, remote), modules_root) + + assert [dep.name for dep in view.dependencies] == ["local-server", "remote-server"] + assert view.provenance == { + "local-server": "local-tools", + "remote-server": "remote-tools", + } + assert view.problems == () + + +def test_root_declaration_wins_duplicate_name(tmp_path: Path) -> None: + """Root-first first-wins dedup keeps root config and no provenance.""" + root = _write_manifest( + tmp_path, + name="root", + mcp=[_self_defined("shared", "root-command")], + ) + package_dir = tmp_path / "packages" / "tools" + _write_manifest( + package_dir, + name="tools", + mcp=[ + _self_defined("shared", "package-command"), + _self_defined("unique", "unique-command"), + ], + ) + locked = LockedDependency( + repo_url="_local/tools", + source="local", + local_path="./packages/tools", + depth=1, + ) + + view = _derive(root, _lock(locked), tmp_path / "apm_modules") + + assert [dep.name for dep in view.dependencies] == ["shared", "unique"] + assert view.configs["shared"]["command"] == "root-command" + assert view.provenance == {"unique": "tools"} + + +def test_symmetric_diff_reports_changed_source_only_and_lock_only() -> None: + """Diff partitions changed and one-sided server names.""" + diff = McpConfigDiff.between( + { + "changed": {"name": "changed", "command": "new"}, + "source-only": {"name": "source-only"}, + "same": {"name": "same"}, + }, + { + "changed": {"name": "changed", "command": "old"}, + "lock-only": {"name": "lock-only"}, + "same": {"name": "same"}, + }, + ) + + assert diff.changed == frozenset({"changed"}) + assert diff.source_only == frozenset({"source-only"}) + assert diff.lock_only == frozenset({"lock-only"}) + assert not diff.is_empty + assert McpConfigDiff.between({}, {}).is_empty + + +def test_provenance_never_exempts_lock_only_name() -> None: + """Historical provenance cannot prove a current declaration exists.""" + view = CurrentMcpConfigView( + dependencies=(), + configs={}, + provenance={"removed": "old-package"}, + problems=(), + ) + + diff = view.diff({"removed": {"name": "removed"}}) + + assert diff.lock_only == frozenset({"removed"}) + + +def test_local_package_config_change_is_detected(tmp_path: Path) -> None: + """Rehome PR 2132: local package config is compared with its baseline.""" + root = _write_manifest(tmp_path, name="root") + package_dir = tmp_path / "packages" / "agent-config" + _write_manifest( + package_dir, + name="agent-config", + mcp=[_self_defined("shadcn", "changed")], + ) + locked = LockedDependency( + repo_url="_local/agent-config", + source="local", + local_path="./packages/agent-config", + depth=1, + ) + view = _derive(root, _lock(locked), tmp_path / "apm_modules") + + diff = view.diff( + { + "shadcn": { + "name": "shadcn", + "registry": False, + "transport": "stdio", + "command": "ready", + } + } + ) + + assert diff.changed == frozenset({"shadcn"}) + + +def test_removed_local_and_remote_declarations_are_lock_only(tmp_path: Path) -> None: + """Rehome PR 2145: removed declarations are symmetric for both source kinds.""" + root = _write_manifest(tmp_path, name="root") + local_dir = tmp_path / "packages" / "local-tools" + _write_manifest(local_dir, name="local-tools") + modules_root = tmp_path / "apm_modules" + remote = LockedDependency(repo_url="owner/remote-tools", depth=1) + _write_manifest( + remote.to_dependency_ref().get_install_path(modules_root), + name="remote-tools", + ) + local = LockedDependency( + repo_url="_local/local-tools", + source="local", + local_path="./packages/local-tools", + depth=1, + ) + + view = _derive(root, _lock(local, remote), modules_root) + diff = view.diff( + { + "local-removed": {"name": "local-removed"}, + "remote-removed": {"name": "remote-removed"}, + } + ) + + assert diff.lock_only == frozenset({"local-removed", "remote-removed"}) + + +def test_missing_package_manifest_records_problem(tmp_path: Path) -> None: + """A locked package missing apm.yml cannot yield a vacuous pass.""" + root = _write_manifest(tmp_path, name="root") + locked = LockedDependency( + repo_url="_local/missing", + source="local", + local_path="./packages/missing", + depth=1, + ) + + view = _derive(root, _lock(locked), tmp_path / "apm_modules") + + assert len(view.problems) == 1 + problem = view.problems[0] + assert problem.package_key == locked.get_unique_key() + assert problem.manifest_path == (tmp_path / "packages" / "missing" / "apm.yml").resolve() + assert "manifest not found" in problem.message + + +def test_invalid_local_and_remote_manifests_record_problems(tmp_path: Path) -> None: + """Rehome PRs 2132/2145: parse errors identify both package sources.""" + root = _write_manifest(tmp_path, name="root") + local = LockedDependency( + repo_url="_local/broken", + source="local", + local_path="./packages/broken", + depth=1, + ) + local_path = tmp_path / "packages" / "broken" + local_path.mkdir(parents=True) + (local_path / "apm.yml").write_text("name: [invalid\n", encoding="utf-8") + + modules_root = tmp_path / "apm_modules" + remote = LockedDependency(repo_url="owner/broken", depth=1) + remote_path = remote.to_dependency_ref().get_install_path(modules_root) + remote_path.mkdir(parents=True) + (remote_path / "apm.yml").write_text("name: [invalid\n", encoding="utf-8") + + view = _derive(root, _lock(local, remote), modules_root) + + assert [problem.package_key for problem in view.problems] == [ + local.get_unique_key(), + remote.get_unique_key(), + ] + assert all("cannot parse package manifest" in problem.message for problem in view.problems) + + +def test_local_resolution_error_records_problem(tmp_path: Path) -> None: + """Rehome PR 2132: a corrupt local lock graph is reported, not raised.""" + root = _write_manifest(tmp_path, name="root") + locked = LockedDependency( + repo_url="_local/child", + source="local", + local_path="../child", + resolved_by="_local/missing-parent", + depth=2, + ) + + view = _derive(root, _lock(locked), tmp_path / "apm_modules") + + assert len(view.problems) == 1 + assert view.problems[0].package_key == locked.get_unique_key() + assert "cannot resolve local package" in view.problems[0].message + + +def test_manifestless_skill_bundle_is_skipped(tmp_path: Path) -> None: + """Manifestless bundles cannot declare MCP and do not create problems.""" + root = _write_manifest(tmp_path, name="root") + bundle = tmp_path / "skills" / "bundle" + bundle.mkdir(parents=True) + (bundle / "SKILL.md").write_text("# Bundle\n", encoding="utf-8") + locked = LockedDependency( + repo_url="_local/bundle", + source="local", + local_path="./skills/bundle", + package_type="skill_bundle", + depth=1, + ) + + view = _derive(root, _lock(locked), tmp_path / "apm_modules") + + assert view.dependencies == () + assert view.problems == () + + +def test_stale_directory_absent_from_lockfile_is_never_scanned(tmp_path: Path) -> None: + """Only lockfile entries bound package-manifest traversal.""" + root = _write_manifest(tmp_path, name="root", mcp=["root-server"]) + stale = tmp_path / "apm_modules" / "stale" / "package" + stale.mkdir(parents=True) + (stale / "apm.yml").write_text("name: [invalid\n", encoding="utf-8") + + view = _derive(root, LockFile(), tmp_path / "apm_modules") + + assert [dep.name for dep in view.dependencies] == ["root-server"] + assert view.problems == () + + +def test_transitive_self_defined_trust_matches_install_behavior(tmp_path: Path) -> None: + """Depth-one servers are trusted; deeper servers require explicit trust.""" + root = _write_manifest(tmp_path, name="root") + direct_dir = tmp_path / "packages" / "direct" + deep_dir = tmp_path / "packages" / "deep" + _write_manifest(direct_dir, name="direct", mcp=[_self_defined("direct-server", "echo")]) + _write_manifest(deep_dir, name="deep", mcp=[_self_defined("deep-server", "echo")]) + direct = LockedDependency( + repo_url="_local/direct", + source="local", + local_path="./packages/direct", + depth=1, + ) + deep = LockedDependency( + repo_url="_local/deep", + source="local", + local_path="./packages/deep", + depth=2, + ) + lockfile = _lock(direct, deep) + + denied = CurrentMcpConfigView.derive( + root, + lockfile, + tmp_path / "apm_modules", + trust_transitive_self_defined=False, + ) + trusted = _derive(root, lockfile, tmp_path / "apm_modules") + + assert [dep.name for dep in denied.dependencies] == ["direct-server"] + assert [dep.name for dep in trusted.dependencies] == ["direct-server", "deep-server"] + + +def test_view_dependencies_are_mcp_dependency_objects(tmp_path: Path) -> None: + """The public dependencies tuple remains strongly typed.""" + root = _write_manifest(tmp_path, name="root", mcp=["server"]) + + view = _derive(root, None, tmp_path / "apm_modules") + + assert all(isinstance(dep, MCPDependency) for dep in view.dependencies) diff --git a/tests/unit/integration/test_skill_integrator.py b/tests/unit/integration/test_skill_integrator.py index 0e044b6b8..1752be4b6 100644 --- a/tests/unit/integration/test_skill_integrator.py +++ b/tests/unit/integration/test_skill_integrator.py @@ -8,6 +8,7 @@ import pytest +from apm_cli.core.target_catalog import TARGET_CAPABILITIES from apm_cli.integration.skill_integrator import ( SkillIntegrationResult, SkillIntegrator, @@ -4234,7 +4235,7 @@ def test_symlink_root_redirect_rejected(self, tmp_path: Path) -> None: from apm_cli.integration.targets import PrimitiveMapping, TargetProfile target = TargetProfile( - name="agent-skills", + capability=TARGET_CAPABILITIES["agent-skills"], root_dir=".agents", auto_create=True, primitives={ diff --git a/tests/unit/integration/test_skill_integrator_cowork.py b/tests/unit/integration/test_skill_integrator_cowork.py index 53e820667..ce90cab21 100644 --- a/tests/unit/integration/test_skill_integrator_cowork.py +++ b/tests/unit/integration/test_skill_integrator_cowork.py @@ -13,6 +13,7 @@ import pytest # noqa: F401 +from apm_cli.core.target_catalog import TARGET_CAPABILITIES from apm_cli.integration.skill_integrator import SkillIntegrator # --------------------------------------------------------------------------- @@ -25,7 +26,7 @@ def _make_cowork_target_profile(): from apm_cli.integration.targets import PrimitiveMapping, TargetProfile return TargetProfile( - name="copilot-cowork", + capability=TARGET_CAPABILITIES["copilot-cowork"], root_dir="copilot-cowork", primitives={ "skills": PrimitiveMapping("skills", "/SKILL.md", "skill_standard"), @@ -42,7 +43,7 @@ def _make_copilot_target_profile(project_root: Path): from apm_cli.integration.targets import PrimitiveMapping, TargetProfile return TargetProfile( - name="copilot", + capability=TARGET_CAPABILITIES["copilot"], root_dir=".github", primitives={ "skills": PrimitiveMapping("skills", "/SKILL.md", "skill_standard"), diff --git a/tests/unit/marketplace/test_marketplace_install_integration.py b/tests/unit/marketplace/test_marketplace_install_integration.py index a32834f10..baa20e52d 100644 --- a/tests/unit/marketplace/test_marketplace_install_integration.py +++ b/tests/unit/marketplace/test_marketplace_install_integration.py @@ -75,54 +75,6 @@ def test_outcome_no_provenance(self): assert outcome.marketplace_provenance is None -class TestInstallExitCodeOnAllFailed: - """Bug B2: install must exit(1) when ALL packages fail validation.""" - - @patch("apm_cli.commands.install._validate_and_add_packages_to_apm_yml") - @patch("apm_cli.commands.install.InstallLogger") - @patch("apm_cli.commands.install.DiagnosticCollector") - def test_all_failed_exits_nonzero( - self, mock_diag_cls, mock_logger_cls, mock_validate, tmp_path, monkeypatch - ): - """When outcome.all_failed is True, install raises SystemExit(1).""" - from apm_cli.core.command_logger import _ValidationOutcome - - outcome = _ValidationOutcome( - valid=[], - invalid=[("bad-pkg", "not found")], - ) - mock_validate.return_value = ([], outcome) - - mock_logger = MagicMock() - mock_logger_cls.return_value = mock_logger - - # Create minimal apm.yml so pre-flight check passes - import yaml - - apm_yml = tmp_path / "apm.yml" - apm_yml.write_text( - yaml.dump( - { - "name": "test", - "version": "0.1.0", - "dependencies": {"apm": []}, - } - ) - ) - monkeypatch.chdir(tmp_path) - - from click.testing import CliRunner - - from apm_cli.commands.install import install - - runner = CliRunner() - runner.invoke(install, ["bad-pkg"], catch_exceptions=False) - # The install command returns early (exit 0) when all packages fail - # validation -- the failures are reported via logger but do not cause - # a non-zero exit. Verify the mock was called with the expected args. - mock_validate.assert_called_once() - - class TestMarketplaceResolutionProvenance: """Resolver carries marketplace source provenance to install validation.""" diff --git a/tests/unit/marketplace/test_registry_integration.py b/tests/unit/marketplace/test_registry_integration.py index 1b4aeb251..1ba59b77c 100644 --- a/tests/unit/marketplace/test_registry_integration.py +++ b/tests/unit/marketplace/test_registry_integration.py @@ -8,6 +8,8 @@ from __future__ import annotations +import pytest + from apm_cli.marketplace.models import ( parse_marketplace_json, ) @@ -59,63 +61,54 @@ def test_plugin_with_valid_registry_routing(self): assert plugin.registry == "corp-main" assert plugin.version == "^3.0.0" - def test_invalid_registry_field_downgrades_silently(self): - # Empty string, non-string, or invalid types: log + downgrade. - manifest = parse_marketplace_json( - { - "name": "acme", - "plugins": [ - { - "name": "x", - "repository": "a/b", - "registry": 123, # not a string - } - ], - }, - source_name="acme", - ) - assert manifest.plugins[0].registry == "" + def test_invalid_registry_field_fails_closed(self): + with pytest.raises(ValueError, match="invalid 'registry' field"): + parse_marketplace_json( + { + "name": "acme", + "plugins": [ + { + "name": "x", + "repository": "a/b", + "registry": 123, + } + ], + }, + source_name="acme", + ) - def test_invalid_semver_disables_registry_routing(self): - # If registry is set but version isn't a semver range, drop the - # routing to "" so the plugin doesn't silently mis-resolve. - manifest = parse_marketplace_json( - { - "name": "acme", - "plugins": [ - { - "name": "x", - "repository": "a/b", - "registry": "corp", - "version": "main", # branch, not semver - } - ], - }, - source_name="acme", - ) - assert manifest.plugins[0].registry == "" + def test_invalid_semver_fails_closed(self): + with pytest.raises(ValueError, match="not a valid semver selector"): + parse_marketplace_json( + { + "name": "acme", + "plugins": [ + { + "name": "x", + "repository": "a/b", + "registry": "corp", + "version": "main", + } + ], + }, + source_name="acme", + ) def test_registry_with_no_version(self): - # Registry set, no version: parser keeps registry routing but - # leaves version="" — the resolver will reject it later. This - # matches "fail at resolve time, not at parse time" since the - # marketplace parser is permissive by design. - manifest = parse_marketplace_json( - { - "name": "acme", - "plugins": [ - { - "name": "x", - "repository": "a/b", - "registry": "corp", - } - ], - }, - source_name="acme", - ) - plugin = manifest.plugins[0] - assert plugin.registry == "corp" - assert plugin.version == "" + with pytest.raises(ValueError, match="declares no version selector"): + parse_marketplace_json( + { + "name": "acme", + "plugins": [ + { + "name": "x", + "repository": "a/b", + "registry": "corp", + } + ], + }, + source_name="acme", + ) def test_existing_source_field_unchanged_alongside_registry(self): # The new ``registry`` field MUST NOT collide with the existing diff --git a/tests/unit/marketplace/test_version_resolver.py b/tests/unit/marketplace/test_version_resolver.py index 2f5c29540..280f38648 100644 --- a/tests/unit/marketplace/test_version_resolver.py +++ b/tests/unit/marketplace/test_version_resolver.py @@ -113,7 +113,7 @@ def test_empty_refs_raises(self, MockResolver): with self.assertRaises(NoMatchingVersionError): resolve_version_constraint("secrets-vault", "acme/plugins", "~1.0.0") - def test_passes_host_and_token(self, MockResolver): + def test_passes_host_token_and_auth_scheme(self, MockResolver): refs = _make_refs("1.0.0", plugin_name="my-plugin") MockResolver.return_value.list_remote_refs.return_value = refs @@ -121,10 +121,38 @@ def test_passes_host_and_token(self, MockResolver): "my-plugin", "owner/repo", "^1.0.0", - host="ghes.example.com", - token="ghp_secret", + host="dev.azure.com", + token="dummy-bearer", + auth_scheme="bearer", + ) + MockResolver.assert_called_once_with( + host="dev.azure.com", + token="dummy-bearer", + auth_scheme="bearer", + ) + + def test_passes_auth_owner_for_ado_retry(self, MockResolver): + refs = _make_refs("1.0.0", plugin_name="my-plugin") + MockResolver.return_value.list_remote_refs.return_value = refs + auth_resolver = object() + + resolve_version_constraint( + "my-plugin", + "owner/repo", + "^1.0.0", + host="dev.azure.com", + token="stale-pat", + auth_scheme="basic", + auth_resolver=auth_resolver, + ) + + MockResolver.assert_called_once_with( + host="dev.azure.com", + token="stale-pat", + auth_scheme="basic", + auth_resolver=auth_resolver, + auth_target="dev.azure.com", ) - MockResolver.assert_called_once_with(host="ghes.example.com", token="ghp_secret") def test_resolver_closed_after_use(self, MockResolver): refs = _make_refs("1.0.0", plugin_name="my-plugin") diff --git a/tests/unit/policy/test_chain_discovery_shared.py b/tests/unit/policy/test_chain_discovery_shared.py index 3293408c5..070e4d9b6 100644 --- a/tests/unit/policy/test_chain_discovery_shared.py +++ b/tests/unit/policy/test_chain_discovery_shared.py @@ -362,19 +362,10 @@ def test_depth_limit_raises(self, mock_discover, mock_write_cache): discover_policy_with_chain(Path("/fake")) assert str(MAX_CHAIN_DEPTH) in str(exc_info.value) - @patch("apm_cli.policy.discovery._rich_warning", create=True) @patch(_PATCH_WRITE_CACHE) @patch(_PATCH_DISCOVER) - def test_partial_chain_emits_warning_and_uses_resolved_policies( - self, mock_discover, mock_write_cache, _mock_warn_unused - ): - """leaf -> mid -> root(404): partial chain (leaf+mid) is used and warning emitted. - - Design choice: when a parent fetch fails midway, we merge the chain - we managed to resolve and emit `_rich_warning` so the operator - learns that an upstream policy was unreachable. - """ - from apm_cli.utils import console as _console + def test_partial_chain_fails_closed(self, mock_discover, mock_write_cache): + """leaf -> mid -> root(404) yields no enforceable partial policy.""" leaf = _make_policy(enforcement="warn", extends="org-mid/.github") mid = _make_policy(enforcement="warn", extends="enterprise-root/.github") @@ -391,23 +382,13 @@ def test_partial_chain_emits_warning_and_uses_resolved_policies( mock_discover.side_effect = [leaf_fetch, mid_fetch, root_fetch] - with patch.object(_console, "_rich_warning") as mock_warn: - result = discover_policy_with_chain(Path("/fake")) - - # We still got a merged policy (leaf + mid). - assert result.policy is not None - - # Cache write happened with the partial 2-level chain_refs. - kw = mock_write_cache.call_args - chain_refs = kw.kwargs.get("chain_refs") or kw[1].get("chain_refs") - assert len(chain_refs) == 2 + result = discover_policy_with_chain(Path("/fake")) - # Warning was emitted with the unreachable ref + count. - assert mock_warn.called - warn_msg = mock_warn.call_args[0][0] - assert "incomplete" in warn_msg.lower() - assert "enterprise-root/.github" in warn_msg - assert "2 of 3" in warn_msg + assert result.policy is None + assert result.outcome == "incomplete_chain" + assert "enterprise-root/.github" in (result.error or "") + assert "2 of 3" in (result.error or "") + mock_write_cache.assert_not_called() @patch(_PATCH_WRITE_CACHE) @patch(_PATCH_DISCOVER) diff --git a/tests/unit/policy/test_ci_checks.py b/tests/unit/policy/test_ci_checks.py index cd073a417..ba230226d 100644 --- a/tests/unit/policy/test_ci_checks.py +++ b/tests/unit/policy/test_ci_checks.py @@ -352,6 +352,12 @@ def test_remote_deps_of_local_path_subpackage_not_orphaned(self, tmp_path): class TestConfigConsistency: def test_pass_no_mcp(self, tmp_path): _write_apm_yml(tmp_path, deps=["owner/repo"]) + package = tmp_path / "apm_modules" / "owner" / "repo" + package.mkdir(parents=True) + (package / "apm.yml").write_text( + "name: repo\nversion: 1.0.0\n", + encoding="utf-8", + ) _write_lockfile( tmp_path, textwrap.dedent("""\ @@ -417,56 +423,6 @@ def test_fail_mcp_config_drift(self, tmp_path): assert not result.passed assert any("my-server" in d and "differs" in d for d in result.details) - def test_transitive_mcp_server_not_flagged_as_orphan(self, tmp_path): - """A server declared by a local-path sub-package is exempt from orphan. - - Topology: root -> ./packages/agent-config (local-path) which declares - a self-defined MCP server 'shadcn'. The root manifest cannot declare - the server, so it is recorded in mcp_configs with provenance pointing - at the declaring package. config-consistency must pass. Regression test - for #2081 (MCP-side sibling of #1846/#1855). - - Fix-toggle: the SAME lockfile without the provenance entry must FAIL, - proving the exemption -- not some unrelated condition -- is what flips - the result. - """ - from apm_cli.deps.lockfile import LockFile, get_lockfile_path - from apm_cli.models.apm_package import APMPackage - - # Root manifest declares only the local-path dependency, no mcp: block. - _write_apm_yml(tmp_path, deps=["./packages/agent-config"]) - - exempt_lock = textwrap.dedent("""\ - lockfile_version: '1' - generated_at: '2025-01-01T00:00:00Z' - dependencies: [] - mcp_configs: - shadcn: - name: shadcn - registry: false - transport: stdio - command: npx - mcp_config_provenance: - shadcn: '@qado/agent-config' - """) - _write_lockfile(tmp_path, exempt_lock) - manifest = APMPackage.from_apm_yml(tmp_path / "apm.yml") - lock = LockFile.read(get_lockfile_path(tmp_path)) - # Provenance must survive the YAML round-trip for the exemption to work. - assert lock.mcp_config_provenance == {"shadcn": "@qado/agent-config"} - result = _check_config_consistency(manifest, lock) - assert result.passed, result.details - - # Fix-toggle: strip the provenance block; the exact same server now - # falls through the orphan branch and fails. - without_provenance = exempt_lock[: exempt_lock.index("mcp_config_provenance:")] - _write_lockfile(tmp_path, without_provenance) - lock_no_prov = LockFile.read(get_lockfile_path(tmp_path)) - assert lock_no_prov.mcp_config_provenance == {} - result_no_prov = _check_config_consistency(manifest, lock_no_prov) - assert not result_no_prov.passed - assert any("shadcn" in d and "not in manifest" in d for d in result_no_prov.details) - def test_genuine_orphan_mcp_still_flagged(self, tmp_path): """Provenance must not mask a real orphan (server absent from manifest and lacking any provenance entry). Negative guard for #2081.""" @@ -778,6 +734,12 @@ def test_hash_covers_self_entry_local_files(self, tmp_path): class TestRunBaselineChecks: def test_all_pass(self, tmp_path): _write_apm_yml(tmp_path, deps=["owner/repo#v1.0.0"]) + package = tmp_path / "apm_modules" / "owner" / "repo" + package.mkdir(parents=True) + (package / "apm.yml").write_text( + "name: repo\nversion: 1.0.0\n", + encoding="utf-8", + ) _make_deployed_file(tmp_path, ".github/prompts/test.md") _write_lockfile( tmp_path, @@ -1562,3 +1524,65 @@ def test_no_artifacts_ci_mode_still_passes(self, tmp_path: Path) -> None: names = [c.name for c in result.checks] assert "manifest-missing" not in names assert result.passed + + +def test_mcp_config_consistency_uses_current_view(tmp_path: Path, monkeypatch) -> None: + """The consistency check delegates all source derivation to the owner.""" + from apm_cli.deps.lockfile import LockFile + from apm_cli.integration.mcp_config_view import CurrentMcpConfigView + + _write_apm_yml(tmp_path, mcp=["server"]) + manifest = APMPackage.from_apm_yml(tmp_path / "apm.yml") + lock = LockFile( + mcp_configs={"server": {"name": "server"}}, + mcp_config_provenance={"removed": "old-package"}, + ) + calls = [] + + def fake_derive( + cls, + derived_root, + derived_lock, + modules_root, + *, + trust_transitive_self_defined, + diagnostics=None, + ): + calls.append( + ( + derived_root, + derived_lock, + modules_root, + trust_transitive_self_defined, + diagnostics, + ) + ) + return CurrentMcpConfigView( + dependencies=(), + configs={"server": {"name": "server"}}, + provenance={}, + problems=(), + ) + + monkeypatch.setattr(CurrentMcpConfigView, "derive", classmethod(fake_derive)) + + result = _check_config_consistency(manifest, lock) + + assert result.passed + assert calls == [(manifest, lock, tmp_path / "apm_modules", True, None)] + + +def test_mcp_config_consistency_has_no_package_manifest_traversal() -> None: + """Architecture guard: the check must not regain sibling traversal logic.""" + import inspect + + source = inspect.getsource(_check_config_consistency) + forbidden = ( + "APMPackage.from_apm_yml", + "collect_transitive", + "get_install_path", + "resolve_local_dep_dir", + "rglob(", + ) + + assert all(token not in source for token in forbidden) diff --git a/tests/unit/policy/test_discovery.py b/tests/unit/policy/test_discovery.py index c3d4af22c..bd26ee153 100644 --- a/tests/unit/policy/test_discovery.py +++ b/tests/unit/policy/test_discovery.py @@ -212,6 +212,43 @@ def test_write_then_read(self): self.assertTrue(result.cached) self.assertEqual(result.source, f"org:{repo_ref}") + def test_policy_warnings_survive_cache_round_trip(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + repo_ref = "contoso/.github" + warnings = ["Unknown top-level policy key: 'enforcment'"] + + _write_cache(repo_ref, _make_test_policy(), root, warnings=warnings) + + result = _read_cache(repo_ref, root) + self.assertIsNotNone(result) + self.assertEqual(result.warnings, warnings) + + def test_corrupt_cached_warnings_render_gracefully(self): + cases = ( + ("not-a-list", [], "none"), + (["unknown key", 7, None], ["unknown key", "7", "None"], "unknown key; 7; None"), + ) + + for corrupt_warnings, expected_warnings, expected_rendering in cases: + with self.subTest(warnings=corrupt_warnings): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + repo_ref = "contoso/.github" + _write_cache(repo_ref, _make_test_policy(), root) + + meta_file = _get_cache_dir(root) / f"{_cache_key(repo_ref)}.meta.json" + meta = json.loads(meta_file.read_text(encoding="utf-8")) + meta["warnings"] = corrupt_warnings + meta_file.write_text(json.dumps(meta), encoding="utf-8") + + result = _read_cache(repo_ref, root) + + self.assertIsNotNone(result) + self.assertEqual(result.warnings, expected_warnings) + rendered = "; ".join(result.warnings) if result.warnings else "none" + self.assertEqual(rendered, expected_rendering) + def test_expired_cache(self): with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) diff --git a/tests/unit/policy/test_discovery_phase3w4.py b/tests/unit/policy/test_discovery_phase3w4.py index e9248d8c1..8c721638c 100644 --- a/tests/unit/policy/test_discovery_phase3w4.py +++ b/tests/unit/policy/test_discovery_phase3w4.py @@ -299,8 +299,8 @@ def test_single_policy_no_extends_returns_immediately(self, tmp_path: Path) -> N mock_warn.assert_not_called() assert fetch_result.policy is original_policy - def test_single_policy_parent_fetch_fails_emits_warning(self, tmp_path: Path) -> None: - """Parent fetch failure when chain has 1 entry emits partial_warning.""" + def test_single_policy_parent_fetch_fails_closed(self, tmp_path: Path) -> None: + """Parent fetch failure clears the weaker leaf policy.""" extends_yaml = "name: leaf\nversion: '1.0'\nenforcement: warn\nextends: owner/.parent\n" leaf_policy, _ = load_policy(extends_yaml) @@ -311,13 +311,12 @@ def test_single_policy_parent_fetch_fails_emits_warning(self, tmp_path: Path) -> failed_parent = PolicyFetchResult(source="org:owner/.parent", outcome="absent") failed_parent.policy = None - with ( - patch("apm_cli.policy.discovery.discover_policy", return_value=failed_parent), - patch("apm_cli.utils.console._rich_warning") as mock_warn, - ): + with patch("apm_cli.policy.discovery.discover_policy", return_value=failed_parent): _resolve_and_persist_chain(fetch_result, tmp_path) - mock_warn.assert_called_once() + assert fetch_result.policy is None + assert fetch_result.outcome == "incomplete_chain" + assert "owner/.parent" in (fetch_result.error or "") # --------------------------------------------------------------------------- diff --git a/tests/unit/policy/test_discovery_policy_resolution.py b/tests/unit/policy/test_discovery_policy_resolution.py index 7cd1faac0..05142f344 100644 --- a/tests/unit/policy/test_discovery_policy_resolution.py +++ b/tests/unit/policy/test_discovery_policy_resolution.py @@ -299,8 +299,8 @@ def test_single_policy_no_extends_returns_immediately(self, tmp_path: Path) -> N mock_warn.assert_not_called() assert fetch_result.policy is original_policy - def test_single_policy_parent_fetch_fails_emits_warning(self, tmp_path: Path) -> None: - """Parent fetch failure when chain has 1 entry emits partial_warning.""" + def test_single_policy_parent_fetch_fails_closed(self, tmp_path: Path) -> None: + """Parent fetch failure clears the weaker leaf policy.""" extends_yaml = "name: leaf\nversion: '1.0'\nenforcement: warn\nextends: owner/.parent\n" leaf_policy, _ = load_policy(extends_yaml) @@ -311,13 +311,12 @@ def test_single_policy_parent_fetch_fails_emits_warning(self, tmp_path: Path) -> failed_parent = PolicyFetchResult(source="org:owner/.parent", outcome="absent") failed_parent.policy = None - with ( - patch("apm_cli.policy.discovery.discover_policy", return_value=failed_parent), - patch("apm_cli.utils.console._rich_warning") as mock_warn, - ): + with patch("apm_cli.policy.discovery.discover_policy", return_value=failed_parent): _resolve_and_persist_chain(fetch_result, tmp_path) - mock_warn.assert_called_once() + assert fetch_result.policy is None + assert fetch_result.outcome == "incomplete_chain" + assert "owner/.parent" in (fetch_result.error or "") # --------------------------------------------------------------------------- diff --git a/tests/unit/policy/test_parser.py b/tests/unit/policy/test_parser.py index 75535cb3a..0b55f16b0 100644 --- a/tests/unit/policy/test_parser.py +++ b/tests/unit/policy/test_parser.py @@ -118,6 +118,43 @@ def test_unknown_top_level_keys_no_error(self): self.assertEqual(len(warnings), 1) self.assertIn("custom_field", warnings[0]) + def test_typo_and_wrong_typed_native_values_are_diagnosed(self): + errors, warnings = validate_policy( + { + "version": "1.0", + "enforcment": True, + "cache": [], + "dependencies": {"allow": ""}, + } + ) + + self.assertEqual(warnings, ["Unknown top-level policy key: 'enforcment'"]) + self.assertEqual( + errors, + [ + "cache must be a YAML mapping", + "dependencies.allow must be a YAML list of package patterns", + ], + ) + + def test_known_policy_blocks_and_pattern_items_use_native_schema_types(self): + errors, _ = validate_policy( + { + "mcp": [], + "manifest": [], + "dependencies": {"allow": [123]}, + } + ) + + self.assertEqual( + errors, + [ + "mcp must be a YAML mapping", + "manifest must be a YAML mapping", + "dependencies.allow must be a YAML list of package patterns", + ], + ) + def test_non_dict_input(self): errors, warnings = validate_policy("not a dict") # type: ignore[arg-type] # noqa: RUF059 self.assertEqual(len(errors), 1) diff --git a/tests/unit/primitives/test_discovery_parser.py b/tests/unit/primitives/test_discovery_parser.py index 6dc963b7d..ac7595963 100644 --- a/tests/unit/primitives/test_discovery_parser.py +++ b/tests/unit/primitives/test_discovery_parser.py @@ -597,7 +597,7 @@ def test_transitive_deps_appended_deduped(self): # picks up the transitive entry. apm_modules dir doesn't need to # actually exist for get_installed_paths to return the entries. (Path(self.tmp) / "apm.lock.yaml").write_text( - "version: 1\n" + "lockfile_version: '1'\n" "dependencies:\n" " - repo_url: owner/direct-dep\n" " resolved_ref: HEAD\n" diff --git a/tests/unit/test_audit_ci_auto_discovery.py b/tests/unit/test_audit_ci_auto_discovery.py index a0fc1a69a..afa92da1d 100644 --- a/tests/unit/test_audit_ci_auto_discovery.py +++ b/tests/unit/test_audit_ci_auto_discovery.py @@ -58,6 +58,12 @@ def _setup_project_with_unmanaged_file(project: Path) -> None: """) (project / "apm.yml").write_text(apm_yml, encoding="utf-8") (project / "apm.lock.yaml").write_text(lockfile, encoding="utf-8") + package = project / "apm_modules" / "owner" / "repo" + package.mkdir(parents=True) + (package / "apm.yml").write_text( + "name: repo\nversion: 1.0.0\n", + encoding="utf-8", + ) prompts_dir = project / ".github" / "prompts" prompts_dir.mkdir(parents=True) (prompts_dir / "managed.md").write_text("ok\n", encoding="utf-8") diff --git a/tests/unit/test_audit_ci_command.py b/tests/unit/test_audit_ci_command.py index 83a455d82..c593ca690 100644 --- a/tests/unit/test_audit_ci_command.py +++ b/tests/unit/test_audit_ci_command.py @@ -49,6 +49,12 @@ def _setup_clean_project(project: Path) -> None: """) (project / "apm.yml").write_text(apm_yml, encoding="utf-8") (project / "apm.lock.yaml").write_text(lockfile, encoding="utf-8") + package = project / "apm_modules" / "owner" / "repo" + package.mkdir(parents=True) + (package / "apm.yml").write_text( + "name: repo\nversion: 1.0.0\n", + encoding="utf-8", + ) prompts_dir = project / ".github" / "prompts" prompts_dir.mkdir(parents=True) (prompts_dir / "test.md").write_text("Clean content\n", encoding="utf-8") @@ -74,6 +80,12 @@ def _setup_failing_project(project: Path) -> None: """) (project / "apm.yml").write_text(apm_yml, encoding="utf-8") (project / "apm.lock.yaml").write_text(lockfile, encoding="utf-8") + package = project / "apm_modules" / "owner" / "repo" + package.mkdir(parents=True) + (package / "apm.yml").write_text( + "name: repo\nversion: 1.0.0\n", + encoding="utf-8", + ) prompts_dir = project / ".github" / "prompts" prompts_dir.mkdir(parents=True) (prompts_dir / "test.md").write_text("content\n", encoding="utf-8") diff --git a/tests/unit/test_audit_phase3w4.py b/tests/unit/test_audit_phase3w4.py index ef68fbde0..21a8c2af4 100644 --- a/tests/unit/test_audit_phase3w4.py +++ b/tests/unit/test_audit_phase3w4.py @@ -523,7 +523,7 @@ def test_package_not_in_lockfile(self, tmp_path): from apm_cli.commands.audit import _audit_content_scan lock = tmp_path / "apm.lock.yaml" - lock.write_text("lock_version: '1'\ndependencies: {}\n", encoding="utf-8") + lock.write_text("lockfile_version: '1'\ndependencies: []\n", encoding="utf-8") cfg = self._make_cfg(tmp_path) @@ -547,7 +547,7 @@ def test_no_deployed_files_in_lockfile(self, tmp_path): from apm_cli.commands.audit import _audit_content_scan lock = tmp_path / "apm.lock.yaml" - lock.write_text("lock_version: '1'\ndependencies: {}\n", encoding="utf-8") + lock.write_text("lockfile_version: '1'\ndependencies: []\n", encoding="utf-8") cfg = self._make_cfg(tmp_path) with ( diff --git a/tests/unit/test_audit_policy_command.py b/tests/unit/test_audit_policy_command.py index b7011101c..74cfc0f0c 100644 --- a/tests/unit/test_audit_policy_command.py +++ b/tests/unit/test_audit_policy_command.py @@ -50,6 +50,12 @@ def _setup_clean_project(project: Path) -> None: """) (project / "apm.yml").write_text(apm_yml, encoding="utf-8") (project / "apm.lock.yaml").write_text(lockfile, encoding="utf-8") + package = project / "apm_modules" / "owner" / "repo" + package.mkdir(parents=True) + (package / "apm.yml").write_text( + "name: repo\nversion: 1.0.0\n", + encoding="utf-8", + ) prompts_dir = project / ".github" / "prompts" prompts_dir.mkdir(parents=True) (prompts_dir / "test.md").write_text("Clean content\n", encoding="utf-8") diff --git a/tests/unit/test_audit_scan_and_render.py b/tests/unit/test_audit_scan_and_render.py index a39637749..034f90348 100644 --- a/tests/unit/test_audit_scan_and_render.py +++ b/tests/unit/test_audit_scan_and_render.py @@ -523,7 +523,7 @@ def test_package_not_in_lockfile(self, tmp_path): from apm_cli.commands.audit import _audit_content_scan lock = tmp_path / "apm.lock.yaml" - lock.write_text("lock_version: '1'\ndependencies: {}\n", encoding="utf-8") + lock.write_text("lockfile_version: '1'\ndependencies: []\n", encoding="utf-8") cfg = self._make_cfg(tmp_path) @@ -547,7 +547,7 @@ def test_no_deployed_files_in_lockfile(self, tmp_path): from apm_cli.commands.audit import _audit_content_scan lock = tmp_path / "apm.lock.yaml" - lock.write_text("lock_version: '1'\ndependencies: {}\n", encoding="utf-8") + lock.write_text("lockfile_version: '1'\ndependencies: []\n", encoding="utf-8") cfg = self._make_cfg(tmp_path) with ( diff --git a/tests/unit/test_command_logger.py b/tests/unit/test_command_logger.py index ebf77c3e7..127fce579 100644 --- a/tests/unit/test_command_logger.py +++ b/tests/unit/test_command_logger.py @@ -62,6 +62,31 @@ def test_error(self, mock_error): logger.error("Failed!") mock_error.assert_called_once_with("Failed!", symbol="error") + @patch("apm_cli.core.command_logger._rich_echo") + def test_error_detail_is_always_visible(self, mock_echo): + logger = CommandLogger("test") + logger.error_detail("Try: az login") + mock_echo.assert_called_once_with("Try: az login") + + @patch("apm_cli.core.command_logger._rich_info") + @patch("apm_cli.core.command_logger._rich_error") + def test_exception_adds_verbose_hint_when_details_are_hidden(self, mock_error, mock_info): + logger = CommandLogger("test", verbose=False) + logger.exception("Failed!") + mock_error.assert_called_once_with("Failed!", symbol="error") + mock_info.assert_called_once_with( + "Run with --verbose for detailed diagnostics", + symbol="info", + ) + + @patch("apm_cli.core.command_logger._rich_info") + @patch("apm_cli.core.command_logger._rich_error") + def test_exception_omits_verbose_hint_when_verbose(self, mock_error, mock_info): + logger = CommandLogger("test", verbose=True) + logger.exception("Failed!") + mock_error.assert_called_once_with("Failed!", symbol="error") + mock_info.assert_not_called() + @patch("apm_cli.core.command_logger._rich_warning") def test_warning(self, mock_warning): logger = CommandLogger("test") diff --git a/tests/unit/test_deps_utils.py b/tests/unit/test_deps_utils.py index ddcc2993c..0b24a739e 100644 --- a/tests/unit/test_deps_utils.py +++ b/tests/unit/test_deps_utils.py @@ -398,6 +398,25 @@ def test_no_apm_yml(self, tmp_path): assert info["version"] == "unknown" assert info["description"] == "No apm.yml found" + def test_empty_version_renders_unknown_symmetric_with_list(self, tmp_path): + """A readable manifest with empty version renders '@unknown', not 'error'. + + The strict identity authority (APMPackage.from_apm_yml) rejects the + empty version, but the tolerant display path must stay symmetric with + `deps list` and surface 'unknown' rather than masking the package. + """ + _make_apm_yml(tmp_path, "nover", version="") + info = _get_detailed_package_info(tmp_path) + assert info["name"] == "nover" + assert info["version"] == "unknown" + assert info["version"] != "error" + + def test_malformed_apm_yml_reports_error(self, tmp_path): + """Genuinely unparsable apm.yml still reports 'error' for the version.""" + (tmp_path / APM_YML_FILENAME).write_text(_MALFORMED_YML) + info = _get_detailed_package_info(tmp_path) + assert info["version"] == "error" + def test_hooks_counted(self, tmp_path): """Hook .json files are reflected in the result.""" _make_apm_yml(tmp_path, "hookpkg", version="1.0.0") diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py index 825b61aa0..43e6b393d 100644 --- a/tests/unit/test_install_command.py +++ b/tests/unit/test_install_command.py @@ -337,6 +337,18 @@ def test_install_auto_created_apm_yml_has_correct_metadata( assert "description" in config assert "APM project" in config["description"] + @patch("apm_cli.commands.install._validate_package_exists", return_value=False) + def test_install_positional_url_total_validation_failure_exits_one(self, mock_validate): + """A positional URL that cannot be validated must fail the command.""" + with self._chdir_tmp(): + result = self.runner.invoke(cli, ["install", "https://127.0.0.1:1/org/repo.git"]) + + assert result.exit_code == 1 + assert "All packages failed validation" in result.output + assert "Install interrupted" not in result.output + assert not Path("apm.yml").exists() + mock_validate.assert_called() + @patch("apm_cli.commands.install._validate_package_exists") def test_install_invalid_package_format_with_no_apm_yml(self, mock_validate): """Test that invalid package format fails gracefully even with auto-bootstrap.""" @@ -344,8 +356,8 @@ def test_install_invalid_package_format_with_no_apm_yml(self, mock_validate): # Don't mock validation - let it handle invalid format result = self.runner.invoke(cli, ["install", "invalid-package"]) - # Should create apm.yml but fail to add invalid package - assert Path("apm.yml").exists() + # Failed first installs must not leave the bootstrap manifest. + assert not Path("apm.yml").exists() assert "invalid format" in result.output @patch("apm_cli.commands.install._validate_package_exists") @@ -390,7 +402,7 @@ def test_install_dry_run_with_no_apm_yml_shows_what_would_be_created( # Should show what would be added assert result.exit_code == 0 assert "Would add" in result.output or "Dry run" in result.output - # apm.yml should still be created (for dry-run to work) + # Dry-run preserves the bootstrap configuration for the next run. assert Path("apm.yml").exists() diff --git a/tests/unit/test_prune_command.py b/tests/unit/test_prune_command.py index b0ff99db6..02d0852c8 100644 --- a/tests/unit/test_prune_command.py +++ b/tests/unit/test_prune_command.py @@ -56,6 +56,8 @@ def _make_package_dir(root: Path, org: str, repo: str) -> Path: def _write_lockfile(root: Path, yaml_content: str) -> Path: """Write an apm.lock.yaml file at *root* (current lockfile format).""" lockfile_path = root / "apm.lock.yaml" + if "lockfile_version:" not in yaml_content: + yaml_content = "lockfile_version: '1'\n" + yaml_content lockfile_path.write_text(yaml_content) return lockfile_path diff --git a/tests/unit/test_transitive_mcp.py b/tests/unit/test_transitive_mcp.py index cddcf7fba..537c3adc4 100644 --- a/tests/unit/test_transitive_mcp.py +++ b/tests/unit/test_transitive_mcp.py @@ -309,6 +309,7 @@ def test_lockfile_with_virtual_path(self, tmp_path): "repo_url": "org/monorepo", "host": "github.com", "virtual_path": "skills/azure", + "is_virtual": True, }, ], } @@ -352,8 +353,10 @@ def test_lockfile_paths_do_not_use_full_rglob_scan(self, tmp_path): assert len(result) == 1 assert result[0].name == "ghcr.io/locked/server" - def test_invalid_lockfile_falls_back_to_rglob_scan(self, tmp_path): - """If lock parsing fails, function falls back to scanning all apm.yml files.""" + def test_invalid_lockfile_fails_closed(self, tmp_path): + """A malformed lock graph must not broaden discovery to every package.""" + from apm_cli.deps.lockfile import LockfileFormatError + apm_modules = tmp_path / "apm_modules" pkg_dir = apm_modules / "org" / "pkg-a" pkg_dir.mkdir(parents=True) @@ -370,9 +373,8 @@ def test_invalid_lockfile_falls_back_to_rglob_scan(self, tmp_path): lock_path = tmp_path / "apm.lock.yaml" lock_path.write_text("dependencies: [") - result = MCPIntegrator.collect_transitive(apm_modules, lock_path) - assert len(result) == 1 - assert result[0].name == "ghcr.io/a/server" + with pytest.raises(LockfileFormatError): + MCPIntegrator.collect_transitive(apm_modules, lock_path) def test_skips_self_defined_by_default(self, tmp_path): """Self-defined servers from transitive packages are skipped without the flag.""" diff --git a/tests/unit/test_uninstall_shared_ownership.py b/tests/unit/test_uninstall_shared_ownership.py new file mode 100644 index 000000000..acb7f4f8a --- /dev/null +++ b/tests/unit/test_uninstall_shared_ownership.py @@ -0,0 +1,79 @@ +"""Regression coverage for shared deployed-path ownership on uninstall.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import yaml +from click.testing import CliRunner + +from apm_cli.cli import cli +from apm_cli.deps.lockfile import LockFile + + +def _write_local_package(root: Path, name: str) -> None: + """Create a local package that provides the shared instruction.""" + instructions = root / ".apm" / "instructions" + instructions.mkdir(parents=True) + (root / "apm.yml").write_text( + f"name: {name}\nversion: 1.0.0\n", + encoding="ascii", + ) + (instructions / "shared.instructions.md").write_text( + "---\ndescription: shared ownership regression\napplyTo: '**'\n---\n# Shared\n", + encoding="ascii", + ) + + +def test_uninstall_transfers_shared_deployed_path_ownership(tmp_path: Path, monkeypatch) -> None: + """The surviving package owns and audits a shared path after uninstall.""" + package_a = tmp_path / "a" + package_b = tmp_path / "b" + _write_local_package(package_a, "package-a") + _write_local_package(package_b, "package-b") + + project = tmp_path / "app" + project.mkdir() + (project / "apm.yml").write_text( + "name: app\n" + "version: 1.0.0\n" + "targets: [copilot]\n" + "dependencies:\n" + " apm:\n" + " - path: ../a\n" + " - path: ../b\n", + encoding="ascii", + ) + monkeypatch.chdir(project) + runner = CliRunner() + + install = runner.invoke(cli, ["install", "--target", "copilot"]) + assert install.exit_code == 0, install.output + + lockfile_writes = [] + original_write = LockFile.write + + def _record_write(lockfile: LockFile, path: Path) -> None: + lockfile_writes.append(path) + original_write(lockfile, path) + + with patch.object(LockFile, "write", _record_write): + uninstall = runner.invoke(cli, ["uninstall", "../b"]) + assert uninstall.exit_code == 0, uninstall.output + assert lockfile_writes == [project / "apm.lock.yaml"] + + deployed_path = ".github/instructions/shared.instructions.md" + deployed_file = project / deployed_path + assert deployed_file.is_file() + + lock_data = yaml.safe_load((project / "apm.lock.yaml").read_text(encoding="ascii")) + assert len(lock_data["dependencies"]) == 1 + surviving = lock_data["dependencies"][0] + assert surviving["name"] == "package-a" + assert deployed_path in surviving["deployed_files"] + assert surviving["deployed_file_hashes"][deployed_path].startswith("sha256:") + + deployed_file.write_text("# tampered\n", encoding="ascii") + audit = runner.invoke(cli, ["audit", "--ci", "--no-policy", "--no-drift"]) + assert audit.exit_code == 1, audit.output