Skip to content

feat(pkg-intel): add package_vulnerabilities (pkg vulns + MCP) - #14

Merged
jlitola merged 1 commit into
mainfrom
feat/pkg-intel-vulnerabilities
Apr 20, 2026
Merged

feat(pkg-intel): add package_vulnerabilities (pkg vulns + MCP)#14
jlitola merged 1 commit into
mainfrom
feat/pkg-intel-vulnerabilities

Conversation

@jlitola

@jlitola jlitola commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the package_vulnerabilities MCP tool and githits pkg vulns 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.

Surface

  • githits pkg vulns <spec> — CLI listing known CVE / OSV advisories with severity, affected ranges, fix versions, and upgrade paths. Supports @version in the spec (unlike pkg info), plus --severity low|medium|high|critical (uppercase tolerated), --include-withdrawn, --verbose, --json.
  • package_vulnerabilities — MCP tool with the same lean envelope. Permissive Zod schema, validation in-handler via buildPackageVulnerabilitiesParams. Advertised in the MCP server-level instructions under the package-tools section, gated on packageIntelligenceService.

The zero-vulns happy path renders as a single-line summary body (No known vulnerabilities.) to keep agent token usage minimal on the common case.

Design choices

  • Server-side filtering. --severity maps to a CVSS float on the wire; --include-withdrawn passes through. The backend returns a filter-aware vulnerabilityCount, so summary.total reflects whatever survived the filter. No client-side filtering, no dual-summary block.
  • Partitioning bySeverity buckets. Every returned advisory lands in exactly one of malware / critical / high / medium / low / unrated. The client-side guarantee is MALWARE + crit + high + medium + low + unrated = advisories.length, so the header breakdown always reconciles with the total — even on RUSTSEC / PYSEC where unbanded advisories are common.
  • Canonical versions only. Tag-style refs with a leading v (e.g. v4.18.0) are rejected client-side with INVALID_ARGUMENT before the backend call. The v prefix is a git-tag convention, not a canonical version on any supported registry; proper ecosystem-aware version parsing belongs in the backend, not in ad hoc CLI normalisation rules.
  • Typed VERSION_NOT_FOUND. When the backend emits extensions.code = "VERSION_NOT_FOUND" with structured fields, the classifier surfaces details: { package, requestedVersion, availableVersions[] }. The CLI terminal error expands this into package: / requested: / available: detail lines so users see the mistake and the alternatives. A narrow message-string fallback promotes generic BackendErrors with matching text to the typed error while the backend catches up; it's guarded to only fire when no graphqlCode is present, so real server faults never have their retryability flipped.
  • Registry coverage. Only npm, PyPI, Hex, and Crates have vulnerability data. The other five known registries are rejected client-side with pkg vulns only supports npm, pypi, hex, and crates. Got: ${registry}. before the backend call.
  • Malware marker. Advisories with isMalicious: true render with a red/bold MALWARE column (optionally combined as MALWARE · crit when both flags are set) and sort to the top of the advisory list regardless of CVSS score. The malware count in the breakdown line is always coloured red.
  • Shared helper scope. The envelope builder is shared between CLI --json and MCP content[0].text; the terminal formatter is CLI-only (MCP always emits JSON). Documented in mcp-cli-parity.md.

Terminal UX refinements

  • latest affected is the only yellow token in the summary line; latest clean stays plain so it doesn't read as a caution.
  • Unrated advisories render with a dim unrated label in the severity gutter rather than an empty column.
  • Affected-range lists adapt to terminal width: 4 entries on narrow (≤119 cols), 6 on standard-wide (120–159), 8 on ultrawide (≥160). The remainder collapses into a dim … (+N more; use -v) hint. Verbose shows every range. JSON is never truncated — machine consumers get the full list.
  • Upgrade options are de-duplicated and sorted ascending by semver-ish comparison (pre-releases rank below their matching base release), so the footer presents the minimum-churn upgrade first.
  • Literal No summary available placeholders from upstream are stripped from both the JSON envelope and the terminal row — absence of summary is the signal.

