From 746082c9fa957ce01da1829899749288d975c5b6 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Mon, 20 Apr 2026 20:48:29 +0300 Subject: [PATCH] feat(pkg-intel): add package_dependencies (pkg deps + MCP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `package_dependencies` MCP tool and `githits pkg deps` CLI command behind the existing `code_navigation` capability gate. Both surfaces share a single request builder, envelope builder, and error classifier; a parity test asserts `toEqual` JSON across the two surfaces for every service-sourced fixture. ## Surfaces - **`githits pkg deps `** — default shows a summary row (counts + hidden-groups mention by name) plus the flat direct- runtime dependency list. `--transitive` replaces the deps list with the full unique transitive closure (alphabetical, one per line). `--transitive --verbose` annotates each entry with `(required by @, …)` computed from the DAG. `--groups` / `--lifecycle` adds a structured groups block beneath the deps list (additive, not a replacement view). Supports `@version`, uppercase-tolerated `--lifecycle`, `--depth` 1–10 (CLI default 3), `--verbose`, `--json`. - **`package_dependencies`** — MCP tool with the same lean envelope. Permissive Zod schema (`lifecycle` accepts string or string array); no `max_depth` default on the MCP path so the backend's full-graph default applies. ## Design choices - **Data-first envelope.** `runtime`, `groups`, `transitive`, and `filter` are independent blocks emitted based on what the backend returned and what the caller asked for, not on caller flags. `runtime` emits whenever the backend returned `direct[]` (including zero-dep packages); `groups` emits whenever the backend returned `dependencyGroups`, distinguishing `{items: []}` (filter matched nothing) from absent (backend has no groups concept). Dependency lists everywhere use `items` for a single consistent key. - **Ergonomic semantic model.** Summary row always leads with the scope signal; `--transitive` replaces (not augments) the deps list; groups view is a separate block composing beneath; hidden groups surface by name in the summary (`Hidden groups: argon2, bcrypt — use --groups.`) rather than as an aggregate footer. - **Best-effort DAG decoder** lives in the formatter (not the service) so the JSON envelope stays fully opaque for `transitive.dag`. Handles `[registry, name, version]` tuples + `{n, v, l}` objects on the node side; `[from, to, constraint?, lifecycle?]` + object form on edges. Returns null on unknown shapes — provenance silently degrades but the transitive list still renders. - **Terminal-only dedup.** Crates target-cfg branches emit duplicate `{name, constraint}` tuples; the terminal formatter collapses them for scannability, the JSON envelope preserves every tuple. A parity fixture exercises the round-trip. - **Ecosystem-aware vocabulary.** PyPI feature-typed groups render as `name (optional, extra)` (PEP 508); Cargo keeps `feature`. - **No `include_groups` MCP input.** The data-first envelope makes it a silently-ignored no-op; omitting it forces the correct mental model. - **Canonical versions only.** Tag-style `v`-prefixed inputs rejected client-side with `INVALID_ARGUMENT`. - **Registry coverage.** npm, PyPI, Hex, Crates, vcpkg, Zig. Other registries rejected client-side with a tool-specific message. - **Shared `promoteGenericVersionNotFound` helper.** Extracted from P2's inline helper; used by both `packageVulnerabilities` and `packageDependencies` executors. - **Untyped passthroughs** (`transitive.dag`, `transitive.conflicts`, `transitive.circularDependencies`, `groups.environmentConstraints`) flow through as opaque `UntypedGenericJSON`. Backend declares them `GenericJSON` and hasn't published concrete shapes; typing them client-side would be speculative. Each field has a `TODO(pkgseer-backend)` anchor flagging the upgrade path when concrete types land upstream. ## CLI UX details - Summary row always shows counts + hidden-groups-by-name. - `--transitive` replaces the deps list with the full unique transitive closure; `--verbose` adds `(required by …)` provenance per entry. - Alphabetical sort on every list is explicit and tested. - `--depth` validates exact integers; partial inputs like `3.5` or `5abc` are rejected rather than silently truncated. - `VERSION_NOT_FOUND` error enriched with `package` / `requested` / `available` detail lines (reuses P2's helper). ## Tests - `bun test` — 951 pass, 0 fail. - `bun run typecheck` / `bun run build` / `bun run lint` — clean. - Parity fixtures (16) cover: happy flat-runtime, zero-dep, full-view, optional-lifecycle (tokio features), multi-lifecycle, filter-matched-nothing, Crates-target-cfg dedup round-trip, transitive with DAG passthrough, versioned match / real-diff, `NOT_FOUND`, `VERSION_NOT_FOUND` with structured details, `BACKEND_ERROR`, and three `INVALID_ARGUMENT` cases. - Live-verified against production pkgseer across npm / PyPI / Crates / Hex / vcpkg, with DAG provenance working on real express data showing importer→constraint relationships. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/implementation/cli-commands.md | 37 + docs/implementation/mcp-cli-parity.md | 62 + docs/implementation/tools.md | 25 +- src/commands/mcp-instructions.test.ts | 7 +- src/commands/mcp-instructions.ts | 4 + src/commands/mcp.test.ts | 39 + src/commands/mcp.ts | 2 + src/commands/pkg/deps.test.ts | 339 ++++ src/commands/pkg/deps.ts | 235 +++ src/commands/pkg/index.test.ts | 3 + src/commands/pkg/index.ts | 2 + src/services/index.ts | 9 + .../package-intelligence-service.test.ts | 242 +++ src/services/package-intelligence-service.ts | 441 +++++- .../promote-version-not-found.test.ts | 81 + src/services/promote-version-not-found.ts | 66 + src/services/test-helpers.ts | 118 ++ src/shared/index.ts | 21 + .../package-dependencies-request.test.ts | 187 +++ src/shared/package-dependencies-request.ts | 207 +++ .../package-dependencies-response.test.ts | 1022 ++++++++++++ src/shared/package-dependencies-response.ts | 1393 +++++++++++++++++ src/tools/index.ts | 1 + src/tools/package-dependencies-parity.test.ts | 555 +++++++ src/tools/package-dependencies.test.ts | 228 +++ src/tools/package-dependencies.ts | 137 ++ 26 files changed, 5415 insertions(+), 48 deletions(-) create mode 100644 src/commands/pkg/deps.test.ts create mode 100644 src/commands/pkg/deps.ts create mode 100644 src/services/promote-version-not-found.test.ts create mode 100644 src/services/promote-version-not-found.ts create mode 100644 src/shared/package-dependencies-request.test.ts create mode 100644 src/shared/package-dependencies-request.ts create mode 100644 src/shared/package-dependencies-response.test.ts create mode 100644 src/shared/package-dependencies-response.ts create mode 100644 src/tools/package-dependencies-parity.test.ts create mode 100644 src/tools/package-dependencies.test.ts create mode 100644 src/tools/package-dependencies.ts diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index a2694510..5891cb44 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -15,6 +15,7 @@ The CLI exposes three primary commands (`search`, `languages`, `feedback`) that | `code search [query]` | package spec | `--keywords`, `--keyword`, `--match-mode`, `--category`, `--kind`, `--file`, `--intent`, `--limit`, `--wait`, `--json` | Search indexed dependency source code | | `pkg info ` | package spec | `--verbose`, `--json` | Show a package overview (latest version, downloads, license, vulnerabilities) | | `pkg vulns ` | package spec (optional `@version`) | `--severity`, `--include-withdrawn`, `--verbose`, `--json` | List known vulnerabilities for a package (npm/pypi/hex/crates) | +| `pkg deps ` | package spec (optional `@version`) | `--groups`, `--lifecycle`, `--transitive`, `--depth`, `--verbose`, `--json` | Analyse dependencies: direct runtime deps, structured groups, optional transitive graph (npm/pypi/hex/crates/vcpkg/zig) | ### `githits init` @@ -149,6 +150,42 @@ Lists known CVE / OSV advisories for a package: severity, affected version range **Troubleshooting.** Same debug areas as `pkg info` (`GITHITS_DEBUG=pkg-intel` for classified errors; `GITHITS_DEBUG=pkg-graphql` for transport failures). +### `githits pkg deps` + +``` +githits pkg deps npm:express +githits pkg deps npm:express --groups +githits pkg deps crates:tokio --lifecycle optional +githits pkg deps npm:express --lifecycle runtime,development +githits pkg deps npm:express --transitive +githits pkg deps npm:express --transitive --depth 2 +githits pkg deps npm:express --json +``` + +Analyses dependencies for a package on npm, PyPI, Hex, Crates, vcpkg, or Zig. Default terminal output is a flat list of direct runtime dependencies with a hint summarising hidden groups. + +**Package spec.** `:[@]`. `@` is accepted (same as `pkg vulns`); defaults to latest. Tag-style inputs such as `@v4.18.0` are rejected client-side with `INVALID_ARGUMENT` — callers must use the canonical version. Only `npm`, `pypi`, `hex`, `crates`, `vcpkg`, and `zig` are supported; other registries are rejected client-side with `pkg deps only supports npm, pypi, hex, crates, vcpkg, and zig. Got: ${registry}.` + +**Two views.** The default runtime view collapses to a single-column list from `dependencies.direct` — the flat answer to "what does this pull in?". The structured groups view (`--groups`, or implicitly via `--lifecycle`) iterates `dependencyGroups.groups` and preserves registry-specific condition metadata (PyPI extras, Crates features, NuGet TFMs). Dev / peer / build / optional deps live only in the groups view — the wire's `direct[]` is always runtime-only. + +**Lifecycle filter.** `-l, --lifecycle ` accepts a comma-separated list of canonical lowercase tokens (`runtime`, `development`, `build`, `peer`, `optional`). Uppercase and whitespace are tolerated. Filters server-side via the backend's `lifecycle: [String!]` input, which only affects `dependencyGroups`; `direct[]` and `transitive[]` are returned regardless. Unknown tokens are rejected with `INVALID_ARGUMENT` and the canonical list. + +**Groups view (`--groups` or any `--lifecycle`).** Headings collapse to `name` when `conditionType === "always"` (e.g. `runtime`, `development`). Feature / TFM groups render `name (lifecycle, conditionType[: conditionValue])` — `conditionValue` is omitted when it equals `name` (the common case on Crates features and PyPI extras). Within each group, entries sort alphabetically. Duplicate `{name, constraint}` tuples inside a group collapse in the terminal for scannability; the JSON envelope preserves every duplicate the backend emitted. + +**Transitive view (`--transitive`).** Replaces the direct-deps list with the full unique transitive closure (alphabetical, `name@version`, one per line). Summary row carries the aggregate counts + conflict / cycle counts, and `(max depth N)` only when `--depth` was applied — otherwise the backend's full-graph traversal is shown. `--depth ` (1–10) caps traversal; there is **no client-side default cap** (matches `npm ls` / `cargo tree` ergonomics). + +**Verbose (`--verbose`).** In both plain and transitive modes, each dep expands to a multi-line block: the first line is `name@version`, followed by indented `- required by @, …` bullets. Importers that share a constraint are collapsed onto one bullet with a comma-separated list. In plain mode each direct dep has exactly one importer (the root package itself); in transitive mode a popular leaf may list many importers grouped by constraint. Conflicts expand into a `Conflicts (N):` table (`name: range1, range2, …`, one row per package); circular dependencies expand into a `Circular dependencies (N):` list (`a → b → a` arrow chain). + +**JSON envelope.** Preprocessed: `runtime.items[].version` surfaces the resolved version alongside the constraint. Under `--transitive`, `transitive.packages[]` carries `{name, version, importers[]}` records so agents get the same provenance signal as the verbose terminal output without decoding the raw DAG. `transitive.conflicts[]` and `transitive.circularDependencies[]` are typed (`{name, requiredVersions}` / `{cycle: string[]}`) when the observed backend shape decodes; raw passthrough otherwise. The raw DAG itself is deliberately **not** in the envelope — a future dedicated `pkg deps-dag` command will expose it under a typed contract for graph visualisation (mermaid / DOT / interactive viewer). + +**Output envelope.** `{registry, name, version, requestedVersion?, runtime?, groups?, transitive?, filter?}`. Data-first: the `runtime` block emits whenever the backend returned `dependencies.direct` (including `{count: 0, items: []}` for zero-dep packages); the `groups` block emits whenever the backend returned `dependencyGroups` (including `{items: []}` when a lifecycle filter matched nothing, so agents distinguish "backend has no groups concept" from "filter excluded everything"). Each group carries its members under `items` (matches the top-level `runtime.items` naming so dependency lists share one key throughout the envelope). `filter.lifecycles` echoes the canonicalised, deduplicated, display-order-sorted list the backend received — not the raw CSV input. + +**Exit codes.** 0 on success including zero-dep packages; 1 on any error. Under `--json`, the error envelope is written to **stderr**. + +**Capability gate.** Same as `pkg info` / `pkg vulns` (inherits from the `code_navigation` token capability). + +**Troubleshooting.** Same debug areas as `pkg info` / `pkg vulns` (`GITHITS_DEBUG=pkg-intel` for classified errors; `GITHITS_DEBUG=pkg-graphql` for transport failures). + ## Architecture ``` diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index eafb11bf..14aa172c 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -150,16 +150,22 @@ When a new tool lands with both MCP and CLI surfaces: | `src/shared/package-summary-response.ts` | Lean JSON envelope builder and terminal formatter for `package_summary`. | | `src/shared/package-vulnerabilities-request.ts` | Shared request builder for `package_vulnerabilities`; owns the tool-local `supportsVulnerabilitiesRegistry` predicate and the severity-label → CVSS float map. | | `src/shared/package-vulnerabilities-response.ts` | Lean JSON envelope builder for `package_vulnerabilities` (shared); terminal formatter (CLI-only). | +| `src/shared/package-dependencies-request.ts` | Shared request builder for `package_dependencies`; owns `supportsDependenciesRegistry` + lifecycle / depth validation. | +| `src/shared/package-dependencies-response.ts` | Lean JSON envelope builder for `package_dependencies` (shared); terminal formatter (CLI-only). | | `src/shared/package-intelligence-error-map.ts` | `mapPackageIntelligenceError` classifier (reuses `MappedError` from the code-nav map). | +| `src/services/promote-version-not-found.ts` | Shared helper that promotes generic backend errors with "no matching version" messages into typed `VERSION_NOT_FOUND`. Used by `packageVulnerabilities` and `packageDependencies` executors. | | `src/tools/search-symbols.ts` | MCP tool definition for `search_symbols`. | | `src/tools/package-summary.ts` | MCP tool definition for `package_summary`. | | `src/tools/package-vulnerabilities.ts` | MCP tool definition for `package_vulnerabilities`. | +| `src/tools/package-dependencies.ts` | MCP tool definition for `package_dependencies`. | | `src/commands/code/search-symbols.ts` | CLI command. | | `src/commands/pkg/info.ts` | CLI command for `pkg info`. | | `src/commands/pkg/vulns.ts` | CLI command for `pkg vulns`. | +| `src/commands/pkg/deps.ts` | CLI command for `pkg deps`. | | `src/tools/search-symbols-parity.test.ts` | Parity tests (cite rule IDs). | | `src/tools/package-summary-parity.test.ts` | Parity tests for `package_summary` (cite rule IDs). | | `src/tools/package-vulnerabilities-parity.test.ts` | Parity tests for `package_vulnerabilities` (cite rule IDs). | +| `src/tools/package-dependencies-parity.test.ts` | Parity tests for `package_dependencies` (cite rule IDs). | ## Per-tool notes @@ -230,3 +236,59 @@ When a new tool lands with both MCP and CLI surfaces: inputs like `v4.18.0` are rejected as `INVALID_ARGUMENT` with an actionable message instead of relying on the current production backend, which returns a generic error for that input. + +### `package_dependencies` + +- **Data-first envelope.** `runtime`, `groups`, and `transitive` are + three independent blocks emitted based on what the backend + returned and what the caller asked for, not on additional caller + flags. An MCP agent decides what to read based on what's in the + envelope — no branching on invocation inputs. +- **No `include_groups` input.** The data-first envelope emits the + `groups` block unconditionally when the backend returned + `dependencyGroups`, so an `include_groups: true` input would be a + silently ignored no-op. Deliberately absent from the MCP schema. +- **Dependency list naming.** Every list of dependencies in the + envelope uses the `items` key: `runtime.items`, `groups.items` + (array of groups), each group's nested `items` (array of member + deps). Symmetric and easy to parse. +- **Lifecycle filter echo.** `filter.lifecycles` is the + canonicalised, deduplicated, display-order-sorted array the + backend actually received (never the raw CSV). Emitted only when + the caller supplied a non-empty input. +- **Null vs empty matters.** `groups` is omitted entirely when the + backend returned `dependencyGroups: null` (zero-dep packages); + emitted with `items: []` when the backend returned a non-null + `dependencyGroups` with zero groups (filter matched nothing). + `runtime` is omitted when `dependencies: null` or `direct: null`; + emitted with `count: 0, items: []` when `direct: []`. +- **Terminal-only dedup.** Crates feature groups can contain + duplicate `{name, constraint}` tuples (target-cfg branching). The + terminal formatter collapses them; the JSON envelope preserves + every duplicate the backend emitted. A parity fixture exercises + the round-trip. +- **Preprocessed transitive.** Backend declares `transitive.conflicts`, + `transitive.circularDependencies`, and the DAG as `GenericJSON`, + but the envelope builder decodes them using best-effort shape + detectors so agents see typed data. `transitive.packages[]` carries + `{name, version, importers[]}` records (importer name / version / + constraint pulled from the DAG); `conflicts[]` is typed + `{name, requiredVersions}` when decodable; `circularDependencies[]` + is typed `{cycle: string[]}` when decodable. When a decoder can't + match, that field falls back to raw `GenericJSON[]` so no data is + lost. The raw DAG itself is deliberately dropped from this tool's + envelope — a future `pkg deps-dag` command will expose it under a + typed contract. `groups.environmentConstraints` remains raw + `GenericJSON[]` (no live shape observed yet). +- **Parity assertion policy** (coded in + `src/tools/package-dependencies-parity.test.ts`): + - `toEqual` across the service-sourced success fixtures: happy + flat-runtime, zero-dep (omits `groups`), full-view, optional- + lifecycle (tokio features), multi-lifecycle filter, + filter-matched-nothing (`groups: {items: []}`), + Crates-target-cfg dedup round-trip, versioned match / diff, + `NOT_FOUND`, `VERSION_NOT_FOUND` with structured details, + `BACKEND_ERROR`. + - `toMatchObject` for builder-sourced `INVALID_ARGUMENT` cases: + unsupported registry (`nuget`), tag-style version (`v4.18.0`), + unknown lifecycle token (`dev`). diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index a1a965d2..3c6d9fb6 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -25,8 +25,9 @@ Both expose the same tools with identical names, parameters, and descriptions. T | `search_symbols` | `target`, `query?`, `keywords?`, `match_mode?`, `category?`, `kind?`, `file_path?`, `limit?`, `file_intent?`, `wait_timeout_ms?` | Capability-gated code navigation search over indexed dependency source. | | `package_summary` | `registry`, `package_name` | Package overview: latest version, license, description, repository, downloads, GitHub metadata, install command, and known vulnerabilities. Always returns the latest published version. | | `package_vulnerabilities` | `registry`, `package_name`, `version?`, `min_severity?`, `include_withdrawn?` | Known vulnerabilities for a package on npm, PyPI, Hex, or Crates. Count summary, per-advisory OSV ID + severity + affected/fix ranges, and upgrade paths. Malware is surfaced in a disjoint bucket. | +| `package_dependencies` | `registry`, `package_name`, `version?`, `lifecycle?`, `include_transitive?`, `max_depth?` | Direct runtime dependency list plus, when the backend has them, structured groups for dev / peer / build / optional with registry-specific condition metadata (PyPI extras, Crates features). Optional transitive block with aggregate edge counts, conflicts, circular-dependency flags, and an opaque DAG. | -`search_symbols`, `package_summary`, and `package_vulnerabilities` are only registered when the startup token advertises `code_navigation` capability. The backend endpoint can be overridden via `GITHITS_CODE_NAV_URL` for local development. Capability gating keeps the tools hidden from public/default flows while the feature is still rolling out. +`search_symbols`, `package_summary`, `package_vulnerabilities`, and `package_dependencies` are only registered when the startup token advertises `code_navigation` capability. The backend endpoint can be overridden via `GITHITS_CODE_NAV_URL` for local development. Capability gating keeps the tools hidden from public/default flows while the feature is still rolling out. `search_symbols` shares request-construction, error classification, and JSON-payload shape with the CLI `githits code search` command via shared helpers under `src/shared/`. The parity rules are codified in [`mcp-cli-parity.md`](./mcp-cli-parity.md); the parity test (`src/tools/search-symbols-parity.test.ts`) asserts that both surfaces emit identical JSON for equivalent inputs. @@ -60,6 +61,28 @@ Both expose the same tools with identical names, parameters, and descriptions. T `package_vulnerabilities` shares its envelope builder with the CLI `githits pkg vulns` command via `src/shared/package-vulnerabilities-request.ts` and `src/shared/package-vulnerabilities-response.ts`. The terminal formatter is CLI-only (MCP always emits JSON). The parity test (`src/tools/package-vulnerabilities-parity.test.ts`) asserts `toEqual` across the service-sourced success and typed-error fixtures, and `toMatchObject` for builder-sourced `INVALID_ARGUMENT` fixtures such as unsupported registries and tag-style `v`-prefixed versions. +### `package_dependencies` response shape + +**Data-first envelope.** `runtime`, `groups`, and `transitive` are three independent blocks emitted based on what the backend returned and what the caller asked for, not on additional caller flags. Agents branch on the envelope's shape rather than inferring from inputs. + +- `runtime` block: emitted whenever the backend returned `dependencies.direct` (including `{count: 0, items: []}` for zero-dep packages). `runtime.count` is computed client-side from `runtime.items.length` — the backend's `summary.directCount` is deliberately not selected so the invariant cannot drift. The wire's `direct[]` is always runtime-only: dev / peer / build / optional deps live in the groups block instead. +- `groups` block: emitted whenever the backend returned `dependencyGroups` — including when a lifecycle filter matched nothing (`{items: []}`). Omitted entirely when the backend returned `dependencyGroups: null` (e.g. on zero-dep packages), so agents can tell "backend has no groups concept" apart from "filter excluded everything". Each group carries its members under `items`, matching `runtime.items` so dependency lists share one key throughout the envelope. Duplicate `{name, constraint}` entries inside a group are preserved verbatim; the terminal formatter dedups for scannability but JSON is lossless. +- `transitive` block: emitted only when the caller set `include_transitive: true`. Carries aggregates (`edges`, `uniquePackages`, `depth?`) plus preprocessed arrays: `packages[]` (each `{name, version, importers[]}` with importer name / version / constraint pulled from the backend DAG), `conflicts[]` (typed `{name, requiredVersions}` when decodable), `circularDependencies[]` (typed `{cycle: string[]}` when decodable). Raw backend DAG is not exposed — the preprocessing happens in the envelope builder so agents consume the same signal the terminal `--verbose` renderer reads without re-implementing the decoder. A future dedicated `pkg deps-dag` command will expose the full DAG under a typed contract for graph-visualisation tooling. + +**`filter.lifecycles` echo.** Canonicalised lowercase array (deduplicated, sorted in canonical display order: `runtime` → `development` → `build` → `peer` → `optional`). Emitted only when the caller supplied a non-empty lifecycle input. Matches what the backend actually received — the raw CSV string is not echoed. + +**Lifecycle scope.** `lifecycle: [String!]` on the wire filters `dependencyGroups.groups` only; `direct[]` and `transitive[]` are returned regardless. Documented on the backend schema and verified in live smoke. + +**Typed decoder on GenericJSON payloads.** Backend declares `transitive.conflicts`, `transitive.circularDependencies`, and the DAG as `GenericJSON`. We ship best-effort decoders in the envelope builder that promote the two observed shapes (`{package_name, required_versions, conflicting_edges}` for conflicts; `{cycle: string[]}` for cycles) into typed arrays in the envelope. If any entry fails to decode against the expected shape, the field falls back to raw passthrough for that response — agents discriminate by checking `"name" in entry` / `Array.isArray(entry.cycle)` on the first element. `groups.environmentConstraints` remains raw `GenericJSON[]` (no observed live shape yet). The raw DAG is deliberately not exposed in this PR; a follow-up `pkg deps-dag` command will provide a typed graph surface for visualisation tooling. + +**Registry coverage.** Only npm, PyPI, Hex, Crates, vcpkg, and Zig support the `packageDependencies` query. NuGet / Maven / Packagist are rejected client-side with a tool-specific message (`pkg deps only supports npm, pypi, hex, crates, vcpkg, and zig. Got: ${registry}.`). Predicate lives in `src/shared/package-dependencies-request.ts`. + +**Version validation.** Same rule as `package_vulnerabilities`: tag-style `v`-prefixed inputs are rejected client-side with `INVALID_ARGUMENT` before the backend call. + +**MCP schema notes.** Permissive (`registry: z.string()`, `package_name: z.string()`, …) with validation in-handler via `buildPackageDependenciesParams`. Deliberately no `include_groups` input — with the data-first envelope emitting `groups` unconditionally when the backend returns `dependencyGroups`, the flag would be a silently ignored no-op. `max_depth` has no client-side default on the MCP surface so the backend's full-graph default applies; the CLI's `--depth` defaults to 3 as a human guardrail. + +`package_dependencies` shares its envelope builder with the CLI `githits pkg deps` command via `src/shared/package-dependencies-request.ts` and `src/shared/package-dependencies-response.ts`. The terminal formatter is CLI-only. The parity test (`src/tools/package-dependencies-parity.test.ts`) asserts `toEqual` across every service-sourced success / error fixture (runtime, zero-dep, full-view, optional-lifecycle, multi-lifecycle, filter-matched-nothing, Crates-target-cfg dedup round-trip, transitive, versioned match / diff, NOT_FOUND, VERSION_NOT_FOUND, BACKEND_ERROR) and `toMatchObject` for builder-sourced `INVALID_ARGUMENT` (unsupported registry, tag-style version, unknown lifecycle). + ## Server instructions The MCP server advertises a short, cross-tool orientation via the protocol's server-level `instructions` field. This is distinct from per-tool `description` text: instructions cover rationale, workflow glue, and decisions that span multiple tools, while per-tool descriptions remain the source of truth for arguments, output shape, and tool-specific constraints. diff --git a/src/commands/mcp-instructions.test.ts b/src/commands/mcp-instructions.test.ts index 2748111e..63a59789 100644 --- a/src/commands/mcp-instructions.test.ts +++ b/src/commands/mcp-instructions.test.ts @@ -49,6 +49,7 @@ const KNOWN_TOOLS = [ "search_symbols", "package_summary", "package_vulnerabilities", + "package_dependencies", ] as const; function mentionedTools(instructions: string): Set { @@ -104,6 +105,7 @@ describe("buildMcpInstructions", () => { expect(instructions).not.toContain("Package tools"); expect(instructions).not.toContain("package_summary"); expect(instructions).not.toContain("package_vulnerabilities"); + expect(instructions).not.toContain("package_dependencies"); expect(instructions).not.toContain("search_symbols"); }); @@ -120,6 +122,7 @@ describe("buildMcpInstructions", () => { expect(instructions).toContain("Package tools"); expect(instructions).toContain("`package_summary`"); expect(instructions).toContain("`package_vulnerabilities`"); + expect(instructions).toContain("`package_dependencies`"); expect(instructions).toContain("`search_symbols`"); }); @@ -164,7 +167,7 @@ describe("buildMcpInstructions", () => { expect(instructions).toContain("natural-language example questions"); }); - it("half-open: only package intelligence service wired → mentions package_summary + package_vulnerabilities but not search_symbols", () => { + it("half-open: only package intelligence service wired → mentions every package tool but not search_symbols", () => { const deps = createTestDeps({ codeNavigationCapability: "enabled", codeNavigationService: undefined, @@ -175,6 +178,7 @@ describe("buildMcpInstructions", () => { expect(instructions).toContain("Package tools"); expect(instructions).toContain("`package_summary`"); expect(instructions).toContain("`package_vulnerabilities`"); + expect(instructions).toContain("`package_dependencies`"); expect(instructions).not.toContain("`search_symbols`"); // The decision tip references search_symbols, so it must not // appear when search_symbols isn't registered. @@ -241,6 +245,7 @@ describe("buildMcpInstructions", () => { "search_symbols", "package_summary", "package_vulnerabilities", + "package_dependencies", ]; for (const name of packageTools) { if (registered.has(name)) { diff --git a/src/commands/mcp-instructions.ts b/src/commands/mcp-instructions.ts index 7efc4637..b6190fdf 100644 --- a/src/commands/mcp-instructions.ts +++ b/src/commands/mcp-instructions.ts @@ -29,6 +29,9 @@ const PACKAGE_SUMMARY_BULLET = const PACKAGE_VULNERABILITIES_BULLET = "- `package_vulnerabilities` — known CVE / OSV advisories for npm, PyPI, Hex, or Crates packages (optionally pinned to `@version`). Malicious-package advisories surface in a disjoint `malware` bucket; filter with `min_severity` or include retracted advisories with `include_withdrawn`."; +const PACKAGE_DEPENDENCIES_BULLET = + "- `package_dependencies` — direct runtime deps plus, when the backend has them, dev / peer / optional / feature groups. Pass `lifecycle` to filter groups server-side, or `include_transitive` for the full graph, conflict detection, and circular-dependency flags. Supports npm, PyPI, Hex, Crates, vcpkg, and Zig."; + const SEARCH_SYMBOLS_BULLET = "- `search_symbols` — text search across a dependency's source. On an INDEXING response, retry with a larger `wait_timeout_ms` (up to 60000)."; @@ -75,6 +78,7 @@ export function buildMcpInstructions(deps: Dependencies): string { if (deps.packageIntelligenceService) { bullets.push(PACKAGE_SUMMARY_BULLET); bullets.push(PACKAGE_VULNERABILITIES_BULLET); + bullets.push(PACKAGE_DEPENDENCIES_BULLET); } if (deps.codeNavigationService) { bullets.push(SEARCH_SYMBOLS_BULLET); diff --git a/src/commands/mcp.test.ts b/src/commands/mcp.test.ts index b9ef8aa3..3cc5124f 100644 --- a/src/commands/mcp.test.ts +++ b/src/commands/mcp.test.ts @@ -187,6 +187,45 @@ describe("createMcpServer", () => { expect(names).toContain("package_vulnerabilities"); } }); + + it("adds package_dependencies when capability is enabled and service wired", () => { + const deps = createTestDeps({ + codeNavigationCapability: "enabled", + codeNavigationUrl: "https://pkgseer.dev", + packageIntelligenceService: createMockPackageIntelligenceService(), + }); + + const tools = getMcpToolDefinitions(deps); + expect(tools.map((tool) => tool.name)).toContain("package_dependencies"); + }); + + it("omits package_dependencies when capability is disabled", () => { + const deps = createTestDeps({ + codeNavigationCapability: "disabled", + codeNavigationUrl: "https://pkgseer.dev", + packageIntelligenceService: createMockPackageIntelligenceService(), + }); + + const tools = getMcpToolDefinitions(deps); + expect(tools.map((tool) => tool.name)).not.toContain( + "package_dependencies", + ); + }); + + it("advertises every package tool together (shared predicate covers deps too)", () => { + const deps = createTestDeps({ + codeNavigationCapability: "enabled", + codeNavigationUrl: "https://pkgseer.dev", + codeNavigationService: createMockCodeNavigationService(), + packageIntelligenceService: createMockPackageIntelligenceService(), + }); + + const names = getMcpToolDefinitions(deps).map((t) => t.name); + if (names.includes("package_summary")) { + expect(names).toContain("package_vulnerabilities"); + expect(names).toContain("package_dependencies"); + } + }); }); describe("startMcpServer", () => { diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 7ea50236..ab42823a 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -6,6 +6,7 @@ import { createContainer, type Dependencies } from "../container.js"; import { dim, highlight, shouldUseColors } from "../shared/colors.js"; import { createFeedbackTool, + createPackageDependenciesTool, createPackageSummaryTool, createPackageVulnerabilitiesTool, createSearchLanguageTool, @@ -41,6 +42,7 @@ export function getMcpToolDefinitions( tools.push( createPackageVulnerabilitiesTool(deps.packageIntelligenceService), ); + tools.push(createPackageDependenciesTool(deps.packageIntelligenceService)); } return tools; diff --git a/src/commands/pkg/deps.test.ts b/src/commands/pkg/deps.test.ts new file mode 100644 index 00000000..3254e77f --- /dev/null +++ b/src/commands/pkg/deps.test.ts @@ -0,0 +1,339 @@ +import { describe, expect, it, mock, spyOn } from "bun:test"; +import { + PackageIntelligenceTargetNotFoundError, + PackageIntelligenceVersionNotFoundError, +} from "../../services/index.js"; +import { + createMockPackageIntelligenceService, + defaultDependencyReport, +} from "../../services/test-helpers.js"; +import { AuthRequiredError } from "../../shared/require-auth.js"; +import { type PkgDepsCommandDependencies, pkgDepsAction } from "./deps.js"; + +describe("pkgDepsAction", () => { + const mcpUrl = "https://mcp.githits.com"; + + function createDeps( + overrides: Partial = {}, + ): PkgDepsCommandDependencies { + return { + packageIntelligenceService: createMockPackageIntelligenceService(), + codeNavigationUrl: "https://pkgseer.dev", + hasValidToken: true, + mcpUrl, + ...overrides, + }; + } + + it("renders the default runtime block via stdout.write", async () => { + const writes: string[] = []; + const writeSpy = spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + writes.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stdout.write); + + await pkgDepsAction("npm:express", {}, createDeps()); + + const combined = writes.join(""); + expect(combined).toContain("express @ 5.2.1 · npm"); + expect(combined).toContain("3 direct runtime dependencies"); + expect(combined).toContain("Hidden groups: development — use --groups."); + writeSpy.mockRestore(); + }); + + it("prints the lean JSON envelope when --json is set", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await pkgDepsAction("npm:express", { json: true }, createDeps()); + + const output = logSpy.mock.calls[0]?.[0] as string; + const payload = JSON.parse(output); + expect(payload.registry).toBe("npm"); + expect(payload.runtime.count).toBe(3); + expect(payload.groups.items.length).toBe(2); + logSpy.mockRestore(); + }); + + it("implies --groups when --lifecycle is set (groups block appears beneath direct deps list)", async () => { + const writes: string[] = []; + const writeSpy = spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + writes.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stdout.write); + + await pkgDepsAction( + "npm:express", + { lifecycle: "development" }, + createDeps(), + ); + + const combined = writes.join(""); + // Under the new semantic model the groups block is additive, not + // replacement. Direct-deps summary + list still render; groups + // block appears beneath. + expect(combined).toContain("direct runtime dependencies"); + expect(combined).toMatch(/\d+ groups? \(/); + writeSpy.mockRestore(); + }); + + it("sends undefined maxDepth when --transitive is set without --depth (backend's full-graph default applies)", async () => { + const packageDependencies = mock(() => + Promise.resolve(defaultDependencyReport), + ); + const service = createMockPackageIntelligenceService({ + packageDependencies, + }); + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + (() => true) as typeof process.stdout.write, + ); + + await pkgDepsAction( + "npm:express", + { transitive: true }, + createDeps({ packageIntelligenceService: service }), + ); + + const calls = packageDependencies.mock.calls as unknown as Array< + [{ includeTransitive?: boolean; maxDepth?: number }] + >; + expect(calls[0]?.[0]?.includeTransitive).toBe(true); + expect(calls[0]?.[0]?.maxDepth).toBeUndefined(); + writeSpy.mockRestore(); + }); + + it("sends maxDepth when --transitive --depth N are both set", async () => { + const packageDependencies = mock(() => + Promise.resolve(defaultDependencyReport), + ); + const service = createMockPackageIntelligenceService({ + packageDependencies, + }); + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + (() => true) as typeof process.stdout.write, + ); + + await pkgDepsAction( + "npm:express", + { transitive: true, depth: "5" }, + createDeps({ packageIntelligenceService: service }), + ); + + const calls = packageDependencies.mock.calls as unknown as Array< + [{ includeTransitive?: boolean; maxDepth?: number }] + >; + expect(calls[0]?.[0]?.includeTransitive).toBe(true); + expect(calls[0]?.[0]?.maxDepth).toBe(5); + writeSpy.mockRestore(); + }); + + it("rejects non-numeric --depth input", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + try { + await pkgDepsAction( + "npm:express", + { transitive: true, depth: "abc" }, + createDeps(), + ); + } catch { + /* expected */ + } + + const msg = errorSpy.mock.calls[0]?.[0] as string; + expect(msg).toContain("--depth expects an integer"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it.each([ + "3.5", + "5abc", + "abc5", + "3.0", + ])("rejects partially-numeric --depth input %s (no silent truncation)", async (input) => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + try { + await pkgDepsAction( + "npm:express", + { transitive: true, depth: input }, + createDeps(), + ); + } catch { + /* expected */ + } + + const msg = errorSpy.mock.calls[0]?.[0] as string; + expect(msg).toContain("--depth expects an integer"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("rejects unsupported registry (nuget) with tool-specific message", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + try { + await pkgDepsAction("nuget:Newtonsoft.Json", {}, createDeps()); + } catch { + /* expected */ + } + + expect(errorSpy.mock.calls[0]?.[0]).toBe( + "pkg deps only supports npm, pypi, hex, crates, vcpkg, and zig. Got: nuget.", + ); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("rejects tag-style versions with INVALID_ARGUMENT hint", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + try { + await pkgDepsAction("npm:express@v4.18.0", {}, createDeps()); + } catch { + /* expected */ + } + + const msg = errorSpy.mock.calls[0]?.[0] as string; + expect(msg).toContain("git tag"); + expect(msg).toContain("4.18.0"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("routes NOT_FOUND through --json error envelope", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + const service = createMockPackageIntelligenceService({ + packageDependencies: mock(() => + Promise.reject( + new PackageIntelligenceTargetNotFoundError("Package not found"), + ), + ), + }); + + try { + await pkgDepsAction( + "npm:ghost", + { json: true }, + createDeps({ packageIntelligenceService: service }), + ); + } catch { + /* expected */ + } + + const output = errorSpy.mock.calls[0]?.[0] as string; + const payload = JSON.parse(output); + expect(payload.code).toBe("NOT_FOUND"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("enriches VERSION_NOT_FOUND terminal output with package + requested version", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + const service = createMockPackageIntelligenceService({ + packageDependencies: mock(() => + Promise.reject( + new PackageIntelligenceVersionNotFoundError( + "No matching version found", + "npm:express", + "99.0.0", + undefined, + ), + ), + ), + }); + + try { + await pkgDepsAction( + "npm:express@99.0.0", + {}, + createDeps({ packageIntelligenceService: service }), + ); + } catch { + /* expected */ + } + + const msg = errorSpy.mock.calls[0]?.[0] as string; + expect(msg).toContain("No matching version found"); + expect(msg).toContain("package: npm:express"); + expect(msg).toContain("requested: 99.0.0"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("throws AuthRequiredError before calling service when unauthenticated", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + const packageDependencies = mock(() => + Promise.resolve(defaultDependencyReport), + ); + const service = createMockPackageIntelligenceService({ + packageDependencies, + }); + + await expect( + pkgDepsAction( + "npm:express", + {}, + createDeps({ + packageIntelligenceService: service, + hasValidToken: false, + }), + ), + ).rejects.toThrow(AuthRequiredError); + + expect(packageDependencies).not.toHaveBeenCalled(); + logSpy.mockRestore(); + }); + + it("errors when pkgseer URL / service are missing", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + try { + await pkgDepsAction( + "npm:express", + {}, + createDeps({ + packageIntelligenceService: undefined, + codeNavigationUrl: undefined, + }), + ); + } catch { + /* expected */ + } + + expect(errorSpy.mock.calls[0]?.[0]).toContain("not configured"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); +}); diff --git a/src/commands/pkg/deps.ts b/src/commands/pkg/deps.ts new file mode 100644 index 00000000..e38ced15 --- /dev/null +++ b/src/commands/pkg/deps.ts @@ -0,0 +1,235 @@ +import type { Command } from "commander"; +import { createContainer } from "../../container.js"; +import type { PackageIntelligenceService } from "../../services/index.js"; +import { shouldUseColors } from "../../shared/colors.js"; +import { + InvalidPackageSpecError, + type MappedError, + mapPackageIntelligenceError, + parsePackageSpec, + requireAuth, +} from "../../shared/index.js"; +import { buildPackageDependenciesParams } from "../../shared/package-dependencies-request.js"; +import { + buildPackageDependenciesSuccessPayload, + formatPackageDependenciesTerminal, +} from "../../shared/package-dependencies-response.js"; + +export interface PkgDepsCommandOptions { + lifecycle?: string; + groups?: boolean; + transitive?: boolean; + depth?: string; + verbose?: boolean; + json?: boolean; +} + +export interface PkgDepsCommandDependencies { + packageIntelligenceService: PackageIntelligenceService | undefined; + codeNavigationUrl: string | undefined; + hasValidToken: boolean; + mcpUrl: string; +} + +/** + * Core `pkg deps` action. Accepts `[@]`. The + * `--lifecycle` filter is server-side (filters `dependencyGroups` + * only) and implies the groups view. `--groups` alone renders the + * structured view without filtering. `--transitive` opts into the + * aggregate counts + conflict / circular-dependency signals. No + * client-side depth cap by default — matches `npm ls` / `cargo + * tree` ergonomics where "show the transitive deps" means the full + * graph. `--depth N` lets callers opt in to a cap. + */ +export async function pkgDepsAction( + spec: string, + options: PkgDepsCommandOptions, + deps: PkgDepsCommandDependencies, +): Promise { + requireAuth(deps); + + try { + if (!deps.codeNavigationUrl || !deps.packageIntelligenceService) { + throw new InvalidPackageSpecError( + "Package intelligence is not configured for this environment.", + ); + } + + const parsed = parsePackageSpec(spec); + + const userDepth = resolveDepth(options); + // Always fetch the transitive DAG on the wire — even in plain + // mode we need it to resolve the concrete version for each + // direct dep (`name@version` in display), and for `--verbose` + // to annotate per-entry importer provenance. When the user + // didn't request `--transitive`, cap at depth 1 so the payload + // stays lean. + const wireIncludeTransitive = true; + const wireMaxDepth = options.transitive ? userDepth : 1; + + const { params, canonicalLifecycles } = buildPackageDependenciesParams({ + registry: parsed.registry, + packageName: parsed.name, + version: parsed.version, + lifecycle: options.lifecycle, + includeTransitive: wireIncludeTransitive, + maxDepth: wireMaxDepth, + }); + + const report = + await deps.packageIntelligenceService.packageDependencies(params); + + if (options.json) { + const payload = buildPackageDependenciesSuccessPayload(report, { + requestedVersion: parsed.version, + canonicalLifecycles, + includeTransitive: options.transitive, + maxDepth: userDepth, + // Tie `--verbose` to JSON richness too: agents reading the + // envelope see the same detail as the terminal's verbose + // output. Default `--json` keeps the payload lean (~4× + // smaller on large graphs like jest). + includeImporters: options.verbose ?? false, + }); + console.log(JSON.stringify(payload)); + return; + } + + // `--lifecycle` implies `--groups`: there is no flat projection + // for non-runtime lifecycles on the wire, and the structured view + // is the only coherent display for filtered lifecycles. + const showGroups = + (options.groups ?? false) || canonicalLifecycles.length > 0; + + const output = formatPackageDependenciesTerminal(report, { + verbose: options.verbose, + useColors: shouldUseColors(), + requestedVersion: parsed.version, + canonicalLifecycles, + includeTransitive: options.transitive, + maxDepth: userDepth, + showGroups, + }); + process.stdout.write(output); + } catch (error) { + handlePkgDepsCommandError(error, options.json ?? false); + } +} + +function resolveDepth(options: PkgDepsCommandOptions): number | undefined { + const raw = options.depth; + if (raw === undefined) return undefined; + // Require the raw string to be an exact integer. `parseInt` would + // silently truncate `3.5 → 3` or `5abc → 5`; on a public CLI that + // silently corrupts caller intent rather than surfacing the typo. + if (!/^-?\d+$/.test(raw.trim())) { + throw new InvalidPackageSpecError( + `--depth expects an integer between 1 and 10. Got '${raw}'.`, + ); + } + const parsed = Number.parseInt(raw, 10); + return parsed; +} + +function handlePkgDepsCommandError(error: unknown, json: boolean): never { + const mapped = mapPackageIntelligenceError(error); + + if (json) { + console.error( + JSON.stringify({ + error: mapped.message, + code: mapped.code, + retryable: mapped.retryable ?? false, + ...(mapped.details ? { details: mapped.details } : {}), + }), + ); + process.exit(1); + } + + console.error(formatDepsTerminalError(mapped)); + process.exit(1); +} + +/** + * Mirrors `pkg vulns` — enriches VERSION_NOT_FOUND with the package + * and requested version, plus an `available:` sample when the backend + * provides one. + */ +function formatDepsTerminalError(mapped: MappedError): string { + if (mapped.code !== "VERSION_NOT_FOUND") return mapped.message; + const detail = mapped.details ?? {}; + const pkg = typeof detail.package === "string" ? detail.package : undefined; + const requested = + typeof detail.requestedVersion === "string" + ? detail.requestedVersion + : undefined; + const lines = [mapped.message]; + if (pkg && requested) { + lines.push(` package: ${pkg}`); + lines.push(` requested: ${requested}`); + } else if (requested) { + lines.push(` requested: ${requested}`); + } + const rawAvailable = Array.isArray(detail.availableVersions) + ? detail.availableVersions + : undefined; + const available = rawAvailable + ?.map((entry) => (typeof entry?.version === "string" ? entry.version : "")) + .filter((v): v is string => v.length > 0); + if (available && available.length > 0) { + const sample = available.slice(0, 5).join(", "); + const more = available.length - 5; + const suffix = more > 0 ? `, … (+${more} more)` : ""; + lines.push(` available: ${sample}${suffix}`); + } + return lines.join("\n"); +} + +const PKG_DEPS_DESCRIPTION = `Analyze package dependencies. By default shows the flat list of +direct runtime dependencies. Use --groups for the structured view +(dev / peer / build / optional, plus registry-specific feature / TFM +groups). --lifecycle filters groups server-side and implies --groups. +--transitive opts into aggregate edge / unique-package counts, +conflict detection, and circular-dependency flags. + +Package spec: :[@]. Supported registries: +npm, pypi, hex, crates, vcpkg, zig. Omit @ for the latest +release.`; + +export function registerPkgDepsCommand(pkgCommand: Command): Command { + return pkgCommand + .command("deps") + .summary("Analyze dependencies for a package") + .description(PKG_DEPS_DESCRIPTION) + .argument("", "Package spec, e.g. npm:express or npm:express@4.18.0") + .option( + "-g, --groups", + "Render the structured groups view instead of the flat runtime list", + ) + .option( + "-l, --lifecycle ", + "Filter groups server-side (runtime, development, build, peer, optional; comma-separated for multi-select). Implies --groups.", + ) + .option( + "-t, --transitive", + "Include aggregate transitive counts, conflicts, and circular dependencies", + ) + .option( + "--depth ", + "Cap transitive traversal depth (1-10). Omit for the full graph.", + ) + .option( + "-v, --verbose", + "Show conditionType / selectionMode / environmentConstraints metadata in the groups view", + ) + .option("--json", "Emit the lean JSON envelope") + .action(async (spec: string, options: PkgDepsCommandOptions) => { + const deps = await createContainer(); + await pkgDepsAction(spec, options, { + packageIntelligenceService: deps.packageIntelligenceService, + codeNavigationUrl: deps.codeNavigationUrl, + hasValidToken: deps.hasValidToken, + mcpUrl: deps.mcpUrl, + }); + }); +} diff --git a/src/commands/pkg/index.test.ts b/src/commands/pkg/index.test.ts index 5354fa19..92b3e487 100644 --- a/src/commands/pkg/index.test.ts +++ b/src/commands/pkg/index.test.ts @@ -34,6 +34,9 @@ describe("registerPkgCommandGroup", () => { expect( pkgCommand?.commands.some((command) => command.name() === "vulns"), ).toBe(true); + expect( + pkgCommand?.commands.some((command) => command.name() === "deps"), + ).toBe(true); }); it("registers the pkg command group when override and URL are set", async () => { diff --git a/src/commands/pkg/index.ts b/src/commands/pkg/index.ts index 8ed63d42..011a6e60 100644 --- a/src/commands/pkg/index.ts +++ b/src/commands/pkg/index.ts @@ -6,6 +6,7 @@ import { getEnvApiToken, isCodeNavigationCliOverrideEnabled, } from "../../services/index.js"; +import { registerPkgDepsCommand } from "./deps.js"; import { registerPkgInfoCommand } from "./info.js"; import { registerPkgVulnsCommand } from "./vulns.js"; @@ -73,4 +74,5 @@ export async function registerPkgCommandGroup( registerPkgInfoCommand(pkgCommand); registerPkgVulnsCommand(pkgCommand); + registerPkgDepsCommand(pkgCommand); } diff --git a/src/services/index.ts b/src/services/index.ts index e17be2f9..9fef26d5 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -82,7 +82,14 @@ export { export { MigratingAuthStorage } from "./migrating-auth-storage.js"; export type { ChangelogEntry, + DependencyBundle, + DependencyGroup, + DependencyGroupsInfo, + DependencyReport, + DirectDependency, GithubRepository, + GroupDependency, + PackageDependenciesParams, PackageIdentity, PackageIntelligenceService, PackageSecurityOverview, @@ -91,6 +98,8 @@ export type { PackageVersionIdentity, PackageVulnerabilitiesParams, QuickstartInfo, + TransitiveDependencySummary, + UntypedGenericJSON, VulnerabilityDetail, VulnerabilityOverview, VulnerabilityReport, diff --git a/src/services/package-intelligence-service.test.ts b/src/services/package-intelligence-service.test.ts index b755f749..8e0f5362 100644 --- a/src/services/package-intelligence-service.test.ts +++ b/src/services/package-intelligence-service.test.ts @@ -1093,3 +1093,245 @@ describe("PackageIntelligenceServiceImpl.packageVulnerabilities", () => { ); }); }); + +describe("PackageIntelligenceServiceImpl — packageDependencies", () => { + const ENDPOINT = "https://pkgseer.dev"; + + const EXPRESS_BODY = { + data: { + packageDependencies: { + package: { name: "express", registry: "NPM", version: "5.2.1" }, + dependencies: { + direct: [ + { name: "accepts", versionConstraint: "^2.0.0", type: "runtime" }, + { name: "cookie", versionConstraint: "^0.7.1", type: "runtime" }, + ], + transitive: null, + }, + dependencyGroups: { + primaryGroup: null, + environmentConstraints: null, + groups: [ + { + name: "runtime", + lifecycle: "runtime", + conditionType: "always", + conditionValue: null, + selectionMode: "required", + exclusiveGroup: null, + fallbackPriority: null, + compatibleWith: null, + defaultEnabled: true, + dependencies: [ + { name: "accepts", constraint: "^2.0.0" }, + { name: "cookie", constraint: "^0.7.1" }, + ], + }, + ], + }, + }, + }, + }; + + it("maps a happy-path response to DependencyReport", async () => { + const fetchFn = mock(() => Promise.resolve(jsonResponse(EXPRESS_BODY))); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + const report = await service.packageDependencies({ + registry: "NPM", + packageName: "express", + }); + expect(report.package.name).toBe("express"); + expect(report.package.version).toBe("5.2.1"); + expect(report.dependencies?.direct?.length).toBe(2); + expect(report.dependencyGroups?.groups[0]?.name).toBe("runtime"); + }); + + it("sends lifecycle + includeTransitive + maxDepth variables on the wire", async () => { + let capturedBody: string | undefined; + const fetchFn = mock((_url: string, init?: RequestInit) => { + capturedBody = init?.body as string; + return Promise.resolve(jsonResponse(EXPRESS_BODY)); + }); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + await service.packageDependencies({ + registry: "NPM", + packageName: "express", + lifecycle: ["runtime", "development"], + includeTransitive: true, + maxDepth: 3, + }); + const parsed = JSON.parse(capturedBody ?? "{}"); + expect(parsed.variables.lifecycle).toEqual(["runtime", "development"]); + expect(parsed.variables.includeTransitive).toBe(true); + expect(parsed.variables.maxDepth).toBe(3); + }); + + it("omits lifecycle when empty array (treated as 'no filter')", async () => { + let capturedBody: string | undefined; + const fetchFn = mock((_url: string, init?: RequestInit) => { + capturedBody = init?.body as string; + return Promise.resolve(jsonResponse(EXPRESS_BODY)); + }); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + await service.packageDependencies({ + registry: "NPM", + packageName: "express", + lifecycle: [], + }); + const parsed = JSON.parse(capturedBody ?? "{}"); + expect(parsed.variables.lifecycle).toBeUndefined(); + }); + + it("promotes a generic 'no matching version' error to VERSION_NOT_FOUND when version was requested", async () => { + const fetchFn = mock(() => + Promise.resolve( + jsonResponse({ errors: [{ message: "No matching version found" }] }), + ), + ); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + try { + await service.packageDependencies({ + registry: "NPM", + packageName: "express", + version: "99.99.99", + }); + throw new Error("expected VERSION_NOT_FOUND promotion"); + } catch (err) { + expect(err).toBeInstanceOf(PackageIntelligenceVersionNotFoundError); + const typed = err as PackageIntelligenceVersionNotFoundError; + expect(typed.packageName).toBe("npm:express"); + expect(typed.requestedVersion).toBe("99.99.99"); + } + }); + + it("does NOT promote when graphqlCode is present", async () => { + const fetchFn = mock(() => + Promise.resolve( + jsonResponse({ + errors: [ + { + message: "no matching version (backend mid-recovery)", + extensions: { code: "INTERNAL_ERROR" }, + }, + ], + }), + ), + ); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + try { + await service.packageDependencies({ + registry: "NPM", + packageName: "express", + version: "99.99.99", + }); + throw new Error("expected BackendError"); + } catch (err) { + expect(err).not.toBeInstanceOf(PackageIntelligenceVersionNotFoundError); + expect(err).toBeInstanceOf(PackageIntelligenceBackendError); + } + }); + + it("classifies typed VERSION_NOT_FOUND response with structured details", async () => { + const fetchFn = mock(() => + Promise.resolve( + jsonResponse({ + errors: [ + { + message: "version missing", + extensions: { + code: "VERSION_NOT_FOUND", + package: "npm:express", + requested_version: "99.0.0", + available_versions: ["5.2.1", "5.2.0"], + }, + }, + ], + }), + ), + ); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + try { + await service.packageDependencies({ + registry: "NPM", + packageName: "express", + version: "99.0.0", + }); + throw new Error("expected VERSION_NOT_FOUND"); + } catch (err) { + expect(err).toBeInstanceOf(PackageIntelligenceVersionNotFoundError); + const typed = err as PackageIntelligenceVersionNotFoundError; + expect(typed.availableVersions).toEqual(["5.2.1", "5.2.0"]); + } + }); + + it("throws Malformed when package.name or package.version is missing", async () => { + const body = { + data: { + packageDependencies: { + package: { name: null, registry: "NPM", version: "5.2.1" }, + dependencies: null, + dependencyGroups: null, + }, + }, + }; + const fetchFn = mock(() => Promise.resolve(jsonResponse(body))); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + await expect( + service.packageDependencies({ registry: "NPM", packageName: "x" }), + ).rejects.toBeInstanceOf(MalformedPackageIntelligenceResponseError); + }); + + it("throws Malformed when a direct[] entry has a null name (no silent empty-string coercion)", async () => { + const body = { + data: { + packageDependencies: { + package: { name: "express", registry: "NPM", version: "5.2.1" }, + dependencies: { + direct: [ + { name: null, versionConstraint: "^1.0.0", type: "runtime" }, + ], + transitive: null, + }, + dependencyGroups: null, + }, + }, + }; + const fetchFn = mock(() => Promise.resolve(jsonResponse(body))); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + await expect( + service.packageDependencies({ registry: "NPM", packageName: "x" }), + ).rejects.toBeInstanceOf(MalformedPackageIntelligenceResponseError); + }); +}); diff --git a/src/services/package-intelligence-service.ts b/src/services/package-intelligence-service.ts index fa4ca462..1fcce388 100644 --- a/src/services/package-intelligence-service.ts +++ b/src/services/package-intelligence-service.ts @@ -25,6 +25,7 @@ import { import type { PkgseerRegistry } from "../shared/pkgseer-registry.js"; import { executeWithTokenRefresh } from "./execute-with-token-refresh.js"; import { AuthenticationError } from "./githits-service.js"; +import { promoteGenericVersionNotFound } from "./promote-version-not-found.js"; import type { TokenProvider } from "./token-manager.js"; export interface PackageSummaryParams { @@ -130,11 +131,108 @@ export interface VulnerabilityReport { security?: VulnerabilitySecurityDetails; } +export interface PackageDependenciesParams { + registry: PkgseerRegistry; + packageName: string; + /** Optional — backend defaults to latest when omitted. */ + version?: string; + /** Optional. Backend returns a full transitive graph when true. */ + includeTransitive?: boolean; + /** + * Optional transitive-traversal depth (1–10). Omit for the backend + * default (full graph) — note the CLI applies a 3-deep guardrail but + * the MCP surface deliberately does not. + */ + maxDepth?: number; + /** + * Optional server-side lifecycle filter. Only affects + * `dependencyGroups`; `direct` and `transitive` are unaffected. + * Canonical lowercase strings — `runtime`, `development`, `build`, + * `peer`, `optional`. + */ + lifecycle?: string[]; +} + +export interface DirectDependency { + name: string; + versionConstraint?: string; + type?: string; +} + +/** + * Opaque GenericJSON passthrough from the backend. Shape is not yet + * typed — see {@link TransitiveDependencySummary} and + * {@link DependencyGroupsInfo} for the fields that carry this type. + * Consumers should treat entries as `unknown` until the backend + * surfaces a typed contract. + * + * TODO(pkgseer-backend): replace with a concrete typed union once the + * backend documents `dag`, `conflicts`, `circularDependencies`, and + * `environmentConstraints` schemas. Parity tests for the current + * passthrough behaviour will fail loudly when the types tighten, + * prompting a deliberate migration. + */ +export type UntypedGenericJSON = unknown; + +export interface TransitiveDependencySummary { + totalEdges?: number; + uniquePackagesCount?: number; + uniqueDependencies?: string[]; + /** TODO(pkgseer-backend): type once real shapes are observed. */ + conflicts?: UntypedGenericJSON[]; + /** TODO(pkgseer-backend): type once real shapes are observed. */ + circularDependencies?: UntypedGenericJSON[]; + /** + * Raw DAG blob; backend-defined `GenericJSON` passthrough. + * TODO(pkgseer-backend): type once real shapes are observed. + */ + dag?: UntypedGenericJSON; +} + +export interface DependencyBundle { + direct?: DirectDependency[]; + transitive?: TransitiveDependencySummary; +} + +export interface GroupDependency { + name: string; + constraint?: string; +} + +export interface DependencyGroup { + name: string; + lifecycle: string; + conditionType: string; + conditionValue?: string; + selectionMode: string; + exclusiveGroup?: string; + fallbackPriority?: number; + compatibleWith?: string[]; + defaultEnabled?: boolean; + dependencies: GroupDependency[]; +} + +export interface DependencyGroupsInfo { + primaryGroup?: string; + /** TODO(pkgseer-backend): type once real shapes are observed. */ + environmentConstraints?: UntypedGenericJSON[]; + groups: DependencyGroup[]; +} + +export interface DependencyReport { + package: PackageVersionIdentity; + dependencies?: DependencyBundle; + dependencyGroups?: DependencyGroupsInfo; +} + export interface PackageIntelligenceService { packageSummary(params: PackageSummaryParams): Promise; packageVulnerabilities( params: PackageVulnerabilitiesParams, ): Promise; + packageDependencies( + params: PackageDependenciesParams, + ): Promise; } // -------------------------------------------------------------------- @@ -456,6 +554,142 @@ query PackageVulnerabilities( } }`; +// -------------------------------------------------------------------- +// Zod schema + query for packageDependencies +// -------------------------------------------------------------------- + +const directDependencySchema = z.object({ + name: z.string().nullable().optional(), + versionConstraint: z.string().nullable().optional(), + type: z.string().nullable().optional(), +}); + +const transitiveDependencySchema = z + .object({ + totalEdges: z.number().int().nullable().optional(), + uniquePackagesCount: z.number().int().nullable().optional(), + uniqueDependencies: z.array(z.string()).nullable().optional(), + conflicts: z.array(z.unknown()).nullable().optional(), + circularDependencies: z.array(z.unknown()).nullable().optional(), + dag: z.unknown().nullable().optional(), + }) + .nullable() + .optional(); + +const dependencyBundleSchema = z + .object({ + direct: z.array(directDependencySchema).nullable().optional(), + transitive: transitiveDependencySchema, + }) + .nullable() + .optional(); + +const groupDependencySchema = z.object({ + name: z.string(), + constraint: z.string().nullable().optional(), +}); + +const dependencyGroupSchema = z.object({ + name: z.string(), + lifecycle: z.string(), + conditionType: z.string(), + conditionValue: z.string().nullable().optional(), + selectionMode: z.string(), + exclusiveGroup: z.string().nullable().optional(), + fallbackPriority: z.number().int().nullable().optional(), + compatibleWith: z.array(z.string()).nullable().optional(), + defaultEnabled: z.boolean().nullable().optional(), + dependencies: z.array(groupDependencySchema), +}); + +const dependencyGroupsInfoSchema = z + .object({ + primaryGroup: z.string().nullable().optional(), + environmentConstraints: z.array(z.unknown()).nullable().optional(), + groups: z.array(dependencyGroupSchema), + }) + .nullable() + .optional(); + +const dependencyReportResponseSchema = z.object({ + package: packageVersionIdentitySchema.nullable().optional(), + dependencies: dependencyBundleSchema, + dependencyGroups: dependencyGroupsInfoSchema, +}); + +const dependenciesGraphQLResponseSchema = z.object({ + data: z + .object({ + packageDependencies: dependencyReportResponseSchema.nullable().optional(), + }) + .nullable() + .optional(), + errors: z.array(graphQLErrorSchema).optional(), +}); + +const PACKAGE_DEPENDENCIES_QUERY = ` +query PackageDependencies( + $registry: Registry! + $name: String! + $version: String + $includeTransitive: Boolean + $maxDepth: Int + $lifecycle: [String!] +) { + packageDependencies( + registry: $registry + name: $name + version: $version + includeTransitive: $includeTransitive + maxDepth: $maxDepth + lifecycle: $lifecycle + ) { + package { + name + registry + version + } + dependencies { + # Backend-side summary block intentionally not selected — our + # envelope computes runtime.count client-side from direct[].length + # so the invariant runtime.count === runtime.items.length always + # holds regardless of backend-side drift. + direct { + name + versionConstraint + type + } + transitive { + totalEdges + uniquePackagesCount + uniqueDependencies + conflicts + circularDependencies + dag + } + } + dependencyGroups { + primaryGroup + environmentConstraints + groups { + name + lifecycle + conditionType + conditionValue + selectionMode + exclusiveGroup + fallbackPriority + compatibleWith + defaultEnabled + dependencies { + name + constraint + } + } + } + } +}`; + // -------------------------------------------------------------------- // Service implementation // -------------------------------------------------------------------- @@ -760,7 +994,7 @@ export class PackageIntelligenceServiceImpl } if (parsed.data.errors && parsed.data.errors.length > 0) { - throw promoteVersionNotFound( + throw promoteGenericVersionNotFound( this.createGraphQLError(parsed.data.errors), params, ); @@ -821,6 +1055,166 @@ export class PackageIntelligenceServiceImpl security, }; } + + async packageDependencies( + params: PackageDependenciesParams, + ): Promise { + return executeWithTokenRefresh({ + getToken: () => this.tokenProvider.getToken(), + forceRefresh: () => this.tokenProvider.forceRefresh(), + shouldRefresh: (error) => error instanceof AuthenticationError, + executeWithToken: (token) => + this.executePackageDependencies(token, params), + }); + } + + private async executePackageDependencies( + token: string, + params: PackageDependenciesParams, + ): Promise { + let response: PkgseerGraphqlResponse; + try { + response = await postPkgseerGraphql({ + endpointUrl: this.endpointUrl, + token, + query: PACKAGE_DEPENDENCIES_QUERY, + variables: { + registry: params.registry, + name: params.packageName, + version: params.version, + includeTransitive: params.includeTransitive, + maxDepth: params.maxDepth, + lifecycle: + params.lifecycle && params.lifecycle.length > 0 + ? params.lifecycle + : undefined, + }, + fetchFn: this.fetchFn, + }); + } catch (cause) { + if (cause instanceof PkgseerTransportError) { + throw new PackageIntelligenceNetworkError( + "Could not reach the package intelligence service. Check your connection or set GITHITS_CODE_NAV_URL.", + { cause }, + ); + } + throw cause; + } + + if (response.status < 200 || response.status >= 300) { + throw this.createHttpError(response); + } + + const parsed = dependenciesGraphQLResponseSchema.safeParse( + response.parsedBody, + ); + if (!parsed.success) { + throw new MalformedPackageIntelligenceResponseError( + "Malformed response from the package-intelligence service.", + ); + } + + if (parsed.data.errors && parsed.data.errors.length > 0) { + throw promoteGenericVersionNotFound( + this.createGraphQLError(parsed.data.errors), + params, + ); + } + + const data = parsed.data.data?.packageDependencies; + if (!data) { + throw new MalformedPackageIntelligenceResponseError( + "Empty response from the package-intelligence service.", + ); + } + + return this.normaliseDependencyReport(data); + } + + private normaliseDependencyReport( + data: z.infer, + ): DependencyReport { + const name = data.package?.name ?? undefined; + const version = data.package?.version ?? undefined; + if (!name || !version) { + throw new MalformedPackageIntelligenceResponseError( + "Package dependencies response missing required name/version.", + ); + } + + const identity: PackageVersionIdentity = { + name, + version, + registry: data.package?.registry ?? undefined, + }; + + const bundle = data.dependencies; + const dependencies: DependencyBundle | undefined = bundle + ? { + direct: + bundle.direct?.map((entry) => { + // `name` is schema-level nullable but semantically + // required — a dep entry with no name is meaningless + // and silently collapsing to `""` would hide backend + // bugs. Throw Malformed instead, matching how we + // handle package.name/version upstream. + if (!entry.name) { + throw new MalformedPackageIntelligenceResponseError( + "Dependency entry missing required name.", + ); + } + return { + name: entry.name, + versionConstraint: entry.versionConstraint ?? undefined, + type: entry.type ?? undefined, + }; + }) ?? undefined, + transitive: bundle.transitive + ? { + totalEdges: bundle.transitive.totalEdges ?? undefined, + uniquePackagesCount: + bundle.transitive.uniquePackagesCount ?? undefined, + uniqueDependencies: + bundle.transitive.uniqueDependencies ?? undefined, + conflicts: bundle.transitive.conflicts ?? undefined, + circularDependencies: + bundle.transitive.circularDependencies ?? undefined, + dag: bundle.transitive.dag ?? undefined, + } + : undefined, + } + : undefined; + + const dependencyGroups: DependencyGroupsInfo | undefined = + data.dependencyGroups + ? { + primaryGroup: data.dependencyGroups.primaryGroup ?? undefined, + environmentConstraints: + data.dependencyGroups.environmentConstraints ?? undefined, + groups: data.dependencyGroups.groups.map((group) => ({ + name: group.name, + lifecycle: group.lifecycle, + conditionType: group.conditionType, + conditionValue: group.conditionValue ?? undefined, + selectionMode: group.selectionMode, + exclusiveGroup: group.exclusiveGroup ?? undefined, + fallbackPriority: group.fallbackPriority ?? undefined, + compatibleWith: group.compatibleWith ?? undefined, + defaultEnabled: group.defaultEnabled ?? undefined, + dependencies: group.dependencies.map((entry) => ({ + name: entry.name, + constraint: entry.constraint ?? undefined, + })), + })), + } + : undefined; + + return { + package: identity, + dependencies, + dependencyGroups, + }; + } } function parseDetail(body: string): string | undefined { @@ -863,48 +1257,3 @@ function parseVersionList(raw: unknown): string[] | undefined { } return versions.length > 0 ? versions : undefined; } - -/** - * Fallback: if the backend returns a generic backend error whose - * message matches the well-known "no matching version" phrase and the - * caller explicitly requested a version, promote it to the typed - * {@link PackageIntelligenceVersionNotFoundError} so downstream - * surfaces can render structured, actionable error details. - * - * TODO(pkgseer-backend): remove this helper once the upstream - * `packageVulnerabilities` resolver emits - * `extensions.code = "VERSION_NOT_FOUND"` with `package`, - * `requested_version`, and `available_versions`. The typed path in - * `createGraphQLError` already handles that shape; deleting this - * helper + its two fallback-specific service tests will be the only - * cleanup needed, and the typed-error parity test will catch any - * regression in the structured-details envelope. - * - * Guard rails: - * - Only promotes when `graphqlCode` is absent. Any explicit code - * (including INTERNAL_ERROR, UPSTREAM_ERROR, TIMEOUT, …) is - * respected as-is so we never swallow real backend signalling or - * flip retryability. - * - Only promotes when `params.version` is set — if the caller asked - * for "latest", a "no matching version" message can only reflect - * an unrelated upstream condition, not a caller-addressable one. - * - `details.package` is qualified with the lowercase registry - * prefix (e.g. `"npm:lodash"`) so CLI / MCP output matches the - * shape produced when the backend sends the typed code. - */ -function promoteVersionNotFound( - error: Error, - params: PackageVulnerabilitiesParams, -): Error { - if (!(error instanceof PackageIntelligenceBackendError)) return error; - if (error.graphqlCode !== undefined) return error; - if (!params.version) return error; - if (!/no matching version/i.test(error.message)) return error; - const qualifiedName = `${params.registry.toLowerCase()}:${params.packageName}`; - return new PackageIntelligenceVersionNotFoundError( - error.message, - qualifiedName, - params.version, - undefined, - ); -} diff --git a/src/services/promote-version-not-found.test.ts b/src/services/promote-version-not-found.test.ts new file mode 100644 index 00000000..0731d1af --- /dev/null +++ b/src/services/promote-version-not-found.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "bun:test"; +import { + PackageIntelligenceBackendError, + PackageIntelligenceTargetNotFoundError, + PackageIntelligenceVersionNotFoundError, +} from "./package-intelligence-service.js"; +import { promoteGenericVersionNotFound } from "./promote-version-not-found.js"; + +const params = { + registry: "NPM" as const, + packageName: "lodash", + version: "99.99.99", +}; + +describe("promoteGenericVersionNotFound", () => { + it("promotes a generic backend error with matching message when graphqlCode is absent and version is set", () => { + const generic = new PackageIntelligenceBackendError( + "No matching version found", + ); + const promoted = promoteGenericVersionNotFound(generic, params); + expect(promoted).toBeInstanceOf(PackageIntelligenceVersionNotFoundError); + const typed = promoted as PackageIntelligenceVersionNotFoundError; + expect(typed.packageName).toBe("npm:lodash"); + expect(typed.requestedVersion).toBe("99.99.99"); + expect(typed.availableVersions).toBeUndefined(); + expect(typed.message).toBe("No matching version found"); + }); + + it("does not promote when graphqlCode is present (backend sent real signal)", () => { + const internal = new PackageIntelligenceBackendError( + "no matching version table while the cluster was recovering", + undefined, + "INTERNAL_ERROR", + ); + expect(promoteGenericVersionNotFound(internal, params)).toBe(internal); + }); + + it("does not promote when caller asked for latest (no version in params)", () => { + const generic = new PackageIntelligenceBackendError( + "No matching version found", + ); + expect( + promoteGenericVersionNotFound(generic, { ...params, version: undefined }), + ).toBe(generic); + }); + + it("does not promote when message does not match the /no matching version/i pattern", () => { + const generic = new PackageIntelligenceBackendError( + "Backend is briefly offline", + ); + expect(promoteGenericVersionNotFound(generic, params)).toBe(generic); + }); + + it("does not promote a non-BackendError", () => { + const notFound = new PackageIntelligenceTargetNotFoundError( + "package not found", + ); + expect(promoteGenericVersionNotFound(notFound, params)).toBe(notFound); + }); + + it("lowercases the registry prefix when qualifying details.package", () => { + const generic = new PackageIntelligenceBackendError( + "No matching version found", + ); + const promoted = promoteGenericVersionNotFound(generic, { + registry: "CRATES", + packageName: "serde", + version: "0.99.0", + }) as PackageIntelligenceVersionNotFoundError; + expect(promoted.packageName).toBe("crates:serde"); + }); + + it("matches the phrase case-insensitively", () => { + const generic = new PackageIntelligenceBackendError( + "no matching VERSION found", + ); + expect(promoteGenericVersionNotFound(generic, params)).toBeInstanceOf( + PackageIntelligenceVersionNotFoundError, + ); + }); +}); diff --git a/src/services/promote-version-not-found.ts b/src/services/promote-version-not-found.ts new file mode 100644 index 00000000..5c364179 --- /dev/null +++ b/src/services/promote-version-not-found.ts @@ -0,0 +1,66 @@ +/** + * Shared "generic error → typed `VERSION_NOT_FOUND`" promoter. + * + * Called from versioned query executors (`packageVulnerabilities`, + * `packageDependencies`) right after `createGraphQLError`. When the + * backend has not yet been updated to emit `extensions.code = + * "VERSION_NOT_FOUND"` with structured `package` / `requested_version` + * / `available_versions` fields, it falls back to a generic + * backend error with the literal message "No matching version + * found". This helper recognises that shape and promotes it to the + * typed {@link PackageIntelligenceVersionNotFoundError} so downstream + * surfaces can render structured, actionable error details. + * + * TODO(pkgseer-backend): remove once the upstream resolvers all emit + * the typed `extensions.code = "VERSION_NOT_FOUND"` payload. The typed + * path in `createGraphQLError` already handles the structured shape; + * deleting this helper plus its fallback-specific service tests will + * be the only cleanup needed, and the typed-error parity tests will + * catch any regression in the structured-details envelope. + * + * Guard rails: + * - Only promotes when `graphqlCode` is absent. Any explicit code + * (including INTERNAL_ERROR, UPSTREAM_ERROR, TIMEOUT, …) is + * respected as-is so we never swallow real backend signalling or + * flip retryability. + * - Only promotes when `params.version` is set — if the caller asked + * for "latest", a "no matching version" message can only reflect + * an unrelated upstream condition, not a caller-addressable one. + * - `details.package` is qualified with the lowercase registry prefix + * (e.g. `"npm:lodash"`) so CLI / MCP output matches the shape + * produced when the backend sends the typed code. + */ + +import type { PkgseerRegistry } from "../shared/pkgseer-registry.js"; +import { + PackageIntelligenceBackendError, + PackageIntelligenceVersionNotFoundError, +} from "./package-intelligence-service.js"; + +/** + * Minimal shape shared by every versioned-query params type we route + * through this helper. `registry` is the uppercase GraphQL enum value; + * we lowercase it for the qualified package name. + */ +export interface PromotableVersionedQueryParams { + registry: PkgseerRegistry; + packageName: string; + version?: string; +} + +export function promoteGenericVersionNotFound( + error: Error, + params: PromotableVersionedQueryParams, +): Error { + if (!(error instanceof PackageIntelligenceBackendError)) return error; + if (error.graphqlCode !== undefined) return error; + if (!params.version) return error; + if (!/no matching version/i.test(error.message)) return error; + const qualifiedName = `${params.registry.toLowerCase()}:${params.packageName}`; + return new PackageIntelligenceVersionNotFoundError( + error.message, + qualifiedName, + params.version, + undefined, + ); +} diff --git a/src/services/test-helpers.ts b/src/services/test-helpers.ts index bbe27f1a..70227e30 100644 --- a/src/services/test-helpers.ts +++ b/src/services/test-helpers.ts @@ -21,6 +21,7 @@ import type { FileSystemService } from "./filesystem-service.js"; import type { GitHitsService } from "./githits-service.js"; import type { KeyringService } from "./keyring-service.js"; import type { + DependencyReport, PackageIntelligenceService, PackageSummary, VulnerabilityReport, @@ -375,6 +376,122 @@ export const defaultVulnerabilityReport: VulnerabilityReport = { }, }; +/** + * Fully-populated `DependencyReport` fixture — npm:express shape with + * a runtime group + a development group. No transitive, no conflicts, + * no circular deps. + */ +export const defaultDependencyReport: DependencyReport = { + package: { + name: "express", + registry: "NPM", + version: "5.2.1", + }, + dependencies: { + direct: [ + { name: "accepts", versionConstraint: "^2.0.0", type: "runtime" }, + { name: "body-parser", versionConstraint: "^2.2.1", type: "runtime" }, + { name: "cookie", versionConstraint: "^0.7.1", type: "runtime" }, + ], + }, + dependencyGroups: { + primaryGroup: undefined, + groups: [ + { + name: "runtime", + lifecycle: "runtime", + conditionType: "always", + selectionMode: "required", + dependencies: [ + { name: "accepts", constraint: "^2.0.0" }, + { name: "body-parser", constraint: "^2.2.1" }, + { name: "cookie", constraint: "^0.7.1" }, + ], + }, + { + name: "development", + lifecycle: "development", + conditionType: "always", + selectionMode: "required", + dependencies: [ + { name: "mocha", constraint: "^10.7.3" }, + { name: "supertest", constraint: "^6.3.0" }, + ], + }, + ], + }, +}; + +/** + * Zero-dep fixture — left-pad shape. Backend returns + * `dependencyGroups: null` for packages without group metadata, which + * is the shape the envelope's omission rules key off of. + */ +export const zeroDepDependencyReport: DependencyReport = { + package: { + name: "left-pad", + registry: "NPM", + version: "1.3.0", + }, + dependencies: { direct: [] }, +}; + +/** + * Crates-shape fixture — tokio with runtime + development + optional + * feature groups, exercising conditionType=feature and conditionValue. + * Includes a synthetic duplicate so terminal-only dedup can be asserted. + */ +export const cratesFeatureDependencyReport: DependencyReport = { + package: { + name: "tokio", + registry: "CRATES", + version: "1.52.1", + }, + dependencies: { + direct: [ + { + name: "pin-project-lite", + versionConstraint: "^0.2.11", + type: "runtime", + }, + ], + }, + dependencyGroups: { + primaryGroup: undefined, + groups: [ + { + name: "runtime", + lifecycle: "runtime", + conditionType: "always", + selectionMode: "required", + dependencies: [{ name: "pin-project-lite", constraint: "^0.2.11" }], + }, + { + name: "full", + lifecycle: "optional", + conditionType: "feature", + conditionValue: "full", + selectionMode: "additive", + defaultEnabled: false, + dependencies: [{ name: "parking_lot", constraint: "^0.12.0" }], + }, + { + name: "net", + lifecycle: "optional", + conditionType: "feature", + conditionValue: "net", + selectionMode: "additive", + defaultEnabled: false, + dependencies: [ + { name: "libc", constraint: "^0.2.168" }, + { name: "libc", constraint: "^0.2.168" }, + { name: "mio", constraint: "^1.2.0" }, + ], + }, + ], + }, +}; + /** * Creates a mock PackageIntelligenceService. Defaults resolve to the * fully-populated fixtures; override per-test as needed. @@ -387,6 +504,7 @@ export function createMockPackageIntelligenceService( packageVulnerabilities: mock(() => Promise.resolve(defaultVulnerabilityReport), ), + packageDependencies: mock(() => Promise.resolve(defaultDependencyReport)), ...impl, }; } diff --git a/src/shared/index.ts b/src/shared/index.ts index e1113e4c..5baf5202 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -41,6 +41,27 @@ export { InvalidKeywordsError, normaliseKeywords, } from "./normalise-keywords.js"; +export { + buildPackageDependenciesParams, + type DependencyLifecycle, + isLifecycle, + type PackageDependenciesRequestBuildResult, + type PackageDependenciesRequestInput, + supportsDependenciesRegistry, + UnsupportedDependenciesRegistryError, +} from "./package-dependencies-request.js"; +export { + buildPackageDependenciesSuccessPayload, + formatPackageDependenciesTerminal, + type LeanDependencyReport, + type LeanDirectDependency, + type LeanFilterBlock, + type LeanGroup, + type LeanGroupDependency, + type LeanGroupsBlock, + type LeanRuntimeBlock, + type LeanTransitiveBlock, +} from "./package-dependencies-response.js"; export { mapPackageIntelligenceError } from "./package-intelligence-error-map.js"; export { InvalidArgumentError, diff --git a/src/shared/package-dependencies-request.test.ts b/src/shared/package-dependencies-request.test.ts new file mode 100644 index 00000000..f076f174 --- /dev/null +++ b/src/shared/package-dependencies-request.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "bun:test"; +import { + buildPackageDependenciesParams, + UnsupportedDependenciesRegistryError, +} from "./package-dependencies-request.js"; +import { + InvalidPackageSpecError, + UnsupportedRegistryError, +} from "./package-spec.js"; + +describe("buildPackageDependenciesParams — registry matrix", () => { + it.each([ + ["npm", "NPM"], + ["pypi", "PYPI"], + ["hex", "HEX"], + ["crates", "CRATES"], + ["vcpkg", "VCPKG"], + ["zig", "ZIG"], + ] as const)("accepts registry %s", (arg, expected) => { + const result = buildPackageDependenciesParams({ + registry: arg, + packageName: "example", + }); + expect(result.params.registry).toBe(expected); + }); + + it.each([ + ["nuget"], + ["maven"], + ["packagist"], + ] as const)("rejects registry %s with tool-specific message", (arg) => { + expect(() => + buildPackageDependenciesParams({ registry: arg, packageName: "x" }), + ).toThrow(UnsupportedDependenciesRegistryError); + }); + + it("rejects truly unknown registries via the shared UnsupportedRegistryError", () => { + expect(() => + buildPackageDependenciesParams({ registry: "cargo", packageName: "x" }), + ).toThrow(UnsupportedRegistryError); + }); + + it("requires a non-empty package name", () => { + expect(() => + buildPackageDependenciesParams({ registry: "npm", packageName: " " }), + ).toThrow(InvalidPackageSpecError); + }); +}); + +describe("buildPackageDependenciesParams — version handling", () => { + it("passes through canonical versions", () => { + const { params } = buildPackageDependenciesParams({ + registry: "npm", + packageName: "express", + version: "5.2.1", + }); + expect(params.version).toBe("5.2.1"); + }); + + it("rejects tag-style versions with a tag-prefix hint", () => { + try { + buildPackageDependenciesParams({ + registry: "npm", + packageName: "express", + version: "v4.18.0", + }); + throw new Error("expected rejection"); + } catch (err) { + expect(err).toBeInstanceOf(InvalidPackageSpecError); + expect((err as Error).message).toContain("git tag"); + expect((err as Error).message).toContain("4.18.0"); + } + }); + + it("rejects a bare 'v' (would be ambiguous as 'latest')", () => { + expect(() => + buildPackageDependenciesParams({ + registry: "npm", + packageName: "express", + version: "v", + }), + ).not.toThrow(); // "v" alone isn't matching /^v[0-9]/ so it'll flow through; backend will reject + }); + + it("omits empty version strings", () => { + const { params } = buildPackageDependenciesParams({ + registry: "npm", + packageName: "express", + version: " ", + }); + expect(params.version).toBeUndefined(); + }); +}); + +describe("buildPackageDependenciesParams — lifecycle parsing", () => { + it("parses a single token", () => { + const { params, canonicalLifecycles } = buildPackageDependenciesParams({ + registry: "npm", + packageName: "x", + lifecycle: "runtime", + }); + expect(params.lifecycle).toEqual(["runtime"]); + expect(canonicalLifecycles).toEqual(["runtime"]); + }); + + it("parses a CSV list, deduplicates, and sorts canonically", () => { + const { params, canonicalLifecycles } = buildPackageDependenciesParams({ + registry: "npm", + packageName: "x", + lifecycle: "optional,development,runtime,development", + }); + expect(canonicalLifecycles).toEqual(["runtime", "development", "optional"]); + expect(params.lifecycle).toEqual(["runtime", "development", "optional"]); + }); + + it("tolerates uppercase / whitespace / repeats", () => { + const { canonicalLifecycles } = buildPackageDependenciesParams({ + registry: "npm", + packageName: "x", + lifecycle: " DEVELOPMENT , Runtime ,,development ", + }); + expect(canonicalLifecycles).toEqual(["runtime", "development"]); + }); + + it("accepts pre-split arrays equivalently to CSV", () => { + const { canonicalLifecycles } = buildPackageDependenciesParams({ + registry: "npm", + packageName: "x", + lifecycle: ["development", "runtime"], + }); + expect(canonicalLifecycles).toEqual(["runtime", "development"]); + }); + + it("rejects unknown tokens with an actionable message", () => { + try { + buildPackageDependenciesParams({ + registry: "npm", + packageName: "x", + lifecycle: "dev", + }); + throw new Error("expected rejection"); + } catch (err) { + expect(err).toBeInstanceOf(InvalidPackageSpecError); + expect((err as Error).message).toContain("Unknown lifecycle 'dev'"); + expect((err as Error).message).toContain("runtime, development, build"); + } + }); + + it("treats empty lifecycle as no filter (lifecycle undefined on wire)", () => { + const { params, canonicalLifecycles } = buildPackageDependenciesParams({ + registry: "npm", + packageName: "x", + lifecycle: "", + }); + expect(params.lifecycle).toBeUndefined(); + expect(canonicalLifecycles).toEqual([]); + }); +}); + +describe("buildPackageDependenciesParams — depth bounds", () => { + it.each([1, 5, 10])("accepts depth %i", (depth) => { + const { params } = buildPackageDependenciesParams({ + registry: "npm", + packageName: "x", + maxDepth: depth, + }); + expect(params.maxDepth).toBe(depth); + }); + + it.each([0, 11, -1, 3.5])("rejects invalid depth %s", (depth) => { + expect(() => + buildPackageDependenciesParams({ + registry: "npm", + packageName: "x", + maxDepth: depth as number, + }), + ).toThrow(InvalidPackageSpecError); + }); + + it("omits depth when undefined", () => { + const { params } = buildPackageDependenciesParams({ + registry: "npm", + packageName: "x", + }); + expect(params.maxDepth).toBeUndefined(); + }); +}); diff --git a/src/shared/package-dependencies-request.ts b/src/shared/package-dependencies-request.ts new file mode 100644 index 00000000..3468ed2c --- /dev/null +++ b/src/shared/package-dependencies-request.ts @@ -0,0 +1,207 @@ +/** + * Shared request builder for the `package_dependencies` tool. Both + * the CLI command and the MCP tool normalise their inputs here so the + * two surfaces cannot diverge on validation rules, registry coercion, + * or lifecycle parsing. + * + * Responsibilities: + * - Trim + validate `packageName`. + * - Normalise registry case and restrict to the registries that the + * upstream `packageDependencies` resolver supports (`npm, pypi, hex, + * crates, vcpkg, zig`). Other known registries are rejected with a + * tool-specific message; truly unknown registries fall through to + * the shared `UnsupportedRegistryError`. + * - Reject tag-style versions (`v4.18.0`) client-side — the `v` prefix + * is a git-tag convention, not a canonical version on any supported + * registry. + * - Parse the comma-separated lifecycle list into the canonical + * lowercase enum set; reject unknown tokens. + * - Enforce `maxDepth` bounds (1–10). + */ + +import type { PackageDependenciesParams } from "../services/index.js"; +import { + InvalidPackageSpecError, + UnsupportedRegistryError, +} from "./package-spec.js"; +import { + isKnownPkgseerRegistryArg, + type PkgseerRegistry, + type PkgseerRegistryArg, + toPkgseerRegistry, +} from "./pkgseer-registry.js"; + +/** + * Raised when the caller targets a registry that is unsupported by + * the `packageDependencies` query specifically. Name-prefix + * `Unsupported` routes via the shared classifier to + * `INVALID_ARGUMENT`. Message is tool-specific. + */ +export class UnsupportedDependenciesRegistryError extends Error { + constructor(message: string) { + super(message); + this.name = "UnsupportedDependenciesRegistryError"; + } +} + +export type DependencyLifecycle = + | "runtime" + | "development" + | "build" + | "peer" + | "optional"; + +const LIFECYCLES: readonly DependencyLifecycle[] = [ + "runtime", + "development", + "build", + "peer", + "optional", +] as const; + +const LIFECYCLE_ORDER: Readonly> = { + runtime: 0, + development: 1, + build: 2, + peer: 3, + optional: 4, +}; + +const SUPPORTED_DEPS_REGISTRIES: ReadonlySet = new Set([ + "NPM", + "PYPI", + "HEX", + "CRATES", + "VCPKG", + "ZIG", +]); + +const SUPPORTED_DEPS_REGISTRIES_HUMAN = + "npm, pypi, hex, crates, vcpkg, and zig"; + +export function supportsDependenciesRegistry( + registry: PkgseerRegistry, +): boolean { + return SUPPORTED_DEPS_REGISTRIES.has(registry); +} + +export interface PackageDependenciesRequestInput { + /** Lowercase registry surface value (`npm`, `pypi`, …). */ + registry: string; + /** Raw package name — may carry surrounding whitespace. */ + packageName: string; + /** Optional version — backend defaults to latest when omitted. */ + version?: string; + /** Optional flag to include transitive graph. */ + includeTransitive?: boolean; + /** Optional traversal depth (1–10). */ + maxDepth?: number; + /** + * Optional lifecycle filter: CSV string or pre-split array. Tokens + * are trimmed, lowercased, validated, deduplicated, and sorted by + * canonical display order before going on the wire. Empty input is + * treated as no filter. + */ + lifecycle?: string | string[]; +} + +export interface PackageDependenciesRequestBuildResult { + params: PackageDependenciesParams; + /** + * Canonical lifecycle list that went on the wire (sorted, + * deduplicated). Surfaces verbatim as the envelope's + * `filter.lifecycles` when non-empty. Empty array means "no filter". + */ + canonicalLifecycles: DependencyLifecycle[]; +} + +export function buildPackageDependenciesParams( + input: PackageDependenciesRequestInput, +): PackageDependenciesRequestBuildResult { + const trimmedName = input.packageName?.trim() ?? ""; + if (!trimmedName) { + throw new InvalidPackageSpecError("Package name is required."); + } + + const normalisedRegistryArg = input.registry?.trim().toLowerCase() ?? ""; + if (!isKnownPkgseerRegistryArg(normalisedRegistryArg)) { + throw new UnsupportedRegistryError( + `Unsupported registry '${input.registry}'. Supported: npm, pypi, hex, crates, nuget, maven, zig, vcpkg, packagist.`, + ); + } + + const registry = toPkgseerRegistry( + normalisedRegistryArg as PkgseerRegistryArg, + ); + if (!supportsDependenciesRegistry(registry)) { + throw new UnsupportedDependenciesRegistryError( + `pkg deps only supports ${SUPPORTED_DEPS_REGISTRIES_HUMAN}. Got: ${normalisedRegistryArg}.`, + ); + } + + const version = normaliseVersion(input.version); + + const canonicalLifecycles = resolveLifecycles(input.lifecycle); + + const maxDepth = input.maxDepth; + if (maxDepth !== undefined) { + if (!Number.isInteger(maxDepth) || maxDepth < 1 || maxDepth > 10) { + throw new InvalidPackageSpecError( + `Transitive depth must be an integer between 1 and 10. Got ${maxDepth}.`, + ); + } + } + + return { + canonicalLifecycles, + params: { + registry, + packageName: trimmedName, + version, + includeTransitive: input.includeTransitive, + maxDepth, + lifecycle: + canonicalLifecycles.length > 0 ? canonicalLifecycles : undefined, + }, + }; +} + +function normaliseVersion(raw: string | undefined): string | undefined { + if (raw === undefined) return undefined; + const trimmed = raw.trim(); + if (trimmed.length === 0) return undefined; + if (/^v[0-9]/i.test(trimmed)) { + throw new InvalidPackageSpecError( + `Version '${trimmed}' looks like a git tag. Use the canonical version without a leading 'v' (e.g. ${trimmed.slice(1)}).`, + ); + } + return trimmed; +} + +function resolveLifecycles( + raw: string | string[] | undefined, +): DependencyLifecycle[] { + if (raw === undefined) return []; + const tokens = Array.isArray(raw) + ? raw.flatMap((entry) => entry.split(",")) + : raw.split(","); + const seen = new Set(); + for (const token of tokens) { + const trimmed = token.trim(); + if (trimmed.length === 0) continue; + const lower = trimmed.toLowerCase(); + if (!isLifecycle(lower)) { + throw new InvalidPackageSpecError( + `Unknown lifecycle '${trimmed}'. Expected one of: ${LIFECYCLES.join(", ")}.`, + ); + } + seen.add(lower); + } + return Array.from(seen).sort( + (a, b) => LIFECYCLE_ORDER[a] - LIFECYCLE_ORDER[b], + ); +} + +export function isLifecycle(value: string): value is DependencyLifecycle { + return (LIFECYCLES as readonly string[]).includes(value); +} diff --git a/src/shared/package-dependencies-response.test.ts b/src/shared/package-dependencies-response.test.ts new file mode 100644 index 00000000..9444d48f --- /dev/null +++ b/src/shared/package-dependencies-response.test.ts @@ -0,0 +1,1022 @@ +import { describe, expect, it } from "bun:test"; +import type { DependencyReport } from "../services/index.js"; +import { + cratesFeatureDependencyReport, + defaultDependencyReport, + zeroDepDependencyReport, +} from "../services/test-helpers.js"; +import { + buildPackageDependenciesSuccessPayload, + formatPackageDependenciesTerminal, +} from "./package-dependencies-response.js"; + +function clone(value: T): T { + return structuredClone(value); +} + +describe("buildPackageDependenciesSuccessPayload — runtime block", () => { + it("emits runtime block with client-computed count when backend returned direct[]", () => { + const payload = buildPackageDependenciesSuccessPayload( + defaultDependencyReport, + ); + expect(payload.runtime?.count).toBe(3); + expect(payload.runtime?.items.length).toBe(3); + expect(payload.runtime?.count).toBe(payload.runtime?.items.length); + }); + + it("emits runtime block with count:0 when direct[] is empty", () => { + const payload = buildPackageDependenciesSuccessPayload( + zeroDepDependencyReport, + ); + expect(payload.runtime).toEqual({ count: 0, items: [] }); + }); + + it("omits runtime block entirely when dependencies is absent", () => { + const fixture: DependencyReport = { + package: { name: "x", version: "1.0.0", registry: "NPM" }, + }; + const payload = buildPackageDependenciesSuccessPayload(fixture); + expect(payload.runtime).toBeUndefined(); + }); + + it("omits runtime block when direct is undefined", () => { + const fixture: DependencyReport = { + package: { name: "x", version: "1.0.0", registry: "NPM" }, + dependencies: {}, + }; + const payload = buildPackageDependenciesSuccessPayload(fixture); + expect(payload.runtime).toBeUndefined(); + }); +}); + +describe("buildPackageDependenciesSuccessPayload — groups block", () => { + it("emits groups block with items when backend returned dependencyGroups", () => { + const payload = buildPackageDependenciesSuccessPayload( + defaultDependencyReport, + ); + expect(payload.groups?.items.length).toBe(2); + expect(payload.groups?.items[0]?.name).toBe("runtime"); + expect(payload.groups?.items[1]?.name).toBe("development"); + }); + + it("omits groups block when dependencyGroups is absent", () => { + const payload = buildPackageDependenciesSuccessPayload( + zeroDepDependencyReport, + ); + expect(payload.groups).toBeUndefined(); + }); + + it("emits groups.items:[] when backend returned non-null groups with zero items (filter-matched-nothing)", () => { + const fixture: DependencyReport = { + package: { name: "x", version: "1.0.0", registry: "NPM" }, + dependencyGroups: { groups: [] }, + }; + const payload = buildPackageDependenciesSuccessPayload(fixture); + expect(payload.groups).toEqual({ items: [] }); + }); + + it("sorts groups: runtime first, then development, build, peer, optional (by defaultEnabled desc, name asc within optional)", () => { + const fixture: DependencyReport = { + package: { name: "x", version: "1.0.0", registry: "NPM" }, + dependencyGroups: { + groups: [ + { + name: "zeta", + lifecycle: "optional", + conditionType: "feature", + selectionMode: "additive", + defaultEnabled: false, + dependencies: [], + }, + { + name: "alpha", + lifecycle: "optional", + conditionType: "feature", + selectionMode: "additive", + defaultEnabled: true, + dependencies: [], + }, + { + name: "peer", + lifecycle: "peer", + conditionType: "always", + selectionMode: "required", + dependencies: [], + }, + { + name: "dev", + lifecycle: "development", + conditionType: "always", + selectionMode: "required", + dependencies: [], + }, + { + name: "runtime", + lifecycle: "runtime", + conditionType: "always", + selectionMode: "required", + dependencies: [], + }, + ], + }, + }; + const names = buildPackageDependenciesSuccessPayload( + fixture, + ).groups?.items.map((g) => g.name); + expect(names).toEqual(["runtime", "dev", "peer", "alpha", "zeta"]); + }); + + it("preserves duplicate {name, constraint} entries verbatim (dedup is terminal-only)", () => { + const payload = buildPackageDependenciesSuccessPayload( + cratesFeatureDependencyReport, + ); + const netGroup = payload.groups?.items.find((g) => g.name === "net"); + expect(netGroup?.items.length).toBe(3); // libc, libc, mio — not deduped + expect(netGroup?.items.filter((i) => i.name === "libc").length).toBe(2); + }); +}); + +describe("buildPackageDependenciesSuccessPayload — transitive block", () => { + it("omits transitive entirely when not requested", () => { + const fixture = clone(defaultDependencyReport); + fixture.dependencies = { + direct: fixture.dependencies?.direct, + transitive: { totalEdges: 80, uniquePackagesCount: 45 }, + }; + const payload = buildPackageDependenciesSuccessPayload(fixture, { + includeTransitive: false, + }); + expect(payload.transitive).toBeUndefined(); + }); + + it("emits transitive block with preprocessed `packages[]` (drops raw dag + uniqueDependencies)", () => { + const fixture = clone(defaultDependencyReport); + fixture.dependencies = { + direct: fixture.dependencies?.direct, + transitive: { + totalEdges: 80, + uniquePackagesCount: 45, + uniqueDependencies: ["accepts@2.0.0", "body-parser@2.2.2"], + dag: { + n: [ + ["npm", "express", "5.2.1"], + ["npm", "accepts", "2.0.0"], + ["npm", "body-parser", "2.2.2"], + ], + e: [ + [0, 1, "^2.0.0", "runtime"], + [0, 2, "^2.2.1", "runtime"], + ], + v: 4, + }, + }, + }; + const payload = buildPackageDependenciesSuccessPayload(fixture, { + includeTransitive: true, + includeImporters: true, + }); + expect(payload.transitive?.edges).toBe(80); + expect(payload.transitive?.uniquePackages).toBe(45); + expect(payload.transitive?.packages).toEqual([ + { + name: "accepts", + version: "2.0.0", + importers: [ + { name: "express", version: "5.2.1", constraint: "^2.0.0" }, + ], + }, + { + name: "body-parser", + version: "2.2.2", + importers: [ + { name: "express", version: "5.2.1", constraint: "^2.2.1" }, + ], + }, + ]); + // `dag` is no longer in the envelope (deferred to a future typed + // `pkg deps-dag` command); `uniqueDependencies` is subsumed by + // `packages[]`. + expect( + (payload.transitive as unknown as Record).dag, + ).toBeUndefined(); + expect( + (payload.transitive as unknown as Record) + .uniqueDependencies, + ).toBeUndefined(); + }); + + it("omits empty conflicts / circularDependencies arrays", () => { + const fixture = clone(defaultDependencyReport); + fixture.dependencies = { + direct: fixture.dependencies?.direct, + transitive: { + totalEdges: 1, + uniquePackagesCount: 1, + conflicts: [], + circularDependencies: [], + }, + }; + const payload = buildPackageDependenciesSuccessPayload(fixture, { + includeTransitive: true, + }); + expect(payload.transitive?.conflicts).toBeUndefined(); + expect(payload.transitive?.circularDependencies).toBeUndefined(); + }); + + it("preserves GenericJSON conflicts + cycles as opaque passthrough", () => { + const fixture = clone(defaultDependencyReport); + fixture.dependencies = { + direct: [], + transitive: { + totalEdges: 0, + uniquePackagesCount: 0, + conflicts: [{ package: "lodash", versions: ["4", "5"] }], + circularDependencies: [{ cycle: ["a", "b", "a"] }], + }, + }; + const payload = buildPackageDependenciesSuccessPayload(fixture, { + includeTransitive: true, + }); + expect(payload.transitive?.conflicts).toEqual([ + { package: "lodash", versions: ["4", "5"] }, + ]); + expect(payload.transitive?.circularDependencies).toEqual([ + { cycle: ["a", "b", "a"] }, + ]); + }); +}); + +describe("buildPackageDependenciesSuccessPayload — filter echo", () => { + it("omits filter when no canonical lifecycles", () => { + const payload = buildPackageDependenciesSuccessPayload( + defaultDependencyReport, + ); + expect(payload.filter).toBeUndefined(); + }); + + it("emits filter.lifecycles verbatim from canonical list", () => { + const payload = buildPackageDependenciesSuccessPayload( + defaultDependencyReport, + { canonicalLifecycles: ["runtime", "optional"] }, + ); + expect(payload.filter).toEqual({ lifecycles: ["runtime", "optional"] }); + }); +}); + +describe("buildPackageDependenciesSuccessPayload — version echo", () => { + it("omits requestedVersion on exact match", () => { + const payload = buildPackageDependenciesSuccessPayload( + defaultDependencyReport, + { requestedVersion: "5.2.1" }, + ); + expect(payload.requestedVersion).toBeUndefined(); + }); + + it("surfaces requestedVersion on any non-empty divergence", () => { + const payload = buildPackageDependenciesSuccessPayload( + defaultDependencyReport, + { requestedVersion: "5.2" }, + ); + expect(payload.requestedVersion).toBe("5.2"); + }); +}); + +describe("formatPackageDependenciesTerminal — runtime view", () => { + it("renders summary row + direct deps list + hidden-groups mention by name", () => { + const output = formatPackageDependenciesTerminal(defaultDependencyReport, { + useColors: false, + }); + expect(output).toContain("express @ 5.2.1 · npm"); + expect(output).toContain("3 direct runtime dependencies"); + expect(output).toContain("accepts"); + expect(output).toContain("^2.0.0"); + expect(output).toContain("Hidden groups: development — use --groups."); + }); + + it("renders zero-dep hot path under 3 lines", () => { + const output = formatPackageDependenciesTerminal(zeroDepDependencyReport, { + useColors: false, + }); + const lines = output.trimEnd().split("\n"); + expect(lines.length).toBeLessThanOrEqual(3); + expect(output).toContain("No direct runtime dependencies"); + }); + + it("pluralises vocabulary correctly for 1 dep", () => { + const fixture = clone(defaultDependencyReport); + if (fixture.dependencies?.direct) { + fixture.dependencies.direct = fixture.dependencies.direct.slice(0, 1); + } + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + }); + expect(output).toContain("1 direct runtime dependency"); + }); + + it("suppresses hidden-groups hint when only runtime group exists", () => { + const fixture: DependencyReport = { + package: { name: "x", version: "1.0.0", registry: "NPM" }, + dependencies: { direct: [{ name: "a", versionConstraint: "^1" }] }, + dependencyGroups: { + groups: [ + { + name: "runtime", + lifecycle: "runtime", + conditionType: "always", + selectionMode: "required", + dependencies: [{ name: "a", constraint: "^1" }], + }, + ], + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + }); + expect(output).not.toContain("hidden — use --groups"); + }); +}); + +describe("formatPackageDependenciesTerminal — groups view", () => { + it("renders groups with lifecycle summary header", () => { + const output = formatPackageDependenciesTerminal( + cratesFeatureDependencyReport, + { useColors: false, showGroups: true }, + ); + expect(output).toContain("tokio @ 1.52.1 · crates"); + expect(output).toContain("3 groups"); + expect(output).toContain("1 runtime, 2 optional"); + }); + + it("collapses heading to `name` for always-typed groups", () => { + const output = formatPackageDependenciesTerminal( + cratesFeatureDependencyReport, + { useColors: false, showGroups: true }, + ); + expect(output).toMatch(/^\s+runtime\s*$/m); + }); + + it("renders `name (lifecycle, conditionType)` when conditionValue === name", () => { + const output = formatPackageDependenciesTerminal( + cratesFeatureDependencyReport, + { useColors: false, showGroups: true }, + ); + expect(output).toContain("full (optional, feature)"); + expect(output).toContain("net (optional, feature)"); + }); + + it("renders `name (lifecycle, conditionType: conditionValue)` when they diverge", () => { + const fixture: DependencyReport = { + package: { name: "x", version: "1.0.0", registry: "NPM" }, + dependencyGroups: { + groups: [ + { + name: "group-alias", + lifecycle: "optional", + conditionType: "feature", + conditionValue: "the-feature-name", + selectionMode: "additive", + defaultEnabled: false, + dependencies: [{ name: "dep", constraint: "^1" }], + }, + ], + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + showGroups: true, + }); + expect(output).toContain( + "group-alias (optional, feature: the-feature-name)", + ); + }); + + it("dedups duplicate {name, constraint} entries in terminal rendering", () => { + const output = formatPackageDependenciesTerminal( + cratesFeatureDependencyReport, + { useColors: false, showGroups: true }, + ); + const libcLines = output.split("\n").filter((l) => /^\s+libc\b/.test(l)); + expect(libcLines.length).toBe(1); + }); + + it("shows conditionType/selectionMode under --verbose", () => { + const output = formatPackageDependenciesTerminal( + cratesFeatureDependencyReport, + { useColors: false, showGroups: true, verbose: true }, + ); + expect(output).toContain("selectionMode:"); + expect(output).toContain("defaultEnabled:"); + }); + + it("renders environmentConstraints block under --verbose when backend provides them", () => { + const fixture: DependencyReport = { + package: { name: "x", version: "1.0.0", registry: "NPM" }, + dependencyGroups: { + environmentConstraints: [{ platform: "linux" }, { platform: "macos" }], + groups: [ + { + name: "runtime", + lifecycle: "runtime", + conditionType: "always", + selectionMode: "required", + dependencies: [{ name: "a", constraint: "^1" }], + }, + ], + }, + }; + const verbose = formatPackageDependenciesTerminal(fixture, { + useColors: false, + showGroups: true, + verbose: true, + }); + expect(verbose).toContain("environmentConstraints (2):"); + expect(verbose).toContain('{"platform":"linux"}'); + + const nonVerbose = formatPackageDependenciesTerminal(fixture, { + useColors: false, + showGroups: true, + verbose: false, + }); + expect(nonVerbose).not.toContain("environmentConstraints"); + }); + + it("renders the filter-matched-nothing case with a helpful message", () => { + const fixture: DependencyReport = { + package: { name: "x", version: "1.0.0", registry: "NPM" }, + dependencyGroups: { groups: [] }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + showGroups: true, + canonicalLifecycles: ["build"], + }); + expect(output).toContain( + "No dependency groups matched lifecycle filter: build.", + ); + }); +}); + +describe("formatPackageDependenciesTerminal — transitive view", () => { + it("promotes edges + unique packages + depth into the summary line and flags no-conflicts case", () => { + const fixture = clone(defaultDependencyReport); + fixture.dependencies = { + direct: fixture.dependencies?.direct, + transitive: { + totalEdges: 80, + uniquePackagesCount: 45, + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + includeTransitive: true, + maxDepth: 3, + }); + expect(output).toContain( + "3 direct runtime dependencies · 80 transitive edges · 45 unique packages (max depth 3)", + ); + expect(output).toContain("No version conflicts or circular dependencies"); + }); + + it("omits depth from summary line when caller did not cap depth (MCP default path)", () => { + const fixture = clone(defaultDependencyReport); + fixture.dependencies = { + direct: fixture.dependencies?.direct, + transitive: { + totalEdges: 80, + uniquePackagesCount: 45, + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + includeTransitive: true, + }); + expect(output).toContain( + "3 direct runtime dependencies · 80 transitive edges · 45 unique packages", + ); + expect(output).not.toContain("depth"); + }); + + it("replaces direct list with full transitive list (alphabetical, no truncation)", () => { + const names = Array.from({ length: 25 }, (_, i) => `pkg-${i + 1}@1.0.0`); + const fixture = clone(defaultDependencyReport); + fixture.dependencies = { + direct: fixture.dependencies?.direct, + transitive: { + totalEdges: 25, + uniquePackagesCount: 25, + uniqueDependencies: names, + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + includeTransitive: true, + }); + // Every transitive entry renders, no truncation hint, no "use -v". + for (let i = 1; i <= 25; i++) { + expect(output).toContain(`pkg-${i}@1.0.0`); + } + expect(output).not.toContain("use -v"); + expect(output).not.toContain("more"); + // Direct runtime list (accepts, body-parser, cookie) is absent — + // --transitive replaces it. + expect(output).not.toMatch(/^\s\saccepts\s+\^2\.0\.0/m); + }); + + it("sorts transitive list alphabetically regardless of backend order", () => { + const fixture = clone(defaultDependencyReport); + fixture.dependencies = { + direct: fixture.dependencies?.direct, + transitive: { + totalEdges: 3, + uniquePackagesCount: 3, + uniqueDependencies: ["zulu@1.0.0", "alpha@2.0.0", "mike@3.0.0"], + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + includeTransitive: true, + }); + const depLines = output.split("\n").filter((l) => /^\s\s[a-z]/.test(l)); + expect(depLines.map((l) => l.trim().split(/@/)[0])).toEqual([ + "alpha", + "mike", + "zulu", + ]); + }); + + it("adds multi-line `- constraint required by importer@version` provenance under --transitive --verbose", () => { + const fixture = clone(defaultDependencyReport); + fixture.dependencies = { + direct: fixture.dependencies?.direct, + transitive: { + totalEdges: 3, + uniquePackagesCount: 3, + uniqueDependencies: ["accepts@2.0.0", "bytes@3.1.2"], + dag: { + n: [ + ["npm", "express", "5.2.1"], + ["npm", "accepts", "2.0.0"], + ["npm", "bytes", "3.1.2"], + ["npm", "body-parser", "2.2.2"], + ], + e: [ + [0, 1, "^2.0.0", "runtime"], + [0, 3, "^2.2.1", "runtime"], + [3, 2, "^3.0.0", "runtime"], + [0, 2, "^3.1.0", "runtime"], + ], + v: 4, + }, + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + includeTransitive: true, + verbose: true, + }); + expect(output).toContain("accepts@2.0.0"); + expect(output).toContain("- ^2.0.0 required by express@5.2.1"); + expect(output).toContain("bytes@3.1.2"); + // bytes has two importers with different constraints — one + // bullet per unique constraint. + expect(output).toContain("- ^3.0.0 required by body-parser@2.2.2"); + expect(output).toContain("- ^3.1.0 required by express@5.2.1"); + }); + + it("collapses multiple importers with the same constraint onto one bullet", () => { + const fixture = clone(defaultDependencyReport); + fixture.dependencies = { + direct: fixture.dependencies?.direct, + transitive: { + totalEdges: 5, + uniquePackagesCount: 5, + uniqueDependencies: ["leaf@1.0.0"], + dag: { + n: [ + ["npm", "root", "1.0.0"], + ["npm", "a", "1.0.0"], + ["npm", "b", "1.0.0"], + ["npm", "c", "1.0.0"], + ["npm", "leaf", "1.0.0"], + ], + e: [ + [0, 1, "^1", "runtime"], + [0, 2, "^1", "runtime"], + [0, 3, "^1", "runtime"], + // Three importers all expressing ^1 for leaf — group them. + [1, 4, "^1", "runtime"], + [2, 4, "^1", "runtime"], + [3, 4, "^1", "runtime"], + ], + v: 4, + }, + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + includeTransitive: true, + verbose: true, + }); + expect(output).toContain(" - ^1 required by a@1.0.0, b@1.0.0, c@1.0.0"); + // Not 3 separate bullets. + expect( + output.split("\n").filter((l) => l.includes("^1 required by")).length, + ).toBe(1); + }); + + it("summary row combines counts + hidden-groups on one block", () => { + const output = formatPackageDependenciesTerminal(defaultDependencyReport, { + useColors: false, + }); + // Both lines in the header block: count line then hidden-groups line. + expect(output).toMatch( + /3 direct runtime dependencies\nHidden groups: development — use --groups\./, + ); + }); + + it("omits hidden-groups line when --groups is active (nothing is hidden)", () => { + const output = formatPackageDependenciesTerminal(defaultDependencyReport, { + useColors: false, + showGroups: true, + }); + expect(output).not.toContain("Hidden groups"); + }); + + it("lists hidden groups by name across many lifecycles (no aggregate rollup)", () => { + const output = formatPackageDependenciesTerminal( + cratesFeatureDependencyReport, + { useColors: false }, + ); + // Tokio has one `full` group + one `net` group under optional — + // both names appear in the hidden-groups line. + expect(output).toContain("Hidden groups:"); + expect(output).toContain("full"); + expect(output).toContain("net"); + }); + + it("groups view composes as a separate block beneath direct deps list", () => { + const output = formatPackageDependenciesTerminal(defaultDependencyReport, { + useColors: false, + showGroups: true, + }); + const directIdx = output.indexOf("accepts"); + const groupsHeadingIdx = output.indexOf("2 groups"); + expect(directIdx).toBeGreaterThan(0); + expect(groupsHeadingIdx).toBeGreaterThan(directIdx); + }); + + it("groups block composes beneath transitive list under --transitive --groups", () => { + const fixture = clone(defaultDependencyReport); + fixture.dependencies = { + direct: fixture.dependencies?.direct, + transitive: { + totalEdges: 3, + uniquePackagesCount: 3, + uniqueDependencies: ["alpha@1", "beta@2", "gamma@3"], + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + showGroups: true, + includeTransitive: true, + }); + const transitiveIdx = output.indexOf("alpha@1"); + const groupsHeadingIdx = output.indexOf("2 groups"); + expect(transitiveIdx).toBeGreaterThan(0); + expect(groupsHeadingIdx).toBeGreaterThan(transitiveIdx); + }); + + it("silently omits provenance when DAG shape is undecodable", () => { + const fixture = clone(defaultDependencyReport); + fixture.dependencies = { + direct: fixture.dependencies?.direct, + transitive: { + totalEdges: 1, + uniquePackagesCount: 1, + uniqueDependencies: ["accepts@2.0.0"], + dag: { garbage: "shape" }, + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + includeTransitive: true, + verbose: true, + }); + expect(output).toContain("accepts@2.0.0"); + expect(output).not.toContain("required by"); + }); + + it("omits the uniqueDependencies block when backend returned none", () => { + const fixture = clone(defaultDependencyReport); + fixture.dependencies = { + direct: fixture.dependencies?.direct, + transitive: { + totalEdges: 0, + uniquePackagesCount: 0, + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + includeTransitive: true, + }); + expect(output).not.toContain("Unique transitive packages"); + // Still surfaces the no-conflicts acknowledgement. + expect(output).toContain( + "No version conflicts or circular dependencies detected.", + ); + }); + + it("surfaces conflict + cycle counts on the summary row when > 0", () => { + const fixture = clone(defaultDependencyReport); + fixture.dependencies = { + direct: fixture.dependencies?.direct, + transitive: { + totalEdges: 2, + uniquePackagesCount: 2, + conflicts: [ + { + package_name: "lodash", + required_versions: ["^4", "^5"], + }, + ], + circularDependencies: [{ cycle: ["a", "b", "a"] }], + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + includeTransitive: true, + }); + expect(output).toMatch(/1 conflict\b/); + expect(output).toMatch(/1 cycle\b/); + // Compact mode — counts on summary, no listing, no hint. + expect(output).not.toContain("Conflicts (1):"); + expect(output).not.toContain("Circular dependencies (1):"); + expect(output).not.toContain("use --verbose"); + }); + + it("pluralises conflict/cycle nouns on the summary row", () => { + const fixture = clone(defaultDependencyReport); + fixture.dependencies = { + direct: fixture.dependencies?.direct, + transitive: { + totalEdges: 5, + uniquePackagesCount: 5, + conflicts: [ + { package_name: "a", required_versions: ["1", "2"] }, + { package_name: "b", required_versions: ["1", "2"] }, + { package_name: "c", required_versions: ["1", "2"] }, + ], + circularDependencies: [{ cycle: ["x"] }, { cycle: ["y"] }], + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + includeTransitive: true, + }); + expect(output).toMatch(/3 conflicts\b/); + expect(output).toMatch(/2 cycles\b/); + }); + + it("renders typed conflicts with `name: range1, range2, …` under --verbose", () => { + const fixture = clone(defaultDependencyReport); + fixture.dependencies = { + direct: fixture.dependencies?.direct, + transitive: { + totalEdges: 2, + uniquePackagesCount: 2, + conflicts: [ + { + package_name: "string-width", + required_versions: [ + "^4.2.3", + "^4.2.0", + "^4.1.0", + "^5.1.2", + "^5.0.1", + ], + conflicting_edges: [], + }, + { + package_name: "emoji-regex", + required_versions: ["^8.0.0", "^9.2.2"], + conflicting_edges: [], + }, + ], + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + includeTransitive: true, + verbose: true, + }); + expect(output).toContain("Conflicts (2):"); + // Alphabetical by name; ranges sorted. + expect(output).toMatch(/emoji-regex:\s+\^8\.0\.0, \^9\.2\.2/); + expect(output).toMatch( + /string-width:\s+\^4\.1\.0, \^4\.2\.0, \^4\.2\.3, \^5\.0\.1, \^5\.1\.2/, + ); + // No raw JSON blob for a recognised shape. + expect(output).not.toContain('{"package_name":'); + }); + + it("renders typed circular dependencies as `a → b → a` arrow chain under --verbose", () => { + const fixture = clone(defaultDependencyReport); + fixture.dependencies = { + direct: fixture.dependencies?.direct, + transitive: { + totalEdges: 3, + uniquePackagesCount: 3, + circularDependencies: [{ cycle: ["a", "b", "a"] }], + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + includeTransitive: true, + verbose: true, + }); + expect(output).toContain("Circular dependencies (1):"); + expect(output).toContain("a → b → a"); + expect(output).not.toContain('{"cycle":'); + }); + + it("falls back to raw JSON under --verbose when conflict / cycle shape is unknown", () => { + const fixture = clone(defaultDependencyReport); + fixture.dependencies = { + direct: fixture.dependencies?.direct, + transitive: { + totalEdges: 2, + uniquePackagesCount: 2, + conflicts: [{ package: "lodash", versions: ["4", "5"] }], + circularDependencies: [{ unknown: "shape" }], + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + includeTransitive: true, + verbose: true, + }); + expect(output).toContain("Conflicts (1):"); + expect(output).toContain("Circular dependencies (1):"); + expect(output).toContain('{"package":"lodash"'); + expect(output).toContain('{"unknown":"shape"}'); + }); + + it("keeps the zero-ack line when neither conflicts nor cycles are present", () => { + const fixture = clone(defaultDependencyReport); + fixture.dependencies = { + direct: fixture.dependencies?.direct, + transitive: { + totalEdges: 1, + uniquePackagesCount: 1, + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + includeTransitive: true, + }); + expect(output).toContain( + "No version conflicts or circular dependencies detected.", + ); + // Summary has no conflict / cycle counts. + expect(output).not.toMatch(/\bconflicts?\b.*direct runtime/); + }); +}); + +describe("formatPackageDependenciesTerminal — no-color", () => { + it("sorts runtime items alphabetically regardless of backend order", () => { + const fixture: DependencyReport = { + package: { name: "x", version: "1.0.0", registry: "NPM" }, + dependencies: { + direct: [ + { name: "zeta", versionConstraint: "^1", type: "runtime" }, + { name: "alpha", versionConstraint: "^2", type: "runtime" }, + { name: "mid", versionConstraint: "^3", type: "runtime" }, + ], + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + }); + const depLines = output.split("\n").filter((l) => /^\s\s[a-z]/.test(l)); + expect(depLines.map((l) => l.trim().split(/\s+/)[0])).toEqual([ + "alpha", + "mid", + "zeta", + ]); + }); + + it("sorts group dependencies alphabetically regardless of backend order", () => { + const fixture: DependencyReport = { + package: { name: "x", version: "1.0.0", registry: "NPM" }, + dependencyGroups: { + groups: [ + { + name: "runtime", + lifecycle: "runtime", + conditionType: "always", + selectionMode: "required", + dependencies: [ + { name: "zulu", constraint: "^1" }, + { name: "alpha", constraint: "^2" }, + { name: "mike", constraint: "^3" }, + ], + }, + ], + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + showGroups: true, + }); + const depLines = output.split("\n").filter((l) => /^\s{4}[a-z]/.test(l)); + expect(depLines.map((l) => l.trim().split(/\s+/)[0])).toEqual([ + "alpha", + "mike", + "zulu", + ]); + }); + + it("renames feature → extra in PyPI group headings (ecosystem vocabulary)", () => { + const fixture: DependencyReport = { + package: { name: "django", version: "6.0.4", registry: "PYPI" }, + dependencyGroups: { + groups: [ + { + name: "argon2", + lifecycle: "optional", + conditionType: "feature", + conditionValue: "argon2", + selectionMode: "additive", + defaultEnabled: false, + dependencies: [{ name: "argon2-cffi", constraint: ">=23.1.0" }], + }, + ], + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + showGroups: true, + }); + expect(output).toContain("argon2 (optional, extra)"); + expect(output).not.toContain("(optional, feature)"); + }); + + it("keeps `feature` vocabulary for Crates packages (Cargo's native term)", () => { + const output = formatPackageDependenciesTerminal( + cratesFeatureDependencyReport, + { useColors: false, showGroups: true }, + ); + expect(output).toContain("(optional, feature)"); + expect(output).not.toContain("(optional, extra)"); + }); + + it("suppresses `selectionMode: required` in verbose mode (default-noise reduction)", () => { + const output = formatPackageDependenciesTerminal(defaultDependencyReport, { + useColors: false, + showGroups: true, + verbose: true, + }); + expect(output).not.toContain("selectionMode: required"); + }); + + it("shows `selectionMode: additive` in verbose mode (load-bearing signal)", () => { + const output = formatPackageDependenciesTerminal( + cratesFeatureDependencyReport, + { useColors: false, showGroups: true, verbose: true }, + ); + expect(output).toContain("selectionMode: additive"); + }); + + it("collapses group heading when conditionValue differs only in case", () => { + const fixture: DependencyReport = { + package: { name: "x", version: "1.0.0", registry: "CRATES" }, + dependencyGroups: { + groups: [ + { + name: "full", + lifecycle: "optional", + conditionType: "feature", + conditionValue: "Full", + selectionMode: "additive", + defaultEnabled: false, + dependencies: [{ name: "parking_lot", constraint: "^0.12.0" }], + }, + ], + }, + }; + const output = formatPackageDependenciesTerminal(fixture, { + useColors: false, + showGroups: true, + }); + expect(output).toContain("full (optional, feature)"); + expect(output).not.toContain("feature: Full"); + }); + + it("contains no ANSI escape sequences when useColors is false", () => { + const output = formatPackageDependenciesTerminal( + cratesFeatureDependencyReport, + { useColors: false, showGroups: true, verbose: true }, + ); + expect(output).not.toContain("\u001b["); + }); +}); diff --git a/src/shared/package-dependencies-response.ts b/src/shared/package-dependencies-response.ts new file mode 100644 index 00000000..0aa9246e --- /dev/null +++ b/src/shared/package-dependencies-response.ts @@ -0,0 +1,1393 @@ +/** + * Hand-crafted response envelope for the `package_dependencies` tool. + * Shared by CLI `--json` output and MCP `content[0].text`. The terminal + * formatter is CLI-only. + * + * Key design commitments (locked in the plan): + * + * - **Data-first envelope.** Whenever the backend returned + * `dependencies.direct`, we emit a `runtime` block with the flat + * list and a client-computed count. Whenever the backend returned + * `dependencyGroups`, we emit a `groups` block with every returned + * group verbatim. Agents don't branch on flags; they branch on + * what's in the envelope. Lifecycle filtering is server-side and + * visible via the optional `filter` metadata block. + * - **Null vs empty matters.** `dependencyGroups: null` → omit + * `groups` entirely ("backend has no groups concept"). Non-null + * with zero members after filtering → `groups: { items: [] }` + * ("filter matched nothing"). Both map to different envelope + * shapes so agents can tell them apart. + * - **No v-prefix normalisation.** Inherited from P2; tag-style + * inputs are rejected in the request builder before we get here. + * - **Terminal-only dedup.** JSON preserves every tuple the backend + * sent (including Crates target-cfg duplicates). Terminal + * rendering strips duplicates inside each group for scannability. + * - **`transitive.dag` is opaque passthrough.** Backend declares it + * `GenericJSON`; we neither parse nor render it. Agents that want + * structured DAG analysis read `transitive.dag` from JSON. Same + * rule applies to `conflicts`, `circularDependencies`, and + * `environmentConstraints`. + */ + +import type { + DependencyGroup, + DependencyReport, + UntypedGenericJSON, +} from "../services/index.js"; +import { colorize, dim } from "./colors.js"; +import type { DependencyLifecycle } from "./package-dependencies-request.js"; +import { toPkgseerRegistryLowercase } from "./pkgseer-registry.js"; + +export interface LeanDirectDependency { + name: string; + /** Caller-declared range from the manifest (e.g. `^2.0.0`). */ + constraint?: string; + /** + * Concrete version the backend resolved for this dep. Surfaced when + * the DAG was fetched alongside direct data (always for + * `pkg deps`; on request for MCP agents). Absent when the backend + * couldn't resolve or we didn't fetch the DAG. + */ + version?: string; +} + +export interface LeanRuntimeBlock { + count: number; + items: LeanDirectDependency[]; +} + +export interface LeanGroupDependency { + name: string; + constraint?: string; +} + +export interface LeanGroup { + name: string; + lifecycle: string; + conditionType: string; + conditionValue?: string; + selectionMode: string; + exclusiveGroup?: string; + fallbackPriority?: number; + compatibleWith?: string[]; + defaultEnabled?: boolean; + items: LeanGroupDependency[]; +} + +export interface LeanGroupsBlock { + primaryGroup?: string; + environmentConstraints?: UntypedGenericJSON[]; + items: LeanGroup[]; +} + +export interface LeanTransitiveImporter { + name: string; + /** Importer's own resolved version, when the DAG node carries one. */ + version?: string; + /** Constraint the importer declared for this dep. */ + constraint?: string; +} + +export interface LeanTransitivePackage { + name: string; + version?: string; + /** + * Importers for this package. Present when the DAG was decodable. + * Empty when the package is the root (no incoming edges) or when + * decoding failed for this node. + */ + importers?: LeanTransitiveImporter[]; +} + +export interface LeanTypedConflict { + name: string; + requiredVersions: string[]; +} + +export interface LeanTypedCycle { + cycle: string[]; +} + +export interface LeanTransitiveBlock { + edges?: number; + uniquePackages?: number; + /** + * Client-side echo of the caller's `maxDepth` input. Surfaces in the + * summary line and the envelope so agents can tell which depth + * produced the aggregate counts. + */ + depth?: number; + /** + * Per-transitive-package records with resolved version + importer + * provenance. Preprocessed from the backend's DAG so agents + * consume the same signal the CLI `--verbose` view renders without + * having to decode `GenericJSON` themselves. + */ + packages?: LeanTransitivePackage[]; + /** + * Typed when every entry decoded against the observed backend + * shape (`{ package_name, required_versions }`). Raw passthrough + * otherwise — agents can discriminate by checking for `name` / + * `requiredVersions` fields. + */ + conflicts?: LeanTypedConflict[] | UntypedGenericJSON[]; + /** + * Typed when every entry decoded (observed: `{ cycle: string[] }`). + * Raw passthrough otherwise. + */ + circularDependencies?: LeanTypedCycle[] | UntypedGenericJSON[]; +} + +export interface LeanFilterBlock { + lifecycles: DependencyLifecycle[]; +} + +export interface LeanDependencyReport { + registry: string; + name: string; + version: string; + requestedVersion?: string; + runtime?: LeanRuntimeBlock; + groups?: LeanGroupsBlock; + transitive?: LeanTransitiveBlock; + filter?: LeanFilterBlock; +} + +export interface BuildDependenciesPayloadOptions { + /** Raw caller-supplied version string (pre-normalisation). */ + requestedVersion?: string; + /** Lifecycles that went on the wire. Empty → no filter. */ + canonicalLifecycles?: DependencyLifecycle[]; + /** Whether the caller asked for the transitive block. */ + includeTransitive?: boolean; + /** + * Caller-supplied maxDepth, echoed verbatim on the envelope for the + * summary line. Omit when the caller asked for "no cap". + */ + maxDepth?: number; + /** + * When true, populate `transitive.packages[].importers` with the + * per-package provenance derived from the DAG. When false (the + * default), packages are emitted with `{name, version}` only — + * agents that just want the install footprint get a ~4× smaller + * envelope. Use `true` when the caller wants the same data the + * CLI `--verbose` view renders. + */ + includeImporters?: boolean; +} + +// -------------------------------------------------------------------- +// Envelope builder +// -------------------------------------------------------------------- + +export function buildPackageDependenciesSuccessPayload( + report: DependencyReport, + options: BuildDependenciesPayloadOptions = {}, +): LeanDependencyReport { + const pkg = report.package; + const payload: LeanDependencyReport = { + registry: lowerRegistry(pkg.registry), + name: pkg.name, + version: pkg.version, + }; + + const requestedEcho = deriveRequestedVersion( + options.requestedVersion, + pkg.version, + ); + if (requestedEcho !== undefined) { + payload.requestedVersion = requestedEcho; + } + + const bundle = report.dependencies; + // Decode the DAG up front; used both for direct-dep version lookup + // (always, when the DAG was fetched) and for verbose-mode importer + // provenance later. Falls back to null on unknown shapes; each + // consumer handles the absence gracefully. + const decodedDagForResolution = decodeDag(bundle?.transitive?.dag); + const directVersionByName = decodedDagForResolution + ? buildDirectVersionLookup(decodedDagForResolution) + : null; + + const directArray = bundle?.direct; + if (directArray !== undefined) { + const items = directArray.map((entry) => + buildDirect(entry, directVersionByName), + ); + payload.runtime = { count: items.length, items }; + } + + const groupsInfo = report.dependencyGroups; + if (groupsInfo !== undefined) { + const groupItems = sortGroups(groupsInfo.groups.map(buildGroup)); + const groupsBlock: LeanGroupsBlock = { items: groupItems }; + if (groupsInfo.primaryGroup) { + groupsBlock.primaryGroup = groupsInfo.primaryGroup; + } + if ( + groupsInfo.environmentConstraints && + groupsInfo.environmentConstraints.length > 0 + ) { + groupsBlock.environmentConstraints = + groupsInfo.environmentConstraints.slice(); + } + payload.groups = groupsBlock; + } + + if (options.includeTransitive) { + const transitive = bundle?.transitive; + if (transitive) { + const block: LeanTransitiveBlock = {}; + if (transitive.totalEdges !== undefined) { + block.edges = transitive.totalEdges; + } + if (transitive.uniquePackagesCount !== undefined) { + block.uniquePackages = transitive.uniquePackagesCount; + } + if (options.maxDepth !== undefined) { + block.depth = options.maxDepth; + } + const packages = buildTransitivePackages( + transitive.uniqueDependencies, + decodedDagForResolution, + options.includeImporters ?? false, + ); + if (packages && packages.length > 0) { + block.packages = packages; + } + if (transitive.conflicts && transitive.conflicts.length > 0) { + block.conflicts = buildTypedConflicts(transitive.conflicts); + } + if ( + transitive.circularDependencies && + transitive.circularDependencies.length > 0 + ) { + block.circularDependencies = buildTypedCycles( + transitive.circularDependencies, + ); + } + payload.transitive = block; + } + } + + if (options.canonicalLifecycles && options.canonicalLifecycles.length > 0) { + payload.filter = { lifecycles: options.canonicalLifecycles.slice() }; + } + + return payload; +} + +function buildDirect( + entry: { name: string; versionConstraint?: string; type?: string }, + directVersionByName: Map | null, +): LeanDirectDependency { + const lean: LeanDirectDependency = { name: entry.name }; + if (entry.versionConstraint) lean.constraint = entry.versionConstraint; + const resolved = directVersionByName?.get(entry.name); + if (resolved) lean.version = resolved; + return lean; +} + +/** + * Build a `name → resolved version` lookup for direct deps by scanning + * the DAG's outgoing edges from the root node. Used during envelope + * construction to annotate `runtime.items[].version` whenever the DAG + * is available. + */ +function buildDirectVersionLookup(dag: DecodedDag): Map | null { + const rootIdx = findRootNodeIdx(dag); + if (rootIdx === null) return null; + const out = new Map(); + for (const edge of dag.edges) { + if (edge.fromIdx !== rootIdx) continue; + const node = dag.nodes[edge.toIdx]; + if (!node || !node.version) continue; + if (!out.has(node.name)) { + out.set(node.name, node.version); + } + } + return out.size > 0 ? out : null; +} + +function findRootNodeIdx(dag: DecodedDag): number | null { + const incoming = new Set(); + for (const e of dag.edges) incoming.add(e.toIdx); + let root: number | null = null; + for (let i = 0; i < dag.nodes.length; i++) { + if (!incoming.has(i)) { + if (root !== null) return null; // ambiguous — multiple roots + root = i; + } + } + return root; +} + +/** + * Build the preprocessed `transitive.packages[]` array. Each entry + * carries the name + resolved version (from the backend's + * `uniqueDependencies` list) plus importer provenance when the DAG + * decoded successfully. Agents consume this directly rather than + * reverse-engineering the raw DAG — same source of truth the + * terminal `--verbose` renderer reads from. + */ +function buildTransitivePackages( + uniqueDependencies: string[] | undefined, + dag: DecodedDag | null, + includeImporters: boolean, +): LeanTransitivePackage[] | null { + if (!uniqueDependencies || uniqueDependencies.length === 0) return null; + + // Build a name→importers lookup once — only needed when we're + // actually emitting importers. + const incoming = includeImporters && dag ? buildIncomingEdgeMap(dag) : null; + + const out: LeanTransitivePackage[] = []; + for (const entry of uniqueDependencies) { + const [name, version] = parseNameAtVersion(entry); + if (!name) continue; + const record: LeanTransitivePackage = { name }; + if (version) record.version = version; + + if (includeImporters && dag && incoming) { + const nodeIdx = findNodeIdx(dag, name, version); + if (nodeIdx !== null) { + const edges = incoming.get(nodeIdx) ?? []; + const importers = buildImportersFromEdges(dag, edges); + if (importers.length > 0) record.importers = importers; + } + } + out.push(record); + } + return out; +} + +function parseNameAtVersion(raw: string): [string | null, string | undefined] { + const trimmed = raw.trim(); + if (!trimmed) return [null, undefined]; + // npm scoped names start with `@`, so split on the LAST `@`. + const atIdx = trimmed.lastIndexOf("@"); + if (atIdx <= 0) return [trimmed, undefined]; + return [trimmed.slice(0, atIdx), trimmed.slice(atIdx + 1)]; +} + +function buildIncomingEdgeMap(dag: DecodedDag): Map { + const map = new Map(); + for (const edge of dag.edges) { + const list = map.get(edge.toIdx); + if (list) list.push(edge); + else map.set(edge.toIdx, [edge]); + } + return map; +} + +function findNodeIdx( + dag: DecodedDag, + name: string, + version: string | undefined, +): number | null { + // Prefer exact name+version match. Fall back to name-only when + // version is absent or the DAG's node carries no version. + let fallback: number | null = null; + for (let i = 0; i < dag.nodes.length; i++) { + const n = dag.nodes[i]; + if (!n) continue; + if (n.name !== name) continue; + if (version && n.version === version) return i; + if (!version && !n.version) return i; + if (fallback === null) fallback = i; + } + return fallback; +} + +function buildImportersFromEdges( + dag: DecodedDag, + edges: DagEdge[], +): LeanTransitiveImporter[] { + const seen = new Set(); + const out: LeanTransitiveImporter[] = []; + for (const edge of edges) { + const from = dag.nodes[edge.fromIdx]; + if (!from) continue; + const key = `${from.name}\u0000${from.version ?? ""}\u0000${edge.constraint ?? ""}`; + if (seen.has(key)) continue; + seen.add(key); + const importer: LeanTransitiveImporter = { name: from.name }; + if (from.version) importer.version = from.version; + if (edge.constraint) importer.constraint = edge.constraint; + out.push(importer); + } + out.sort((a, b) => { + if (a.name !== b.name) return a.name < b.name ? -1 : 1; + const av = a.version ?? ""; + const bv = b.version ?? ""; + if (av !== bv) return av < bv ? -1 : 1; + const ac = a.constraint ?? ""; + const bc = b.constraint ?? ""; + return ac < bc ? -1 : ac > bc ? 1 : 0; + }); + return out; +} + +/** + * Promote `transitive.conflicts[]` to typed objects when every entry + * matches the observed backend shape (`{ package_name, + * required_versions }`). Falls back to the raw array when any entry + * fails to decode — agents can discriminate by checking for `name` / + * `requiredVersions` keys on the first element. + */ +function buildTypedConflicts( + raw: UntypedGenericJSON[], +): LeanTypedConflict[] | UntypedGenericJSON[] { + const typed: LeanTypedConflict[] = []; + for (const entry of raw) { + const decoded = decodeConflictEntryForEnvelope(entry); + if (!decoded) return raw.slice(); // fall back to raw passthrough + typed.push(decoded); + } + return typed; +} + +function decodeConflictEntryForEnvelope( + raw: unknown, +): LeanTypedConflict | null { + if (!raw || typeof raw !== "object") return null; + const obj = raw as Record; + const name = + typeof obj.package_name === "string" + ? obj.package_name + : typeof obj.packageName === "string" + ? obj.packageName + : null; + if (!name) return null; + const rangesRaw = obj.required_versions ?? obj.requiredVersions; + if (!Array.isArray(rangesRaw)) return null; + const ranges: string[] = []; + for (const r of rangesRaw) { + if (typeof r === "string" && r.length > 0 && !ranges.includes(r)) { + ranges.push(r); + } + } + if (ranges.length === 0) return null; + ranges.sort(); + return { name, requiredVersions: ranges }; +} + +/** + * Promote `transitive.circularDependencies[]` to typed objects when + * every entry matches the expected `{ cycle: string[] }` shape. + * Raw-passthrough fallback otherwise. + */ +function buildTypedCycles( + raw: UntypedGenericJSON[], +): LeanTypedCycle[] | UntypedGenericJSON[] { + const typed: LeanTypedCycle[] = []; + for (const entry of raw) { + const decoded = decodeCycleEntryForEnvelope(entry); + if (!decoded) return raw.slice(); + typed.push({ cycle: decoded }); + } + return typed; +} + +function decodeCycleEntryForEnvelope(raw: unknown): string[] | null { + if (Array.isArray(raw) && raw.every((x) => typeof x === "string")) { + return raw as string[]; + } + if (!raw || typeof raw !== "object") return null; + const obj = raw as Record; + const source = obj.cycle ?? obj.packages ?? obj.path; + if (!Array.isArray(source)) return null; + const names = source.filter((x): x is string => typeof x === "string"); + return names.length > 0 ? names : null; +} + +function buildGroup(group: DependencyGroup): LeanGroup { + const lean: LeanGroup = { + name: group.name, + lifecycle: group.lifecycle, + conditionType: group.conditionType, + selectionMode: group.selectionMode, + items: group.dependencies.map((dep) => { + const entry: LeanGroupDependency = { name: dep.name }; + if (dep.constraint) entry.constraint = dep.constraint; + return entry; + }), + }; + if (group.conditionValue) lean.conditionValue = group.conditionValue; + if (group.exclusiveGroup) lean.exclusiveGroup = group.exclusiveGroup; + if (group.fallbackPriority !== undefined) { + lean.fallbackPriority = group.fallbackPriority; + } + if (group.compatibleWith && group.compatibleWith.length > 0) { + lean.compatibleWith = group.compatibleWith.slice(); + } + if (group.defaultEnabled !== undefined) { + lean.defaultEnabled = group.defaultEnabled; + } + return lean; +} + +const LIFECYCLE_ORDER: Record = { + runtime: 0, + development: 1, + build: 2, + peer: 3, + optional: 4, +}; + +function sortGroups(groups: LeanGroup[]): LeanGroup[] { + return groups.slice().sort((a, b) => { + const la = LIFECYCLE_ORDER[a.lifecycle] ?? 99; + const lb = LIFECYCLE_ORDER[b.lifecycle] ?? 99; + if (la !== lb) return la - lb; + if (a.lifecycle === "optional") { + const ad = a.defaultEnabled === true ? 0 : 1; + const bd = b.defaultEnabled === true ? 0 : 1; + if (ad !== bd) return ad - bd; + } + return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; + }); +} + +function deriveRequestedVersion( + requested: string | undefined, + resolved: string, +): string | undefined { + if (requested === undefined) return undefined; + const trimmed = requested.trim(); + if (trimmed.length === 0) return undefined; + if (trimmed === resolved) return undefined; + return trimmed; +} + +function lowerRegistry(value: string | undefined): string { + if (!value) return ""; + const upper = value.toUpperCase(); + try { + // biome-ignore lint/suspicious/noExplicitAny: boundary guard + return toPkgseerRegistryLowercase(upper as any); + } catch { + return value.toLowerCase(); + } +} + +// -------------------------------------------------------------------- +// Terminal formatter (CLI-only) +// -------------------------------------------------------------------- + +/** + * Semantic model (locked post-UX review): + * + * - **Summary row always.** Renders counts (`N direct runtime deps` + * plain; `+ M transitive edges · P unique packages (depth D)` when + * `--transitive`) and lists hidden non-runtime groups by name so the + * caller sees what exists without digging. + * - **`--transitive` replaces the deps list.** Default shows direct + * deps; `--transitive` swaps the block to the full unique transitive + * list (alphabetical, one per line, `name@version`). No truncation — + * if you asked for transitive, you get it all. + * - **`--verbose` with `--transitive` adds provenance.** Each + * transitive entry gets `(required by @, …)` + * derived from the DAG edges. Best-effort decoding; if the DAG + * shape drifts, provenance silently degrades (list still renders). + * - **Groups is a separate block below the deps list.** Shown when + * `--groups` or `--lifecycle` is set, composes cleanly with either + * the direct or transitive deps list above. + * - **Conflicts / cycles section** surfaces after the transitive list + * only (they come from the transitive graph). + */ + +export interface FormatDependenciesTerminalOptions { + verbose?: boolean; + useColors?: boolean; + requestedVersion?: string; + canonicalLifecycles?: DependencyLifecycle[]; + includeTransitive?: boolean; + /** Caller-supplied traversal depth; surfaces in the summary row. */ + maxDepth?: number; + /** If true, render the groups block beneath the deps list. */ + showGroups?: boolean; +} + +export function formatPackageDependenciesTerminal( + report: DependencyReport, + options: FormatDependenciesTerminalOptions = {}, +): string { + const verbose = options.verbose ?? false; + // Terminal verbose output renders importers, so the envelope + // must carry them. Non-verbose terminals don't need importers; + // skipping them keeps the intermediate payload the formatter + // walks small. + const payload = buildPackageDependenciesSuccessPayload(report, { + requestedVersion: options.requestedVersion, + canonicalLifecycles: options.canonicalLifecycles, + includeTransitive: options.includeTransitive, + maxDepth: options.maxDepth, + includeImporters: verbose, + }); + const useColors = options.useColors ?? false; + const showGroups = options.showGroups ?? false; + const includeTransitive = options.includeTransitive ?? false; + + const blocks: string[] = []; + + blocks.push(formatHeaderBlock(payload, useColors, showGroups)); + + if (includeTransitive) { + blocks.push(formatTransitiveDepsList(payload, verbose, useColors)); + const issues = formatConflictsAndCycles(payload, verbose, useColors); + if (issues) blocks.push(issues); + } else { + blocks.push(formatDirectDepsList(payload, verbose, useColors)); + } + + if (showGroups) { + blocks.push(formatGroupsBlock(payload, verbose, useColors)); + } + + return `${blocks.filter((b) => b.length > 0).join("\n\n")}\n`; +} + +// -------------------------------------------------------------------- +// Header + summary row +// -------------------------------------------------------------------- + +function formatHeaderBlock( + payload: LeanDependencyReport, + useColors: boolean, + showGroups: boolean, +): string { + const name = colorize(payload.name, "bold", useColors); + const lines: string[] = [ + `${name} @ ${payload.version} · ${payload.registry}`, + ]; + if (payload.requestedVersion) { + lines.push(dim(`(requested ${payload.requestedVersion})`, useColors)); + } + lines.push(formatSummaryRow(payload, useColors, showGroups)); + return lines.join("\n"); +} + +/** + * Single summary row that always renders. Combines runtime / transitive + * counts with a "Hidden: …" mention listing non-runtime groups by + * name. When `--groups` is active the "Hidden: …" section is omitted + * because nothing is hidden. + */ +function formatSummaryRow( + payload: LeanDependencyReport, + useColors: boolean, + showGroups: boolean, +): string { + const countParts: string[] = []; + const runtimeCount = payload.runtime?.count ?? 0; + if (runtimeCount === 0) { + countParts.push("No direct runtime dependencies"); + } else { + const noun = runtimeCount === 1 ? "dependency" : "dependencies"; + countParts.push(`${runtimeCount} direct runtime ${noun}`); + } + const t = payload.transitive; + if (t) { + if (t.edges !== undefined) { + const edgeNoun = t.edges === 1 ? "edge" : "edges"; + countParts.push(`${t.edges} transitive ${edgeNoun}`); + } + if (t.uniquePackages !== undefined) { + const pkgNoun = t.uniquePackages === 1 ? "package" : "packages"; + const depthSuffix = + t.depth !== undefined ? ` (max depth ${t.depth})` : ""; + countParts.push(`${t.uniquePackages} unique ${pkgNoun}${depthSuffix}`); + } + const conflictCount = t.conflicts?.length ?? 0; + if (conflictCount > 0) { + const noun = conflictCount === 1 ? "conflict" : "conflicts"; + countParts.push( + colorize(`${conflictCount} ${noun}`, "yellow", useColors), + ); + } + const cycleCount = t.circularDependencies?.length ?? 0; + if (cycleCount > 0) { + const noun = cycleCount === 1 ? "cycle" : "cycles"; + countParts.push(colorize(`${cycleCount} ${noun}`, "red", useColors)); + } + } + const countLine = countParts.join(" · "); + + if (showGroups) return countLine; + + const hidden = collectHiddenGroupNames(payload); + if (hidden.length === 0) return countLine; + const hiddenLine = dim( + `Hidden groups: ${hidden.join(", ")} — use --groups.`, + useColors, + ); + return `${countLine}\n${hiddenLine}`; +} + +function collectHiddenGroupNames(payload: LeanDependencyReport): string[] { + const groups = payload.groups; + if (!groups) return []; + return groups.items + .filter((g) => g.lifecycle !== "runtime") + .map((g) => g.name); +} + +// -------------------------------------------------------------------- +// Direct-deps list (default view) +// +// Plain mode and --transitive share the same per-entry presentation: +// +// Compact: ` name@version` +// Verbose: ` name@version` +// ` - required by @` +// +// When resolved version is unavailable (DAG wasn't in scope), falls +// back to `name constraint` so output stays informative. +// -------------------------------------------------------------------- + +function formatDirectDepsList( + payload: LeanDependencyReport, + verbose: boolean, + useColors: boolean, +): string { + const runtime = payload.runtime; + if (!runtime || runtime.count === 0) return ""; + const sorted = sortAlphabetically(runtime.items, (i) => i.name); + + if (!verbose) { + return sorted.map((item) => ` ${formatDepLabel(item)}`).join("\n"); + } + + // Verbose: multi-line entry per dep. Direct deps have exactly one + // importer — the root package itself. + const rootLabel = `${payload.name}@${payload.version}`; + return sorted + .map((item) => { + const head = ` ${formatDepLabel(item)}`; + const constraintLabel = item.constraint ?? "*"; + const line = dim( + ` - ${constraintLabel} required by ${rootLabel}`, + useColors, + ); + return `${head}\n${line}`; + }) + .join("\n"); +} + +function formatDepLabel(item: LeanDirectDependency): string { + if (item.version) return `${item.name}@${item.version}`; + // Fallback when the DAG wasn't fetched / resolution failed — keep + // the constraint so callers still see something useful. + if (item.constraint) return `${item.name} ${item.constraint}`; + return item.name; +} + +// -------------------------------------------------------------------- +// Transitive-deps list (replaces direct when --transitive) +// -------------------------------------------------------------------- + +function formatTransitiveDepsList( + payload: LeanDependencyReport, + verbose: boolean, + useColors: boolean, +): string { + const packages = payload.transitive?.packages ?? []; + if (packages.length === 0) return ""; + + const sorted = [...packages].sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0, + ); + + if (!verbose) { + return sorted.map((pkg) => ` ${formatPackageLabel(pkg)}`).join("\n"); + } + + return sorted + .map((pkg) => { + const head = ` ${formatPackageLabel(pkg)}`; + const importers = pkg.importers ?? []; + if (importers.length === 0) return head; + const bullets = formatImporterBullets(importers, useColors); + return `${head}\n${bullets}`; + }) + .join("\n"); +} + +function formatPackageLabel(pkg: LeanTransitivePackage): string { + return pkg.version ? `${pkg.name}@${pkg.version}` : pkg.name; +} + +/** + * Collapse a list of importers into one bullet per unique + * constraint, comma-separating the `name@version` labels. Importers + * without a constraint (fallback path) get rendered under a `*` + * bucket so they still surface. + */ +function formatImporterBullets( + importers: LeanTransitiveImporter[], + useColors: boolean, +): string { + const byConstraint = new Map(); + for (const i of importers) { + const key = i.constraint ?? "*"; + const label = i.version ? `${i.name}@${i.version}` : i.name; + const list = byConstraint.get(key); + if (list) { + if (!list.includes(label)) list.push(label); + } else { + byConstraint.set(key, [label]); + } + } + // Stable display order: sort by constraint string. + const constraints = [...byConstraint.keys()].sort((a, b) => + a < b ? -1 : a > b ? 1 : 0, + ); + return constraints + .map((constraint) => { + const labels = byConstraint.get(constraint) ?? []; + labels.sort(); + return dim( + ` - ${constraint} required by ${labels.join(", ")}`, + useColors, + ); + }) + .join("\n"); +} + +// -------------------------------------------------------------------- +// Conflicts + cycles (only when --transitive) +// -------------------------------------------------------------------- + +function formatConflictsAndCycles( + payload: LeanDependencyReport, + verbose: boolean, + useColors: boolean, +): string { + const t = payload.transitive; + if (!t) return ""; + const conflicts = t.conflicts ?? []; + const cycles = t.circularDependencies ?? []; + const lines: string[] = []; + + if (conflicts.length === 0 && cycles.length === 0) { + lines.push( + dim("No version conflicts or circular dependencies detected.", useColors), + ); + return lines.join("\n"); + } + // Non-zero compact: counts already live on the summary row and + // `--help` covers `--verbose`. Any extra hint here is noise. The + // full per-entry listing surfaces only under `--verbose`. + if (!verbose) return ""; + if (conflicts.length > 0) { + lines.push( + colorize(`Conflicts (${conflicts.length}):`, "yellow", useColors), + ); + const typed = isTypedConflictArray(conflicts) ? conflicts : null; + if (typed) { + const nameWidth = Math.max(...typed.map((c) => c.name.length)); + const sorted = [...typed].sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0, + ); + for (const c of sorted) { + const padded = `${c.name}:`.padEnd(nameWidth + 2); + lines.push(` ${padded} ${c.requiredVersions.join(", ")}`); + } + } else { + for (const c of conflicts) lines.push(` ${JSON.stringify(c)}`); + } + } + if (cycles.length > 0) { + if (conflicts.length > 0) lines.push(""); + lines.push( + colorize(`Circular dependencies (${cycles.length}):`, "red", useColors), + ); + const typed = isTypedCycleArray(cycles) ? cycles : null; + if (typed) { + for (const c of typed) lines.push(` ${c.cycle.join(" → ")}`); + } else { + for (const c of cycles) lines.push(` ${JSON.stringify(c)}`); + } + } + + return lines.join("\n"); +} + +function isTypedConflictArray( + arr: LeanTypedConflict[] | UntypedGenericJSON[], +): arr is LeanTypedConflict[] { + const first = arr[0]; + if (!first || typeof first !== "object") return false; + const obj = first as Record; + return typeof obj.name === "string" && Array.isArray(obj.requiredVersions); +} + +function isTypedCycleArray( + arr: LeanTypedCycle[] | UntypedGenericJSON[], +): arr is LeanTypedCycle[] { + const first = arr[0]; + if (!first || typeof first !== "object") return false; + const obj = first as Record; + return Array.isArray(obj.cycle); +} + +/** + * Best-effort decoder for a `transitive.conflicts[]` entry. Backend + * ships these as `GenericJSON`; observed shape on npm:jest is: + * + * { + * package_name: string, + * required_versions: string[], // deduped constraint ranges + * conflicting_edges: [{ data: { version_constraint, dependency_type }, + * from: "npm", to: "npm" }, ...] + * } + * + * Note `from`/`to` are registry strings, not importer node IDs — so + * per-range provenance is lost. That's a backend gap; see + * `/tmp/githits-cli-pkg-intel-backend-gaps.md` item #9 for follow-up. + */ +interface DecodedConflict { + name: string; + ranges: string[]; +} + +function decodeConflictEntry(raw: unknown): DecodedConflict | null { + if (!raw || typeof raw !== "object") return null; + const obj = raw as Record; + const name = + typeof obj.package_name === "string" + ? obj.package_name + : typeof obj.packageName === "string" + ? obj.packageName + : null; + if (!name) return null; + const rangesRaw = obj.required_versions ?? obj.requiredVersions; + if (!Array.isArray(rangesRaw)) return null; + const ranges: string[] = []; + for (const r of rangesRaw) { + if (typeof r === "string" && r.length > 0 && !ranges.includes(r)) { + ranges.push(r); + } + } + if (ranges.length === 0) return null; + ranges.sort(); + return { name, ranges }; +} + +/** + * Best-effort decoder for a `transitive.circularDependencies[]` entry. + * No live observation yet; designed to handle plausible shapes: + * + * { cycle: string[] } — array of package names along the loop + * { packages: string[] } — alias + * string[] — a bare array + */ +function decodeCycleEntry(raw: unknown): string[] | null { + if (Array.isArray(raw) && raw.every((x) => typeof x === "string")) { + return raw as string[]; + } + if (!raw || typeof raw !== "object") return null; + const obj = raw as Record; + const source = obj.cycle ?? obj.packages ?? obj.path; + if (!Array.isArray(source)) return null; + const names = source.filter((x): x is string => typeof x === "string"); + return names.length > 0 ? names : null; +} + +// -------------------------------------------------------------------- +// Groups block (separate; shown when --groups or --lifecycle) +// -------------------------------------------------------------------- + +function formatGroupsBlock( + payload: LeanDependencyReport, + verbose: boolean, + useColors: boolean, +): string { + const groups = payload.groups; + const lines: string[] = []; + + if (!groups || groups.items.length === 0) { + lines.push( + payload.filter + ? `No dependency groups matched lifecycle filter: ${payload.filter.lifecycles.join(", ")}.` + : "No dependency groups available.", + ); + return lines.join("\n"); + } + + const summary = summariseGroupsByLifecycle(groups.items); + const groupNoun = groups.items.length === 1 ? "group" : "groups"; + lines.push( + colorize( + `${groups.items.length} ${groupNoun} (${summary}):`, + "bold", + useColors, + ), + ); + lines.push(""); + + if ( + verbose && + groups.environmentConstraints && + groups.environmentConstraints.length > 0 + ) { + lines.push( + dim( + `environmentConstraints (${groups.environmentConstraints.length}):`, + useColors, + ), + ); + for (const entry of groups.environmentConstraints) { + lines.push(dim(` ${JSON.stringify(entry)}`, useColors)); + } + lines.push(""); + } + + for (const group of groups.items) { + const heading = formatGroupHeading(group, payload.registry); + lines.push(` ${colorize(heading, "bold", useColors)}`); + if (verbose) { + const metaLines = formatGroupMeta(group); + for (const meta of metaLines) { + lines.push(` ${dim(meta, useColors)}`); + } + } + const deps = sortAlphabetically( + dedupeGroupItems(group.items), + (d) => d.name, + ); + if (deps.length === 0) { + lines.push(` ${dim("(no dependencies)", useColors)}`); + } else { + const nameWidth = Math.max(...deps.map((d) => d.name.length)); + for (const dep of deps) { + const name = dep.name.padEnd(nameWidth); + const constraint = dep.constraint ?? ""; + lines.push(` ${name} ${constraint}`.trimEnd()); + } + } + lines.push(""); + } + + return lines.join("\n").trimEnd(); +} + +function formatGroupHeading(group: LeanGroup, registry: string): string { + if (group.conditionType === "always") { + return group.name; + } + const displayCondition = displayConditionType(group.conditionType, registry); + const showValue = + group.conditionValue !== undefined && + group.conditionValue.toLowerCase() !== group.name.toLowerCase(); + const tail = showValue + ? `${displayCondition}: ${group.conditionValue}` + : displayCondition; + return `${group.name} (${group.lifecycle}, ${tail})`; +} + +/** + * Map the backend's internal `conditionType` vocabulary to the noun + * each ecosystem uses in its own documentation. PyPI calls feature- + * gated dependency groups "extras" (PEP 508); Crates calls them + * "features". Other ecosystems keep the backend term since they + * either don't surface these groups or use the raw token. + */ +function displayConditionType(conditionType: string, registry: string): string { + if (conditionType === "feature" && registry === "pypi") return "extra"; + return conditionType; +} + +function formatGroupMeta(group: LeanGroup): string[] { + const rows: string[] = []; + // Suppress `selectionMode: required` — it's the uninteresting default + // for always-typed groups (runtime/development). + if (group.selectionMode !== "required") { + rows.push(`selectionMode: ${group.selectionMode}`); + } + if (group.defaultEnabled !== undefined) { + rows.push(`defaultEnabled: ${group.defaultEnabled}`); + } + if (group.exclusiveGroup) { + rows.push(`exclusiveGroup: ${group.exclusiveGroup}`); + } + if (group.fallbackPriority !== undefined) { + rows.push(`fallbackPriority: ${group.fallbackPriority}`); + } + if (group.compatibleWith && group.compatibleWith.length > 0) { + rows.push(`compatibleWith: ${group.compatibleWith.join(", ")}`); + } + return rows; +} + +/** + * Terminal-only dedup. Collapses duplicate `{name, constraint}` tuples + * inside a single group (common on Crates feature groups with + * target-cfg branching). JSON envelope preserves duplicates verbatim. + */ +function dedupeGroupItems(items: LeanGroupDependency[]): LeanGroupDependency[] { + const seen = new Set(); + const out: LeanGroupDependency[] = []; + for (const item of items) { + const key = `${item.name}\u0000${item.constraint ?? ""}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(item); + } + return out; +} + +function summariseGroupsByLifecycle(groups: LeanGroup[]): string { + const counts = new Map(); + for (const g of groups) { + counts.set(g.lifecycle, (counts.get(g.lifecycle) ?? 0) + 1); + } + const parts: string[] = []; + for (const lc of ["runtime", "development", "build", "peer", "optional"]) { + const n = counts.get(lc); + if (n && n > 0) parts.push(`${n} ${lc}`); + } + return parts.join(", ") || "0"; +} + +function sortAlphabetically( + items: readonly T[], + key: (item: T) => string, +): T[] { + return items.slice().sort((a, b) => { + const ka = key(a); + const kb = key(b); + return ka < kb ? -1 : ka > kb ? 1 : 0; + }); +} + +// -------------------------------------------------------------------- +// Best-effort DAG decoder + provenance lookup +// +// Backend declares `transitive.dag` as `GenericJSON`. The shape we've +// observed live (npm, PyPI, Crates) is: +// +// { +// n: Array<[registry, name, version]> // node list, indexed by position +// e: Array<[fromIdx, toIdx, constraint?, lifecycle?]> +// v: number // format version marker +// } +// +// The decoder also tolerates the object-shape documented by +// `pkgseer-cli` (`n: { id: { n, v?, l? } }`) so that if backend +// formats diverge we don't break the terminal — provenance just +// silently stops rendering. +// -------------------------------------------------------------------- + +interface DagNode { + name: string; + version?: string; + registry?: string; +} + +interface DagEdge { + fromIdx: number; + toIdx: number; + constraint?: string; + lifecycle?: string; +} + +interface DecodedDag { + nodes: DagNode[]; + edges: DagEdge[]; +} + +function decodeDag(raw: unknown): DecodedDag | null { + if (!raw || typeof raw !== "object") return null; + const obj = raw as Record; + const rawNodes = obj.n ?? obj.nodes; + const rawEdges = obj.e ?? obj.edges; + + const nodes = decodeNodes(rawNodes); + if (!nodes) return null; + const edges = decodeEdges(rawEdges); + if (!edges) return null; + return { nodes, edges }; +} + +function decodeNodes(raw: unknown): DagNode[] | null { + if (Array.isArray(raw)) { + // Tuple form: [registry, name, version] + const result: DagNode[] = []; + for (const entry of raw) { + if (!Array.isArray(entry)) { + if (typeof entry === "object" && entry !== null) { + const n = decodeObjectNode(entry as Record); + if (!n) return null; + result.push(n); + continue; + } + return null; + } + const [registry, name, version] = entry as unknown[]; + if (typeof name !== "string") return null; + result.push({ + name, + version: typeof version === "string" ? version : undefined, + registry: typeof registry === "string" ? registry : undefined, + }); + } + return result; + } + if (raw && typeof raw === "object") { + // Object form: { "": { n: name, v?: version, l?: label } } + const result: DagNode[] = []; + for (const entry of Object.values(raw as Record)) { + if (!entry || typeof entry !== "object") return null; + const n = decodeObjectNode(entry as Record); + if (!n) return null; + result.push(n); + } + return result; + } + return null; +} + +function decodeObjectNode(entry: Record): DagNode | null { + const name = + typeof entry.n === "string" + ? entry.n + : typeof entry.name === "string" + ? entry.name + : null; + if (!name) return null; + const version = + typeof entry.v === "string" + ? entry.v + : typeof entry.version === "string" + ? entry.version + : undefined; + return { name, version }; +} + +function decodeEdges(raw: unknown): DagEdge[] | null { + if (!Array.isArray(raw)) return null; + const out: DagEdge[] = []; + for (const entry of raw) { + if (Array.isArray(entry)) { + const [from, to, constraint, lifecycle] = entry as unknown[]; + if (typeof from !== "number" || typeof to !== "number") return null; + out.push({ + fromIdx: from, + toIdx: to, + constraint: typeof constraint === "string" ? constraint : undefined, + lifecycle: typeof lifecycle === "string" ? lifecycle : undefined, + }); + continue; + } + if (entry && typeof entry === "object") { + const obj = entry as Record; + const from = obj.f ?? obj.from; + const to = obj.t ?? obj.to; + if (typeof from !== "number" || typeof to !== "number") return null; + out.push({ + fromIdx: from, + toIdx: to, + constraint: typeof obj.c === "string" ? obj.c : undefined, + lifecycle: typeof obj.l === "string" ? obj.l : undefined, + }); + continue; + } + return null; + } + return out; +} + +interface ProvenanceEntry { + name: string; + constraint?: string; + /** + * The importer's own resolved version (e.g. `express@5.2.1` → + * `"5.2.1"`). Populated when {@link buildProvenanceLookup} was + * called with `includeImporterVersion = true` and the DAG node + * for the importer carried a version. Optional because older + * DAG shapes may lack version metadata. + */ + importerVersion?: string; +} + +/** + * Build a lookup `key → importers[]` where `key` matches the strings + * in `transitive.uniqueDependencies`. Observed backend strings are + * `name@version`; we index by both `name@version` and bare `name` so + * either form works. + * + * When `includeImporterVersion` is true, each entry carries the + * importer's own resolved version — used by the multi-line verbose + * renderer to display `- required by @`. + */ +function buildProvenanceLookup( + dag: DecodedDag, + includeImporterVersion = false, +): Map { + const nodes = dag.nodes; + const incoming = new Map(); + for (const edge of dag.edges) { + const list = incoming.get(edge.toIdx); + if (list) { + list.push(edge); + } else { + incoming.set(edge.toIdx, [edge]); + } + } + + const lookup = new Map(); + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (!node) continue; + const importers = incoming.get(i) ?? []; + const entries: ProvenanceEntry[] = []; + const seen = new Set(); + for (const edge of importers) { + const from = nodes[edge.fromIdx]; + if (!from) continue; + const key = `${from.name}\u0000${from.version ?? ""}\u0000${edge.constraint ?? ""}`; + if (seen.has(key)) continue; + seen.add(key); + const entry: ProvenanceEntry = { + name: from.name, + constraint: edge.constraint, + }; + if (includeImporterVersion && from.version) { + entry.importerVersion = from.version; + } + entries.push(entry); + } + entries.sort((a, b) => { + if (a.name !== b.name) return a.name < b.name ? -1 : 1; + const av = a.importerVersion ?? ""; + const bv = b.importerVersion ?? ""; + return av < bv ? -1 : av > bv ? 1 : 0; + }); + + // Index by both `name@version` and bare `name`. + if (node.version) { + lookup.set(`${node.name}@${node.version}`, entries); + } + const existingBare = lookup.get(node.name); + if (existingBare) { + // Multiple versions of the same name — merge importers. + for (const e of entries) { + const key = `${e.name}\u0000${e.importerVersion ?? ""}\u0000${e.constraint ?? ""}`; + if ( + !existingBare.some( + (x) => + `${x.name}\u0000${x.importerVersion ?? ""}\u0000${x.constraint ?? ""}` === + key, + ) + ) { + existingBare.push(e); + } + } + } else { + lookup.set(node.name, [...entries]); + } + } + return lookup; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index d95d51e1..33f07318 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,4 +1,5 @@ export { createFeedbackTool } from "./feedback.js"; +export { createPackageDependenciesTool } from "./package-dependencies.js"; export { createPackageSummaryTool } from "./package-summary.js"; export { createPackageVulnerabilitiesTool } from "./package-vulnerabilities.js"; export { createSearchTool } from "./search.js"; diff --git a/src/tools/package-dependencies-parity.test.ts b/src/tools/package-dependencies-parity.test.ts new file mode 100644 index 00000000..63d33261 --- /dev/null +++ b/src/tools/package-dependencies-parity.test.ts @@ -0,0 +1,555 @@ +// PARITY TEST — enforces rule IDs from docs/implementation/mcp-cli-parity.md: +// PARITY-JSON-KEYS CLI --json output and MCP text payload parse to +// deepEqual JSON objects for equivalent inputs. +// PARITY-ERROR-ENVELOPE Both surfaces emit { error, code, retryable, +// details? } on every error path; MCP error text is +// always valid JSON. +// +// Assertion policy (locked in the P3 plan; matches shipped +// search_symbols / package_summary / package_vulnerabilities +// precedent): +// - Service-sourced success and error fixtures use `toEqual`: both +// surfaces route through the same request builder and envelope +// shaper, so envelopes are byte-identical. +// - `INVALID_ARGUMENT` fixtures use `toMatchObject`: CLI rejects +// in `buildPackageDependenciesParams` after `parsePackageSpec`; +// MCP rejects in the same builder via the in-handler pattern. +// Same envelope shape, surface-specific error text. + +import { describe, expect, it, mock, spyOn } from "bun:test"; +import { + type PkgDepsCommandDependencies, + pkgDepsAction, +} from "../commands/pkg/deps.js"; +import type { DependencyReport } from "../services/index.js"; +import { + PackageIntelligenceBackendError, + PackageIntelligenceTargetNotFoundError, + PackageIntelligenceVersionNotFoundError, +} from "../services/index.js"; +import { + cratesFeatureDependencyReport, + createMockPackageIntelligenceService, + defaultDependencyReport, + zeroDepDependencyReport, +} from "../services/test-helpers.js"; +import { createPackageDependenciesTool } from "./package-dependencies.js"; + +function cliDeps( + overrides: Partial = {}, +): PkgDepsCommandDependencies { + return { + packageIntelligenceService: createMockPackageIntelligenceService(), + codeNavigationUrl: "https://pkgseer.dev", + hasValidToken: true, + mcpUrl: "https://mcp.example.com", + ...overrides, + }; +} + +async function cliJson( + spec: string, + options: Parameters[1] = {}, + deps: PkgDepsCommandDependencies = cliDeps(), +): Promise { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + try { + await pkgDepsAction(spec, { ...options, json: true }, deps); + } catch { + // CLI error paths call process.exit — caught. + } + const fromLog = logSpy.mock.calls[0]?.[0] as string | undefined; + const fromErr = errSpy.mock.calls[0]?.[0] as string | undefined; + const raw = fromLog ?? fromErr; + return raw ? JSON.parse(raw) : undefined; + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + } +} + +async function mcpJson( + args: { + registry: string; + package_name: string; + version?: string; + lifecycle?: string; + include_transitive?: boolean; + include_importers?: boolean; + max_depth?: number; + }, + packageDependenciesMock?: () => Promise, +): Promise<{ json: unknown; isError: boolean | undefined }> { + const service = createMockPackageIntelligenceService( + packageDependenciesMock + ? { packageDependencies: packageDependenciesMock as never } + : {}, + ); + const tool = createPackageDependenciesTool(service); + const result = await tool.handler(args, {}); + const text = result.content[0]?.text ?? ""; + return { json: JSON.parse(text), isError: result.isError }; +} + +describe("package_dependencies parity", () => { + it("PARITY-JSON-KEYS: happy flat-runtime CLI === MCP", async () => { + const cli = await cliJson("npm:express"); + const { json, isError } = await mcpJson({ + registry: "npm", + package_name: "express", + }); + expect(isError).toBeUndefined(); + expect(cli).toEqual(json); + }); + + it("PARITY-JSON-KEYS: zero-dep hot path CLI === MCP (omits groups block)", async () => { + const zeroFn = mock(() => Promise.resolve(zeroDepDependencyReport)); + const cli = await cliJson( + "npm:left-pad", + {}, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageDependencies: zeroFn as never, + }), + }), + ); + const { json } = await mcpJson( + { registry: "npm", package_name: "left-pad" }, + zeroFn as never, + ); + expect(cli).toEqual(json); + expect((cli as { groups?: unknown }).groups).toBeUndefined(); + }); + + it("PARITY-JSON-KEYS: full-view express (no filter) CLI === MCP", async () => { + const fn = mock(() => Promise.resolve(defaultDependencyReport)); + const cli = await cliJson( + "npm:express", + { groups: true }, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageDependencies: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { registry: "npm", package_name: "express" }, + fn as never, + ); + expect(cli).toEqual(json); + }); + + it("PARITY-JSON-KEYS: lifecycle=optional CLI === MCP (tokio optional groups)", async () => { + const fn = mock(() => Promise.resolve(cratesFeatureDependencyReport)); + const cli = await cliJson( + "crates:tokio", + { lifecycle: "optional" }, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageDependencies: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { + registry: "crates", + package_name: "tokio", + lifecycle: "optional", + }, + fn as never, + ); + expect(cli).toEqual(json); + }); + + it("PARITY-JSON-KEYS: lifecycle=runtime,development CLI === MCP", async () => { + const fn = mock(() => Promise.resolve(defaultDependencyReport)); + const cli = await cliJson( + "npm:express", + { lifecycle: "runtime,development" }, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageDependencies: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { + registry: "npm", + package_name: "express", + lifecycle: "runtime,development", + }, + fn as never, + ); + expect(cli).toEqual(json); + // filter echo is canonicalised + sorted + expect((cli as { filter?: { lifecycles: string[] } }).filter).toEqual({ + lifecycles: ["runtime", "development"], + }); + }); + + it("PARITY-JSON-KEYS: lifecycle=build matches nothing → groups.items:[] CLI === MCP", async () => { + const filterEmptyReport: DependencyReport = { + package: { name: "express", registry: "NPM", version: "5.2.1" }, + dependencies: { + direct: [ + { name: "accepts", versionConstraint: "^2.0.0", type: "runtime" }, + ], + }, + dependencyGroups: { groups: [] }, + }; + const fn = mock(() => Promise.resolve(filterEmptyReport)); + const cli = await cliJson( + "npm:express", + { lifecycle: "build" }, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageDependencies: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { + registry: "npm", + package_name: "express", + lifecycle: "build", + }, + fn as never, + ); + expect(cli).toEqual(json); + expect((cli as { groups?: { items: unknown[] } }).groups).toEqual({ + items: [], + }); + }); + + it("PARITY-JSON-KEYS: Crates-target-cfg dedup round-trip preserves duplicates in JSON on both surfaces", async () => { + const fn = mock(() => Promise.resolve(cratesFeatureDependencyReport)); + const cli = await cliJson( + "crates:tokio", + { groups: true }, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageDependencies: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { registry: "crates", package_name: "tokio" }, + fn as never, + ); + expect(cli).toEqual(json); + const items = ( + cli as { + groups: { items: Array<{ name: string; items: unknown[] }> }; + } + ).groups.items; + const net = items.find((g) => g.name === "net"); + expect(net?.items.length).toBe(3); // libc, libc, mio — duplicates preserved + }); + + it("PARITY-JSON-KEYS: include_transitive CLI === MCP (preprocessed packages[], no raw dag)", async () => { + const transitiveReport: DependencyReport = { + package: { name: "express", registry: "NPM", version: "5.2.1" }, + dependencies: { + direct: [ + { name: "accepts", versionConstraint: "^2.0.0", type: "runtime" }, + ], + transitive: { + totalEdges: 80, + uniquePackagesCount: 45, + uniqueDependencies: ["accepts@2.0.0"], + conflicts: [], + circularDependencies: [], + dag: { + n: [ + ["npm", "express", "5.2.1"], + ["npm", "accepts", "2.0.0"], + ], + e: [[0, 1, "^2.0.0", "runtime"]], + v: 4, + }, + }, + }, + }; + const fn = mock(() => Promise.resolve(transitiveReport)); + // --verbose on the CLI enables importers in JSON; include_importers + // on MCP does the same. Both surfaces route to the same lean + // envelope shaper. + const cli = await cliJson( + "npm:express", + { transitive: true, verbose: true }, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageDependencies: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { + registry: "npm", + package_name: "express", + include_transitive: true, + include_importers: true, + }, + fn as never, + ); + expect(cli).toEqual(json); + const transitiveEnvelope = ( + cli as { + transitive?: { packages?: unknown[]; dag?: unknown }; + } + ).transitive; + expect(transitiveEnvelope?.packages).toEqual([ + { + name: "accepts", + version: "2.0.0", + importers: [ + { name: "express", version: "5.2.1", constraint: "^2.0.0" }, + ], + }, + ]); + expect(transitiveEnvelope?.dag).toBeUndefined(); + }); + + it("PARITY-JSON-KEYS: include_transitive defaults to lean packages (no importers) on both surfaces", async () => { + const transitiveReport: DependencyReport = { + package: { name: "express", registry: "NPM", version: "5.2.1" }, + dependencies: { + direct: [], + transitive: { + totalEdges: 1, + uniquePackagesCount: 1, + uniqueDependencies: ["accepts@2.0.0"], + conflicts: [], + circularDependencies: [], + dag: { + n: [ + ["npm", "express", "5.2.1"], + ["npm", "accepts", "2.0.0"], + ], + e: [[0, 1, "^2.0.0", "runtime"]], + v: 4, + }, + }, + }, + }; + const fn = mock(() => Promise.resolve(transitiveReport)); + const cli = await cliJson( + "npm:express", + { transitive: true }, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageDependencies: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { + registry: "npm", + package_name: "express", + include_transitive: true, + }, + fn as never, + ); + expect(cli).toEqual(json); + const pkgs = ( + cli as { transitive?: { packages?: Array> } } + ).transitive?.packages; + expect(pkgs).toEqual([{ name: "accepts", version: "2.0.0" }]); + // Importers absent — lean default. + expect(pkgs?.[0]?.importers).toBeUndefined(); + }); + + it("PARITY-JSON-KEYS: versioned match suppresses requestedVersion on both surfaces", async () => { + const fn = mock(() => Promise.resolve(defaultDependencyReport)); + const cli = await cliJson( + "npm:express@5.2.1", + {}, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageDependencies: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { registry: "npm", package_name: "express", version: "5.2.1" }, + fn as never, + ); + expect(cli).toEqual(json); + expect( + (cli as { requestedVersion?: string }).requestedVersion, + ).toBeUndefined(); + }); + + it("PARITY-JSON-KEYS: versioned non-trivial diff surfaces requestedVersion on both surfaces", async () => { + const resolvedReport: DependencyReport = { + package: { name: "express", registry: "NPM", version: "4.17.2" }, + dependencies: { direct: [] }, + }; + const fn = mock(() => Promise.resolve(resolvedReport)); + const cli = await cliJson( + "npm:express@4.17", + {}, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageDependencies: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { registry: "npm", package_name: "express", version: "4.17" }, + fn as never, + ); + expect(cli).toEqual(json); + expect((cli as { requestedVersion?: string }).requestedVersion).toBe( + "4.17", + ); + }); + + it("PARITY-ERROR-ENVELOPE: NOT_FOUND CLI === MCP", async () => { + const error = new PackageIntelligenceTargetNotFoundError( + "Package 'npm:ghost' not found.", + ); + const fn = mock(() => Promise.reject(error)); + const cli = await cliJson( + "npm:ghost", + {}, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageDependencies: fn as never, + }), + }), + ); + const { json, isError } = await mcpJson( + { registry: "npm", package_name: "ghost" }, + fn as never, + ); + expect(isError).toBe(true); + expect(cli).toEqual(json); + expect(cli).toEqual({ + error: "Package 'npm:ghost' not found.", + code: "NOT_FOUND", + retryable: false, + }); + }); + + it("PARITY-ERROR-ENVELOPE: VERSION_NOT_FOUND with structured details CLI === MCP", async () => { + const error = new PackageIntelligenceVersionNotFoundError( + "Version 99.0.0 not found", + "npm:express", + "99.0.0", + ["5.2.1", "5.2.0"], + ); + const fn = mock(() => Promise.reject(error)); + const cli = await cliJson( + "npm:express@99.0.0", + {}, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageDependencies: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { + registry: "npm", + package_name: "express", + version: "99.0.0", + }, + fn as never, + ); + expect(cli).toEqual(json); + expect(cli).toMatchObject({ + code: "VERSION_NOT_FOUND", + retryable: false, + details: { + package: "npm:express", + requestedVersion: "99.0.0", + availableVersions: [ + { version: "5.2.1", ref: "5.2.1" }, + { version: "5.2.0", ref: "5.2.0" }, + ], + }, + }); + }); + + it("PARITY-ERROR-ENVELOPE: BACKEND_ERROR (TIMEOUT) CLI === MCP", async () => { + const error = new PackageIntelligenceBackendError( + "upstream timed out", + 504, + "TIMEOUT", + ); + const fn = mock(() => Promise.reject(error)); + const cli = await cliJson( + "npm:express", + {}, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageDependencies: fn as never, + }), + }), + ); + const { json, isError } = await mcpJson( + { registry: "npm", package_name: "express" }, + fn as never, + ); + expect(isError).toBe(true); + expect(cli).toEqual(json); + expect(cli).toMatchObject({ + code: "TIMEOUT", + retryable: true, + error: "upstream timed out", + }); + }); + + it("PARITY-ERROR-ENVELOPE: INVALID_ARGUMENT (unsupported registry) — shape match", async () => { + const cli = await cliJson("nuget:foo"); + const { json, isError } = await mcpJson({ + registry: "nuget", + package_name: "foo", + }); + expect(isError).toBe(true); + expect(cli).toMatchObject({ + code: "INVALID_ARGUMENT", + retryable: false, + error: expect.any(String), + }); + expect(json).toMatchObject({ + code: "INVALID_ARGUMENT", + retryable: false, + error: expect.any(String), + }); + expect(Object.keys(cli as object).sort()).toEqual( + Object.keys(json as object).sort(), + ); + }); + + it("PARITY-ERROR-ENVELOPE: INVALID_ARGUMENT (tag-style version) — shape match", async () => { + const cli = await cliJson("npm:express@v4.18.0"); + const { json, isError } = await mcpJson({ + registry: "npm", + package_name: "express", + version: "v4.18.0", + }); + expect(isError).toBe(true); + expect(cli).toMatchObject({ code: "INVALID_ARGUMENT", retryable: false }); + expect(json).toMatchObject({ code: "INVALID_ARGUMENT", retryable: false }); + }); + + it("PARITY-ERROR-ENVELOPE: INVALID_ARGUMENT (invalid lifecycle token) — shape match", async () => { + const cli = await cliJson("npm:express", { lifecycle: "dev" }); + const { json, isError } = await mcpJson({ + registry: "npm", + package_name: "express", + lifecycle: "dev", + }); + expect(isError).toBe(true); + expect(cli).toMatchObject({ code: "INVALID_ARGUMENT", retryable: false }); + expect(json).toMatchObject({ code: "INVALID_ARGUMENT", retryable: false }); + }); +}); diff --git a/src/tools/package-dependencies.test.ts b/src/tools/package-dependencies.test.ts new file mode 100644 index 00000000..dc82cddc --- /dev/null +++ b/src/tools/package-dependencies.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it, mock } from "bun:test"; +import { PackageIntelligenceTargetNotFoundError } from "../services/index.js"; +import { + createMockPackageIntelligenceService, + defaultDependencyReport, +} from "../services/test-helpers.js"; +import { createPackageDependenciesTool } from "./package-dependencies.js"; + +function parseText(result: { content: Array<{ text: string }> }): unknown { + return JSON.parse(result.content[0]?.text ?? ""); +} + +describe("createPackageDependenciesTool — metadata", () => { + it("registers the correct tool name, description, and schema keys", () => { + const tool = createPackageDependenciesTool( + createMockPackageIntelligenceService(), + ); + expect(tool.name).toBe("package_dependencies"); + expect(tool.description).toContain("npm, PyPI, Hex, Crates"); + expect(Object.keys(tool.schema).sort()).toEqual([ + "include_importers", + "include_transitive", + "lifecycle", + "max_depth", + "package_name", + "registry", + "version", + ]); + expect(tool.annotations?.readOnlyHint).toBe(true); + }); + + it("does NOT expose an include_groups input (data-first envelope makes it a no-op)", () => { + const tool = createPackageDependenciesTool( + createMockPackageIntelligenceService(), + ); + expect(Object.keys(tool.schema)).not.toContain("include_groups"); + }); +}); + +describe("createPackageDependenciesTool — happy path", () => { + it("calls service.packageDependencies with normalised params", async () => { + const packageDependencies = mock(() => + Promise.resolve(defaultDependencyReport), + ); + const service = createMockPackageIntelligenceService({ + packageDependencies, + }); + const tool = createPackageDependenciesTool(service); + + await tool.handler( + { + registry: "npm", + package_name: "express", + version: "5.2.1", + lifecycle: "runtime,development", + include_transitive: true, + max_depth: 3, + }, + {}, + ); + + const calls = packageDependencies.mock.calls as unknown as Array< + [ + { + registry: string; + packageName: string; + version?: string; + lifecycle?: string[]; + includeTransitive?: boolean; + maxDepth?: number; + }, + ] + >; + expect(calls[0]?.[0]?.registry).toBe("NPM"); + expect(calls[0]?.[0]?.packageName).toBe("express"); + expect(calls[0]?.[0]?.version).toBe("5.2.1"); + expect(calls[0]?.[0]?.lifecycle).toEqual(["runtime", "development"]); + expect(calls[0]?.[0]?.includeTransitive).toBe(true); + expect(calls[0]?.[0]?.maxDepth).toBe(3); + }); + + it("emits the lean JSON envelope with runtime + groups blocks", async () => { + const tool = createPackageDependenciesTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { registry: "npm", package_name: "express" }, + {}, + ); + expect(result.isError).toBeUndefined(); + const payload = parseText(result) as { + registry: string; + name: string; + runtime: { count: number }; + groups: { items: unknown[] }; + }; + expect(payload.registry).toBe("npm"); + expect(payload.name).toBe("express"); + expect(payload.runtime.count).toBe(3); + expect(payload.groups.items.length).toBe(2); + }); + + it("surfaces filter.lifecycles when lifecycle is set", async () => { + const tool = createPackageDependenciesTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { + registry: "npm", + package_name: "express", + lifecycle: "development", + }, + {}, + ); + const payload = parseText(result) as { + filter?: { lifecycles: string[] }; + }; + expect(payload.filter?.lifecycles).toEqual(["development"]); + }); + + it("accepts lifecycle as a pre-split array (MCP agents' natural shape)", async () => { + const tool = createPackageDependenciesTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { + registry: "npm", + package_name: "express", + lifecycle: ["runtime", "development"], + }, + {}, + ); + const payload = parseText(result) as { + filter?: { lifecycles: string[] }; + }; + expect(payload.filter?.lifecycles).toEqual(["runtime", "development"]); + }); + + it("emits transitive block only when include_transitive is set", async () => { + const tool = createPackageDependenciesTool( + createMockPackageIntelligenceService(), + ); + const withoutTransitive = parseText( + await tool.handler({ registry: "npm", package_name: "express" }, {}), + ) as { transitive?: unknown }; + expect(withoutTransitive.transitive).toBeUndefined(); + }); +}); + +describe("createPackageDependenciesTool — validation errors via in-handler builder", () => { + it("returns INVALID_ARGUMENT for unsupported registry (nuget)", async () => { + const tool = createPackageDependenciesTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { registry: "nuget", package_name: "Newtonsoft.Json" }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string; error: string }; + expect(payload.code).toBe("INVALID_ARGUMENT"); + expect(payload.error).toBe( + "pkg deps only supports npm, pypi, hex, crates, vcpkg, and zig. Got: nuget.", + ); + }); + + it("returns INVALID_ARGUMENT for tag-style version", async () => { + const tool = createPackageDependenciesTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { registry: "npm", package_name: "express", version: "v4.18.0" }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string; error: string }; + expect(payload.code).toBe("INVALID_ARGUMENT"); + expect(payload.error).toContain("git tag"); + }); + + it("returns INVALID_ARGUMENT for unknown lifecycle token", async () => { + const tool = createPackageDependenciesTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { registry: "npm", package_name: "express", lifecycle: "dev" }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string; error: string }; + expect(payload.code).toBe("INVALID_ARGUMENT"); + expect(payload.error).toContain("Unknown lifecycle 'dev'"); + }); +}); + +describe("createPackageDependenciesTool — service errors", () => { + it("classifies PackageIntelligenceTargetNotFoundError as NOT_FOUND envelope", async () => { + const service = createMockPackageIntelligenceService({ + packageDependencies: mock(() => + Promise.reject( + new PackageIntelligenceTargetNotFoundError("Package not found"), + ), + ), + }); + const tool = createPackageDependenciesTool(service); + const result = await tool.handler( + { registry: "npm", package_name: "ghost" }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string }; + expect(payload.code).toBe("NOT_FOUND"); + }); + + it("classifies unexpected Error as UNKNOWN", async () => { + const service = createMockPackageIntelligenceService({ + packageDependencies: mock(() => Promise.reject(new Error("boom"))), + }); + const tool = createPackageDependenciesTool(service); + const result = await tool.handler( + { registry: "npm", package_name: "express" }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string }; + expect(payload.code).toBe("UNKNOWN"); + }); +}); diff --git a/src/tools/package-dependencies.ts b/src/tools/package-dependencies.ts new file mode 100644 index 00000000..7f04e3a0 --- /dev/null +++ b/src/tools/package-dependencies.ts @@ -0,0 +1,137 @@ +import { z } from "zod"; +import type { PackageIntelligenceService } from "../services/index.js"; +import { buildPackageDependenciesParams } from "../shared/package-dependencies-request.js"; +import { buildPackageDependenciesSuccessPayload } from "../shared/package-dependencies-response.js"; +import { mapPackageIntelligenceError } from "../shared/package-intelligence-error-map.js"; +import { type ToolDefinition, textResult } from "./types.js"; + +export interface PackageDependenciesArgs { + registry: string; + package_name: string; + version?: string; + lifecycle?: string | string[]; + include_transitive?: boolean; + include_importers?: boolean; + max_depth?: number; +} + +/** + * Permissive schema — in-handler validation via + * `buildPackageDependenciesParams` is the single validation path so + * raw Zod errors never surface to agents. Matches the shipped + * `search_symbols` / `package_summary` / `package_vulnerabilities` + * pattern. + * + * No `include_groups` input. The data-first envelope emits the + * `groups` block unconditionally when the backend returned + * `dependencyGroups`, so an `include_groups: true` flag would have no + * observable effect — and a silently ignored flag would confuse + * agents. + */ +const schema = { + registry: z + .string() + .describe( + "Package registry. Dependency data is available on npm, pypi, hex, crates, vcpkg, and zig.", + ), + package_name: z + .string() + .describe("Package name (scoped names ok: @types/node)."), + version: z + .string() + .optional() + .describe( + "Specific version to inspect. Defaults to latest when omitted. Tag-style inputs with a leading `v` (for example `v4.18.0`) are rejected — pass the canonical version (`4.18.0`).", + ), + lifecycle: z + .union([z.string(), z.array(z.string())]) + .optional() + .describe( + 'Filter the `groups` block server-side by lifecycle phase. Accepts a single value, a comma-separated string (e.g. `"runtime,development"`), or an array of strings. Canonical values: `runtime`, `development`, `build`, `peer`, `optional`. Uppercase is tolerated. When the filter matches nothing the response still includes `groups: { items: [] }` so you can tell an empty-match apart from a registry that has no groups concept.', + ), + include_transitive: z + .boolean() + .optional() + .describe( + "When true the response gains a `transitive` block with aggregate counts (`edges`, `uniquePackages`), the preprocessed `packages[]` list (each `{name, version}` — the complete install footprint), plus typed `conflicts[]` (`{name, requiredVersions}`) and `circularDependencies[]` (`{cycle: string[]}`) when the backend reported any. Off by default.", + ), + include_importers: z + .boolean() + .optional() + .describe( + "Requires `include_transitive: true`. When true, each entry in `transitive.packages[]` also carries an `importers` array — every upstream package that pulls it in, with that importer's own resolved version and the constraint it declared. Off by default because adding provenance roughly quadruples the envelope size on heavy graphs. Turn on when you need to trace why a specific transitive dep is present.", + ), + max_depth: z + .number() + .int() + .min(1) + .max(10) + .optional() + .describe( + "Cap the transitive traversal at this depth (1–10). Omit to get the backend's full graph. Only meaningful alongside `include_transitive: true`.", + ), +}; + +const DESCRIPTION = + "Analyze a package's dependency graph. The response always includes " + + "a `runtime` block listing the direct runtime dependencies as " + + "`{name, version, constraint}` records (the backend resolves each " + + "constraint to a concrete version for you). It also always includes " + + "a structured `groups` block whenever the backend returns group " + + "metadata — one group per lifecycle (`runtime`, `development`, " + + "`build`, `peer`, `optional`) plus feature-conditional groups for " + + "registries that have them (PyPI extras, Crates features). Use " + + "`lifecycle` to filter `groups` server-side. Set " + + "`include_transitive: true` to add a `transitive` block with the " + + "full install footprint, conflict detection, and circular-" + + "dependency flags; layer `include_importers: true` on top when you " + + "also need per-package provenance. Supports npm, PyPI, Hex, Crates, " + + "vcpkg, and Zig."; + +export function createPackageDependenciesTool( + service: PackageIntelligenceService, +): ToolDefinition { + return { + name: "package_dependencies", + description: DESCRIPTION, + schema, + annotations: { readOnlyHint: true }, + handler: async (args) => { + try { + const { params, canonicalLifecycles } = buildPackageDependenciesParams({ + registry: args.registry, + packageName: args.package_name, + version: args.version, + includeTransitive: args.include_transitive, + maxDepth: args.max_depth, + lifecycle: args.lifecycle, + }); + const report = await service.packageDependencies(params); + const payload = buildPackageDependenciesSuccessPayload(report, { + requestedVersion: args.version, + canonicalLifecycles, + includeTransitive: args.include_transitive, + maxDepth: args.max_depth, + includeImporters: args.include_importers ?? false, + }); + return textResult(JSON.stringify(payload)); + } catch (error) { + const mapped = mapPackageIntelligenceError(error); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + error: mapped.message, + code: mapped.code, + retryable: mapped.retryable ?? false, + ...(mapped.details ? { details: mapped.details } : {}), + }), + }, + ], + isError: true, + }; + } + }, + }; +}