fix(deps): preserve transitive dependencies in marketplace and remote subdir installs (closes #1666)#1687
Conversation
When a dual-format package (plugin.json + apm.yml) was downloaded and validated, synthesize_apm_yml_from_plugin() overwrote the existing apm.yml with identity-only fields, discarding all dependencies, devDependencies, registries, targets, includes, and scripts. This broke transitive dependency resolution for marketplace and remote subdirectory installs: the resolver read the stripped manifest, found no sub-dependencies, and stopped at depth 1. The fix makes plugin normalisation non-destructive: before overwriting, the existing apm.yml is loaded and resolution-critical blocks are merged into the synthesized manifest. Plugin-derived dependencies (MCP from mcpServers, LSP from lspServers) are unioned with existing entries without duplicates. Fixes #1666 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a dependency-resolution regression for dual-format packages (those shipping both plugin.json/.claude-plugin/ signals and an apm.yml) by making plugin normalization non-destructive: instead of overwriting the existing apm.yml with identity-only fields, it now loads the existing manifest and merges/retains resolution-critical blocks so transitive APM/MCP deps continue to resolve for marketplace and remote-subdir installs.
Changes:
- Load any pre-existing
apm.ymlduringsynthesize_apm_yml_from_plugin()and merge its dependency blocks into the synthesized output. - Add
_union_dep_list()to union dependency lists (supporting both string and dict dependency entries) without duplicates. - Add regression tests ensuring dependencies and other resolution-critical manifest blocks survive synthesis/normalization.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/apm_cli/deps/plugin_parser.py |
Loads existing apm.yml and merges dependencies + preserves key blocks during plugin->apm.yml synthesis to avoid stripping transitive deps. |
tests/unit/test_plugin_parser.py |
Adds regression tests covering preservation/merging behavior across synthesize/normalize/validate flows for dual-format packages. |
…dentity fallback - Narrow except from broad Exception to (OSError, yaml.YAMLError) - Use list[Any] type annotations in _union_dep_list - Fall back to existing apm.yml identity fields (name, version, description, author, license, etc.) before hard defaults Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 3 | 2 | Correct fix for #1666; 3 recommended: bare-except with no log (line 224), dict-form dep silencing (line 835), SRP leak in _generate_apm_yml (line 792). |
| CLI Logging Expert | 0 | 2 | 1 | Silent except-pass on corrupt apm.yml re-silences the #1666 regression with no [!] warning; verbose merge path is also invisible to agents. |
| DevX UX Expert | 0 | 2 | 1 | Fix restores correct transitive resolution mental model; one silent-failure surface remains where malformed apm.yml still drops deps without any user-visible warning. |
| Supply Chain Security Expert | 0 | 3 | 2 | Fix is correct; three forward-risk surfaces elevated by preservation of registries, scripts, and unaudited dep injection via existing apm.yml -- none exploitable today but warrant hardening. |
| OSS Growth Hacker | 0 | 1 | 1 | Critical silent data-loss fix restoring the README's core promise (transitive resolution). Excellent community contribution pattern. CHANGELOG entry missing; dual-format authoring story untold. |
| Doc Writer | 0 | 2 | 1 | CHANGELOG.md has no entry for #1666; reference/package-types.md omits dual-format (plugin.json + apm.yml) behavior now fixed by this PR. No other doc updates needed. |
| Test Coverage Expert | 0 | 4 | 1 | 8 new tests meet integration-fixture tier for core #1666 regression; 3 edge-case gaps: malformed-YAML fallback silently strips deps (no trap), dict-form existing deps skipped without test, and LSP preservation from existing manifest untested. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [CLI Logging Expert + Python Architect + DevX UX Expert + Supply Chain Security Expert + Test Coverage Expert] (blocking-severity) Replace bare
except Exception: passat line 224 with_surface_warning()emitting a human-readable warning that transitive deps may not be preserved, and add a regression-trap test for the malformed-YAML fallback path -- Four panelists flagged the silent except independently; test-coverage-expert confirmed the fallback path has no regression trap (outcome: missing on a portability-by-manifest surface). The fix re-creates the Bug: marketplace and remote subdir installs drop transitive dependencies #1666 symptom under YAML corruption with zero user diagnostic. One-line code change matching the pattern already established at lines 404, 424, and 568; one test case. Pre-ship. - [OSS Growth Hacker + Doc Writer] (blocking-severity) Add CHANGELOG.md [Unreleased] ### Fixed entry crediting @sergio-sisternes-epam and closing Bug: marketplace and remote subdir installs drop transitive dependencies #1666 -- CONTRIBUTING.md requires a CHANGELOG entry for behavior changes. The [Unreleased] section currently carries entries for apm install never updates existing .claude/rules/*.md from local .apm/instructions/ (deployed rule files missing from lockfile local_deployed_files) #1662 and refactor(auth): debug-log all exception handlers in credential cascade (closes #935) #1664 but nothing for the fix that restores the project headline promise. Two panelists flag this independently. Pre-ship.
- [Python Architect + Test Coverage Expert] Fix dict-form existing dep blocks silently skipped at line 835 by removing or guarding the
isinstance(val, list)check, and add a regression-trap test for this dep shape -- The isinstance guard silently discards dict-form dependency sub-keys from existing apm.yml -- the same data-loss pattern this PR fixes for list-form deps. test-coverage-expert confirms no test for this shape (outcome: missing). Two panelists flagged this independently. One-line fix; one test. - [Supply Chain Security Expert] Open a tracked issue to design lifecycle-key allowlisting for
scriptsblocks and root-project registry URL authority checking before any lifecycle-hook or registry-federation feature is introduced -- scripts and registries are now preserved from existing apm.yml without content validation or URL authority checks. Neither surface is exploitable today, but both become active attack vectors when auto-execution or cross-plugin registry propagation lands. - [Doc Writer + OSS Growth Hacker] Add dual-format package authoring documentation to
reference/package-types.mddescribing the plugin.json + apm.yml merge contract -- Dual-format is now a supported, non-destructive pattern but no documentation tells a producer this combination works or what the merge semantics are.apm plugin initgenerates dual-format packages on day one, making this the default authoring path for new contributors.
Architecture
classDiagram
direction LR
class plugin_parser {
<<module>>
+normalize_plugin_directory(plugin_path, plugin_json_path) Path
+synthesize_apm_yml_from_plugin(plugin_path, manifest) Path
-_generate_apm_yml(manifest, existing_manifest) str
-_union_dep_list(merged, key, new_entries) None
}
class yaml_io {
<<IOBoundary>>
+load_yaml(path) dict
+yaml_to_str(data) str
}
class validation {
<<module>>
+validate_apm_package(package_path) ValidationResult
-_validate_marketplace_plugin(package_path, plugin_json_path, result) ValidationResult
}
class APMPackage {
<<ValueObject>>
+name str
+version str
+dependencies dict
+targets list
+from_apm_yml(apm_yml_path) APMPackage
-_parse_dependency_dict(raw_deps) dict
}
class ValidationResult {
<<ValueObject>>
+is_valid bool
+package APMPackage
+package_type PackageType
}
validation ..> plugin_parser : calls normalize_plugin_directory
plugin_parser ..> yaml_io : load_yaml reads existing apm.yml
plugin_parser ..> yaml_io : yaml_to_str writes merged content
validation ..> APMPackage : from_apm_yml consumes synthesized file
ValidationResult *-- APMPackage : contains
class plugin_parser:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
A["validate_apm_package\nvalidation.py:305"] --> B["detect_package_type MARKETPLACE_PLUGIN\nvalidation.py:335"]
B --> C["_validate_marketplace_plugin\nvalidation.py:681"]
C --> D["normalize_plugin_directory\nplugin_parser.py:135"]
D --> E["synthesize_apm_yml_from_plugin\nplugin_parser.py:168"]
E --> F{"apm.yml exists?\nplugin_parser.py:217"}
F -- yes --> G["load_yaml(apm_yml_path)\nexisting_manifest populated"]
F -- no --> H["existing_manifest = None\noriginal behavior preserved"]
G --> I["_generate_apm_yml(manifest, existing_manifest)\nplugin_parser.py:792"]
H --> I
I --> J{"existing_manifest present?\nplugin_parser.py:831"}
J -- yes --> K["copy list-typed dep keys only\nif isinstance val list: merged_deps key\nplugin_parser.py:834-836\nWARN: dict-form vals silently dropped"]
J -- no --> L["merged_deps = {}"]
K --> M["_union_dep_list: layer plugin apm/mcp/lsp deps\nplugin_parser.py:841-855"]
L --> M
M --> N["preserve devDeps, registries, target/targets, includes, scripts\nplugin_parser.py:863-873"]
N --> O["write merged apm.yml\nplugin_parser.py:230"]
O --> P["APMPackage.from_apm_yml\napm_package.py:312"]
P --> Q["_parse_dependency_dict: parse full dep tree"]
Q --> R["ValidationResult: transitive deps intact"]
Recommendation
Ship after two pre-ship items are addressed in this PR: (1) replace the bare except Exception: pass at line 224 with a _surface_warning() call matching the pattern at lines 404, 424, and 568, and add a malformed-YAML regression-trap test -- one-line code change, one test, unanimously flagged by four panelists and confirmed by a missing-evidence gap on a portability-by-manifest surface; (2) add the CHANGELOG.md [Unreleased] ### Fixed entry as required by CONTRIBUTING.md. The remaining three follow-ups -- scripts and registries hardening design, dict-form dep gap with regression trap, and dual-format authoring docs -- are correctly scoped as post-merge issues and do not block the regression fix from landing. The supply-chain recommendation to strip scripts and registries from preserved-blocks is rejected in favor of a tracked forward-risk issue; stripping would regress the data-fidelity goal this fix establishes. This is a high-quality community contribution from an EPAM engineer that closes a silent data-loss regression affecting every dual-format marketplace package; it should merge promptly once the two pre-ship items are in.
Full per-persona findings
Python Architect
-
[recommended] Dict-form existing deps silently dropped; inconsistent with plugin-derived dict deps which ARE preserved at
src/apm_cli/deps/plugin_parser.py:835
Lines 834-836: the loop only copies list-typed dep sub-keys from the existing manifest. A dep sub-key with a non-list value (e.g. a dict like {pkg: ^1.0}) is silently discarded with no warning. Asymmetric with plugin-derived deps (lines 843-845) where a non-list value IS preserved via setdefault.
Suggested: Either (a) drop the isinstance guard and usemerged_deps[key] = valto faithfully round-trip any shape; or (b) keep the guard but add an else branch calling_surface_warning()to surface the skipped key. -
[recommended] Bare
except Exception: passon apm.yml load silently swallows YAML corruption, logging nothing atsrc/apm_cli/deps/plugin_parser.py:224
Ifload_yaml()raises yaml.YAMLError or PermissionError, the code silently falls back to plugin-only synthesis, stripping all deps -- exactly the regression this PR is fixing._surface_warning()is already imported and used throughout this module for best-effort diagnostics.
Suggested: Replaceexcept Exception: passwithexcept Exception as exc: _surface_warning(f'Could not read existing apm.yml for merge; transitive deps may be lost: {exc}', _logger). -
[recommended] _generate_apm_yml() now mixes generation and merge, violating single responsibility at
src/apm_cli/deps/plugin_parser.py:792
_generate_apm_yml()was a pure transformation: (plugin manifest dict) -> YAML string. Addingexisting_manifestcouples a second concern into the same function, making the merge logic harder to test in isolation (now 90+ lines).
Suggested: Extract merge logic into_merge_existing_into(plugin_manifest: dict, existing: dict) -> dict(pure, no I/O). Call fromsynthesize_apm_yml_from_plugin():merged = _merge_existing_into(manifest, existing_manifest), then_generate_apm_yml(merged)with the original single-param signature. -
[nit] Docstring mentions only 'targets' but code also preserves singular 'target' (legacy form) at
src/apm_cli/deps/plugin_parser.py:177
The preservation loop includes both 'target' (singular legacy) and 'targets' (canonical plural) but only 'targets' is mentioned in the docstring. -
[nit]
_union_dep_list()type hints use bare unparameterizedlistatsrc/apm_cli/deps/plugin_parser.py:884
Withfrom typing import Anyalready imported,dict[str, list[Any]]andlist[Any]are more precise.
Suggested: Change signature to_union_dep_list(merged: dict[str, list[Any]], key: str, new_entries: list[Any]) -> None:.
CLI Logging Expert
-
[recommended] Silent
except Exception: passswallows load failure with no user-visible warning atsrc/apm_cli/deps/plugin_parser.py:224
When an existing apm.yml fails to parse,existing_manifeststays None and_generate_apm_ymlsilently strips deps -- reproducing the exact symptom of Bug: marketplace and remote subdir installs drop transitive dependencies #1666 with no signal. Every other error path in this file that discards data uses_surface_warning()(lines 404, 424, 568). This path breaks that contract.
Suggested: Changeexcept Exception: passtoexcept Exception as exc: _surface_warning(f"Could not load existing apm.yml for '{plugin_path.name}'; transitive dependencies may not be preserved: {exc}", _logger). -
[recommended] No debug/verbose signal on the successful dep-preservation path at
src/apm_cli/deps/plugin_parser.py:222
Whenexisting_manifestloads cleanly and deps are merged, the output to agents running with--verboseis zero. Resolution decisions should be traceable.
Suggested: Afterexisting_manifest = data, add:_logger.debug("Merging existing apm.yml for '%s' into synthesized manifest (preserving %d dep blocks)", plugin_path.name, len(existing_manifest.get('dependencies') or {})). -
[nit]
_generate_apm_ymlpreserves 6 resolution-critical keys with no per-key trace atsrc/apm_cli/deps/plugin_parser.py:863
A single_logger.debuglisting which keys were preserved would let operators and agents confirm the full merge without reading source code.
DevX UX Expert
-
[recommended] Malformed apm.yml silently drops all transitive deps with no user-visible warning at
src/apm_cli/deps/plugin_parser.py:224
The bareexcept Exception: passmeans a user whose apm.yml has a YAML syntax error will still hit the exact symptom described in Bug: marketplace and remote subdir installs drop transitive dependencies #1666 with zero diagnostic output. npm, pip, and cargo all treat a corrupt manifest as a hard, loud failure. This file already uses_surface_warning()and imports_rich_warningfor similar situations.
Suggested: Replacepass # Best-effort; fall back to plugin-only metadatawith_surface_warning(f'Could not read existing apm.yml at {apm_yml_path}: dependencies may be incomplete. Run apm install --verbose for details.', _logger). -
[recommended] No positive signal when transitive deps are loaded from existing apm.yml during marketplace install at
src/apm_cli/deps/plugin_parser.py:223
The bug was silent; the fix is also silent. A user who hit Bug: marketplace and remote subdir installs drop transitive dependencies #1666 and upgrades APM has no way to confirm the fix is working without inspecting the lockfile manually. npm prints each package it resolves; cargo announces downloading N crates.
Suggested: Afterexisting_manifest = data, emit:_logger.debug('Loaded existing apm.yml from %s; %d top-level keys preserved', apm_yml_path, len(data)). -
[nit]
except Exceptionis too broad and masks I/O errors distinct from YAML parse errors atsrc/apm_cli/deps/plugin_parser.py:224
A PermissionError or OSError is not a YAML parse failure; conflating them makes triage harder.
Suggested: Split intoexcept (yaml.YAMLError, ValueError)for parse errors and a separateexcept OSErrorfor I/O failures, each with its own warning text.
Supply Chain Security Expert
-
[recommended]
scriptsblock from existing apm.yml preserved verbatim with no lifecycle isolation atsrc/apm_cli/deps/plugin_parser.py:870
APM's install pipeline does not execute scripts today (ScriptRunner requires explicitapm run), but there is no structural guard preventing a future lifecycle hook from auto-executing them. Unlikeregistries(validated by_parse_registries_block),scriptsvalues undergo zero content validation in this code path. A compromised plugin maintainer can embed any shell payload.
Suggested: Dropscriptsfrom the preserved-blocks tuple, or add an explicit allowlist of safe script names and strip known lifecycle keys (postinstall, prepare, install, preinstall) before writing. -
[recommended]
registriesblock emitted to synthesized manifest with no URL authority check atsrc/apm_cli/deps/plugin_parser.py:866
_parse_registries_blockvalidates format when the file is loaded, but does NOT check URLs against any project-level allowlist. The plugin's registries are currently isolated from root-level resolution, but if any future code treats the plugin'sAPMPackage.registriesas a resolution authority, those URLs activate silently.
Suggested: Stripregistriesfrom the preserved-blocks tuple. The synthesized manifest does not need registry configuration -- the root project governs registry resolution. -
[recommended] Existing apm.yml dependencies are now an active dep-injection surface, previously inert due to bug Bug: marketplace and remote subdir installs drop transitive dependencies #1666 at
src/apm_cli/deps/plugin_parser.py:831
Before this PR, normalization discarded all apm.yml deps (the bug). After this PR, merged_deps are faithfully written and resolved transitively. This is the correct fix, but it elevates apm.yml to the same injection authority as plugin.json with no corresponding visibility -- no log or warning is emitted when deps are merged fromexisting_manifest.
Suggested: Emit a_logger.infoor_rich_warningwhen non-empty deps are merged from an existing apm.yml, e.g. 'Merging N deps from existing apm.yml in plugin-name'. -
[nit]
_union_dep_listdedup uses full==equality; dict deps differing only in git URL both survive dedup atsrc/apm_cli/deps/plugin_parser.py:895
Document in the_union_dep_listdocstring that dedup is structural (==) not semantic. If the same logical package appears with different URLs, both are retained -- this is intentional but callers should be aware. -
[nit] TOCTOU window between
apm_yml_path.exists()andopen()write is technically present but safe atsrc/apm_cli/deps/plugin_parser.py:217
Practical risk is negligible (filesystem access to install directory + sub-millisecond race window required). No action needed today.
OSS Growth Hacker
-
[recommended] CHANGELOG [Unreleased] has no entry for this fix despite it restoring the project's headline promise at
CHANGELOG.md:8
The README hero copy reads 'transitive dependency resolution, just like npm or pip' -- that promise was silently broken for every dual-format package until this PR. The [Unreleased] section lists lower-visibility fixes but omits this one.
Suggested: Add under [Unreleased] ### Fixed: 'Marketplace and remote subdir installs now preserve transitive dependencies for dual-format packages (plugin.json + apm.yml); previously, synthesizing apm.yml from plugin.json silently discarded existing dependencies, devDependencies, registries, targets, includes, and scripts blocks. (Bug: marketplace and remote subdir installs drop transitive dependencies #1666, by @sergio-sisternes-epam)' -
[nit] Dual-format package authoring is now a supported, non-destructive pattern but docs don't say so at
docs/src/content/docs/getting-started/first-package.md:289
A one-sentence callout would turn a silent fix into a documentable authoring pattern that closes the trust loop for authors who were burned by the old behavior.
Auth Expert -- inactive
No fast-path files changed; dep-merging fix does not alter token resolution, host classification, or AuthResolver call sites.
Doc Writer
-
[recommended] CHANGELOG.md [Unreleased] section missing a Fixed entry for Bug: marketplace and remote subdir installs drop transitive dependencies #1666 at
CHANGELOG.md
CONTRIBUTING.md requires a CHANGELOG.md entry for behavior changes. The [Unreleased] ### Fixed section has an entry for apm install never updates existing .claude/rules/*.md from local .apm/instructions/ (deployed rule files missing from lockfile local_deployed_files) #1662 but none for this fix. The bug caused silent data loss (dependencies stripped from dual-format packages on install) -- a high-impact regression that should be recorded.
Suggested: Add under [Unreleased] ### Fixed: 'apm installnow preservesdependencies,devDependencies,registries,targets, andscriptsfromapm.ymlwhen a package also hasplugin.jsonor.claude-plugin/, fixing transitive resolution for marketplace and remote subdirectory installs. (by @sergio-sisternes-epam, closes Bug: marketplace and remote subdir installs drop transitive dependencies #1666)' -
[recommended]
reference/package-types.md: Plugin collection section does not document dual-format (plugin.json + apm.yml) packages or their transitive-dep semantics atdocs/src/content/docs/reference/package-types.md
With this fix, the dual-format pattern now works correctly. A producer authoring a Claude plugin with sub-dependencies has no documentation telling them this combination is supported or what the merge contract is.
Suggested: In the 'Plugin collection (plugin.json)' section, add a paragraph: 'If the package also has anapm.yml, APM treats this as a dual-format package. Plugin identity fields (name, version, description, author) are sourced from the plugin artifact. All other blocks --dependencies,devDependencies,registries,targets,scripts,includes-- are read fromapm.ymland resolved transitively on install.' -
[nit] PR body uses 'identity-only fields' without defining the term in the TL;DR
Minor: 'identity fields' could be parenthetically explained in the TL;DR as '(name, version, description)' for reviewers who skim.
Test Coverage Expert
-
[recommended] Malformed existing apm.yml triggers
except Exception: passfallback -- deps silently stripped, no regression trap attests/unit/test_plugin_parser.py
When existing apm.yml has a YAML syntax error,existing_manifeststays None. The entire dep-merging block is skipped. Result is identical to pre-fix behavior: all deps silently discarded. Grep of tests/unit/test_plugin_parser.py confirmed zero tests for malformed/corrupt YAML in the context ofsynthesize_apm_yml_from_plugin.
Suggested: Addtest_malformed_existing_apm_yml_falls_back_to_plugin_only_metadatainTestSynthesizePreservesExistingManifest: write a malformed apm.yml, callsynthesize_apm_yml_from_plugin, assert the call succeeds and the synthesized apm.yml contains the plugin identity fields.
Proof (missing at integration-with-fixtures):tests/unit/test_plugin_parser.py::TestSynthesizePreservesExistingManifest::test_malformed_existing_apm_yml_falls_back_to_plugin_only_metadata-- proves: A malformed existing apm.yml does not crash synthesis and produces a working (if incomplete) manifest [portability-by-manifest, devx] -
[recommended] Dict-form existing dependencies silently skipped -- no test for this shape at
tests/unit/test_plugin_parser.py
Lines 833-836 gate onisinstance(val, list). If the existing manifest carries dependencies in dict form (a format some tooling generates), the isinstance check fails and the dep block is silently excluded. Grep confirmed no test where the existing manifest has dict-form dependencies.
Suggested: Addtest_existing_apm_deps_dict_form_preserved_or_warnedinTestSynthesizePreservesExistingManifest: write an apm.yml withdependencies: {apm: {my-package: '>=1.0.0'}}(dict, not list), callsynthesize_apm_yml_from_plugin, assert either the dict is preserved or a warning is emitted.
Proof (missing at integration-with-fixtures):tests/unit/test_plugin_parser.py::TestSynthesizePreservesExistingManifest::test_existing_apm_deps_dict_form_preserved_or_warned-- proves: When existing apm.yml uses dict-format dependencies, user gets predictable behavior rather than silent data loss [portability-by-manifest, devx] -
[recommended] 8 new integration-fixture-tier tests verified present and correctly structured; cannot run-certify due to sandbox at
tests/unit/test_plugin_parser.py
8 tests inTestSynthesizePreservesExistingManifestuse real filesystem operations (tmp_path, real YAML read/write, no mocks at dep-merging layer) -- integration-with-fixtures tier. Cannot produce irrefutable run evidence per S7 contract; author reports 16,638 passed in PR body.
Suggested: Maintainer can verify with:uv run --extra dev pytest tests/unit/test_plugin_parser.py::TestSynthesizePreservesExistingManifest -v
Proof (unknown at integration-with-fixtures):tests/unit/test_plugin_parser.py::TestSynthesizePreservesExistingManifest-- proves: Plugin normalisation does not strip transitive deps from dual-format packages (regression trap for Bug: marketplace and remote subdir installs drop transitive dependencies #1666) [portability-by-manifest, devx] -
[recommended] PR body lacks Scenario Evidence table mapping changed behavior to APM principles at
PR body
Per the PR description rubric, behavior-change PRs should include a scenario-to-principle mapping table. The PR body has a Validation Evidence table and strong prose, but no row-by-row mapping of the 8 new scenarios to APM principles (Portability, DevX, etc.).
Suggested: Add a Scenario Evidence table with one row per new test, naming the APM principle each scenario defends. -
[nit] LSP dep preservation from existing manifest has no explicit regression-trap test at
tests/unit/test_plugin_parser.py
The dep-merging loop handles 'lsp' via the same isinstance gate, but no test putsdependencies.lspin an existing manifest and verifies it survives synthesis.
Suggested: Parametrizetest_preserves_existing_apm_dependencieswith dep_type 'apm', 'mcp', 'lsp', or add a standalone test.
Proof (missing at integration-with-fixtures):tests/unit/test_plugin_parser.py::TestSynthesizePreservesExistingManifest::test_preserves_existing_lsp_dependencies-- proves: When an existing apm.yml carries LSP server dependencies, they survive plugin normalisation [portability-by-manifest]
This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.
Warning
Firewall blocked 2 domains
The following domains were blocked by the firewall during workflow execution:
astral.shpypi.org
To allow these domains, add them to the
network.allowedlist in your workflow frontmatter:
network:
allowed:
- defaults
- "astral.sh"
- "pypi.org"See Network Configuration for more information.
Generated by PR Review Panel for issue #1687 · sonnet46 16M · ◷
TL;DR
Plugin normalisation was silently overwriting
apm.ymlwith identity-only fields, stripping all dependencies from dual-format packages. This broke transitive resolution for marketplace and remote subdirectory installs.Problem (WHY)
When installing a package that has both
plugin.json(or.claude-plugin/) andapm.yml, the package type detection cascade classifies it asMARKETPLACE_PLUGINand triggerssynthesize_apm_yml_from_plugin(). This function unconditionally overwrites the existingapm.ymlwith a synthesized version containing only identity fields (name,version,description,author,license,type), discarding:dependencies.apmanddependencies.mcp-- breaking transitive resolutiondevDependencies-- breaking dev install flowsregistries,targets,includes,scripts-- losing resolution-critical metadataThis manifests as two user-facing symptoms reported in #1666:
git: parentmonorepo sibling chainsBoth are the same root cause: the resolver reads the stripped manifest, finds no sub-dependencies, and stops.
Approach (WHAT)
Make plugin normalisation non-destructive for dual-format packages:
apm.ymlviaload_yaml()mcpServers)Plugin identity fields override (name, version, etc.). Dependency lists are unioned. Resolution-critical blocks (
devDependencies,registries,targets,includes,scripts) are preserved from the original manifest.Implementation (HOW)
src/apm_cli/deps/plugin_parser.pysynthesize_apm_yml_from_plugin()-- reads existingapm.ymlbefore overwriting and passes it to_generate_apm_yml()asexisting_manifest_generate_apm_yml()-- newexisting_manifestparameter; starts from existing deps as base, then layers plugin-derived deps via_union_dep_list()_union_dep_list()-- new helper that appends entries to a dep list without duplicates, handling both string and dict entries (e.g.{git: parent, path: ...})tests/unit/test_plugin_parser.py8 new regression tests in
TestSynthesizePreservesExistingManifest:dependencies.apmanddependencies.mcpAPMPackage.from_apm_yml()with non-empty depsdevDependenciesregistries,targets,scripts,includes.claude-plugin/directory withoutplugin.jsonpreserves depsvalidate_apm_package()chain on dual-format package preserves depsTrade-offs
apm.ymlentries take priority in the union. Plugin-derived deps that duplicate existing entries are skipped. This is the safe default -- the apm.yml is the author's intent.dependenciesas a dict ({"name": "^version"}) is preserved as-is underdependencies.apm(matching pre-existing behaviour), while list-format deps go through the union path.Validation evidence
ruff check src/ tests/ruff format --check src/ tests/pylint R0801(duplication)lint-auth-signals.shHow to test
Closes #1666