Skip to content

fix: resolve transitive local paths against declaring package (#857)#940

Open
JahanzaibTayyab wants to merge 2 commits intomicrosoft:mainfrom
JahanzaibTayyab:fix/857-transitive-local-path-resolution
Open

fix: resolve transitive local paths against declaring package (#857)#940
JahanzaibTayyab wants to merge 2 commits intomicrosoft:mainfrom
JahanzaibTayyab:fix/857-transitive-local-path-resolution

Conversation

@JahanzaibTayyab
Copy link
Copy Markdown

Description

Fixes #857.

Relative local_path dependencies declared inside a non-root package's apm.yml were resolved against the root consumer's project directory rather than the package's own source directory. This made it impossible to compose two local sibling packages where one declares the other as a dependency.

After this PR, relative paths in any apm.yml resolve against the directory containing that apm.yml, matching the semantic every other package manager (npm, pip, cargo, go modules, Bicep) uses.

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Maintenance / refactor

Repro

A monorepo with two local packages:

my-project/
  apm.yml                                  # consumer
  packages/
    editorial-pipeline/apm.yml             # base
    handbook-agents/apm.yml                # depends on the base via ../editorial-pipeline

packages/handbook-agents/apm.yml:

dependencies:
  apm:
    - ../editorial-pipeline

Before this PR: apm install fails because APM resolves ../editorial-pipeline against the root consumer (my-project/), looking for my-project/../editorial-pipeline.

After this PR: APM resolves it against the directory holding the apm.yml that declared it (packages/handbook-agents/), so it finds the sibling package.

Implementation

The fix follows the design proposed in the issue:

  1. APMPackage gains source_path: Optional[Path] = None -- absolute on-disk directory holding this package's apm.yml. For the root project and for remote deps this matches the install path; for local deps it is the original source directory (not the apm_modules/_local/ copy).

  2. DownloadCallback Protocol gains parent_pkg: Optional[APMPackage] = None -- the package that declared the dependency.

  3. _try_load_dependency_package accepts parent_pkg and computes the loaded package's source_path via a small new helper _compute_dep_source_path. The caller in build_dependency_tree already has the parent node, so it just forwards parent_node.package.

  4. download_callback (in install/phases/resolve.py) computes base_dir = parent_pkg.source_path or project_root and passes that to _copy_local_package.

  5. _copy_local_package's third parameter renamed project_root -> base_dir to reflect the new semantic. Existing positional callers are unaffected.

The root project's source_path is set to project_root immediately after APMPackage.from_apm_yml() so direct deps work identically to today.

Behavior matrix

Scenario Before After
Root: ./packages/foo resolves vs project_root resolves vs project_root
Local-in-local: ../sibling broken resolves vs the parent package's source dir ✅
Remote-in-remote: ./internal-helper meaningless resolves vs the clone dir ✅
Absolute paths works works (handled by the absolute branch already)

Areas needing careful review

  1. Backwards compatibility on DownloadCallback. The Protocol now has a fourth keyword-arg parent_pkg. Any existing user-defined callback that doesn't accept it (and lacks **kwargs) will fail with a TypeError when called from _try_load_dependency_package. The resolver currently catches that as a generic download miss, so the failure is silent rather than loud. Two options if maintainers prefer different ergonomics:

    • Use inspect.signature in _try_load_dependency_package to omit parent_pkg for legacy callbacks (backwards-compatible, slightly hidden complexity).
    • Keep as-is and call this out in CHANGELOG.md as a minor breaking change for callback authors.
      I went with the second since the issue explicitly proposed the Protocol change and the only in-tree caller is the one we control. Happy to switch.
  2. Lockfile entries for transitive local deps. lockfile.py serialises local_path as a string. For transitive local deps, the string's anchor changes (relative-to-source vs. relative-to-root). A regenerate handles it; direct local deps are unaffected. I did not modify the lockfile schema, in line with the "Out of scope" section of the issue.

  3. Install-path collisions (_local/<dirname> collision when two parents declare local deps with the same final folder name) is pre-existing and unchanged by this PR. The issue calls this out as orthogonal.

Tests

7 new tests in tests/test_apm_resolver.py:

  • TestSourcePathField (2): default-None, settable.
  • TestComputeDepSourcePath (5): remote dep returns install_path; local absolute resolved; local relative uses parent's source_path (the bug-fix path); local relative falls back to project_root when no parent; backwards-compat fallback when parent has no source_path.
  • TestResolverSetsRootSourcePath (1): root source_path populated after resolve.
  • TestDownloadCallbackReceivesParent (1): parent_pkg arg flows through to the callback.

One existing test (tests/unit/test_install_command.py::TestTransitiveDepParentChain::test_download_callback_includes_chain_in_error) was updated -- its mock callback signature gained parent_pkg=None to match the extended Protocol.

Testing

  • Tested locally
  • All existing tests pass (uv run pytest tests/unit tests/test_console.py: 5,466 passed, matches main minus the pre-existing f-string-in-test_audit_report.py collection error which exists on main)
  • Added tests for new functionality

Prepared with assistance from Claude (Anthropic) under human review.

…oft#857)

Relative ``local_path`` dependencies declared inside a non-root
package's apm.yml were resolved against the root consumer's project
directory rather than the package's own source directory. This made it
impossible to compose two local sibling packages where one declares
the other as a dependency.

Introduce ``APMPackage.source_path`` to track the on-disk directory
holding each package's apm.yml, and thread the declaring package
through the download callback so ``_copy_local_package`` can anchor
relative paths on the right base directory. Aligns with the semantic
every other package manager uses for relative paths in manifests.

Prepared with assistance from Claude (Anthropic) under human review.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes transitive local dependency resolution so relative local_path entries in a package's apm.yml resolve relative to the declaring package directory (not the root consumer), enabling sibling local package composition.

Changes:

  • Add APMPackage.source_path to track the on-disk directory that owns a package's apm.yml.
  • Extend the resolver download callback to receive parent_pkg and compute the correct base directory for resolving transitive local paths.
  • Add unit tests covering source_path and source-path computation behavior.

Reviewed changes

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

Show a summary per file
File Description
tests/unit/test_install_command.py Updates a resolver callback mock signature to accept the new parent_pkg argument.
tests/test_apm_resolver.py Adds tests for APMPackage.source_path, _compute_dep_source_path, and callback argument threading.
src/apm_cli/models/apm_package.py Introduces source_path field on APMPackage to anchor relative local path resolution.
src/apm_cli/install/phases/resolve.py Uses parent_pkg.source_path (when available) to resolve transitive local paths against the declaring package.
src/apm_cli/install/phases/local_content.py Renames _copy_local_package parameter project_root -> base_dir and updates resolution accordingly.
src/apm_cli/deps/apm_resolver.py Threads parent_pkg through resolution, sets source_path, and adds _compute_dep_source_path.

Comment thread src/apm_cli/deps/apm_resolver.py
Comment thread src/apm_cli/install/phases/resolve.py
Comment thread tests/test_apm_resolver.py
Comment thread src/apm_cli/deps/apm_resolver.py Outdated
…tion (microsoft#940)

- Detect whether download_callback accepts parent_pkg via signature
  inspection. Legacy 3-arg callbacks no longer raise a silent TypeError
  that would mask the dependency; the resolver gracefully falls back to
  the pre-microsoft#857 call shape and pre-microsoft#857 resolution semantic.
- Disambiguate the per-resolution download dedup key for local deps:
  the literal local_path string can refer to different on-disk packages
  depending on the declaring parent's source_path, so include the
  resolved absolute source path in the cache key (`local::<resolved>`).
  Remote deps are unchanged.
- Update docs/guides/dependencies.md to reflect the new resolution
  semantic ("relative to this apm.yml's directory") and add a bullet
  explaining how relative paths in nested packages compose.
- Tests:
  - Transitive case where parent_pkg is non-None and source_path is set
    (mid-pkg declares user/leaf-dep; assert callback receives mid-pkg).
  - Legacy callback compatibility: 3-arg callable still invoked.
  - Signature detection: modern, legacy, and **kwargs callables.
  - Local dedup key separation: same local_path under different parents
    produces distinct keys.
@JahanzaibTayyab
Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@JahanzaibTayyab
Copy link
Copy Markdown
Author

Thanks for the careful Copilot review. Pushed c568b9d addressing all four comments:

1. Silent TypeError on legacy callbacks (apm_resolver.py:424) — added _signature_accepts_parent_pkg() that inspects download_callback.__signature__ once at construction. The resolver now branches: modern callbacks get the 4-arg call (with parent_pkg), legacy 3-arg callbacks get the pre-#857 call shape. No silent skip, no breaking change for existing callback authors. Detection treats **kwargs as accepting parent_pkg and falls back to the modern path if signature introspection fails (e.g. C extensions).

2. Docs (resolve.py:158docs/src/content/docs/guides/dependencies.md) — both occurrences of # relative to project root now say # relative to this apm.yml's directory, and the "How it works" bullet list gains an explicit note explaining that nested-package declarations resolve against their own directory (with the npm/pip/cargo analogy and the absolute-path escape hatch).

3. Transitive callback test (test_apm_resolver.py:608) — added test_callback_receives_parent_for_transitive_dep which sets up a real 2-level structure: root → local ./packages/mid → remote user/leaf-dep. It asserts the leaf invocation carries the mid-pkg APMPackage as parent_pkg and that parent_pkg.source_path is populated, pinning the contract that #857 actually depends on.

4. Local dedup key collision (apm_resolver.py:411) — Copilot's analysis is correct: the literal local_path (e.g. ../common) can refer to different on-disk dirs once #857 anchors it on the parent. I left DependencyReference.get_unique_key() alone (it's used in many places, including the lockfile, and changing it broadly is out of scope per the issue's "Lockfile schema unchanged" note), and instead introduced _download_dedup_key() scoped to the per-resolution download cache. For remote deps it returns dep_ref.get_unique_key() unchanged; for local deps it returns local::<resolved-abs-path>. New tests pin both branches and the separation across two synthetic parents pointing at different siblings.

Also added a TestLegacyDownloadCallbackCompatibility class with three signature-detection tests (modern / legacy / **kwargs) and an end-to-end test that the resolver successfully invokes a 3-arg callback.

Full suite: uv run pytest tests/unit tests/test_console.py5,466 passed (matches main).

Also signed the CLA above.

The pre-existing concerns called out in the PR description (lockfile entries for transitive local deps, _local/<dirname> install-path collisions) remain as the issue notes — happy to spin those off into separate PRs if maintainers want them tackled here.

@danielmeppiel danielmeppiel added the panel-review Trigger the apm-review-panel gh-aw workflow label Apr 28, 2026
@github-actions
Copy link
Copy Markdown

APM Review Panel Verdict

Disposition: REQUEST_CHANGES (two pre-merge doc/admin actions; code and tests are solid)


Per-persona findings

Python Architect:

This is a routine PR -- bug fix in the dependency resolution engine with backward-compat introspection and a new optional field on a dataclass. Not a major architectural change; one class diagram + one flow diagram.

1. OO / class diagram

classDiagram
    direction TB
    class APMDependencyResolver {
        <<CoreEngine>>
        +_project_root: Path
        +_download_callback: DownloadCallback
        +_callback_accepts_parent_pkg: bool
        +resolve_dependencies(project_root) DependencyGraph
        +_try_load_dependency_package(dep_ref, parent_chain, parent_pkg) APMPackage
        +_compute_dep_source_path(dep_ref, install_path, parent_pkg) Path
        +_download_dedup_key(dep_ref, parent_pkg) str
        +_signature_accepts_parent_pkg(callback) bool
    }
    class APMPackage {
        <<ValueObject>>
        +name: str
        +version: str
        +package_path: Optional[Path]
        +source_path: Optional[Path]
        +dependencies: Dict
        +from_apm_yml(path) APMPackage
    }
    class DownloadCallback {
        <<Protocol>>
        +__call__(dep_ref, apm_modules_dir, parent_chain, parent_pkg) Optional[Path]
    }
    class DependencyReference {
        +repo_url: str
        +is_local: bool
        +local_path: str
        +get_install_path(apm_modules_dir) Path
        +get_unique_key() str
    }
    class DependencyGraph {
        +root_package: APMPackage
        +dependencies: List
    }
    class DependencyNode {
        +package: APMPackage
        +get_ancestor_chain() str
    }
    APMDependencyResolver *-- DownloadCallback : invokes
    APMDependencyResolver ..> APMPackage : creates/reads
    APMDependencyResolver ..> DependencyReference : reads
    APMDependencyResolver ..> DependencyGraph : produces
    APMDependencyResolver ..> DependencyNode : traverses
    DownloadCallback ..> APMPackage : receives parent_pkg
    DependencyGraph *-- APMPackage : root_package
    DependencyNode *-- APMPackage : package
    note for APMDependencyResolver "Adapter: _signature_accepts_parent_pkg\ndetects legacy callbacks at init time"
    class APMDependencyResolver:::touched
    class APMPackage:::touched
    class DownloadCallback:::touched
    classDef touched fill:#fff3b0,stroke:#d47600
Loading

2. Execution flow diagram

flowchart TD
    A["apm install (CLI)"] --> B["resolve.run(ctx)\nsrc/apm_cli/install/phases/resolve.py"]
    B --> C["APMDependencyResolver.resolve_dependencies(project_root)\nsrc/apm_cli/deps/apm_resolver.py"]
    C --> D["[I/O] APMPackage.from_apm_yml(apm_yml_path)\nroot_package.source_path = project_root.resolve()"]
    D --> E["build_dependency_tree: BFS deque of DependencyNodes"]
    E --> F{"next dep_ref in queue?"}
    F -->|yes| G["_try_load_dependency_package(dep_ref, parent_chain,\nparent_pkg=parent_node.package)"]
    G --> H{"install_path.exists()?"}
    H -->|no| I{"_callback_accepts_parent_pkg?"}
    I -->|yes| J["download_callback(dep_ref, modules_dir,\nparent_chain, parent_pkg)\nresolve.py closure"]
    I -->|no| K["legacy: download_callback(dep_ref, modules_dir, parent_chain)\nsilent fallback to project_root anchor"]
    J --> L{"parent_pkg.source_path set?"}
    L -->|yes| M["[FS] _copy_local_package(dep_ref, install_path,\nbase_dir=parent_pkg.source_path)\nlocal_content.py"]
    L -->|no| N["[FS] _copy_local_package(dep_ref, install_path,\nbase_dir=project_root)\nlocal_content.py"]
    H -->|yes| O["_compute_dep_source_path(dep_ref, install_path, parent_pkg)"]
    M --> O
    N --> O
    O --> P{"dep_ref.is_local?"}
    P -->|absolute| Q["return local.resolve()"]
    P -->|relative + parent.source_path| R["return (parent.source_path / local).resolve()"]
    P -->|relative + no parent| S["return (project_root / local).resolve()"]
    P -->|remote dep| T["return install_path.resolve()"]
    Q & R & S & T --> U["package.source_path = resolved_source_path\nrecurse into package's own deps"]
    U --> F
    F -->|empty| V["[LOCK] produce DependencyGraph"]
```

#### 3. Design patterns

**Design patterns**
- Used in this PR: Adapter -- `_signature_accepts_parent_pkg` (static method on `APMDependencyResolver`) detects legacy 3-arg callback signatures at construction time so the modern 4-arg path is preferred and the legacy path degrades gracefully without a TypeError surface. Dataclass-as-value-object -- `APMPackage.source_path` carries the resolved anchor immutably through the BFS traversal.
- Pragmatic suggestion: The base_dir computation `(parent_pkg.source_path if parent_pkg is not None and parent_pkg.source_path is not None else self._project_root)` is duplicated verbatim in `_download_dedup_key` and `_compute_dep_source_path`. Extract to a one-liner helper method `_effective_base_dir(parent_pkg)` to eliminate the two-site duplication. The refactor is four lines; the benefit is a single place to change if the fallback logic evolves.

**Assessment**: Architecture is clean and appropriately scoped. The BFS parent-node forwarding (`parent_node.package if parent_node else None`) is the idiomatic way to thread context through the tree walk. The `inspect.signature` detection is a well-understood backward-compat pattern. The `source_path` field is the right place on `APMPackage`. One minor concern: `_signature_accepts_parent_pkg` returns `True` for unresolvable signatures (C extensions), which means the resolver will attempt the 4-arg call and fall into the `except Exception: pass` in `_try_load_dependency_package`, silently dropping the dependency. This is pre-existing behavior for the exception-swallow; the True default is the right bias. No blocker.

---

**CLI Logging Expert**:

The PR adds no new user-facing log output, STATUS_SYMBOLS, CommandLogger methods, or diagnostic messages. The `_copy_local_package` function receives its `logger` parameter and passes it through unchanged -- correct.

One pre-existing note (not introduced by this PR): the `except Exception: pass` in `_try_load_dependency_package` swallows download failures silently. A verbose-mode log of the caught exception at `[!]` level would help users debug silent dep-miss failures. This is pre-existing technical debt; flagging for follow-up only. No output-path misuse, no direct `_rich_*` calls outside console code. CLEAN.

---

**DevX UX Expert**:

The fix aligns APM with npm, pip, cargo, and go module semantics for relative paths -- exactly the right call. A monorepo user who hit this bug will now find APM behaves as expected without reading docs.

**Positive**: The doc comment update (`"relative to this apm.yml's directory"`) and the bolded note in `dependencies.md` are clear and accurate. The `inspect.signature` mitigation means existing callback authors are not silently broken at runtime.

**Required (Rule 4)**: `packages/apm-guide/.apm/skills/apm-usage/dependencies.md` is NOT updated. The local-path section of that file currently describes paths as resolving relative to the project root. The behavioral change (anchor on declaring `apm.yml`) must be reflected there so agents that consume the skill resource give users correct guidance. The update is one sentence.

**Required (repo convention)**: `CHANGELOG.md` has no entry for this fix. Per repo changelog convention, every merged PR that changes behavior must have a "Fixed" entry. For a `DownloadCallback` Protocol signature change (even with compat shim), a brief note is owed to callback authors. Suggested entry:

```
### Fixed
- Relative `local_path` dependencies declared in nested packages now resolve
  against the declaring package's directory instead of the project root,
  matching npm/pip/cargo semantics; enables sibling-package composition in
  monorepos (#857) -- by @JahanzaibTayyab (#940)
Loading

Supply Chain Security Expert:

Path traversal surface: _copy_local_package resolves local = (base_dir / dep_ref.local_path).resolve() but does NOT call path_security.validate_path_segments on dep_ref.local_path before joining, nor ensure_path_within(local, project_root) after resolution. This is pre-existing -- the PR inherits this gap, it does not introduce it. The install_path target is guarded via safe_rmtree and the apm_modules_dir containment comment at line 128, but the source path (local) is not containment-checked.

New exposure introduced by this PR: base_dir can now be parent_pkg.source_path, which for a remote package is apm_modules/<owner>/<repo>/. A malicious remote package could declare ../../../.ssh as a local dep, resolving base_dir/../../../.ssh = project_root/.ssh or higher. This widens (slightly) the pre-existing gap because base_dir is no longer always the user-controlled project root. Flag as a follow-up security hardening issue (not a blocker for this PR, since: 1) remote packages declaring local deps is semantically unusual and would fail validation, 2) the gap predates this PR).

Dedup key: _download_dedup_key using resolved absolute paths for local deps is correct -- prevents the same on-disk package from being downloaded twice regardless of how it is referenced textually. No security concern.

Backward-compat shim: inspect.signature detection is safe; no credential or token surface is touched.

Fails-closed posture: No change -- local path resolution fails closed (missing dir, missing apm.yml, or missing SKILL.md all return None or produce an error log).


Auth Expert: Not activated -- PR modifies only local dependency path resolution in apm_resolver.py, local_content.py, resolve.py, apm_package.py, and tests; no authentication, token management, credential resolution, or host classification code is touched.


OSS Growth Hacker:

Story angle: "APM now resolves local deps the same way npm, pip, and cargo do." This is a high-value one-liner for the release notes -- it directly addresses a pain point that would cause monorepo users to churn. The fix enables composable multi-agent pipelines (e.g. handbook-agents depending on editorial-pipeline as sibling packages), which reinforces the "AI-native development" positioning.

External contributor (@JahanzaibTayyab) deserves visible credit in the CHANGELOG. External contributor PRs are the most powerful OSS signal -- highlighting them in release notes compounds contributor acquisition.

Side-channel note to CEO: This fix is worth a dedicated mention in the next release post. The before/after monorepo repro in the PR description is already release-post-ready. Consider it as evidence for a "monorepo-native" positioning beat.


CEO arbitration

The specialist panel is aligned. The code change is correct, clean, well-tested (9 new test cases covering the bug path, backward compat, and edge cases), and matches the semantic contract of every major package manager. The inspect.signature backward-compat shim is the right pragmatic call for the protocol change -- the PR author correctly identified and implemented the preferred option.

Two administrative gaps block merge: a missing CHANGELOG.md entry (repo convention and external-contributor credit) and a missing update to packages/apm-guide/.apm/skills/apm-usage/dependencies.md (Rule 4, behavioral change to dependency format). Both are one-to-three line changes; neither requires code rework. The supply chain path-traversal gap is pre-existing and should be tracked as a follow-up issue rather than blocking this PR, since the PR narrows not widens the practical attack surface (relative local deps from remote packages were already unguarded and would fail package validation before copy). The growth hacker's note about release narrative is well-taken; I will flag this PR for the next release summary.


Required actions before merge

  1. Add a CHANGELOG.md entry under ## [Unreleased] > ### Fixed describing the transitive local-path anchor fix with credit to @JahanzaibTayyab. See the suggested entry in the DevX section above.
  2. Update packages/apm-guide/.apm/skills/apm-usage/dependencies.md local-path section to note that relative paths anchor on the declaring apm.yml's directory, not the project root. One sentence aligned with the dependencies.md doc update already in this PR.

Optional follow-ups

  • Extract _effective_base_dir(parent_pkg) helper method on APMDependencyResolver to eliminate the duplicated parent_pkg.source_path or self._project_root logic in _download_dedup_key and _compute_dep_source_path.
  • Open a follow-up security hardening issue to add path_security.validate_path_segments(dep_ref.local_path) and ensure_path_within(local, project_root) guards in _copy_local_package, covering both the pre-existing gap and the slightly widened surface from base_dir variability.
  • Add a verbose-mode log ([!] level) in the except Exception: pass block of _try_load_dependency_package to surface silent download failures when --verbose is active.
  • Consider explicitly rejecting local_path declarations found inside remotely-fetched packages (not user-controlled local deps), as they are semantically surprising and constitute the most realistic path-traversal vector.

Generated by PR Review Panel for issue #940 · ● 757.3K ·

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.

Local relative paths in transitive deps resolve against root consumer instead of declaring package

3 participants