Skip to content

feat(registry): align object-form registry deps with git skills:#2166

Open
nadav-y wants to merge 5 commits into
microsoft:mainfrom
nadav-y:feat/registry-skills-subset
Open

feat(registry): align object-form registry deps with git skills:#2166
nadav-y wants to merge 5 commits into
microsoft:mainfrom
nadav-y:feat/registry-skills-subset

Conversation

@nadav-y

@nadav-y nadav-y commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Object-form git dependencies (- git: ...) have long supported a skills: array to install a subset of skills from a SKILL_BUNDLE package. The registry-longhand form (- id: owner/repo, registry:, version:) rejected the same field as an unknown key, with no way to narrow which skills get deployed. This aligns registry-longhand with git-longhand by reusing the same parse_skill_subset validator (non-empty, no path traversal, sorted+deduped).
  • Also fixes to_apm_yml_entry(), which had no branch for source == "registry": any registry dependency with a skill/target subset set (e.g. via apm install --skill ... --save) previously serialized as a malformed git: entry, and even an alias-only registry entry collapsed to a bare canonical string, silently dropping registry:/id:/version:.

Test plan

  • Added unit tests in tests/unit/test_reference_registry_shorthand.py::TestObjectFormRegistry covering: parsing skills:, empty-list rejection, path-traversal rejection, round-trip through to_apm_yml_entry(), and that alias-only registry entries no longer collapse to a plain string.
  • Full relevant suite passes (test_reference_registry_shorthand.py, test_skill_subset_persistence.py, and broader dependency/install/lockfile tests).
  • Verified end-to-end against a live registry: published a package with 4 skills, installed a consumer with skills: [skill-alpha, skill-beta], confirmed only 2 skills were deployed to .claude/skills/, and apm.lock.yaml's skill_subset matched exactly.

…lls:

Object-form git dependencies (- git: ...) have long supported a skills:
array to install a subset of a SKILL_BUNDLE package. The registry-longhand
form (- id: owner/repo, registry:, version:) rejected the same field as
unknown, with no way to narrow which skills get deployed.

Reuses the existing parse_skill_subset validator so both forms share
identical validation (non-empty, no path traversal, sorted+deduped).

Also fixes to_apm_yml_entry(), which had no branch for source == "registry"
at all: any registry dependency with a skill/target subset set (e.g. via
apm install --skill ... --save) would previously serialize as a malformed
git: entry, and even an alias-only registry entry collapsed to a bare
canonical string, silently dropping registry:/id:/version:.

Verified end-to-end against a live registry: published a package with 4
skills, installed a consumer with skills: [a, b], confirmed only 2 were
deployed and apm.lock.yaml's skill_subset matched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 12, 2026 08:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Aligns registry object-form dependency parsing/serialization with the existing git object-form behavior by allowing a skills: subset and ensuring registry deps round-trip correctly through to_apm_yml_entry(). This improves manifest fidelity when users install a subset of skills from a registry-hosted SKILL_BUNDLE and then persist it back to apm.yml.

Changes:

  • Accept skills: on registry longhand (id/registry/version) entries by reusing the shared parse_skill_subset validator.
  • Add a dedicated source == "registry" branch in DependencyReference.to_apm_yml_entry() so registry deps serialize as registry dict entries rather than malformed git entries.
  • Add unit tests covering registry skills: parsing, validation, and round-trip serialization.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
tests/unit/test_reference_registry_shorthand.py Adds unit tests for registry object-form skills: parsing/validation and to_apm_yml_entry() round-trips.
src/apm_cli/models/dependency/reference.py Adds skills: support to registry object-form parsing and adds a registry serialization branch in to_apm_yml_entry().

