Skip to content

Commit 61fe506

Browse files
feat(deps): add 'apm deps why <pkg>' to explain transitive dependencies (#1495)
* feat(deps): add 'apm deps why <pkg>' to explain transitive dependencies Implements #1490. Adds a new subcommand under the existing 'apm deps' group that walks the lockfile bottom-up from a target package back to the direct dependencies that pulled it in. Inspired by 'npm why', 'yarn why', and 'cargo tree -i'. Pure walker (src/apm_cli/deps/why_walker.py) is decoupled from the Click command (src/apm_cli/commands/deps/why.py). The walker inverts resolved_by edges, walks parents iteratively, breaks cycles via a per-traversal visited set, and returns frozen WhyEdge / WhyPath / WhyResult dataclasses for deterministic output. Soft dependency on #1488: the walker reads LockedDependency.constraint via getattr; on main today the attribute is absent so edges carry constraint=None and human output collapses to "[declared in apm.yml]". Once #1488 lands, edges automatically gain "[constraint: ^1.2.0, declared in apm.yml]" with zero further code change. A regression test pins the graceful-degrade path. Query supports bare basename (when unique), owner/repo, or full repo URL. Ambiguous basenames list all matches with a hint to use the canonical form. Exit codes: 0 ok, 1 not-found/ambiguous, 2 no-lockfile. --json emits a stable schema for scripting. Tests - 13 unit tests for the walker - 9 unit tests for the CLI command (incl. no-network regression trap asserting subprocess.run and urlopen are never touched) - 4 integration tests against a real on-disk lockfile artifact Verification: ruff check + format clean, pylint R0801 clean, auth-signals lint clean, 242 existing deps-area tests still pass. Closes #1490 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(deps-why): address panel round-1 feedback Folds in the high-signal items from the apm-review-panel round 1 on PR #1495: JSON stream discipline (cli-logging-expert REQUIRED + devx-ux-expert + supply-chain-security convergent): - Under --json, set_console_stderr(True) early so all logger output and error JSON envelopes route to stderr, keeping stdout clean for `apm deps why <pkg> --json | jq`. Mirrors `apm pack --json`. - New regression-trap test: `test_why_no_lockfile_json_emits_error_ envelope_on_stderr` asserts stdout is empty and the error payload lands on stderr. Docs (devx-ux-expert + oss-growth-hacker + doc-writer): - docs/src/content/docs/reference/cli/deps.md: add `apm deps why` row in subcommand table and a full section documenting flags, identifier styles, exit codes, and JSON stream discipline. - CHANGELOG.md [Unreleased] / Added: new entry for `apm deps why`. - manage-dependencies.md: add the "Coming from npm/yarn/cargo?" switcher callout, fix sample-output indentation (5->4 spaces), state identifier styles inline (decouples accuracy from the `apm deps info` resolver), cross-link to The lockfile / apm deps tree / apm deps info, document --json stderr routing. - packages/apm-guide/.apm/skills/apm-usage/commands.md: append "analogue of `npm why` / `yarn why`" to the why row. Walker contract (python-architect): - compute_why docstring tightened: states single resolved_by chain per record today, notes the iterative worklist generalises to N paths if #1488's resolver evolves multi-parent fan-in. - WhyResult: NOTE clarifying that `target` is a borrowed reference to the live LockedDependency; do not mutate. - Add a defensive max_paths=256 cap on the worklist (supply-chain-security nit; today unreachable, matters once multi-parent lands). - Rename test_compute_why_diamond_dependency_lists_both_routes -> test_compute_why_diamond_records_single_resolved_by_path so the test name matches the actual assertion (single canonical chain). Lint chain (ruff check/format, pylint R0801, auth-signals) clean. Tests: 27 why-area tests pass (was 26); full deps + integration regression suite at 10057 passed / 223 skipped. Refs: #1490 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(deps-why): address Copilot review on PR #1495 Render-layer fixes: - _render_human: drop self-appended trailing newline; click.echo() already appends one, so the old code emitted a blank trailing line. - _render_human / _render_json: derive 'is_root' / 'is_direct' from edge.parent_key is None, not chain position. The walker can emit truncated chains (missing parent, cycle break, depth cap) whose first edge is NOT a real root; the old position-based logic mislabeled those edges as direct dependencies / 'declared in apm.yml'. Test rename: drop misleading 'multiple_paths' test name -- the assertions and comments document single-parent walk behavior. Docs: clarify in manage-dependencies.md that the lockfile records a single resolved parent per package, so the output is one root-to-target chain (not a fan-out of every theoretical route). Regression-trap tests added (each verified to fail without its fix): - test_why_human_output_has_no_trailing_blank_line - test_why_corrupt_chain_human_output_omits_declared_annotation - test_why_json_corrupt_chain_marks_no_edge_as_direct Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(deps-why): add per-promise e2e tests for 'apm deps why' Folds in missing integration-tier coverage for every user-visible promise of 'apm deps why <pkg>' surfaced by the apm-review-panel test-coverage lens. One test per promise, named as the promise sentence, all run end-to-end through CliRunner against a real on-disk apm.lock.yaml fixture. Promises now defended at integration tier: - A: human chain renders root-to-target in order - B: exit 0 on success; exit 1 with clear error when pkg missing - C: --json mode keeps stdout jq-safe; errors go to stderr - D: constraint annotation surfaces when present (post-#1488) and degrades gracefully when absent (parametrized) - E: shared transitive collapses to the documented single chain (matches docs/.../manage-dependencies.md contract) - F: is_direct is derived from edge.parent_key being None, NOT from idx==0 (Copilot regression-trap, lifted to e2e) - G: lockfile resolves at the cwd project root; no upward walk - H: --help advertises the flag names actually wired to click Each test was confirmed to fail under a targeted production mutation and pass once the mutation was reverted (mutation-break gate per test-coverage-expert procedure). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: danielmeppiel <danielmeppiel@users.noreply.github.com>
1 parent 196712e commit 61fe506

13 files changed

Lines changed: 1722 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- `apm deps why <package>` explains why a transitive dependency is installed by walking the lockfile's `resolved_by` chain back to the user's direct declaration in `apm.yml`. Supports `--global` for user-scope lockfiles and `--json` for scriptable output (JSON to stdout, all logs to stderr; analogue of `npm why` / `yarn why`). Exits `0` on success, `1` when the package isn't installed or the query is ambiguous, `2` when no lockfile exists. (#1490)
13+
1014
## [0.15.0] - 2026-05-27
1115

1216
### Security

‎docs/src/content/docs/consumer/manage-dependencies.md‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,58 @@ too: `apm update` refreshes dependencies to the latest matching refs
192192
fail-on-drift install for CI (like `npm ci`). To upgrade the `apm` CLI
193193
binary itself, use `apm self-update`.
194194
:::
195+
196+
## Explain a transitive dependency: `apm deps why`
197+
198+
After `apm install`, `apm_modules/` may contain transitive packages that
199+
you did not declare directly. To answer "who pulled this in?", use
200+
`apm deps why <pkg>`:
201+
202+
:::note[Coming from npm, yarn, or cargo?]
203+
`apm deps why` is the APM analogue of `npm why` / `yarn why` /
204+
`cargo tree -i`. Same mental model: ask the lockfile, get back the
205+
chain that explains why something is on disk.
206+
:::
207+
208+
```bash
209+
$ apm deps why shared-utils
210+
$ apm deps why acme-org/shared-utils
211+
$ apm deps why acme-org/shared-utils --json
212+
$ apm deps why -g shared-utils
213+
```
214+
215+
The [lockfile](#the-lockfile) is the source of truth -- the command is
216+
fully offline and walks the `resolved_by` parent chain bottom-up. The
217+
lockfile records a single resolved parent per package, so the output is
218+
one root-to-target chain (not a fan-out of every theoretical route):
219+
220+
```
221+
[i] acme-org/shared-utils@1.4.2 (transitive)
222+
223+
acme-org/big-skills [declared in apm.yml]
224+
+-- acme-org/shared-utils
225+
```
226+
227+
`<pkg>` accepts four identifier styles, tried in order: unique key
228+
(`acme-org_shared-utils`), full repo URL
229+
(`https://github.com/acme-org/shared-utils`), `owner/repo`, or bare
230+
basename (`shared-utils`) when unambiguous. An ambiguous bare name
231+
exits `1` and lists the candidates.
232+
233+
Pass `--json` for scripting; the JSON document goes to stdout and all
234+
logs / hints go to stderr, so `apm deps why pkg --json | jq` is safe:
235+
236+
```json
237+
{
238+
"package": {"repo_url": "acme-org/shared-utils", "is_direct": false, "...": "..."},
239+
"paths": [{"chain": [{"repo_url": "acme-org/big-skills", "is_direct": true}, ...]}]
240+
}
241+
```
242+
243+
Exit codes: `0` on success, `1` when the package is not installed or the
244+
query is ambiguous, `2` when no lockfile exists yet (run `apm install`).
245+
246+
See also: [`apm deps tree`](../../reference/cli/deps/#apm-deps-tree) for
247+
the top-down graph view, and
248+
[`apm deps info`](../../reference/cli/deps/#apm-deps-info) for full
249+
metadata of one package.

‎docs/src/content/docs/reference/cli/deps.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ All subcommands operate on the project scope (`./apm_modules/`) by default. Pass
2626
| `list` | List installed dependencies with per-primitive counts. |
2727
| `tree` | Render the dependency graph as a tree. |
2828
| `info PACKAGE` | Show detailed metadata for one installed package. |
29+
| `why PACKAGE` | Explain why a transitive dependency is installed (analogue of `npm why`). |
2930
| `update [PACKAGES...]` | Re-resolve git refs and reinstall. |
3031
| `clean` | Remove the entire `apm_modules/` directory. |
3132

@@ -67,6 +68,25 @@ apm deps info PACKAGE
6768
|---|---|
6869
| `PACKAGE` | Name of an installed package under `apm_modules/`. Required. |
6970

71+
### `apm deps why`
72+
73+
Explain why a transitive dependency is installed, by walking the lockfile's `resolved_by` chain from the queried package back to the user's direct declaration in `apm.yml`. The APM analogue of `npm why` / `yarn why` / `cargo tree -i`.
74+
75+
```bash
76+
apm deps why PACKAGE [OPTIONS]
77+
```
78+
79+
| Argument | Description |
80+
|---|---|
81+
| `PACKAGE` | The installed package to explain. Accepts the same identifier styles as `apm deps info`: unique key (`owner_repo`), repo URL (`https://github.com/owner/repo`), `owner/repo`, or bare basename when unambiguous. |
82+
83+
| Option | Description |
84+
|---|---|
85+
| `-g, --global` | Read the user-scope lockfile at `~/.apm/apm.lock.yaml` instead of the project lockfile. |
86+
| `--json` | Emit a machine-readable JSON document to stdout. All logs and error payloads are routed to stderr so `apm deps why pkg --json \| jq` is safe. |
87+
88+
Exit codes: `0` on success, `1` when the package is not installed or the query matches multiple packages, `2` when no lockfile exists.
89+
7090
### `apm deps update`
7191

7292
Re-resolve git references for installed dependencies (direct and transitive), download updated content, re-integrate primitives, and regenerate `apm.lock.yaml`.

‎packages/apm-guide/.apm/skills/apm-usage/commands.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
| `apm prune` | Remove orphaned packages | `--dry-run` |
1717
| `apm deps list` | List installed packages | `-g` global, `--all` both scopes, `--insecure` |
1818
| `apm deps tree` | Show dependency tree | -- |
19+
| `apm deps why PKG` | Explain why a package is installed (walks lockfile bottom-up to direct deps; analogue of `npm why` / `yarn why`) | `-g` global, `--json` |
1920
| `apm view PKG [FIELD]` | View package details or remote refs | `-g` global, `FIELD=versions` |
2021
| `apm outdated` | Check locked deps via SHA/semver comparison | `-g` global, `-v` verbose, `-j N` parallel checks |
2122
| `apm deps info PKG` | Alias for `apm view PKG` local metadata | -- |

‎src/apm_cli/commands/deps/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
_is_nested_under_package,
1111
)
1212
from .cli import clean, deps, info, list_packages, tree, update
13+
from .why import why
1314

1415
__all__ = [ # noqa: RUF022
1516
# CLI commands
@@ -19,6 +20,7 @@
1920
"clean",
2021
"update",
2122
"info",
23+
"why",
2224
# Utility functions (used by tests)
2325
"_is_nested_under_package",
2426
"_count_primitives",

‎src/apm_cli/commands/deps/cli.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
_get_package_display_info,
1818
_is_nested_under_package,
1919
)
20+
from .why import why as _why_cmd
2021

2122
# ---------------------------------------------------------------------------
2223
# Shared helpers
@@ -258,6 +259,9 @@ def deps():
258259
pass
259260

260261

262+
deps.add_command(_why_cmd)
263+
264+
261265
def _show_scope_deps(scope_label, apm_dir, logger, console, has_rich, insecure_only=False):
262266
"""Display dependencies for a single scope (Project or Global)."""
263267
installed_packages, orphaned_packages = _resolve_scope_deps(apm_dir, logger, insecure_only)

‎src/apm_cli/commands/deps/why.py‎

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
"""``apm deps why <pkg>`` -- explain why a package is installed.
2+
3+
Inverts the lockfile dependency graph from a target package back to one or
4+
more direct dependencies that pulled it in. The lockfile is the source of
5+
truth -- no remote calls.
6+
7+
Output formats:
8+
9+
* Default: a plain-text indented tree (ASCII ``+--``), one chain per
10+
direct dependency that transitively required the target. Rich is
11+
intentionally not used here -- a list of N short root-to-leaf chains
12+
is not a single tree, and ``rich.tree`` would imply a hierarchy that
13+
does not exist.
14+
* ``--json``: a machine-readable JSON document on stdout.
15+
16+
Exit codes:
17+
18+
* ``0`` -- target found and explained.
19+
* ``1`` -- package not installed or query ambiguous.
20+
* ``2`` -- no lockfile / project misconfiguration.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import json
26+
import sys
27+
28+
import click
29+
30+
from ...core.command_logger import CommandLogger
31+
from ...core.scope import InstallScope, get_apm_dir
32+
from ...deps.lockfile import LockFile, get_lockfile_path, migrate_lockfile_if_needed
33+
from ...deps.why_walker import (
34+
AmbiguousPackageError,
35+
PackageNotInstalledError,
36+
WhyEdge,
37+
WhyResult,
38+
compute_why,
39+
resolve_package_query,
40+
)
41+
from ...utils.console import set_console_stderr
42+
43+
# Exit codes
44+
_EXIT_OK = 0
45+
_EXIT_NOT_FOUND = 1
46+
_EXIT_NO_LOCKFILE = 2
47+
48+
49+
# ---------------------------------------------------------------------------
50+
# Formatting helpers
51+
# ---------------------------------------------------------------------------
52+
53+
54+
def _format_dep_label(dep) -> str:
55+
"""Render ``repo_url@version`` for the human output header."""
56+
version = (
57+
dep.version
58+
or (dep.resolved_commit[:7] if dep.resolved_commit else None)
59+
or dep.resolved_ref
60+
or "unknown"
61+
)
62+
return f"{dep.repo_url}@{version}"
63+
64+
65+
def _edge_annotation(edge: WhyEdge, is_root: bool) -> str:
66+
"""Render the bracketed annotation for a single chain edge.
67+
68+
Mirrors the design-pack convention:
69+
* Root edge (direct dep): ``[constraint: ^1.2.0, declared in apm.yml]``
70+
or ``[declared in apm.yml]`` when constraint is unknown.
71+
* Inner edges: ``[constraint: ^1.4.0]`` or empty.
72+
"""
73+
parts: list[str] = []
74+
if edge.constraint:
75+
parts.append(f"constraint: {edge.constraint}")
76+
if is_root:
77+
parts.append("declared in apm.yml")
78+
if not parts:
79+
return ""
80+
return f" [{', '.join(parts)}]"
81+
82+
83+
def _render_human(result: WhyResult) -> str:
84+
"""Render the human-readable explanation as a single string."""
85+
target = result.target
86+
header_kind = "direct dependency" if result.is_direct else "transitive"
87+
lines: list[str] = [f"[i] {_format_dep_label(target)} ({header_kind})", ""]
88+
89+
if result.is_direct:
90+
# Single trivial chain.
91+
edge = result.paths[0].chain[0]
92+
annotation = _edge_annotation(edge, is_root=True)
93+
lines.append(f" {target.repo_url}{annotation}")
94+
return "\n".join(lines)
95+
96+
for path in result.paths:
97+
for idx, edge in enumerate(path.chain):
98+
# Root is determined by the absence of a recorded parent, NOT by
99+
# position in the chain: compute_why() may emit truncated chains
100+
# (missing parent in lockfile, cycle break, depth-cap hit) where
101+
# the first element is NOT a real root. See PR #1495 review.
102+
is_root = edge.parent_key is None
103+
annotation = _edge_annotation(edge, is_root=is_root)
104+
prefix = " " if idx == 0 else (" " + " " * (idx - 1) + "+-- ")
105+
lines.append(f"{prefix}{edge.child_key}{annotation}")
106+
lines.append("") # blank line between chains
107+
# Caller uses click.echo() which appends its own newline; do not add one
108+
# here or we emit a trailing blank line.
109+
return "\n".join(lines).rstrip()
110+
111+
112+
def _render_json(result: WhyResult) -> str:
113+
"""Render the JSON explanation."""
114+
target = result.target
115+
payload = {
116+
"package": {
117+
"repo_url": target.repo_url,
118+
"version": target.version or target.resolved_ref or target.resolved_commit,
119+
"source": target.source or "git",
120+
"is_direct": result.is_direct,
121+
},
122+
"paths": [
123+
{
124+
"chain": [
125+
{
126+
"repo_url": edge.child_key,
127+
"constraint": edge.constraint,
128+
# Directness is recorded by the walker as a missing
129+
# parent_key, not by position: a corrupt or
130+
# depth-capped chain may not start at a true root.
131+
"is_direct": edge.parent_key is None,
132+
}
133+
for edge in path.chain
134+
]
135+
}
136+
for path in result.paths
137+
],
138+
}
139+
return json.dumps(payload, indent=2, sort_keys=True)
140+
141+
142+
# ---------------------------------------------------------------------------
143+
# Lockfile loading
144+
# ---------------------------------------------------------------------------
145+
146+
147+
def _load_lockfile(apm_dir, logger: CommandLogger) -> LockFile | None:
148+
"""Load the lockfile for *apm_dir*; emit an error and return ``None``
149+
when missing or unreadable. Callers translate that into exit code 2.
150+
"""
151+
migrate_lockfile_if_needed(apm_dir)
152+
lockfile_path = get_lockfile_path(apm_dir)
153+
if not lockfile_path.exists():
154+
logger.error("no apm.lock.yaml found in this project.")
155+
logger.info("Hint: run 'apm install' first.")
156+
return None
157+
lockfile = LockFile.read(lockfile_path)
158+
if lockfile is None:
159+
logger.error(f"could not read lockfile at {lockfile_path}")
160+
return None
161+
return lockfile
162+
163+
164+
# ---------------------------------------------------------------------------
165+
# CLI entry point
166+
# ---------------------------------------------------------------------------
167+
168+
169+
_HELP = (
170+
"Explain why a package is installed by walking the lockfile back to "
171+
"one or more direct dependencies that pulled it in.\n\n"
172+
"Examples:\n"
173+
" apm deps why shared-utils\n"
174+
" apm deps why acme-org/shared-utils --json\n"
175+
" apm deps why shared-utils --global"
176+
)
177+
178+
179+
@click.command(name="why", help=_HELP)
180+
@click.argument("package", required=True)
181+
@click.option(
182+
"--global",
183+
"-g",
184+
"global_",
185+
is_flag=True,
186+
default=False,
187+
help="Resolve against the user-scope lockfile (~/.apm/apm.lock.yaml).",
188+
)
189+
@click.option(
190+
"--json",
191+
"as_json",
192+
is_flag=True,
193+
default=False,
194+
help="Emit machine-readable JSON to stdout.",
195+
)
196+
def why(package: str, global_: bool, as_json: bool) -> None:
197+
"""Entry point for ``apm deps why <pkg>``."""
198+
# Stream discipline: under --json, route ALL human-facing output to
199+
# stderr so that downstream tools (jq, scripts) can consume stdout
200+
# as a clean JSON document. Mirrors the convention established by
201+
# `apm pack --json` (commands/pack.py) and by npm / yarn / cargo.
202+
if as_json:
203+
set_console_stderr(True)
204+
205+
logger = CommandLogger("deps-why")
206+
scope = InstallScope.USER if global_ else InstallScope.PROJECT
207+
apm_dir = get_apm_dir(scope)
208+
209+
lockfile = _load_lockfile(apm_dir, logger)
210+
if lockfile is None:
211+
if as_json:
212+
click.echo(json.dumps({"error": "no_lockfile"}), err=True)
213+
sys.exit(_EXIT_NO_LOCKFILE)
214+
215+
try:
216+
target = resolve_package_query(lockfile, package)
217+
except AmbiguousPackageError as exc:
218+
if as_json:
219+
click.echo(
220+
json.dumps({"error": "ambiguous", "query": exc.query, "matches": exc.matches}),
221+
err=True,
222+
)
223+
else:
224+
logger.error(f"'{exc.query}' matches multiple packages:")
225+
for match in exc.matches:
226+
click.echo(f" - {match}", err=True)
227+
logger.info("Hint: use the full owner/repo form.")
228+
sys.exit(_EXIT_NOT_FOUND)
229+
except PackageNotInstalledError as exc:
230+
if as_json:
231+
click.echo(
232+
json.dumps({"error": "not_installed", "query": exc.query}),
233+
err=True,
234+
)
235+
else:
236+
logger.error(f"'{exc.query}' is not installed (not in apm.lock.yaml).")
237+
logger.info("Hint: run 'apm deps list' to see installed packages.")
238+
sys.exit(_EXIT_NOT_FOUND)
239+
240+
result = compute_why(lockfile, target)
241+
242+
if as_json:
243+
click.echo(_render_json(result))
244+
else:
245+
click.echo(_render_human(result))
246+
247+
sys.exit(_EXIT_OK)

0 commit comments

Comments
 (0)