Tests

  • bun test — 810 pass, 0 fail, 1784 expect() calls.
  • bun run typecheck / bun run build / bun run lint — clean.
  • Parity fixtures: happy, zero-vulns, filtered success, versioned-match, v-prefixed (rejected client-side), versioned real-diff, NOT_FOUND, VERSION_NOT_FOUND with structured details, BACKEND_ERROR, plus INVALID_ARGUMENT fixtures for unsupported registry and invalid severity label.
  • Live-verified against production pkgseer across npm, PyPI, Crates, Hex, scoped-npm, and MCP stdio.

Test plan

  • bun test passes
  • bun run typecheck clean
  • bun run build clean
  • bun run lint clean
  • pkg vulns scenarios smoked against a token with code_navigation capability (happy, zero-vulns, version pin, malware fixture, severity filter, withdrawn toggle, tag-style rejection, scoped-npm name, registry rejection)
  • MCP tools/list shows package_vulnerabilities under an open-gate token, absent under a gate-closed token
  • MCP server-level instructions include the package_vulnerabilities bullet under an open-gate token
  • pkg vulns --help renders under an open-gate token

@jlitola
jlitola force-pushed the feat/pkg-intel-vulnerabilities branch from 4f0a9f6 to 2dc48e9 Compare April 20, 2026 12:34
Adds the `package_vulnerabilities` MCP tool and `githits pkg vulns`
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.

Key design choices:

- Server-side filtering. `--severity` / `min_severity` map to a CVSS
  float on the wire; `--include-withdrawn` passes through untouched.
  The backend returns a filter-aware `vulnerabilityCount`, so
  `summary.total` reflects whatever survived the filter. No
  client-side filtering, no dual-summary block.

- Partitioning bySeverity buckets. `summary.bySeverity` splits
  returned advisories into disjoint `malware` (isMalicious === true),
  CVSS bands (`critical` / `high` / `medium` / `low`), and `unrated`
  (non-malicious, no CVSS score) buckets. The client-side guarantee
  is that every returned advisory lands in exactly one bucket, so
  the header breakdown reconciles with the total even on
  registries (RUSTSEC, PYSEC) where unbanded advisories are common.

- Permissive MCP schema + in-handler validation. `registry` and
  `package_name` accept any string; validation runs in
  `buildPackageVulnerabilitiesParams` and emits the shared
  `{ error, code, retryable, details? }` envelope on invalid input.
  `min_severity` tolerates uppercase input.

- Canonical versions only. Tag-style refs with a leading `v`
  (e.g. `v4.18.0`) are rejected client-side with `INVALID_ARGUMENT`
  before the backend call — the `v` prefix is a git-tag convention,
  not a canonical version on any supported registry. Proper
  ecosystem-aware version parsing belongs in the backend, not in
  ad hoc CLI normalisation rules.

- Typed `VERSION_NOT_FOUND`. When the backend emits
  `extensions.code = "VERSION_NOT_FOUND"` with structured fields,
  the classifier surfaces `details: { package, requestedVersion,
  availableVersions[] }` in the envelope. The CLI terminal error
  expands this into `package:` / `requested:` / `available:`
  detail lines so users see the mistake and the alternatives.
  A narrow message-string fallback promotes generic BackendErrors
  with matching text to the typed error while the backend catches
  up; guarded to only fire when no `graphqlCode` is present so
  real server faults never have their retryability flipped.

- Registry coverage. Only npm, PyPI, Hex, and Crates have
  vulnerability data. The other five known registries are rejected
  client-side with a tool-specific message.

- Malware marker. Malicious advisories render with a red/bold
  `MALWARE` column (optionally combined as `MALWARE · crit`) and
  sort to the top of the advisory list regardless of score. The
  malware count in the breakdown line is always coloured red.

Terminal UX refinements:

- `latest affected` is the only yellow token in the summary line;
  `latest clean` stays plain so it doesn't read as a caution signal.
- Unrated advisories render with a dim `unrated` label in the
  severity gutter rather than an empty column.
- Affected-range lists cap at 4 / 6 / 8 entries on narrow /
  standard-wide / ultrawide terminals (≥120 / ≥160 cols) with a
  `… (+N more; use -v)` hint; verbose mode shows every range. JSON
  output is never truncated.
- Upgrade options are de-duplicated and sorted ascending by
  semver-ish comparison (pre-releases rank below their base),
  presenting the minimum-churn upgrade first.
- Literal `No summary available` placeholders from upstream are
  stripped from both the JSON envelope and the terminal row.