Comment thread src/apm_cli/models/dependency/reference.py Outdated
Comment thread src/apm_cli/models/dependency/reference.py
Comment thread src/apm_cli/models/dependency/reference.py Outdated
Comment thread src/apm_cli/models/dependency/reference.py Outdated
nadav-y and others added 2 commits July 12, 2026 11:18
Copilot review on microsoft#2166 correctly flagged that the new source == "registry"
branch in to_apm_yml_entry() dropped target_subset: set_target_subset_for_entry()
sets ref.target_subset directly and calls to_apm_yml_entry(), so a registry
dependency's targets: write-back was silently lost.

Since the PR's premise is full parity with git-longhand (which already
supports both skills: and targets:), close the gap by adding targets:
parsing/validation to the registry object-form parser (same
parse_target_subset validator as git/local-path forms) and emitting it in
to_apm_yml_entry().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…le-length gate

CI's file-length guardrail failed: reference.py was already at 2079 lines on
main (the existing worst offender driving the 2100-line cap) and this PR's
additions tipped it to 2124.

Move _parse_registry_object_entry into a new sibling module
models/dependency/registry_entry.py, matching the existing pattern of
object_fields.py/subsets.py/identity.py/types.py -- reference.py delegates
via a thin classmethod wrapper. The new module takes the DependencyReference
class as a parameter instead of importing it, so there's no import cycle.

reference.py: 2124 -> 2028 lines. Scoped to only the registry object-form
parsing this PR touches; the rest of reference.py (git/URL shorthand
parsing, GitLab/ADO handling, virtual package detection) is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nadav-y

nadav-y commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed the file-length CI failure: extracted _parse_registry_object_entry into a new sibling module models/dependency/registry_entry.py (matching the existing object_fields.py/subsets.py/identity.py/types.py pattern), scoped to just the registry object-form parsing this PR touches. reference.py was already at 2079 lines on main (the file driving the 2100-line cap); this PR's additions tipped it to 2124. Now back down to 2028.

Repro: apm.yml already has an object-form registry dependency
(`- id: owner/repo`, `version: ...`). Running
`apm install "owner/repo#1.0.0" --skill some-skill` rewrote that entry into
a `git:`-shaped structured entry and then failed ls-remote (or worse,
silently succeeded if `owner/repo` also happened to resolve as a real
GitHub repo -- a dependency-confusion-shaped footgun).

Root cause: DependencyReference.parse() (the CLI positional parser) has no
registry awareness -- source is always None/"git" for a bare string. The
--skill code path in _resolve_package_references() calls
dep_ref.to_apm_yml_entry() before the (separate) default-registry routing
check runs, so the registry-aware branch in to_apm_yml_entry() never fires
for a freshly-parsed CLI arg, even when apm.yml already has this exact
identity pinned to a registry.

Fix:
- _resolve_package_references() now looks up the existing apm.yml entry for
  the parsed identity before doing anything else; if it's source=="registry",
  propagate source/registry_name onto the freshly-parsed dep_ref so
  to_apm_yml_entry() takes the registry branch, and route the probe-skip
  decision the same way (routes_unscoped_to_registry treats an already-
  known registry source as "not ambiguous", so the existing-registry fact
  has to be OR'd in explicitly).
- merge_structured_entry_into_current_deps() gets a defense-in-depth guard:
  refuse (ValueError, caught and reported as a normal invalid-package
  outcome) to overwrite a registry-sourced manifest entry with a non-
  registry-shaped structured entry, regardless of which upstream code path
  produced it.
- Extracted the repeated "parse current_deps entries, find the one matching
  this identity" loop into get_existing_dep_ref_for_identity() (used by the
  new check and by get_existing_skill_subset(), which had its own copy of
  the same loop).

Verified live against a real registry: the exact repro command now leaves
apm.yml as a proper id/registry/version dict with skills: added, and
apm.lock.yaml resolves it as source: registry from the actual registry URL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nadav-y

nadav-y commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed d49e5784, fixing the must-fix bug from manual testing: apm install "owner/repo#version" --skill X was silently converting an existing registry-longhand dependency into a git-shaped entry.

Root cause: DependencyReference.parse() (the CLI positional parser) has no registry awareness — a bare string always parses with source=None. _resolve_package_references() serializes dep_ref.to_apm_yml_entry() for the --skill persistence path before the separate default-registry routing check runs, so the registry branch in to_apm_yml_entry() never fires for a freshly-parsed CLI arg — even when apm.yml already has that exact identity pinned to a registry.

Fix:

  • _resolve_package_references() now looks up the existing apm.yml entry for the identity up front; if it's source == "registry", propagates that onto the freshly-parsed dep_ref (and updates the probe-skip routing condition accordingly, since routes_unscoped_to_registry treats an already-known registry source as "not ambiguous").
  • merge_structured_entry_into_current_deps() gets a defense-in-depth guard: refuses to overwrite a registry-sourced entry with a non-registry-shaped structured entry, regardless of which caller produced it.
  • Extracted the repeated identity-lookup loop into get_existing_dep_ref_for_identity().

Verified live against the real registry: the exact repro command now correctly leaves apm.yml as {id, registry, version, skills} and the lockfile resolves source: registry from the actual registry URL, instead of failing ls-remote (or worse, silently succeeding against a same-named GitHub repo).

Also confirmed via a separate manual test: the other finding (apm install --skill X with no positional package filters the deploy for that run but doesn't persist to apm.yml/lockfile at all) reproduces identically for plain git/local dependencies — it's a pre-existing, general gap in the --skill CLI flag's persistence path, unrelated to registry-longhand. Tracking separately rather than folding into this PR.

…rewrite

Spec-citation fold closing the Mode B silent-extension gate on
src/apm_cli/install/package_resolution.py (the previous commit's fix for
--skill silently converting a registry-longhand dependency to git form).

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; 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.

Three-step ritual: spec anchor + Appendix C row + Section 4.9/11.3.2
enumerations + revision history row (Section 1.3 and Appendix C counts:
98 -> 99, 93 -> 94 MUST); manifest entry in
openapm-v0.1.requirements.yml; @pytest.mark.req("req-mf-022") test driving
the real merge_structured_entry_into_current_deps behavior. Regenerated
CONFORMANCE.{md,json}. orphan_check passes (99 requirements aligned).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nadav-y

nadav-y commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed the Spec conformance gate / Mode B detector failure: my previous commit's fix in src/apm_cli/install/package_resolution.py (a critical path per OpenAPM v0.1) landed real behavioral changes with no spec citation.

Since this is a genuine bug fix (not a no-op refactor), I did the proper three-step ritual rather than reaching for apm-spec-waiver::

  • Added req-mf-022 (Section 4.3.2, consumer MUST) to docs/src/content/docs/specs/openapm-v0.1.md: a consumer MUST NOT silently rewrite an existing id:-form (registry-sourced) manifest entry into a git:-form entry when persisting a CLI-driven update (e.g. an additive --skill pin) for the same identity; an update that would otherwise replace a registry-sourced entry with a non-registry-shaped one MUST be rejected with a diagnostic.
  • Added the matching Appendix C row, Section 4.9/11.3.2 enumerations, and a revision-history entry (statement count 98 → 99, 93 → 94 MUST).
  • Added the manifest entry in openapm-v0.1.requirements.yml.
  • Added @pytest.mark.req("req-mf-022") in tests/spec_conformance/test_manifest_reqs.py, driving the real merge_structured_entry_into_current_deps behavior (not just a spec-text grep).
  • Regenerated CONFORMANCE.{md,json}.

Verified locally: orphan_check reports 99 requirements aligned across anchors/manifest/Appendix C/markers, the full tests/spec_conformance/ suite passes (132 passed, 2 skipped — pre-existing unrelated waivers), and mode_b_detector.sh now short-circuits cleanly ("spec-concurrent edit detected; orphan_check owns this PR"). Pushed as 959bb649.

@nadav-y nadav-y added the panel-review Trigger the apm-review-panel gh-aw workflow label Jul 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

panel-review Trigger the apm-review-panel gh-aw workflow

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants