Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
15 changes: 13 additions & 2 deletions CONFORMANCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,17 @@
"tests/spec_conformance/test_manifest_reqs.py::test_producer_workspaces_must_not_use_in_v0_1"
]
},
{
"conformance_class": "consumer",
"id": "req-mf-022",
"keyword": "MUST",
"section": "4.3.2",
"status": "active",
"test_count": 1,
"tests": [
"tests/spec_conformance/test_manifest_reqs.py::test_consumer_preserves_registry_identity_on_structured_rewrite"
]
},
{
"conformance_class": "governance",
"id": "req-pl-001",
Expand Down Expand Up @@ -1112,7 +1123,7 @@
"spec_version": "v0.1.1",
"summary_by_class": {
"consumer": {
"active": 69,
"active": 70,
"skipped": 1,
"unbound": 0,
"xfail": 0
Expand All @@ -1136,5 +1147,5 @@
"xfail": 0
}
},
"total_requirements": 98
"total_requirements": 99
}
3 changes: 2 additions & 1 deletion CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac
| Class | Active | Skipped | Xfail | Unbound |
|-------|-------:|--------:|------:|--------:|
| Producer | 12 | 0 | 0 | 0 |
| Consumer | 69 | 1 | 0 | 0 |
| Consumer | 70 | 1 | 0 | 0 |
| Registry | 1 | 0 | 0 | 0 |
| Governance | 15 | 0 | 0 | 0 |

Expand Down Expand Up @@ -72,6 +72,7 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac
| [req-mf-019](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-019) | MUST | 4.2.4 | consumer | active | 1 |
| [req-mf-020](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-020) | MUST | 4.1 | consumer | active | 1 |
| [req-mf-021](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-021) | MUST | 4.8 | producer | active | 1 |
| [req-mf-022](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-022) | MUST | 4.3.2 | consumer | active | 1 |
| [req-pl-001](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-001) | MUST | 6.1 | governance | active | 1 |
| [req-pl-002](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-002) | MUST | 6.2 | governance | active | 1 |
| [req-pl-003](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-003) | MUST | 6.4 | governance | active | 1 |
Expand Down
4 changes: 4 additions & 0 deletions docs/public/specs/manifests/openapm-v0.1.requirements.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ requirements:
keyword: MUST
section: "4.8"
conformance_class: producer
- id: req-mf-022
keyword: MUST
section: "4.3.2"
conformance_class: consumer
- id: req-ext-001
keyword: MUST
section: "4.1"
Expand Down
27 changes: 22 additions & 5 deletions docs/src/content/docs/specs/openapm-v0.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ between the companion corpus and the implementation.

### 1.3 Document conventions