- `pkg vulns` accepts `@version` in the spec (unlike `pkg info`,
  which always returns latest).

MCP server instructions: `src/commands/mcp-instructions.ts` gains a
one-line bullet for `package_vulnerabilities` in the package-tools
section, gated on `packageIntelligenceService` presence. The
mention↔registration invariant test covers the new tool across
gate-closed, gate-open, half-open, and opaque-token scenarios.

Tests: 810 pass. Parity test covers happy, zero-vulns, filtered,
versioned-match, `v`-prefixed (rejected), versioned real-diff,
`NOT_FOUND`, `VERSION_NOT_FOUND` with structured details,
`BACKEND_ERROR`, plus `INVALID_ARGUMENT` fixtures for unsupported
registry and invalid severity label.

Live-verified against production pkgseer across npm, PyPI, Crates,
Hex, scoped-npm, and MCP stdio.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jlitola
jlitola force-pushed the feat/pkg-intel-vulnerabilities branch from f569005 to 679ea6a Compare April 20, 2026 15:28
@jlitola
jlitola merged commit 86fe969 into main Apr 20, 2026
3 checks passed
jlitola added a commit that referenced this pull request Apr 20, 2026
Fourth Wave 1 package-intelligence tool, following `package_summary`
(#13), `package_vulnerabilities` (#14), `package_dependencies` (#16
+ follow-up #17). 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 changelog [spec]`** — default latest-mode output is
  a summary row (identity · source · mode · entry count) plus a
  one-line `version  date  url` per entry, newest-first. `--from
  <v>` switches to range mode (all entries between `--from` and
  `--to`/latest; `--limit` rejected). `--to <v>` / `--limit <n>`
  shape latest mode. `--verbose` expands each entry with its full
  markdown body, indented and dimmed. `--no-body` drops body fields
  from both terminal and JSON. `--git-ref` targets a specific
  branch/tag for CHANGELOG.md source. `--repo-url <url>` is an
  alternative addressing mode (mutually exclusive with `<spec>`).
- **`package_changelog`** — MCP tool with the same envelope. Dual
  addressing: `registry` + `package_name` XOR `repo_url`. Permissive
  Zod schema (in-handler validation). `include_bodies` (default
  true) mirrors CLI's `--no-body`. Description is self-contained —
  no CLI-flag references.

## Design choices

- **Dual addressing is unique to this tool.** P1 / P2 / P3 all
  accept only `registry` + `package_name` because their underlying
  backend queries are registry-metadata APIs. `packageChangelog` is
  intrinsically repo-level (its sources are GitHub Releases,
  CHANGELOG.md, HexDocs), so `repoUrl` is a peer addressing mode in
  the GraphQL signature, not a bolt-on. Exposing it on MCP was
  non-negotiable: `packageSummary` cannot resolve a repo URL → spec,
  so agents starting from a repo URL had no path in. Documented in
  `tools.md` so future tool authors don't cargo-cult the asymmetry.
- **`<spec>@<version>` rejected.** `pkg vulns` / `pkg deps` treat
  `@version` as "for this exact version"; changelog has no single-
  version query. Silently remapping to `toVersion` would be a
  client-invented semantic shift. Rejected client-side with
  `INVALID_ARGUMENT` and a hint pointing to `--to` / `--from`.
- **Data-first envelope.** `{registry|repoUrl, source, mode,
  entries: {count, items}, filter?}`. `source` always present here
  (null-source case is promoted to `NOT_FOUND` at the service
  boundary and never reaches the envelope). `mode` derived from
  request — `"range"` iff `fromVersion` non-null after
  normalisation, `"latest"` otherwise. `entries.count` computed
  client-side from `items.length`; the backend's count isn't
  selected on the wire so the invariant holds by construction.
- **`version` kept when null, other per-entry nullables stripped.**
  `version` is the primary key agents index by, so the slot is
  always present (possibly null); stripping other nullables keeps
  the envelope lean. `body` additionally stripped under
  `include_bodies: false`.
- **`filter.*` echo tracks explicit fields only.** Request builder
  tracks `explicitFilterFields` set; envelope emits `filter.*` only
  for caller-supplied inputs. Backend defaults (`limit: 10`,
  `toVersion: <latest>`) never round-trip as caller intent.
- **`include_bodies` lever.** Release bodies on large packages can
  run 10 KB+ per entry; `include_bodies: false` drops `body` from
  all items explicitly (not silent truncation). Other fields
  preserved so agents still get the version / date / URL timeline.
- **`metadata` dropped from envelope in v1.** Source-specific opaque
  `GenericJSON`; revisit via agent feedback with a
  `TODO(pkgseer-backend)` anchor if demand surfaces.
- **No client-side registry restriction.** Unlike P3's 6-registry
  client-side gate, `packageChangelog` is source-pull rather than
  registry-query; the backend returns `NOT_FOUND` for registries
  without changelog sources. Live-smoke matrix documented in the
  plan to verify behaviour per registry.
- **Mode mutual exclusion enforced client-side.** `--from` /
  `from_version` + `--limit` / `limit` together → `INVALID_ARGUMENT`
  with actionable hint.
- **`source: null` promoted to NOT_FOUND.** Typed
  `PackageIntelligenceChangelogSourceNotFoundError` at the service
  boundary; shared classifier routes to `NOT_FOUND` with a message
  naming the sources tried (GitHub Releases, CHANGELOG.md, HexDocs).
  Empty `entries.items: []` with a valid `source` is success, not
  error.
- **`--verbose` vs `--no-body` vs `--json` interaction.** `--verbose`
  is terminal-only (expands markdown bodies per entry); does not
  change `--json`. `--no-body` affects both terminal and `--json`.
  `--json` output shape is independent of `--verbose`.
- **Shared `promoteGenericVersionNotFound` extended.** The helper
  now recognises `fromVersion` / `toVersion` in addition to
  `version`; preference order `version → fromVersion → toVersion`.
  `registry` / `packageName` made optional so repo-URL-addressed
  requests flow through without a spec. P2 / P3 regressions guarded
  by the existing tests plus 4 new helper tests.

## Tests

- `bun test` — 1053 pass, 0 fail (was 966; +87 new tests).
- `bun run typecheck` / `bun run build` / `bun run lint` — clean.
- Unit tests: request-builder (addressing XOR, `<spec>@<version>`
  rejection, `--from`/`--limit` mutex, limit bounds, tag-style
  rejection, pre-release versions on `--from`/`--to`, explicit-flag
  tracking), envelope builder (shape, null handling, `include_bodies`,
  mode derivation, filter echo, empty entries), terminal formatter
  (default one-liners, `--verbose` expansion, missing fields), CLI
  action (13 cases incl. addressing, errors, flag interactions),
  MCP tool (15 cases incl. validation + service errors).
- Parity test: 12 fixtures (happy latest, range mode, repo-URL
  addressing, `--no-body` / `include_bodies: false`, default bodies,
  empty entries, `NOT_FOUND` no-source, `TargetNotFoundError`,
  `VERSION_NOT_FOUND` with structured details, `BACKEND_ERROR`,
  `INVALID_ARGUMENT` for `<spec>@<version>` and `--from`+`--limit`).
- Live-smoke deferred to review cycle (local non-interactive bash
  keychain prompt blocks the CLI startup handshake; shape correctness
  is locked in via the verbatim schema Zod parse + parity test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jlitola added a commit that referenced this pull request Apr 21, 2026
Fourth Wave 1 package-intelligence tool, following `package_summary`
(#13), `package_vulnerabilities` (#14), `package_dependencies` (#16
+ follow-up #17). 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 changelog [spec]`** — default latest-mode output is
  a summary row (identity · source · mode · entry count) plus a
  one-line `version  date  url` per entry, newest-first. `--from
  <v>` switches to range mode (all entries between `--from` and
  `--to`/latest; `--limit` rejected). `--to <v>` / `--limit <n>`
  shape latest mode. `--verbose` expands each entry with its full
  markdown body, indented and dimmed. `--no-body` drops body fields
  from both terminal and JSON. `--git-ref` targets a specific
  branch/tag for CHANGELOG.md source. `--repo-url <url>` is an
  alternative addressing mode (mutually exclusive with `<spec>`).
- **`package_changelog`** — MCP tool with the same envelope. Dual
  addressing: `registry` + `package_name` XOR `repo_url`. Permissive
  Zod schema (in-handler validation). `include_bodies` (default
  true) mirrors CLI's `--no-body`. Description is self-contained —
  no CLI-flag references.

## Design choices

- **Dual addressing is unique to this tool.** P1 / P2 / P3 all
  accept only `registry` + `package_name` because their underlying
  backend queries are registry-metadata APIs. `packageChangelog` is
  intrinsically repo-level (its sources are GitHub Releases,
  CHANGELOG.md, HexDocs), so `repoUrl` is a peer addressing mode in
  the GraphQL signature, not a bolt-on. Exposing it on MCP was
  non-negotiable: `packageSummary` cannot resolve a repo URL → spec,
  so agents starting from a repo URL had no path in. Documented in
  `tools.md` so future tool authors don't cargo-cult the asymmetry.
- **`<spec>@<version>` rejected.** `pkg vulns` / `pkg deps` treat
  `@version` as "for this exact version"; changelog has no single-
  version query. Silently remapping to `toVersion` would be a
  client-invented semantic shift. Rejected client-side with
  `INVALID_ARGUMENT` and a hint pointing to `--to` / `--from`.
- **Data-first envelope.** `{registry|repoUrl, source, mode,
  entries: {count, items}, filter?}`. `source` always present here
  (null-source case is promoted to `NOT_FOUND` at the service
  boundary and never reaches the envelope). `mode` derived from
  request — `"range"` iff `fromVersion` non-null after
  normalisation, `"latest"` otherwise. `entries.count` computed
  client-side from `items.length`; the backend's count isn't
  selected on the wire so the invariant holds by construction.
- **`version` kept when null, other per-entry nullables stripped.**
  `version` is the primary key agents index by, so the slot is
  always present (possibly null); stripping other nullables keeps
  the envelope lean. `body` additionally stripped under
  `include_bodies: false`.
- **`filter.*` echo tracks explicit fields only.** Request builder
  tracks `explicitFilterFields` set; envelope emits `filter.*` only
  for caller-supplied inputs. Backend defaults (`limit: 10`,
  `toVersion: <latest>`) never round-trip as caller intent.
- **`include_bodies` lever.** Release bodies on large packages can
  run 10 KB+ per entry; `include_bodies: false` drops `body` from
  all items explicitly (not silent truncation). Other fields
  preserved so agents still get the version / date / URL timeline.
- **`metadata` dropped from envelope in v1.** Source-specific opaque
  `GenericJSON`; revisit via agent feedback with a
  `TODO(pkgseer-backend)` anchor if demand surfaces.
- **No client-side registry restriction.** Unlike P3's 6-registry
  client-side gate, `packageChangelog` is source-pull rather than
  registry-query; the backend returns `NOT_FOUND` for registries
  without changelog sources. Live-smoke matrix documented in the
  plan to verify behaviour per registry.
- **Mode mutual exclusion enforced client-side.** `--from` /
  `from_version` + `--limit` / `limit` together → `INVALID_ARGUMENT`
  with actionable hint.
- **`source: null` promoted to NOT_FOUND.** Typed
  `PackageIntelligenceChangelogSourceNotFoundError` at the service
  boundary; shared classifier routes to `NOT_FOUND` with a message
  naming the sources tried (GitHub Releases, CHANGELOG.md, HexDocs).
  Empty `entries.items: []` with a valid `source` is success, not
  error.
- **`--verbose` vs `--no-body` vs `--json` interaction.** `--verbose`
  is terminal-only (expands markdown bodies per entry); does not
  change `--json`. `--no-body` affects both terminal and `--json`.
  `--json` output shape is independent of `--verbose`.
- **Shared `promoteGenericVersionNotFound` extended.** The helper
  now recognises `fromVersion` / `toVersion` in addition to
  `version`; preference order `version → fromVersion → toVersion`.
  `registry` / `packageName` made optional so repo-URL-addressed
  requests flow through without a spec. P2 / P3 regressions guarded
  by the existing tests plus 4 new helper tests.

## Tests

- `bun test` — 1053 pass, 0 fail (was 966; +87 new tests).
- `bun run typecheck` / `bun run build` / `bun run lint` — clean.
- Unit tests: request-builder (addressing XOR, `<spec>@<version>`
  rejection, `--from`/`--limit` mutex, limit bounds, tag-style
  rejection, pre-release versions on `--from`/`--to`, explicit-flag
  tracking), envelope builder (shape, null handling, `include_bodies`,
  mode derivation, filter echo, empty entries), terminal formatter
  (default one-liners, `--verbose` expansion, missing fields), CLI
  action (13 cases incl. addressing, errors, flag interactions),
  MCP tool (15 cases incl. validation + service errors).
- Parity test: 12 fixtures (happy latest, range mode, repo-URL
  addressing, `--no-body` / `include_bodies: false`, default bodies,
  empty entries, `NOT_FOUND` no-source, `TargetNotFoundError`,
  `VERSION_NOT_FOUND` with structured details, `BACKEND_ERROR`,
  `INVALID_ARGUMENT` for `<spec>@<version>` and `--from`+`--limit`).
- Live-smoke deferred to review cycle (local non-interactive bash
  keychain prompt blocks the CLI startup handshake; shape correctness
  is locked in via the verbatim schema Zod parse + parity test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jlitola added a commit that referenced this pull request Apr 21, 2026
Fourth Wave 1 package-intelligence tool, following `package_summary`
(#13), `package_vulnerabilities` (#14), `package_dependencies` (#16
+ follow-up #17). 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 changelog [spec]`** — default latest-mode output is
  a summary row (identity · source · mode · entry count) plus, per
  entry, a `version  date  url` header followed by the first 10
  lines of the markdown body (with a `… (+N more lines — use
  --verbose for the full body)` footer when the body is longer).
  `--verbose` uncaps the body preview; `--no-body` drops bodies
  entirely from both terminal and `--json`. `--from <v>` switches
  to range mode (all entries between `--from` and `--to`/latest;
  `--limit` rejected). `--to <v>` / `--limit <n>` shape latest
  mode. `--git-ref` targets a branch/tag for CHANGELOG.md source.
  `--repo-url <url>` is an alternative addressing mode, mutually
  exclusive with `<spec>`.
- **`package_changelog`** — MCP tool with the same envelope. Dual
  addressing: `registry` + `package_name` XOR `repo_url`.
  Permissive Zod schema + in-handler validation. `include_bodies`
  (default true) mirrors CLI's `--no-body` and controls JSON
  envelope bodies.

## Design choices

- **Dual addressing is unique to this tool.** P1 / P2 / P3 all
  accept only `registry` + `package_name` because their underlying
  backend queries are registry-metadata APIs. `packageChangelog`
  is intrinsically repo-level (sources: GitHub Releases,
  CHANGELOG.md, HexDocs), so `repoUrl` is a peer addressing mode
  in the GraphQL signature. Exposing it on MCP was non-negotiable:
  `packageSummary` cannot resolve repo-URL → spec, so agents
  starting from a repo URL had no path in. Documented in
  `tools.md` so future tool authors don't cargo-cult the
  asymmetry.
- **`<spec>@<version>` rejected.** `pkg vulns` / `pkg deps` treat
  `@version` as "for this exact version"; changelog has no
  single-version query. Silently remapping to `toVersion` would be
  a client-invented semantic shift. Rejected with
  `INVALID_ARGUMENT` and a hint pointing to `--to` / `--from`.
- **Body preview capped at 10 lines by default.** Release bodies
  routinely run 50-100+ lines — showing them unbounded would
  swamp the terminal; showing none would make the whole command
  useless for answering "what changed". 10-line cap shows the
  first one or two sections plus preamble, which is usually
  enough to answer the question; `--verbose` lifts the cap when
  full context is needed; `--no-body` drops bodies entirely for
  a pure timeline view. JSON envelope is always full-body unless
  `--no-body` / `include_bodies: false` is set — `--verbose` is
  terminal-only.
- **Data-first envelope.** `{registry|repoUrl, source, mode,
  entries: {count, items}, filter?}`. `source` always present
  here (null-source case promoted to `NOT_FOUND` at the service
  boundary; envelope builder has a defence-in-depth throw if the
  invariant is ever violated upstream). `mode` derived from
  request — `"range"` iff `fromVersion` non-null, `"latest"`
  otherwise. `entries.count` computed client-side from
  `items.length`; backend count not selected on the wire.
- **`version` kept when null, other per-entry nullables stripped.**
  `version` is the primary key agents index by. Empty-string
  values (e.g. `body: ""`) are preserved — distinct from absent
  — so agents can tell "empty release notes" from "no notes
  field". Terminal formatter renders `(empty release notes)`
  sentinel for empty-string bodies.
- **`filter.*` tracks explicit fields only.** Builder returns an
  `explicitFilterFields` set; envelope emits `filter.*` only for
  caller-supplied inputs. Backend defaults (`limit: 10`,
  `toVersion: latest`) never round-trip as caller intent.
- **`include_bodies` lever.** Default true. `false` drops `body`
  from every item explicitly; other fields preserved so agents
  still get the version / date / URL timeline. Measured 5.13×
  envelope size reduction on a 20-entry `npm:typescript` request
  (17.3 KB → 3.4 KB).
- **`--no-body` + `--verbose` is an error.** Contradictory
  intents; CLI rejects with an actionable hint rather than
  silently ignoring `--verbose`.
- **MCP `limit` validation in the shared builder, not Zod.** If
  Zod hard-rejected `limit: 51` at the SDK level, the agent would
  see a raw SDK error instead of our shared
  `{error, code, retryable}` envelope. Schema is permissive;
  builder enforces bounds.
- **Registry coverage — all 9 on the wire, no client-side
  restriction.** `packageChangelog` is source-pull, not
  registry-query. Live-smoke confirmed npm / pypi / hex / crates
  / vcpkg / maven / packagist return useful data; nuget returns a
  generic `BACKEND_ERROR`; zig packages weren't in the backend
  index. All paths produce the shared envelope correctly.
- **Mode mutex enforced client-side.** `--from` / `from_version`
  + `--limit` / `limit` → `INVALID_ARGUMENT` with actionable hint.
- **`metadata` dropped in v1.** Source-specific opaque
  `GenericJSON`; revisit via agent feedback.
- **Shared `promoteGenericVersionNotFound` extended.** Now
  recognises `fromVersion` / `toVersion` in addition to
  `version`. `registry` / `packageName` made optional so
  repo-URL-addressed requests flow through. Preference order
  `version → fromVersion → toVersion`. P2 / P3 regressions
  guarded by existing tests + 5 new helper tests.

## Tests

- `bun test` — 1062 pass, 0 fail.
- `bun run typecheck` / `bun run build` / `bun run lint` — clean.
- Request builder, envelope builder, terminal formatter, MCP tool,
  CLI action, parity fixtures (12) — see docs for full matrix.
- Live-smoke against production pkgseer: latest / range /
  repo-URL / `--no-body` / error paths / registry matrix /
  body-preview cap / `--verbose` uncap / truncation footer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jlitola added a commit that referenced this pull request Apr 21, 2026
Fourth Wave 1 package-intelligence tool, following `package_summary`
(#13), `package_vulnerabilities` (#14), `package_dependencies` (#16
+ follow-up #17). 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 changelog [spec]`** — default latest-mode output is
  a summary row (identity · source · mode · entry count) plus, per
  entry, a `version  date  url` header followed by the first 10
  lines of the markdown body (with a `… (+N more lines — use
  --verbose for the full body)` footer when the body is longer).
  `--verbose` uncaps the body preview; `--no-body` drops bodies
  entirely from both terminal and `--json`. `--from <v>` switches
  to range mode (all entries between `--from` and `--to`/latest;
  `--limit` rejected). `--to <v>` / `--limit <n>` shape latest
  mode. `--git-ref` targets a branch/tag for CHANGELOG.md source.
  `--repo-url <url>` is an alternative addressing mode, mutually
  exclusive with `<spec>`.
- **`package_changelog`** — MCP tool with the same envelope. Dual
  addressing: `registry` + `package_name` XOR `repo_url`.
  Permissive Zod schema + in-handler validation. `include_bodies`
  (default true) mirrors CLI's `--no-body` and controls JSON
  envelope bodies.

## Design choices

- **Dual addressing is unique to this tool.** P1 / P2 / P3 all
  accept only `registry` + `package_name` because their underlying
  backend queries are registry-metadata APIs. `packageChangelog`
  is intrinsically repo-level (sources: GitHub Releases,
  CHANGELOG.md, HexDocs), so `repoUrl` is a peer addressing mode
  in the GraphQL signature. Exposing it on MCP was non-negotiable:
  `packageSummary` cannot resolve repo-URL → spec, so agents
  starting from a repo URL had no path in. Documented in
  `tools.md` so future tool authors don't cargo-cult the
  asymmetry.
- **`<spec>@<version>` rejected.** `pkg vulns` / `pkg deps` treat
  `@version` as "for this exact version"; changelog has no
  single-version query. Silently remapping to `toVersion` would be
  a client-invented semantic shift. Rejected with
  `INVALID_ARGUMENT` and a hint pointing to `--to` / `--from`.
- **Body preview capped at 10 lines by default.** Release bodies
  routinely run 50-100+ lines — showing them unbounded would
  swamp the terminal; showing none would make the whole command
  useless for answering "what changed". 10-line cap shows the
  first one or two sections plus preamble, which is usually
  enough to answer the question; `--verbose` lifts the cap when
  full context is needed; `--no-body` drops bodies entirely for
  a pure timeline view. JSON envelope is always full-body unless
  `--no-body` / `include_bodies: false` is set — `--verbose` is
  terminal-only.
- **Data-first envelope.** `{registry|repoUrl, source, mode,
  entries: {count, items}, filter?}`. `source` always present
  here (null-source case promoted to `NOT_FOUND` at the service
  boundary; envelope builder has a defence-in-depth throw if the
  invariant is ever violated upstream). `mode` derived from
  request — `"range"` iff `fromVersion` non-null, `"latest"`
  otherwise. `entries.count` computed client-side from
  `items.length`; backend count not selected on the wire.
- **`version` kept when null, other per-entry nullables stripped.**
  `version` is the primary key agents index by. Empty-string
  values (e.g. `body: ""`) are preserved — distinct from absent
  — so agents can tell "empty release notes" from "no notes
  field". Terminal formatter renders `(empty release notes)`
  sentinel for empty-string bodies.
- **`filter.*` tracks explicit fields only.** Builder returns an
  `explicitFilterFields` set; envelope emits `filter.*` only for
  caller-supplied inputs. Backend defaults (`limit: 10`,
  `toVersion: latest`) never round-trip as caller intent.
- **`include_bodies` lever.** Default true. `false` drops `body`
  from every item explicitly; other fields preserved so agents
  still get the version / date / URL timeline. Measured 5.13×
  envelope size reduction on a 20-entry `npm:typescript` request
  (17.3 KB → 3.4 KB).
- **`--no-body` + `--verbose` is an error.** Contradictory
  intents; CLI rejects with an actionable hint rather than
  silently ignoring `--verbose`.
- **MCP `limit` validation in the shared builder, not Zod.** If
  Zod hard-rejected `limit: 51` at the SDK level, the agent would
  see a raw SDK error instead of our shared
  `{error, code, retryable}` envelope. Schema is permissive;
  builder enforces bounds.
- **Registry coverage — all 9 on the wire, no client-side
  restriction.** `packageChangelog` is source-pull, not
  registry-query. Live-smoke confirmed npm / pypi / hex / crates
  / vcpkg / maven / packagist return useful data; nuget returns a
  generic `BACKEND_ERROR`; zig packages weren't in the backend
  index. All paths produce the shared envelope correctly.
- **Mode mutex enforced client-side.** `--from` / `from_version`
  + `--limit` / `limit` → `INVALID_ARGUMENT` with actionable hint.
- **`metadata` dropped in v1.** Source-specific opaque
  `GenericJSON`; revisit via agent feedback.
- **Shared `promoteGenericVersionNotFound` extended.** Now
  recognises `fromVersion` / `toVersion` in addition to
  `version`. `registry` / `packageName` made optional so
  repo-URL-addressed requests flow through. Preference order
  `version → fromVersion → toVersion`. P2 / P3 regressions
  guarded by existing tests + 5 new helper tests.

## Tests

- `bun test` — 1062 pass, 0 fail.
- `bun run typecheck` / `bun run build` / `bun run lint` — clean.
- Request builder, envelope builder, terminal formatter, MCP tool,
  CLI action, parity fixtures (12) — see docs for full matrix.
- Live-smoke against production pkgseer: latest / range /
  repo-URL / `--no-body` / error paths / registry matrix /
  body-preview cap / `--verbose` uncap / truncation footer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant