Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .apm/architecture/owners/install-deployment.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
{
"version": 1,
"owners": [
{
"id": "immutable-dependency-requirements",
"decision": "Commit-based compatibility of immutable dependency edges and frozen lock pins before hoisting",
"owner": "deps/immutable_requirements.py (ImmutableRequirements)",
"selectors": ["src/apm_cli/deps/immutable_requirements.py"],
"guards": ["install-deployment-immutable-requirements"]
},
{
"id": "effective-package-target-authorization",
"decision": "Effective package target authorization",
Expand Down
15 changes: 15 additions & 0 deletions docs/src/content/docs/consumer/manage-dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,21 @@ For reserved aliases `.` and `..`, see [Rejected dependency aliases](../../troub
For registry-sourced dependencies (internal packages on Artifactory or a custom registry), see
[Registries](../../guides/registries/).

## Incompatible immutable requirements

APM installs one version per package identity. If two dependency paths require
tags or commit SHAs that resolve to different commits, install fails with both
root-to-package paths and requested refs. Different tag names, or a tag and its
commit SHA, are compatible when they identify the same commit. APM may query
Git refs to verify this; a failed lookup is not treated as proof of compatibility.

Align the refs in your `apm.yml`, or select a parent package release that requires
the same commit. Then run `apm install` to regenerate the lockfile. Do not edit
`apm.lock.yaml` to hide a conflict. `--frozen` also rejects a locked commit that
drops an immutable requirement discovered in a dependency manifest, including
on a cold cache after the parent package is fetched. Side-by-side versions are
not supported.

## Add a dependency

You have two paths.
Expand Down
11 changes: 10 additions & 1 deletion docs/src/content/docs/reference/cli/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ With no arguments it installs everything from `apm.yml`. With one or more `PACKA
| Flag | Default | Description |
|---|---|---|
| `--update` | off | Re-resolve dependencies to the latest version or Git ref allowed by `apm.yml` and rewrite `apm.lock.yaml`. Mutable Git refs must resolve against upstream; APM does not fall back to stale refs from the local bare Git cache. Mutually exclusive with `--frozen`. For interactive use with a confirmation prompt, use [`apm update`](../update/) instead. |
| `--frozen` | off | Lockfile-only install: refuse to resolve anything new and fail before any project, config, deployment, or cache write if `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP state. Mirrors `npm ci`. Mutually exclusive with `--update`, positional package additions, and `--mcp`. |
| `--frozen` | off | Require `apm.lock.yaml` and reject manifest or MCP state drift before the install pipeline. Verify transitive immutable requirements as locked package manifests become available, before deployment and lockfile commit. A cold cache may need locked-parent downloads for that check. Mutually exclusive with `--update`, positional package additions, and `--mcp`. |
| `--dry-run` | off | Print the install plan without deployment writes. Positional packages and ref changes appear in the preview after validation but do not change an existing `apm.yml`. Project auto-bootstrap still keeps its new manifest and any explicit `--target` selection for the next run; global dry-run bootstrap uses temporary preview state and does not create `~/.apm`. The `-g --mcp` path creates no user manifest, lockfile, or runtime configuration. |
| `--force` | off | Overwrite locally-authored files on collision **and** bypass the security scan's critical-finding block. Does **not** suppress general install errors (any reported error still exits `1`, matching npm / pip / cargo) or select ref freshness. Add `--update` or `--refresh` to resolve mutable refs upstream; [`apm update`](../update/) does so with or without `--force`. Use only after independent verification. |
| `--verbose`, `-v` | off | Show per-file paths and full error context in the diagnostic summary. |
Expand Down Expand Up @@ -116,6 +116,15 @@ in `apm.yml`, then run `apm install` again.

## Behavior

- **Immutable dependency conflicts.** Install fails when two paths require
different immutable commits for one package identity, showing both paths and
requested refs. Equivalent tag/SHA spellings remain valid. `--frozen` also
checks transitive immutable requirements against locked commits as package
manifests become available; a cold cache may need parent-package downloads
before a conflict is discoverable. Align the root/parent refs and run a normal
install to regenerate the lockfile. See
[Manage dependencies](../../../consumer/manage-dependencies/#incompatible-immutable-requirements).

- **Auto-bootstrap.** `apm install <pkg>` with no `apm.yml` creates a minimal one. Its name comes from the current directory (or home directory for global installs) and falls back to `my-project` if that derived name is invalid. `apm install --dry-run -g <pkg>` validates through a temporary manifest when `~/.apm/apm.yml` is absent, reports the real user manifest path, and leaves `~/.apm` uncreated. If `~/.apm/apm.yml` already exists, global dry-run reads it in place without writing changes. Bare `apm install` with no `apm.yml` exits with a hint to run `apm init` or `apm install <org/repo>`.
- **Target persistence on bootstrap.** When `--target` maps to recognized manifest targets, those target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` redeploys to the same targets without re-specifying `--target`. For absent user manifests, `apm install --dry-run -g --target ... <pkg>` previews that target field but does not write it.
- **One effective target.** Package primitives, MCP servers, and LSP servers consume one target decision per invocation: `--target` > `apm.yml targets:` > `apm config set target ...` > auto-detect. A saved target therefore applies to `apm install`, `apm install --mcp`, and later `apm update` runs without another flag.
Expand Down
9 changes: 8 additions & 1 deletion docs/src/content/docs/reference/lockfile-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ shipped.
| Command | Reads | Writes |
|---|---|---|
| `apm install` | existing lockfile (for `--frozen` and incremental reuse) | full rewrite on resolution change |
| `apm install --frozen` | required | never writes; fails on a missing pin or MCP config/server-name drift |
| `apm install --frozen` | required | rejects missing pins, incompatible immutable requirements, or MCP config/server-name drift |
| `apm compile` | yes (resolution + integrity) | no |
| `apm audit` | yes | no |
| `apm prune` | yes (orphans and `deployments` ownership, even with nothing else to prune) | yes (after removing orphans and reconciling `deployments`) |
Expand All @@ -342,6 +342,13 @@ lockfile includes the field, APM retains it for compatibility and refreshes it
only on a substantive write. To migrate a legacy lockfile manually, delete the
`generated_at: ...` line from `apm.lock.yaml` once; APM will not add it back.

Frozen replay checks immutable requirements from dependency manifests as they
are loaded. A lock entry cannot silently replace a required tag or SHA with a
different commit. Equivalent tag/SHA spellings are accepted after verification;
checking distinct tag names may require a remote ref lookup. On a cold cache,
APM may fetch a locked parent before discovering its conflicting requirement.
Fix the manifest refs and regenerate the lockfile with a normal install.

## Drift and integrity

The lockfile is what `apm audit` compares the workspace against. Each baseline
Expand Down
7 changes: 7 additions & 0 deletions packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@

## Dependency management

`apm install` rejects incompatible immutable requirements for one package
identity and reports both dependency paths and refs. Equivalent tag/SHA
spellings are accepted. `--frozen` verifies transitive immutable requirements
as parent manifests become available, including after cold-cache downloads.
Align manifest refs and regenerate the lockfile with a normal install to repair
a collapsed graph; `--force` does not bypass this check.

| Command | Purpose | Key flags |
|---------|---------|-----------|
| `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; plain/frozen installs replay locked refs and cache state, while `--update` and `--refresh` require current upstream mutable refs; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` CI-safe install that fails before any durable write when `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP config state (mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; positional packages and ref changes are previewed without changing an existing `apm.yml`; a newly bootstrapped manifest and explicit targets are kept), `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use explicit target `hermes` to deploy skills and home-scoped MCP servers to `$HERMES_HOME/config.yaml` (or `~/.hermes/config.yaml` when unset or blank); run `apm compile` separately for `AGENTS.md`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when selected explicitly), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or source-relative path under skills/; a CLI name or path that matches no declared skill fails before manifest or lockfile commit with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. |
Expand Down
7 changes: 7 additions & 0 deletions packages/apm-guide/.apm/skills/apm-usage/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,13 @@ package uninstall revoke only entries it wrote.

## Version pinning

APM uses one version per package identity. Incompatible tag/SHA requirements
fail with both dependency paths and requested refs; unequal ref strings alone
are not a conflict when they resolve to the same commit. Align the root or
parent manifest refs, then run `apm install` to regenerate the lockfile.
`--frozen` rejects locked commits that drop an immutable transitive requirement.
Distinct named refs may need a Git lookup to establish equivalence.

| Strategy | Syntax | When to use |
|----------|--------|-------------|
| Tag | `owner/repo#v1.0.0` | Production -- immutable reference |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,21 @@
check_source_plan,
)
from scripts.architecture_linter.checks.install_uninstall_and_resolution import (
_GUARD_IMMUTABLE_REQUIREMENTS,
_GUARD_RESOLUTION_REPLACEMENT,
_GUARD_UNINSTALL_SELECTION,
check_immutable_requirements,
check_resolution_replacement,
check_uninstall_selection,
)
from scripts.architecture_linter.models import Rule

RULES: tuple[Rule, ...] = (
_rule(
_GUARD_IMMUTABLE_REQUIREMENTS,
"Immutable dependency compatibility is checked by one owner before hoisting.",
check_immutable_requirements,
),
_rule(
_GUARD_PACKAGE_TARGET,
"Restriction-only package target authorization has one owner (install/target_filter.py).",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
_UNINSTALL_ENGINE,
_all_names,
_count_re,
_duplicate_definition_lines,
_facts_for,
_line_findings,
_present,
Expand All @@ -31,6 +32,42 @@


_GUARD_UNINSTALL_SELECTION = "install-deployment-uninstall-selection"
_GUARD_IMMUTABLE_REQUIREMENTS = "install-deployment-immutable-requirements"


def check_immutable_requirements(provider: FactsProvider) -> tuple[Violation, ...]:
"""Keep immutable compatibility in one owner and mandatory before hoisting."""
rule_id = _GUARD_IMMUTABLE_REQUIREMENTS
owner = "src/apm_cli/deps/immutable_requirements.py"
findings = list(
_duplicate_definition_lines(
provider,
rule_id=rule_id,
prefix=_SRC_PREFIX,
pattern=re.compile(r"^\s*(?:class ImmutableRequirements\b|def _require_equal\()"),
owner=owner,
message="Immutable requirement compatibility belongs in deps/immutable_requirements.py",
respect_exempt=False,
)
)
required = {
owner: ("class ImmutableRequirements:", "def _require_equal("),
"src/apm_cli/deps/apm_resolver.py": ("requirements.add(node)", "ImmutableRequirements("),
"src/apm_cli/install/phases/resolve.py": (
"reference_resolver=downloader",
"frozen=ctx.frozen",
),
"src/apm_cli/install/service.py": ("frozen=request.frozen",),
"src/apm_cli/install/pipeline.py": ("frozen=frozen",),
}
for path, fragments in required.items():
facts, failures = _facts_for(provider, path, rule_id)
findings.extend(failures)
if not failures and not all(_present(facts, fragment) for fragment in fragments):
findings.append(
_summary(rule_id, path, "Immutable requirements must be checked before hoisting")
)
return tuple(findings)


def _call_terminal_name(node: ast.Call) -> str | None:
Expand Down
22 changes: 22 additions & 0 deletions src/apm_cli/deps/apm_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
DependencyTree,
FlatDependencyMap,
)
from .immutable_requirements import (
ImmutableRequirementError,
ImmutableRequirements,
ReferenceResolver,
)

if TYPE_CHECKING:
from .lockfile import LockFile
Expand Down Expand Up @@ -100,6 +105,8 @@ def __init__(
existing_lockfile: "LockFile | None" = None,
activation_callback: ActivationCallback | None = None,
cache_validation_callback: Callable[[Path, str], Path | None] | None = None,
reference_resolver: ReferenceResolver | None = None,
frozen: bool = False,
):
"""Initialize the resolver with maximum recursion depth.

Expand Down Expand Up @@ -139,6 +146,8 @@ def __init__(
self._cache_validation_callback = cache_validation_callback
self._update_refs = update_refs
self._existing_lockfile = existing_lockfile
self._reference_resolver = reference_resolver
self._frozen = frozen
# Whether ``download_callback`` accepts ``parent_pkg`` (added in #857).
# Detected once via signature inspection so legacy callbacks that
# predate the field still work without raising a silent TypeError
Expand Down Expand Up @@ -653,6 +662,13 @@ def build_dependency_tree(

# Initialize the tree
tree = DependencyTree(root_package=root_package)
requirements = ImmutableRequirements(
root_package.name,
self._reference_resolver,
self._existing_lockfile,
frozen=self._frozen,
update_refs=self._update_refs,
)

# Queue for breadth-first traversal: (dependency_ref, depth, parent_node, is_dev)
processing_queue: deque[tuple[DependencyReference, int, DependencyNode | None, bool]] = (
Expand Down Expand Up @@ -782,6 +798,12 @@ def build_dependency_tree(
work_items.append((node, dep_ref, parent_node, is_dev))

winner_candidates.extend(item[0] for item in work_items)
for node, _, _, _ in work_items:
try:
requirements.add(node)
except ImmutableRequirementError as exc:
tree.resolution_errors.append(str(exc))
return tree
_, winner_ids = _select_dependency_winners(winner_candidates)
work_items = [
item
Expand Down
Loading
Loading