- OpenAPM v0.1 carries **98 normative statements** indexed in
- OpenAPM v0.1 carries **99 normative statements** indexed in
[Appendix C](#appendix-c-index-of-normative-statements).
- All on-disk files defined by this specification are **YAML 1.2**
parsed under the safe subset defined in
Expand Down Expand Up @@ -556,6 +556,20 @@ package's `host`, `repo_url`, and resolved `ref`, with `virtual_path`
taken from `path`. The literal `parent` MUST NOT appear in the
lockfile as durable identity (`repo_url` or `source`).

<a id="req-mf-022"></a>
**[req-mf-022]** A conforming **consumer** implementation MUST NOT
silently rewrite an existing `id:`-form (registry-sourced) manifest
entry into a `git:`-form entry when persisting a subsequent CLI-driven
manifest update (e.g. an additive `--skill` pin) for the same
dependency identity. When a CLI-parsed reference is ambiguous about
its source (git vs. registry) but an existing manifest entry for the
same identity already resolves to the `registry` source, the
implementation MUST honor the existing entry's source when
serializing the updated entry. If an update would otherwise replace a
registry-sourced entry with a non-registry-shaped entry, the
implementation MUST reject the update with a diagnostic naming the
identity, rather than silently converting it.

#### 4.3.3 Virtual packages

A dependency MAY target a subdirectory or a file within a repository
Expand Down Expand Up @@ -736,8 +750,8 @@ This section's normative statements are:
[req-mf-012](#req-mf-012), [req-mf-013](#req-mf-013),
[req-mf-016](#req-mf-016), [req-mf-018](#req-mf-018),
[req-mf-019](#req-mf-019), [req-mf-020](#req-mf-020),
[req-mf-021](#req-mf-021), [req-ext-001](#req-ext-001),
[req-ext-002](#req-ext-002),
[req-mf-021](#req-mf-021), [req-mf-022](#req-mf-022),
[req-ext-001](#req-ext-001), [req-ext-002](#req-ext-002),
[req-tg-004](#req-tg-004), [req-sc-006](#req-sc-006).

---
Expand Down Expand Up @@ -2565,7 +2579,8 @@ conformance statement identifying:
[req-mf-012](#req-mf-012), [req-mf-013](#req-mf-013),
[req-mf-016](#req-mf-016), [req-mf-018](#req-mf-018),
[req-mf-019](#req-mf-019), [req-mf-020](#req-mf-020),
[req-mf-021](#req-mf-021), [req-ext-001](#req-ext-001),
[req-mf-021](#req-mf-021), [req-mf-022](#req-mf-022),
[req-ext-001](#req-ext-001),
[req-lk-001](#req-lk-001), [req-lk-002](#req-lk-002),
[req-lk-003](#req-lk-003), [req-lk-004](#req-lk-004),
[req-lk-005](#req-lk-005), [req-lk-006](#req-lk-006),
Expand Down Expand Up @@ -2936,6 +2951,7 @@ renumbering of conformance classes.
| [req-mf-019](#req-mf-019) | MUST | 4.2.4 | consumer |
| [req-mf-020](#req-mf-020) | MUST | 4.1 | consumer |
| [req-mf-021](#req-mf-021) | MUST | 4.8 | producer |
| [req-mf-022](#req-mf-022) | MUST | 4.3.2 | consumer |
| [req-ext-001](#req-ext-001) | MUST | 4.1 | consumer |
| [req-ext-002](#req-ext-002) | MUST | 4.1 | producer |
| [req-lk-001](#req-lk-001) | MUST | 5.1 | consumer |
Expand Down Expand Up @@ -3014,7 +3030,7 @@ renumbering of conformance classes.
| [req-cf-001](#req-cf-001) | MUST | 12.5 | consumer |
| [req-cf-002](#req-cf-002) | MUST | 12.3 | consumer |

**Total normative statements: 98** (93 MUST, 5 SHOULD).
**Total normative statements: 99** (94 MUST, 5 SHOULD).

---

Expand All @@ -3036,6 +3052,7 @@ renumbering of conformance classes.
| 0.1.10 | 2026-07-04 | Spec-citation fold for Antigravity native instruction rules (closes the #1984 Mode-B silent-extension gate). Added [req-tg-005] (Section 8.5, consumer MUST): Antigravity instruction rules are deployed under `.agents/rules/<name>.md`, `applyTo` is rendered as `trigger: glob` plus `globs` (scalar or sequence), and compile-time deduplication only treats expected Antigravity rule filenames as deployed rules so unrelated `.md` files cannot suppress `AGENTS.md` content. Added `antigravity` to the Section 4.2.1 canonical target set and clarified that `all` excludes explicit-only targets. Statement count: 96 -> 97 (92 MUST, 5 SHOULD). |
| 0.1.11 | 2026-07-09 | Spec-guardian editorial+defensive fold on the Antigravity instruction-rule contract (no new normative statements; statement count remains 97 (92 MUST, 5 SHOULD)). Section 4.2.1: defined the **auto-detectable** vs **explicit-only** target taxonomy deterministically (a target is auto-detectable when the OpenAPM Target Registry publishes at least one detection predicate) and rewrote the `all` expansion to key off it, naming `agent-skills` and `antigravity` as the v0.1 explicit-only set (with a Section 8.4 cross-reference). [req-tg-001] extended: a target registered without a detection predicate MUST NOT be auto-detected and MUST be excluded from `all`, generalising the prior `agent-skills`-only clause to cover `antigravity`. [req-tg-005] extended: pinned a canonical `globs` representation (YAML scalar for exactly one glob, YAML block sequence for two or more, no frontmatter block when `applyTo` is absent) so deployed-file content hashes are reproducible across implementations; redefined the deduplication scope from "expected Antigravity rule filenames" to filenames derived from the currently-resolved instruction primitives recorded in `apm.lock.yaml` and the manifest, closing a fail-open interpretation where an unrelated `.agents/rules/*.md` file could suppress `AGENTS.md` content; lowercased the `antigravity` identifier and added an editorial note scoping the normative citation of the concrete deploy path. [req-tg-002] subdirectory-partition list updated to include `.agents/rules/`. No normative count change. |
| 0.1.12 | 2026-07-10 | Spec-citation fold for inactive-target lockfile reconciliation. Added [req-lk-020] (Section 5.2, consumer MUST): a non-frozen rewrite with a declared target set preserves paths attributable to current, another declared, or implementation-recognized targets that activate outside the manifest; removes prior paths attributable to none of them; applies the same decision to per-entry and top-level deployed-file lists and hash maps; and preserves prior paths when no target set is declared or attribution is indeterminate. Statement count: 97 -> 98 (93 MUST, 5 SHOULD). |
| 0.1.13 | 2026-07-12 | Spec-citation fold for object-form registry identity preservation on CLI-driven manifest updates. Added [req-mf-022] (Section 4.3.2, consumer MUST): a consumer MUST NOT silently rewrite an existing `id:`-form (registry-sourced) manifest entry into a `git:`-form entry when persisting a subsequent CLI-driven update (e.g. an additive `--skill` pin) for the same dependency identity; when a CLI-parsed reference is ambiguous about its source but an existing manifest entry for the same identity already resolves to the `registry` source, the existing entry's source MUST be honored, and an update that would otherwise replace a registry-sourced entry with a non-registry-shaped entry MUST be rejected with a diagnostic naming the identity. Section 4.9 and Section 11.3.2 Consumer enumerations and Appendix C updated. Statement count: 98 -> 99 (94 MUST, 5 SHOULD). |

Errata (none at publication).

Expand Down
53 changes: 42 additions & 11 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
apply_cli_skill_pin,
cli_skill_subset,
dependency_reference_to_yaml_entry,
get_existing_dep_ref_for_identity,
persist_dependency_list_if_changed,
resolve_parsed_dependency_reference,
update_existing_dependency_entry_if_needed,
Expand Down Expand Up @@ -409,6 +410,22 @@ def warning_handler(msg):
)
canonical = dep_ref.to_canonical()
identity = dep_ref.get_identity()
# A bare CLI positional (e.g. "owner/repo#1.0.0") always parses with
# source=None/"git" -- DependencyReference.parse() has no registry
# awareness. When this identity is already declared as a registry
# dependency in apm.yml, propagate that so downstream serialization
# (to_apm_yml_entry) and probe routing don't silently convert it to
# a git dependency (see #2166 review discussion).
existing_ref_for_identity = get_existing_dep_ref_for_identity(
current_deps, identity, dependency_reference_cls=DependencyReference
)
existing_source_is_registry = (
existing_ref_for_identity is not None
and getattr(existing_ref_for_identity, "source", None) == "registry"
)
if existing_source_is_registry:
dep_ref.source = "registry"
dep_ref.registry_name = existing_ref_for_identity.registry_name
apply_cli_skill_pin(
dep_ref,
skill_subset,
Expand Down Expand Up @@ -460,7 +477,14 @@ def warning_handler(msg):
already_in_deps = identity in existing_identities

verbose = bool(logger and logger.verbose)
if should_skip_github_probe_for_dep(dep_ref, default_registry):
# existing_source_is_registry short-circuits the probe-skip decision
# the same way should_skip_github_probe_for_dep does for a freshly
# ambiguous ref -- otherwise routes_unscoped_to_registry (which treats
# source == "registry" as already-decided, not ambiguous) would send
# this already-known registry identity through the GitHub probe.
if existing_source_is_registry or should_skip_github_probe_for_dep(
dep_ref, default_registry
):
ref_ok, ref_err = validate_registry_ref(dep_ref)
if not ref_ok:
invalid_outcomes.append((package, ref_err))
Expand All @@ -477,16 +501,23 @@ def warning_handler(msg):
dep_ref=dep_ref,
)
if package_accessible:
updates_existing_entry = update_existing_dependency_entry_if_needed(
current_deps,
already_in_deps=already_in_deps,
apm_yml_entries=_apm_yml_entries,
canonical=canonical,
dep_ref=dep_ref,
identity=identity,
dependency_reference_cls=DependencyReference,
logger=logger,
)
try:
updates_existing_entry = update_existing_dependency_entry_if_needed(
current_deps,
already_in_deps=already_in_deps,
apm_yml_entries=_apm_yml_entries,
canonical=canonical,
dep_ref=dep_ref,
identity=identity,
dependency_reference_cls=DependencyReference,
logger=logger,
)
except ValueError as e:
reason = str(e)
invalid_outcomes.append((package, reason))
if logger:
logger.validation_fail(package, reason)
continue
valid_outcomes.append((canonical, already_in_deps))
if logger:
logger.validation_pass(canonical, already_in_deps, updates_existing_entry)
Expand Down
38 changes: 32 additions & 6 deletions src/apm_cli/install/package_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,13 +138,13 @@ def user_scope_rejection_reason(dep_ref: Any, scope: Any) -> str | None:
return None


def get_existing_skill_subset(
def get_existing_dep_ref_for_identity(
current_deps: builtins.list,
identity: str,
*,
dependency_reference_cls: Any,
) -> builtins.list[str] | None:
"""Return the persisted ``skills:`` list for *identity*, or None."""
) -> Any | None:
"""Return the parsed existing manifest entry matching *identity*, or None."""
for dep_entry in current_deps:
try:
if isinstance(dep_entry, builtins.str):
Expand All @@ -156,11 +156,26 @@ def get_existing_skill_subset(
except (ValueError, TypeError, AttributeError, KeyError):
continue
if existing_ref.get_identity() == identity:
subset = getattr(existing_ref, "skill_subset", None)
return list(subset) if subset else None
return existing_ref
return None


def get_existing_skill_subset(
current_deps: builtins.list,
identity: str,
*,
dependency_reference_cls: Any,
) -> builtins.list[str] | None:
"""Return the persisted ``skills:`` list for *identity*, or None."""
existing_ref = get_existing_dep_ref_for_identity(
current_deps, identity, dependency_reference_cls=dependency_reference_cls
)
if existing_ref is None:
return None
subset = getattr(existing_ref, "skill_subset", None)
return list(subset) if subset else None


def normalize_and_merge_skill_subset(
cli_subset: builtins.tuple[str, ...],
current_deps: builtins.list,
Expand Down Expand Up @@ -351,11 +366,22 @@ def merge_structured_entry_into_current_deps(
except (ValueError, TypeError, AttributeError, KeyError):
continue
if existing_ref.get_identity() == identity:
if getattr(existing_ref, "source", None) == "registry" and not (
isinstance(structured_entry, dict)
and ("id" in structured_entry or "registry" in structured_entry)
):
raise ValueError(
f"'{identity}' is already declared as a registry dependency; "
f"refusing to silently convert it to a git dependency. Use the "
f"object form ('- id: ...' / '- registry: ...') to change its "
f"version, or an explicit '- git:' entry to intentionally "
f"switch resolvers."
)
current_deps[idx] = structured_entry
replaced = True
if logger:
logger.verbose_detail(
f"Updated existing dependency entry to structured git+path form: {canonical}"
f"Updated existing dependency entry to structured form: {canonical}"
)
break
if not replaced:
Expand Down
Loading
Loading