From c8746965935eeb0ab9dd8307da8636ef4c20d093 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Fri, 18 Sep 2026 13:16:24 +0300 Subject: [PATCH 1/6] feat: make pkg changelog package-only compact targets MCP pkg_changelog now takes one required package target for latest, exact, and interval lookups instead of structured coordinates or repository addressing. CLI keeps --from/--to as package flags, exact pins use packageInfo changelog, and empty timeline selections succeed. Co-authored-by: Cursor --- .../package-only-changelog-targets.changed.md | 6 + docs/implementation/cli-commands.md | 25 +- docs/implementation/mcp-cli-parity.md | 26 +- docs/implementation/repository-targets.md | 9 +- docs/implementation/tools.md | 37 +- docs/plans/mcp-tool-surface-simplification.md | 482 +++++++++++++----- eval/agentic/README.md | 2 +- eval/agentic/context-loading/routing-guide.ts | 2 +- .../workloads/package-changelog-exact.md | 7 + eval/mock-mcp/server.test.ts | 32 +- eval/mock-mcp/server.ts | 10 +- .../package-intelligence-service.test.ts | 184 ++++++- .../services/package-intelligence-service.ts | 241 ++++++--- .../src/services/promote-version-not-found.ts | 8 +- packages/mcp/src/internal.ts | 1 + packages/mcp/src/mcp/instructions.ts | 2 +- packages/mcp/src/mcp/server.test.ts | 23 +- packages/mcp/src/services/test-helpers.ts | 1 - .../shared/package-changelog-request.test.ts | 310 +++++------ .../src/shared/package-changelog-request.ts | 249 ++++----- .../shared/package-changelog-response.test.ts | 77 ++- .../src/shared/package-changelog-response.ts | 137 ++--- .../shared/package-changelog-target.test.ts | 137 +++++ .../src/shared/package-changelog-target.ts | 122 +++++ .../shared/package-intelligence-error-map.ts | 6 +- packages/mcp/src/smoke-test.test.ts | 22 + packages/mcp/src/smoke-test.ts | 46 +- .../mcp/src/tools/package-changelog.test.ts | 289 ++++++----- packages/mcp/src/tools/package-changelog.ts | 103 +--- scripts/cli-smoke.ts | 21 +- scripts/smoke-scripts.test.ts | 3 +- skills/githits-mcp/SKILL.md | 2 +- skills/githits-package/SKILL.md | 6 +- skills/githits-package/references/package.md | 8 +- src/commands/pkg/changelog.test.ts | 101 ++-- src/commands/pkg/changelog.ts | 86 ++-- src/services/test-helpers.ts | 1 - src/tools/package-changelog-parity.test.ts | 189 +++---- 38 files changed, 1899 insertions(+), 1114 deletions(-) create mode 100644 changes/package-only-changelog-targets.changed.md create mode 100644 eval/agentic/workloads/package-changelog-exact.md create mode 100644 packages/mcp/src/shared/package-changelog-target.test.ts create mode 100644 packages/mcp/src/shared/package-changelog-target.ts diff --git a/changes/package-only-changelog-targets.changed.md b/changes/package-only-changelog-targets.changed.md new file mode 100644 index 00000000..80d8f3f4 --- /dev/null +++ b/changes/package-only-changelog-targets.changed.md @@ -0,0 +1,6 @@ +--- +"githits": minor +"@githits/mcp": minor +--- + +- **Package-only changelog targets** - MCP `pkg_changelog` replaces `registry`, `package_name`, `repo_url`, `git_ref`, `from_version`, and `to_version` with one required `target` (`npm:express`, `npm:express@5.2.1`, or `npm:express@4.21.2..5.2.1`). CLI drops `--repo-url` and `--git-ref`, accepts the same package forms, and keeps `--from`/`--to` as package range flags. Exact pins return one selected release or `VERSION_NOT_FOUND`; empty timeline selections succeed instead of becoming `NOT_FOUND`. diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 4b6c8345..c758eada 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -54,7 +54,7 @@ envelope when `--json` is requested; terminal output remains human-readable. | `pkg info ` | package spec | `--verbose`, `--json` | Show a package overview (latest version, downloads, license, vulnerabilities) | | `pkg vulns ` | package spec (optional `@version`) | `--severity`, `--scope`, `--include-withdrawn`, `--transitive`, `--verbose`, `--json` | List known vulnerabilities for a package (npm/pypi/hex/crates/nuget/maven/packagist/rubygems/go/swift), optionally including affected versions resolved in its dependency graph | | `pkg deps ` | package spec (optional `@version`) | `--lifecycle`, `--depth`, `--issues`, `--verbose`, `--json` | Analyse dependencies: direct runtime deps, structured groups, optional capped transitive graph, and opt-in dependency issue analysis (npm/pypi/hex/crates/nuget/maven/zig/vcpkg/packagist/rubygems/go/swift) | -| `pkg changelog [spec]` | package spec OR `--repo-url` | `--from`, `--to`, `--limit`, `--git-ref`, `--no-body`, `--verbose`, `--json` | Release notes / changelog entries for a package or public repository (GitHub Releases, CHANGELOG.md, or HexDocs). Default shows each entry with a 10-line body preview; `--verbose` uncaps, `--no-body` drops. | +| `pkg changelog ` | package spec (`registry:name[@version\|@from..to]`) | `--from`, `--to`, `--limit`, `--no-body`, `--verbose`, `--json` | Release notes / changelog entries for a package. Default shows each entry with a 10-line body preview; pin a version for one selected release; `--verbose` uncaps, `--no-body` drops. | | `pkg upgrade-review [spec]` | single package spec with current version plus `--to`, positional package range, OR repeatable `--package` ranges | `--to`, repeatable `--package`, `--no-transitive-security`, `--dependency-issues`, `--min-severity`, `--verbose`, `--json` | Compare current and target versions for upgrade evidence: vulnerabilities, changelog entries, deprecation metadata, peer changes, dependency changes, and transitive security evidence by default. Reports facts only. | | `docs list ` | package spec (optional `@version`) | `--limit`, `--after`, `--verbose`, `--json` | List hosted/crawled and repository-backed documentation pages. Text emits target-based read commands; JSON retains `docsReadTarget`, stable `pageId`, provenance `sourceUrl`, and exact repo-file metadata when available. | | `read [path]` | docs target/page ID, or package/repo target plus exact path | `--lines`, `--wait`, `--verbose`, `--json`; code also accepts `--start`, `--end`, `--repo-url`, `--git-ref` | Compact unified read; target alone reads docs, path selects code, and the compact path calls `ReadService`/`Query.read` once. `--repo-url` remains the legacy compatibility path. Fragments select indexed sections without bounds. See [unified read](unified-read.md). | @@ -696,34 +696,35 @@ Compact issue evidence also stays within the resolved terminal width, using ASCI ``` githits pkg changelog npm:express +githits pkg changelog npm:express@5.2.1 +githits pkg changelog npm:express@4.0.0..5.2.1 githits pkg changelog npm:express --from 4.0.0 githits pkg changelog npm:express --to 4.18.0 --limit 5 -githits pkg changelog --repo-url https://github.com/expressjs/express --git-ref main githits pkg changelog npm:express --json githits pkg changelog pypi:requests --no-body --json # lean timeline ``` -Fetches release notes or changelog entries for a package or public repository. Output preserves source ordering, which may interleave maintained release lines, and includes a summary header identifying the source (GitHub Releases, CHANGELOG.md, or HexDocs). +Fetches release notes or changelog entries for a package. Output preserves source ordering, which may interleave maintained release lines, and includes a summary header identifying the source. -**Addressing.** `` (`registry:name`, same parser as `pkg info` / `pkg vulns` / `pkg deps`) **or** `--repo-url `, mutually exclusive. Unlike the other `pkg` commands, `pkg changelog` is intrinsically repo-level, so repo-URL addressing is a first-class peer mode. +**Addressing.** Required `` in `registry:name`, `registry:name@version`, or `registry:name@from..to` form. Repository and site targets are rejected. -**`@` rejected.** `pkg vulns` and `pkg deps` both treat `@version` as "for this exact version", but `pkg changelog` has no single-version query: all entries live on a timeline. Remapping `@version` to `--to` would be a silent semantic shift. CLI rejects with `INVALID_ARGUMENT` and a hint pointing to `--to ` (or `--from ` for range mode). +**Exact selected release.** `@` selects one backend-resolved release, including prereleases. Missing concrete versions return `VERSION_NOT_FOUND`. A selected release without notes succeeds and says release notes are unavailable. -**Two modes.** Latest mode is the default; `--limit ` (1–50, default 10) caps entry count. `--from ` switches to range mode — returns every entry after `--from` through `--to` (or latest), `(from, to]`, with no count cap. The lower bound is exclusive. `--to ` is an upper cap in either mode, not an exact-release lookup. `--from` + `--limit` together is rejected client-side with a hint. +**Three modes.** Latest mode is the default; `--limit ` (1–50, default 10) caps entry count. `--from ` or an inline from bound switches to range mode — returns every entry after the from bound through `--to` (or latest), `(from, to]`, with no count cap. An upper-cap target or `--to` remains latest mode. `--from` + `--limit` together is rejected client-side with a hint. Inline exact pins reject `--from`, `--to`, and `--limit`. -**Pre-release versions.** Normalised versions flow through unchanged (`5.0.0-rc.1`, `2.32.0.dev0`, `1.7.0-rc.5` round-trip cleanly on `--from` / `--to`). Tag-style `v`-prefixed inputs are rejected on any version flag, consistent with `pkg vulns` / `pkg deps`. +**Pre-release versions.** Normalised versions flow through unchanged (`5.0.0-rc.1`, `2.32.0.dev0`, `1.7.0-rc.5`). Tag-style `v`-prefixed inputs are rejected except for Go canonicalisation and Swift. -**Default terminal output.** Summary header (`name | registry | source | mode | entry count`) followed by each entry's `version date url` header plus the first 10 lines of its markdown body, indented and dimmed. Bodies longer than the cap show a footer `... (+N more lines - use --verbose for the full body)`. Missing dates render as `-`; missing versions render as `(unversioned)`. The version column is padded to the longest entry in the current response (no fixed width). +**Default terminal output.** Summary header (`name | registry | source | mode | entry count`) followed by each entry's `version date url` header plus the first 10 lines of its markdown body, indented and dimmed. Bodies longer than the cap show a footer `... (+N more lines - use --verbose for the full body)`. Missing dates render as `-`; missing versions render as `(unversioned)`. Exact no-notes results say `Release notes are unavailable.` **`--verbose`.** Uncaps the body preview — every entry's full markdown body renders, indented and dimmed, with no truncation footer. Terminal-only — does not change `--json` output. -**`--no-body`.** Drops body fields from entries. Affects both terminal output (no body preview, no footer) and `--json` (entry objects lose the `body` field). Mirrors MCP's `omit_bodies: true`. Default `--json` keeps full markdown bodies; use `--no-body` when you only need the version / date / URL timeline (drops 10 KB+ per entry on large release notes — measured 5.13× size reduction on `npm:typescript --limit 20`). +**`--no-body`.** Drops body fields from entries. Affects both terminal output (no body preview, no footer) and `--json` (entry objects lose the `body` field). Mirrors MCP's `omit_bodies: true`. Default `--json` keeps full markdown bodies; use `--no-body` when you only need the version / date / URL timeline. -**JSON envelope.** `{registry?, name?, repoUrl?, source, mode, entries: {count, items}, filter?}`. `source` is always present (the null-source case is promoted to `NOT_FOUND` at the service boundary and never reaches this shape). `entries.count` is computed client-side from `items.length`. `filter` emits only when the caller explicitly supplied one of `--from`, `--to`, `--limit`, `--git-ref`; backend defaults don't round-trip as caller intent. +**JSON envelope.** `{registry?, name?, source?, mode, entries: {count, items}, filter?}`. `source` is omitted when the backend returned no concrete source. `entries.count` is computed client-side from `items.length`. `filter` emits only when the caller explicitly supplied one of `--from`, `--to`, `--limit`, or an exact version selector; backend defaults don't round-trip as caller intent. Exact mode adds `filter.version` and `hasChangelog` on the single entry. -**Per-entry shape.** `{version, normalizedVersion?, publishedAt?, htmlUrl?, body?}`. `version` is kept even when null so agents can map `items.map(e => e.version)` without guarding; other nullable fields are stripped. The backend's opaque per-entry `metadata` GenericJSON is deliberately dropped from the envelope — revisit via agent feedback. +**Per-entry shape.** `{version, normalizedVersion?, publishedAt?, htmlUrl?, body?, hasChangelog?}`. `version` is kept even when null so agents can map `items.map(e => e.version)` without guarding; other nullable fields are stripped. `hasChangelog` is exact-mode only. -**Errors.** `NOT_FOUND` covers both the backend's "package not found" case and the distinct "package exists but no changelog source resolved" case (typed `PackageIntelligenceChangelogSourceNotFoundError`; message names the sources that were tried). `VERSION_NOT_FOUND` enriches with structured `package` / `requested` / `available` detail lines from the shared `promoteGenericVersionNotFound` helper — which was extended in this PR to recognise `--from` and `--to` as promotable version inputs. +**Errors.** `NOT_FOUND` covers a missing package. Empty timeline selections and exact releases without notes are successful. `VERSION_NOT_FOUND` enriches with structured `package` / `requested` / `available` detail lines. **Troubleshooting.** Same debug areas as the rest of the `pkg` family. diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index ee607e93..abc8f3dc 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -693,26 +693,12 @@ When a new tool lands with both MCP and CLI surfaces: ### `pkg_changelog` -- **Dual addressing — the only pkg-intel tool with it.** `registry` - + `package_name` XOR `repo_url` on both surfaces, because - `packageChangelog` is intrinsically repo-level. -- **`@` rejected.** Other `pkg` commands give - `@version` a meaning, but changelog has no single-version query - — remapping to `to_version` would be a client-invented semantic - shift. Both surfaces redirect callers to `--to` / `to_version`. -- **Mode mutex enforced client-side.** `--from` / `from_version` + - `--limit` / `limit` together → `INVALID_ARGUMENT`. -- **`filter.*` echo tracks explicit fields only.** Backend-default - values never round-trip as caller intent. -- **`entries: { count, items }` shape.** Mirrors `runtime: {count, - items}` from `pkg_deps`. -- **Missing source with entries succeeds.** Package-version entries can - arrive with null or empty `source` when no concrete changelog text - exists for that version. Both surfaces omit `source` in the success - envelope and keep the version entries. Missing source plus no entries - is promoted to `PackageIntelligenceChangelogSourceNotFoundError` with - a message naming the sources tried (GitHub Releases, CHANGELOG.md, - HexDocs). +- **Package-only compact target.** MCP `target` and CLI positional spec accept `registry:name`, `@version`, and `@from..to`. Repository and site targets are rejected before network access. CLI `--from` / `--to` remain human-oriented flags on a bare spec. +- **Exact selected release.** `@version` queries `packageInfo.selectedVersion.changelog` and returns one resolved release. Missing pins are `VERSION_NOT_FOUND`; no-notes releases succeed with `hasChangelog: false`. +- **Mode mutex enforced client-side.** A from bound + `--limit` / `limit` together → `INVALID_ARGUMENT`. Exact pins also reject `limit`. +- **`filter.*` echo tracks explicit fields only.** Backend-default values never round-trip as caller intent. Exact mode echoes `filter.version`. +- **`entries: { count, items }` shape.** Mirrors `runtime: {count, items}` from `pkg_deps`. +- **Empty selections succeed.** `source: null` plus no entries is a successful empty timeline, not `NOT_FOUND`. - **`--verbose` / `--no-body` / `--json` interaction.** Default terminal output truncates each entry's body at 10 lines. `--verbose` lifts the cap (terminal-only). `--no-body` mirrors diff --git a/docs/implementation/repository-targets.md b/docs/implementation/repository-targets.md index 868d2722..2daa080c 100644 --- a/docs/implementation/repository-targets.md +++ b/docs/implementation/repository-targets.md @@ -61,8 +61,8 @@ Neither external repository is changed from this worktree. The backend still receives canonical HTTPS `repo_url` and optional `git_ref` (the service layer names these `repoUrl`/`gitRef`). No provider field or API selection is added. Existing structured URL addressing remains separate from -compact strings. `pkg changelog --repo-url` and MCP `repo_url` remain full URL -fields; this increment widens their wording only. +compact strings. `pkg changelog` is package-only and rejects repository +targets. ## Consumers and response identity @@ -234,7 +234,6 @@ To repeat the live direct-target checks with the unpublished build, use fixture through `code files`, `code grep`, `code read`, CODE/DOCS `search`, emitted documentation locators, and `code diff .. --name-status`; MCP counterparts are `code_files`, `code_grep`, `read`, `search`, -and `code_diff`. Keep `pkg changelog --repo-url` / -`pkg_changelog.repo_url` in full-URL form and inspect body fields rather than -summary-only output. Set `GITHITS_CODE_NAV_URL` to the verified dev endpoint +and `code_diff`. `pkg changelog` is package-only; do not pass repository +targets to it. Set `GITHITS_CODE_NAV_URL` to the verified dev endpoint for its replay; never print authentication state or credential values. diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 12a1c454..e13ef354 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -165,7 +165,7 @@ see [Unified read](unified-read.md). | `pkg_info` | `target` (unpinned package string), `verbose?`, `format?` | Assess latest package health and adoption: license, downloads, and activity. Requires an unpinned package target and always returns latest; latest-affected and package-wide history counts are distinct. | | `pkg_vulns` | `target` (package string), `min_severity?`, `advisory_scope?`, `include_withdrawn?`, `include_transitive?`, `verbose?`, `format?` | Check current package advisories. Use current evidence, not memory or cutoff disclaimers; distinguish selected-version risk from package history. Transitive evidence is opt-in and adds graph-analysis cost; selected fields define filter/scope limits. | | `pkg_deps` | `target` (package string), `lifecycle?`, `include_importers?`, `include_issues?`, `max_depth?`, `format?` | Inspect what a package depends on, directly or transitively. Direct runtime dependencies are the default; fields opt into other groups, transitive footprint, importer provenance or issue analysis. Public graphs are not application lockfile/reachability evidence. | -| `pkg_changelog` | `registry?`, `package_name?`, `repo_url?`, `from_version?`, `to_version?`, `limit?`, `git_ref?`, `omit_bodies?`, `verbose?`, `body_lines?`, `format?` | Find release notes and changelog history for a package or public repository. Latest mode caps entries without promising date order or exact-release selection; range mode covers `(from_version, to_version]`. `to_version` is an upper cap, not an exact lookup. | +| `pkg_changelog` | `target`, `limit?`, `omit_bodies?`, `verbose?`, `body_lines?`, `format?` | Find release notes and changelog history for a package. Latest mode caps entries; pin `target` for one selected release; `@from..to` covers a closed interval. Empty selections succeed with no entries. | | `pkg_upgrade_review` | `registry?`, `package_name?`, `current_version?`, `target_version?`, `packages?`, `skip_transitive_security?`, `include_dependency_issues?`, `min_severity?`, `verbose?`, `format?` | Review a package upgrade: vulnerabilities, releases, peers, dependency changes. Reports facts, not upgrade risk or acceptance. Supports a single package or at most 30 batch upgrades. | | `code_files` | `target` (compact string), `path?`, `path_prefix?`, `globs?`, `extensions?`, `file_types?`, `languages?`, `file_intent?`, `file_intents?`, `exclude_file_intents?`, `exclude_doc_files?`, `exclude_test_files?`, `include_hidden?`, `limit?`, `wait_timeout_ms?`, `format?` | List indexed files and paths in a public repo or package. Returned paths chain into `read.path` or scope `code_grep`; `path_prefix` narrows directory enumeration. `INDEXING` errors expose retry candidates when known. | | `read` | `target` (string), `path?`, `start_line?`, `end_line?`, `wait_timeout_ms?`, `format?` | Read a code file with target + path, or docs page with target alone. Fragments select indexed sections unless explicit bounds override them. Text displays 150/300 lines; code caps before fetching, while docs JSON keeps the backend selection. Wait applies to code indexing only. See [unified read](unified-read.md). | @@ -173,13 +173,14 @@ see [Unified read](unified-read.md). `quick_start`, `get_example`, `search`, `search_status`, `docs_list`, `pkg_info`, `pkg_vulns`, `pkg_deps`, `pkg_changelog`, `pkg_upgrade_review`, `code_files`, `read`, and `code_grep` are registered by default. The package/source service URL defaults to the GitHits-managed endpoint and can be overridden via `GITHITS_CODE_NAV_URL` for local development. -`docs_list`, `pkg_vulns`, and `pkg_deps` require `target: "registry:name[@version]"`, -for example `npm:express@5.2.1`; omit the pin for latest. `pkg_info` requires an -unpinned target such as `npm:express`. These four tools accept package coordinates, -not repository, site, or read locators. Scoped npm and Maven names retain their -`@` and `:` respectively. Their old separate coordinate inputs are removed, not -aliases. `pkg_changelog` and `pkg_upgrade_review` still use the structured inputs -listed above; CLI positional specs and options are unchanged. +`docs_list`, `pkg_vulns`, `pkg_deps`, and `pkg_changelog` require +`target: "registry:name[@version]"`, for example `npm:express@5.2.1`; omit the +pin for latest. `pkg_changelog` also accepts `@from..to` intervals. +`pkg_info` requires an unpinned target such as `npm:express`. These tools accept +package coordinates, not repository, site, or read locators. Scoped npm and Maven +names retain their `@` and `:` respectively. Their old separate coordinate inputs +are removed, not aliases. `pkg_upgrade_review` still uses the structured inputs +listed above; CLI positional specs and options for it are unchanged. ## Transitive vulnerability audits @@ -472,25 +473,25 @@ JSON retains backend order and multiplicity. ### `pkg_changelog` response shape -**Data-first envelope.** The top level carries addressing (`registry` + `name` for spec addressing, or `repoUrl` for repo-URL addressing), optional `source` (`"releases"` / `"changelog_file"` / `"hexdocs"`) when a concrete changelog source exists, and `mode` (`"latest"` or `"range"`). Entries live under `entries: { count, items }` — matching the `{count, items}` shape used by `pkg_deps.runtime`. `count` is computed client-side from `items.length`, so the invariant holds regardless of backend drift. +**Data-first envelope.** The top level carries addressing (`registry` + `name`), optional `source` when a concrete changelog source exists, and `mode` (`"latest"`, `"exact"`, or `"range"`). Entries live under `entries: { count, items }`. `count` is computed client-side from `items.length`. -**Per-entry shape.** `{version, normalizedVersion?, publishedAt?, htmlUrl?, body?}`. `version` is kept in the envelope even when `null` so agents can write `items.map(e => e.version)` without guarding; every other nullable field is stripped when absent. `body` is additionally stripped when the caller set `omit_bodies: true`. The backend's opaque per-entry `metadata` GenericJSON is deliberately dropped from the envelope in v1 — revisit via agent feedback. +**Per-entry shape.** `{version, normalizedVersion?, publishedAt?, htmlUrl?, body?, hasChangelog?}`. `version` is kept even when `null` so agents can write `items.map(e => e.version)` without guarding; every other nullable field is stripped when absent. `hasChangelog` is present only for exact selected-release results. `body` is additionally stripped when the caller set `omit_bodies: true`. The backend's opaque per-entry `metadata` GenericJSON is deliberately dropped from the envelope. -**Dual addressing (`registry` + `package_name` XOR `repo_url`).** `pkg_changelog` retains both structured addressing modes. `pkg_info`, `pkg_vulns`, and `pkg_deps` accept compact package `target` strings without repository alternatives because they are registry-metadata lookups. Changelog sources are intrinsically repo-level — GitHub Releases, CHANGELOG.md, and HexDocs — so repository addressing is a peer mode, not a bolt-on. +**Package-only compact target.** MCP `pkg_changelog` takes one required `target` string: `registry:name` for latest, `registry:name@version` for one selected release, and `registry:name@from..to` for a closed interval. Open bounds `from..` and `..to` are accepted. Repository and site targets are rejected before network access. Exact pins query `packageInfo.selectedVersion.changelog`; latest and interval targets query `packageChangelog`. -**Mode selection.** `from_version` triggers range mode (returns every entry in `(fromVersion, toVersion]` with no cap). The lower bound is exclusive, so an equal start/end range has no entries. Latest mode is the default, capped by `limit` (1–50, backend default 10); `to_version` supplies an upper cap, not an exact-release lookup. `from_version` + `limit` is rejected client-side with `INVALID_ARGUMENT` rather than silently routed to one mode. +**Mode selection.** A from bound triggers range mode (every entry in `(fromVersion, toVersion]` with no cap). Latest mode is the default, capped by `limit` (1–50, backend default 10); an upper-cap target or `--to` supplies a latest-mode cap. Exact mode returns exactly one backend-selected release. `limit` is rejected for exact and lower-bound range targets. -**`omit_bodies` lever and body previews.** Release bodies on large packages (Kubernetes, Node) can run 10 KB+ per entry; a 100-entry range could produce a multi-hundred-KB envelope. `omit_bodies: true` opts out explicitly in JSON and text — not silent truncation. Other fields (version / normalizedVersion / publishedAt / htmlUrl) remain so agents still get the release timeline. Text mode caps each body preview at 10 lines by default. MCP adds text-only `body_lines` (1-50) to tune the cap and `verbose:true` to uncap text bodies; both are ignored for JSON. `verbose:true` conflicts with `omit_bodies:true` and `body_lines`. CLI terminal output uses the same default preview cap and gives the CLI-native `--verbose` hint; `--verbose` uncaps terminal previews but does not change `--json` output. +**`omit_bodies` lever and body previews.** Release bodies on large packages (Kubernetes, Node) can run 10 KB+ per entry; a 100-entry range could produce a multi-hundred-KB envelope. `omit_bodies: true` opts out explicitly in JSON and text — not silent truncation. Other fields (version / normalizedVersion / publishedAt / htmlUrl) remain so agents still get the release timeline. Text mode caps each body preview at 10 lines by default. MCP adds text-only `body_lines` (1-50) to tune the cap and `verbose:true` to uncap text bodies; both are ignored for JSON. `verbose:true` conflicts with `omit_bodies:true` and `body_lines`. CLI terminal output uses the same default preview cap and gives the CLI-native `--verbose` hint; `--verbose` uncaps terminal previews but does not change `--json` output. Exact results without notes say "Release notes are unavailable." -**`filter.*` echo.** `filter` is emitted only when the caller explicitly supplied at least one of `from_version`, `to_version`, `limit`, or `git_ref`. Backend-default `limit: 10` / `toVersion: ` is never echoed. The request builder tracks explicit-vs-defaulted via an `explicitFilterFields` set so defaults don't round-trip as caller intent. +**`filter.*` echo.** `filter` is emitted only when the caller explicitly supplied at least one of `fromVersion`, `toVersion`, `limit`, or `version`. Backend-default `limit: 10` / `toVersion: ` is never echoed. Exact mode always echoes `filter.version` as the normalized requested selector; `entries.items[0].version` is the resolved concrete release. -**Version validation.** Same shared rule as `pkg_vulns` / `pkg_deps`: exact Go `from_version` / `to_version` bounds are accepted with or without `v` and sent with canonical `v`; unsupported tag-style inputs remain client-side `INVALID_ARGUMENT` errors. `@` is still rejected — the `pkg changelog` family has no single-version query, and silently remapping to `to_version` would be a client-invented semantic shift. Hint text redirects callers to `--to` / `to_version`. +**Version validation.** Same shared rule as `pkg_vulns` / `pkg_deps`: exact Go bounds are accepted with or without `v` and sent with canonical `v`; unsupported tag-style inputs remain client-side `INVALID_ARGUMENT` errors. A non-range suffix is a registry-aware single-release selector, not a client SemVer validator. -**NOT_FOUND semantics.** Backend `source === null` or `source === ""` means there is no concrete changelog source for the returned package versions. If entries are present, this is a success and the envelope omits `source`; terminal output labels it `source: package versions`. If both source and entries are absent, the service promotes the response to `PackageIntelligenceChangelogSourceNotFoundError`, which the shared classifier routes to the `NOT_FOUND` envelope with a message naming the sources that were tried. Empty `entries.items: []` with a valid `source` is also a success — "no entries in this range" is a legitimate neutral outcome. +**Empty and no-notes success.** Backend `source === null` plus empty entries is a successful empty timeline. Exact selected releases without notes succeed with `hasChangelog: false`. Actual missing packages and missing exact versions continue through `NOT_FOUND` / `VERSION_NOT_FOUND`. -**Overlap with `pkg_info`.** `pkg_info` already surfaces a short-form `recentChanges` block (from the backend's `latestChangelogs` field on `PackageSummaryResult`). For a quick "what shipped recently" glance embedded in a package overview, use `pkg_info`. For the full range-capable, body-rich, `omit_bodies`-toggleable changelog with `--no-body` timeline mode and repo-URL addressing, use `pkg_changelog`. +**Overlap with `pkg_info`.** `pkg_info` already surfaces a short-form `recentChanges` block (from the backend's `latestChangelogs` field on `PackageSummaryResult`). For a quick "what shipped recently" glance embedded in a package overview, use `pkg_info`. For range-capable, exact-release, body-rich changelog lookup, use `pkg_changelog`. -`pkg_changelog` shares its envelope builder and text formatter with the CLI `githits pkg changelog` command via `packages/mcp/src/shared/package-changelog-request.ts` and `packages/mcp/src/shared/package-changelog-response.ts`. MCP defaults to compact text with MCP-native `verbose=true`, `body_lines=`, and `format="json"` hints for full bodies. The parity test (`src/tools/package-changelog-parity.test.ts`) passes `format: "json"`, asserts `toEqual` across every service-sourced success / error fixture (happy latest, range mode, repo-URL addressing, no-source package-version entries, `--no-body` / `omit_bodies: true`, default bodies, empty entries, NOT_FOUND, PackageIntelligenceTargetNotFoundError, VERSION_NOT_FOUND, BACKEND_ERROR), and uses `toMatchObject` for builder-sourced `INVALID_ARGUMENT`. +`pkg_changelog` shares its envelope builder and text formatter with the CLI `githits pkg changelog` command via `packages/mcp/src/shared/package-changelog-request.ts` and `packages/mcp/src/shared/package-changelog-response.ts`. MCP defaults to compact text with MCP-native `verbose=true`, `body_lines=`, and `format="json"` hints for full bodies. The parity test (`src/tools/package-changelog-parity.test.ts`) passes `format: "json"` and asserts CLI/MCP equality for latest, exact, range, omitted bodies, empty selections, and mapped errors. ### `pkg_upgrade_review` response shape diff --git a/docs/plans/mcp-tool-surface-simplification.md b/docs/plans/mcp-tool-surface-simplification.md index d387ce8d..e9cbc451 100644 --- a/docs/plans/mcp-tool-surface-simplification.md +++ b/docs/plans/mcp-tool-surface-simplification.md @@ -3,11 +3,11 @@ ## Status - Overall: ACTIVE -- Current boundary: Phase 6 instruction ownership and copy cleanup, IMPLEMENTED; - draft PR #403 awaiting merge and disposition of the complete Ask smoke limitation -- Baseline: `9be81a9` (`origin/main`, 2026-09-17; PR #402 package targets merged) -- Planning branch: `jlitola/compact-agent-instructions` -- Last verified: 2026-09-17 +- Current boundary: Phase 2b package-only changelog targets, IMPLEMENTING +- Baseline: `27f57d4` (`origin/main`, 2026-09-18; PR #403 instruction cleanup + and PR #405 documentation follow-ups merged) +- Planning branch: `jlitola/mcp-tool-surface-plan` +- Last verified: 2026-09-18 ## Problem and expected outcome @@ -19,14 +19,17 @@ backend query language now expresses directly; the implementation has passed cod review, but subsequent production verification found a name-qualifier composition gap described in the Phase 3 verification addendum below. -At the Phase 3 baseline, the catalog advertised both inline and structured qualifiers even though -the production backend now validates and reports inline syntax robustly. Other large -opportunities remain unsettled: code-navigation tools expose many overlapping -controls, and repeated output-format copy must not be shortened until lower-cost-agent -evals show that agents continue to omit `format` rather than selecting JSON -unnecessarily. Ask is a new intentional answer surface, not a retirement candidate. -Example-language recovery is settled: `get_example` keeps the language filter, and -`search_language` is removed. +At the Phase 3 baseline, the catalog advertised both inline and structured qualifiers +even though the production backend now validates and reports inline syntax robustly. +Phase 6 subsequently reduced repeated instruction copy while retaining the format +reminders that matched evals showed agents still need. The next settled opportunity +is `pkg_changelog`: it still exposes structured package coordinates plus repository +addressing even though package tools own package identities. Repository changelogs +are not a deterministic package concept, especially for monorepositories, so Phase 2b +removes that accidental surface instead of inventing repository release semantics. +Code-navigation controls remain a later unsettled opportunity. Ask is an intentional +answer surface, not a retirement candidate. Example-language recovery is settled: +`get_example` keeps the language filter, and `search_language` is removed. When this effort is complete, MCP exposes one concise way to express each settled concept, while the CLI retains human-friendly flags where they are useful. Tool @@ -148,6 +151,10 @@ In scope for the overall effort: - preserving output behavior and backend request semantics unless a later phase explicitly changes them; - preserving CLI search flags while MCP callers use the backend query language; +- making `pkg_changelog` package-only on both MCP and CLI, with compact exact and + interval package targets on MCP and compatible human-oriented CLI flags; +- selecting the backend's exact package-release query for exact changelog targets + while retaining its package timeline query for latest and interval targets; - validating agent-facing changes with descriptor-only real-agent evals and the existing static context inventory; - correcting current durable documentation as each contract changes; and @@ -162,7 +169,10 @@ Out of scope: - removing public TypeScript aliases solely for source cleanup when that does not reduce the agent-visible catalog; - changing backend GraphQL/REST selections, service URLs, transport, auth, result - formats, or text-output content beyond required callable-coordinate hint migrations; + formats, or text-output content except for Phase 2b's minimal exact-release query, + truthful empty-result handling, and exact-notes presentation; +- defining repository or site changelog semantics, resolving a repository to one + package in a monorepo, or retaining repository changelog inputs as hidden aliases; - adding aliases, hidden fallback schemas, feature flags, or rollout machinery; - changing hosted production, publishing packages, deploying `remote-mcp`, or merging a release without the separately required authorization; and @@ -179,14 +189,14 @@ remain thin data-access adapters. ```text MCP compact target string - -> existing shared target/package parser - -> existing request builder and validation - -> unchanged service interface and backend request + -> shared target/package parser + -> operation-specific request builder and validation + -> minimal service query for that operation CLI positional spec and flags -> existing CLI parsing -> same request builder and validation - -> unchanged service interface and backend request + -> same operation-specific service query MCP search query with inline qualifiers -> existing client request builder (required trim only) @@ -206,6 +216,14 @@ and its evolving enum vocabulary. Removing CLI flags would make the human surfac worse without reducing MCP context, so the shared request builder remains their adapter rather than becoming MCP-visible. +`pkg_changelog` follows the package boundary rather than the source-storage boundary. +A package release may obtain notes from registry metadata, a release, or a changelog +file, but those are evidence sources for the selected package release; they do not make +an arbitrary repository a package target. Exact package pins use +`packageInfo(...).selectedVersion.changelog`; latest and interval package targets use +`packageChangelog`. Repository and site inputs fail at the client boundary before a +network request. + For guidance ownership: - tool name plus first description sentence owns tool selection; @@ -245,6 +263,10 @@ addressing shapes. `lang:` qualifiers exclusively. The CLI retains its structured flags. The backend owns qualifier validation and per-source compatibility reporting. 7. Navigation-control consolidation requires a separate product discussion. +8. `pkg_changelog` is package-only. MCP uses one compact package target; CLI may + retain package-oriented range flags, but `repo_url` / `--repo-url` and + `git_ref` / `--git-ref` are removed. Repository and site changelogs remain + undefined rather than guessing which monorepo package or release line they mean. ### Assumptions @@ -260,22 +282,19 @@ addressing shapes. ### Later-phase unknowns -- Package tools: upgrade-review single/batch MCP representation remains later work; - its existing CLI `@current..target` spelling is verified. Changelog exact-release - selection and upper-tag snapshot behavior need backend support. Neither blocks - the four package-coordinate tools in Phase 2a. +- Package tools: Phase 2b's package-only changelog contract is settled and its backend + support is merged and development-verified. Upgrade-review single/batch MCP + representation remains later work; its existing CLI `@current..target` spelling + is verified. - Navigation: which path, intent, context, and result-limit controls real callers need, including whether singular/plural variants should collapse. Resolve through product discussion and observed call shapes before Phase 4. - Language: backend fail-fast recovery with up to five canonical names is live; `search_language` is removed. -- Format copy: the shortest wording that keeps lower-cost agents on default text. - Resolve with a matched candidate eval before Phase 6 accepts a copy change. - `search_status`: its long-term continuation boundary is not settled by this plan. Do not remove or merge it without a separate product decision. -Unresolved backend/navigation decisions do not block Phase 6. Shorter format copy -must pass its matched-eval acceptance gate; that result is not assumed in planning. +Navigation and upgrade-review decisions do not block Phase 2b. ## Cross-cutting constraints @@ -292,13 +311,21 @@ must pass its matched-eval acceptance gate; that result is not assumed in planni routing, and Ask source projection. Use a pending minor fragment for both public artifacts, following the established pre-1.0 breaking-surface convention recorded in the repository's prior removal and public-contract plans. +- **Phase 2b compatibility:** MCP replaces six coordinate/range fields + (`registry`, `package_name`, `repo_url`, `git_ref`, `from_version`, `to_version`) + with one `target`. CLI removes repository-only `--repo-url` and `--git-ref`, accepts + exact and inline interval package specs, and retains package-oriented `--from` / + `--to` flags. Hard-coded callers receive a pending minor change record for both + public artifacts; there is no hidden dual-schema period. - **Migration:** Release notes must show direct conversions such as `{registry:"npm",package_name:"express",version:"5.2.1"}` to `"npm:express@5.2.1"` and `{repo_url:"https://github.com/expressjs/express", git_ref:"main"}` to `"github:expressjs/express@main"`. They must also show the search-only conversion `{site:"https://expressjs.com/"}` to `"site:https://expressjs.com/"` or its canonical equivalent - `"site:expressjs.com"`. No server-side dual-schema period is planned. + `"site:expressjs.com"`. Repository conversions apply only to code/discovery tools; + changelog callers must migrate repository inputs to an explicit package identity + or stop calling `pkg_changelog`. No server-side dual-schema period is planned. - **Phase 3 compatibility and migration:** Removing six advertised MCP fields is a breaking schema change for hard-coded callers. Migrate `kind:"function"`, `category:"callable"`, `path_prefix:"lib/"`, `file_intent:"production"`, @@ -313,10 +340,10 @@ must pass its matched-eval acceptance gate; that result is not assumed in planni - **Testing:** Schema shape, parsing, normalized service calls, error envelopes, stable/local registration, smoke behavior, and real-agent argument shapes all need evidence. Existing service mocks remain sufficient. -- **Operations:** The required backend contract is deployed. Hosted MCP clients change - only after `@githits/mcp` is released, adopted by `remote-mcp`, and deployed. Those - are separate repositories/actions and are not authorized by implementation of this - plan. +- **Operations:** Phase 2b's required backend contract is deployed to development and + live-verified; production deployment is unverified. Hosted MCP clients change only + after `@githits/mcp` is released, adopted by `remote-mcp`, and deployed. Those are + separate repositories/actions and are not authorized by implementation of this plan. - **Documentation:** Update current contracts, not immutable historical eval records. Keep stable `buildMcpQuickStart()` and the public skill's terminal guide byte-aligned if either needs to change. Generated plugin assets are never edited directly. @@ -327,9 +354,11 @@ must pass its matched-eval acceptance gate; that result is not assumed in planni `code_files`, `code_grep`, and experimental `code_diff` advertise and accept only compact string targets; CLI/service behavior and legacy read routing stay intact. 2. **Phase 2 — compact package-tool coordinates (PARTIALLY MERGED):** Phase 2a migrated - `docs_list`, `pkg_info`, `pkg_vulns`, and `pkg_deps` in PR #402. Phase 2b changelog - waits for verified backend exact-release/snapshot support; Phase 2c upgrade review - follows later reorientation. CLI ergonomics and service contracts stay intact. + `docs_list`, `pkg_info`, `pkg_vulns`, and `pkg_deps` in PR #402. Phase 2b is ready + to migrate package-only changelog latest, exact, and interval calls after backend + [PR #2583](https://github.com/githits-com/pkgseer-backend/pull/2583) and + [PR #2585](https://github.com/githits-com/pkgseer-backend/pull/2585). Phase 2c + upgrade review follows later reorientation. 3. **Phase 3 — one MCP search-filter language (MERGED):** the six backend-supported inline qualifiers replace their duplicate MCP fields while CLI flags and `public_only` remain. @@ -339,8 +368,8 @@ must pass its matched-eval acceptance gate; that result is not assumed in planni `get_example` keeps language filtering. Unresolved languages fail before generation and return up to five canonical retry names. `search_language` and `githits languages` are removed. -6. **Phase 6 — shared instruction ownership and concise tool copy (IMPLEMENTED, - draft PR #403):** recurring policy lives in skills/quick-start; tools retain their +6. **Phase 6 — shared instruction ownership and concise tool copy (MERGED, + PR #403):** recurring policy lives in skills/quick-start; tools retain their own call contract without repeating that policy at length. Ask and original format reminders remain. The shorter format candidate was rejected after evals. Search-status copy shrank without redesigning its continuation protocol. @@ -598,19 +627,18 @@ are deliberate migration signals, backend contracts, or dated evaluation history ### Phase 2: compact package-tool coordinates -**Status:** Phase 2a MERGED (PR #402, `9be81a9`); Phase 2b BLOCKED ON BACKEND; Phase 2c PENDING +**Status:** Phase 2a MERGED (PR #402, `9be81a9`); Phase 2b IMPLEMENTING; Phase 2c PENDING **Expected outcome:** `docs_list`, `pkg_info`, `pkg_vulns`, `pkg_deps`, -`pkg_changelog`, and `pkg_upgrade_review` expose compact package/repository/range -coordinates without changing their evidence or output semantics. +`pkg_changelog`, and `pkg_upgrade_review` expose compact package coordinates without +duplicating structured MCP addressing. Package tools do not advertise repository or +site targets. **Assumptions:** The shared package parser remains canonical; CLI positional specs and flags remain; latest-only tools reject embedded versions actionably. -**Unknowns or product decisions:** none for Phase 2a. Phase 2b requires verified -backend exact-release/ref-kind/source selection and a later decision on open-ended -repository intervals. Phase 2c needs its single/batch MCP representation settled; -reuse the existing CLI interval spelling. Canonical `@ref` is merged. +**Unknowns or product decisions:** none for Phase 2a or Phase 2b. Phase 2c needs its +single/batch MCP representation settled; reuse the existing CLI interval spelling. #### Phase 2a: one four-tool package-coordinate increment @@ -873,10 +901,11 @@ drift in place: replaced the vague `docs_*` routing wildcard with `docs_list`/`r and corrected the guardrails document to nine distinct third-party-content tools and the actual `pkg_info` prose surfaces (no install/usage snippets). No stable guide/public skill change, generated asset change or descriptor-prefix change. -Existing changelog exact-release wording is still ahead of the verified backend -contract; that is the already-deferred Phase 2b gap, not a compact-target regression. -Do not interpret this four-tool audit as proof that repository exact release -lookup works or that changelog/upgrade inputs have migrated. +At Phase 2a closure, existing changelog exact-release wording was still ahead of the +then-verified backend contract; that was the deferred Phase 2b gap, not a +compact-target regression. That four-tool audit did not prove repository exact-release +lookup or migrate changelog/upgrade inputs. The Phase 2b replan below supersedes the +repository premise and records the later package-only backend work. 1. Generated schemas and over-the-wire client calls prove the four tools require string `target` and advertise none of the removed coordinate fields. Registered @@ -918,83 +947,270 @@ policy without another round for documentation-only findings. No product input remains for Phase 2a. The reviewer requested an additional clean round, rejected as contrary to that explicit documentation-only clean-round policy. -#### Phase 2b/2c: later package operations - -Changelog is excluded from Phase 2a at the user's direction after exact-target -verification. Ref classification and exact-release/source selection naturally belong -to the backend; do not infer them from tag spelling, capped release scans, or an -unrelated code-diff call. A separately dispatched backend Codex worktree investigates -this contract (diagnosis only, no fix/deploy); independent hand-off is not supervised -here. After fixes are implemented, deployed and verified, reorient and detail the -compact changelog increment, preserving the accepted grammar below. Acceptance: -single pins select exactly one release; ranges preserve exclusive-start/inclusive-end -bounds; upper repository tags choose the requested CHANGELOG snapshot; missing -release/ref targets fail actionably rather than selecting unrelated entries. - -Upgrade review remains unchanged. Later reorientation must settle its single/batch -MCP shape using existing CLI `@current..target` syntax. Acceptance: one addressing -form with equivalent single/batch normalized calls and review evidence, actionable -invalid endpoints, and unchanged CLI behavior. No tactical work is scheduled now. - -Phase 2 interview history (2026-09-16; initial five-tool scope superseded by the -four-tool Phase 2a decision above): - -- The next increment covers `docs_list`, `pkg_info`, `pkg_vulns`, `pkg_deps`, and - `pkg_changelog`; upgrade-review redesign remains outside it. -- Include compact changelog release ranges in the same PR if implementation size - stays within the user's simplicity budget. Use inline targets such as - `npm:express@4.21.2..5.2.1` and - `github:expressjs/express@v4.21.2..v5.2.1`, not a separate range field. -- The upper repository tag selects the CHANGELOG-file snapshot. Release entries - use the corresponding exclusive-start/inclusive-end release bounds. -- A single package version, such as `npm:express@5.2.1`, selects exactly that - release for changelog, not recent entries capped at that version. Unversioned - package changelog targets retain the current recent-entry default. -- The user also requested single repository release tags to select exactly their - release, while branch/commit targets select CHANGELOG snapshots; verification - of backend support is recorded below before treating this as implementable. -- A production probe of the equivalent repository request (`fromVersion:4.21.2`, - `toVersion:5.2.1`, `gitRef:v5.2.1`) returned nine release entries, confirming the - backend accepts the combined inputs. It used the `releases` source, so it does - not itself prove CHANGELOG-file snapshot behavior. -- Existing CLI upgrade-review code already parses `@current..target`; reuse its - syntax rather than claiming the interval spelling is wholly undecided. The - earlier separate-range proposal and addressing-only scope were not accepted. -- Single repository-tag behavior is selected but backend exact-target support - remains unresolved. Open-ended repository intervals still need their source - revision semantics settled before finalizing the Phase 2 implementation contract. - -Repository exact-target verification (2026-09-16): - -- Backend `main` source was inspected read-only through GitHub at - `f29298eb1be0760185961131d876f05cbfe5242a`, not through the independent name - diagnosis worktree. `priv/graphql/schema.graphql` exposes `refKind` through - code-diff ref resolution, and internal ref facts include SHA/tag/branch/head. - Changelog's API exposes only independent `gitRef`, `fromVersion`, `toVersion`, - and latest-entry `limit`; it has no exact selector or ref classification result. -- Repository request `gitRef:v5.2.1,limit:3` returned recent release entries - `v4.22.3`, `v4.22.2`, `v4.22.1`. The tag does not filter the releases source. -- Repository request `gitRef:v5.2.1,toVersion:5.2.1,limit:1` returned `v4.22.3`, - not `v5.2.1`. The latest-entry cap is publication-ordered and not an exact lookup. - A missing-version control `toVersion:5.2.999` also returned `v4.22.3`. -- Package control `npm:express,toVersion:5.2.1,limit:1` returned exactly `5.2.1`; - package and repository addressing use different selection semantics. This - positive case does not prove missing-version or prerelease exact-pin behavior. -- Therefore the earlier estimate of a thin adapter is invalid for exact repository - releases. Ref classification and exact-release/source selection naturally belong - to the backend. Exposing those facts by executing a whole code diff would be the - wrong boundary; neither client tag-spelling guesses nor capped-list scans are - accepted substitutes. Backend support must be resolved before finalizing the - agreed exact repository-tag contract. No fixes or additional backend hand-off - were authorized by the verification request. - -**Dependencies:** Phase 2a needs no backend changes. Phase 2b requires backend -exact-release/source-selection support; Phase 2c depends on later product reorientation. - -**Acceptance criteria:** Each package operation has one MCP addressing form; all -latest, pinned, range, repository, and batch semantics remain deterministic; invalid -versions retain actionable mapped errors; catalog size decreases under the same -inventory; CLI and service contracts remain stable. +#### Phase 2b: compact package-only changelog targets + +**Status:** IMPLEMENTING + +**Expected outcome:** MCP `pkg_changelog` accepts one required package `target` +instead of structured package, repository, and range coordinates. Bare, exact, +closed-range, and open-ended package targets select deterministic backend operations. +CLI remains package-oriented, supports the same positional package forms, and keeps +its useful package range flags. Repository and site changelog inputs are removed from +both surfaces. + +**Product decision:** On 2026-09-18 the user confirmed that package tools support only +package surfaces. Repository changelogs are not well-defined, particularly for +monorepositories, and must not be inferred from repository tags, changelog files, or a +guessed package mapping. + +**Verified backend dependency:** `pkgseer-backend` +[PR #2583](https://github.com/githits-com/pkgseer-backend/pull/2583) merged as +`9967243432`; +[PR #2585](https://github.com/githits-com/pkgseer-backend/pull/2585) records +development deployment at Fly release 1665 and live resolver checks. Exact stable and +prerelease pins select their requested releases, +missing pins return `VERSION_NOT_FOUND`, closed intervals preserve +exclusive-start/inclusive-end membership, and an authoritative empty package +selection returns `entries: []` with `source: null`. Exact pins use +`packageInfo(registry, name, version).selectedVersion.changelog`; timeline requests +use `packageChangelog`. Production deployment is not claimed by this evidence. + +**Assumptions:** Existing package-version normalization remains canonical, including +Go `v` normalization and Swift's accepted leading `v`. `PackageChangelog` source +fields describe evidence attached to selected package releases, not an alternate +repository identity. Existing output entries and body controls remain useful. + +**Unknowns or product decisions:** none. + +**Dependencies:** Merged backend contract above; current package parser, request and +response helpers; package-intelligence service injection; stable MCP/CLI parity and +smoke harnesses; authenticated development access for live verification. + +##### Behavioral contract + +MCP advertises: + +```text +pkg_changelog( + target, + limit?, + omit_bodies?, + verbose?, + body_lines?, + format? +) +``` + +`target` is a package coordinate with an explicit registry: + +| Target | Mode | Backend operation | +| --- | --- | --- | +| `npm:express` | latest | `packageChangelog`, default latest entries | +| `npm:express@5.2.1` | one selected release | `packageInfo.selectedVersion.changelog` | +| `npm:express@4.21.2..5.2.1` | range | `packageChangelog`, `(4.21.2, 5.2.1]` | +| `npm:express@4.21.2..` | range to latest | `packageChangelog`, lower bound only | +| `npm:express@..5.2.1` | latest up to cap | `packageChangelog`, upper bound only | + +Reject an empty interval (`@..`), more than one `..`, `...`, missing registry/name, +unsupported registry, malformed interval endpoint, repository/site target, or a +leading tag-style `v` where package normalization does not allow it. Scoped npm names +continue to split at the last `@`. A non-range suffix uses the backend's existing +registry-aware single-release selector; a concrete published version such as `5.2.1` +is exact, while any registry-compatible constraint resolves to one concrete release +and the returned release identity remains visible. Do not invent a cross-registry +client validator that assumes all package versions are SemVer. `limit` is accepted +only for bare latest and upper-cap targets; single-release and lower-bound range +targets reject it before network access. + +MCP removes `registry`, `package_name`, `repo_url`, `git_ref`, `from_version`, and +`to_version`; no aliases or union schema remain. It keeps body/output controls +unchanged. + +CLI removes `--repo-url` and `--git-ref`. It accepts the five package target forms +above. Existing `--from` and `--to` remain as human-oriented package range/cap flags, +and `--limit` remains for latest mode. Inline single-release targets reject `--from`, +`--to`, and `--limit`; inline interval endpoints reject duplicate `--from`/`--to`; +lower-bound intervals reject `--limit`. Existing flag-only package calls remain +compatible. + +Single-release success contains exactly one backend-selected release, including +prereleases. A missing concrete version remains an actionable `VERSION_NOT_FOUND` +error; a registry-compatible constraint reports the resolved concrete release rather +than presenting the constraint as a release identity. A selected release without notes +is successful: JSON explicitly reports `hasChangelog: false`, text says release notes +are unavailable, and neither surface substitutes another release. Results with notes +retain source provenance and body controls. + +The public exact-mode JSON shape extends the existing envelope narrowly: +`mode: "exact"`; `entries.items` contains exactly one entry; that entry adds +`hasChangelog: boolean`; and top-level `source`, when present, is the backend +`detailSource` normalized to lower snake case. Accepted exact source values are +`releases`, `changelog_file`, `hexdocs`, `registry_release_notes`, `registry_link`, +`generated_github_url`, and `package_version`. Timeline entries omit +`hasChangelog`; their existing source values and shape remain unchanged. Exact mode +adds caller-explicit `filter.version` containing the normalized requested selector; +`entries.items[0].version` is the backend's resolved concrete release. Do not duplicate +`resolvedVersion` elsewhere or present a constraint as the release identity. + +Latest/range empty selections are successful on both text and JSON surfaces: +`entries: {count: 0, items: []}` and no source. Remove the current client promotion of +`source: null` plus no entries to `NOT_FOUND`; actual package/version/backend errors +continue through the existing mapped envelopes. `mode` expands to `latest | exact | +range`; `LeanChangelogFilter` adds optional `version` for single-release selectors, +while existing range/cap fields continue to echo only caller-explicit values +represented by the normalized target or CLI flags. + +##### Implementation boundaries and likely files + +1. Add a pure package-changelog target parser under + `packages/mcp/src/shared/` that composes `parsePackageSpec`, classifies latest, + exact, and interval suffixes, and returns normalized endpoint intent. Reuse the + interval grammar already established by upgrade-review without moving or + redesigning Phase 2c. Keep validation in the shared builder so MCP and CLI receive + mapped errors rather than raw Zod failures. +2. Refactor `package-changelog-request.ts` around package-only discriminated modes. + Remove repository/ref inputs and produce one of exact or timeline service params, + plus explicit mode/filter metadata. Preserve legacy CLI `--from`/`--to` adaptation + and define the conflict rules above in this one owner. +3. In `packages/core-internal/src/services/package-intelligence-service.ts`, remove + repository-only changelog params and query variables. Add the exact package-info + query path under the existing injected `packageChangelog` service operation. + Exact mode selects only `resolvedVersion` and the changelog fields consumed + by text/JSON (`detailSource`, `hasChangelog`, and entry fields used in output); + the requested selector stays client-side as `filter.version`. Gate `body` with + the existing include-bodies variable. Timeline mode + keeps the existing package query but omits unused repository/ref variables and + response fields. Add wire-contract tests for both operations and body modes. +4. Normalize both backend operations into the shared changelog report without + fabricating release identity. Preserve exact `hasChangelog`, resolved release + identity, requested selector, and detail-source provenance in the public shape + defined above. Map the requested selector only to `filter.version` and the resolved + release only to the entry's `version`. Accept package timeline `source: null` plus + empty entries as success; + retire the client-only changelog-source-not-found promotion and its stale tests if + no remaining caller uses it. +5. Change `packages/mcp/src/tools/package-changelog.ts` to one required string target, + route through the shared builder, and update the standalone selection sentence, + schema examples, package-only boundary, exact/range behavior, and recovery wording. + Keep `readOnlyHint: true` and existing guardrails. +6. Update `src/commands/pkg/changelog.ts` to parse the positional package target, + remove repository options, retain compatible package flags, and share the same + builder, formatter, and errors. Do not add repository-to-package resolution or a + network fallback. +7. Extend `package-changelog-response.ts` for exact mode and explicit no-notes + presentation while preserving latest/range envelopes and body previews. Keep CLI + `--json` and MCP `format: "json"` losslessly aligned. +8. Update mock factories, MCP/CLI parity, command metadata, stable/local catalog + contracts, and smoke fixtures. Remove repository fixtures; add bare latest, exact + stable/prerelease, missing exact, closed/open interval, empty interval result, + invalid interval, and repository/site rejection before service access. + Migrate the independent schema and comments in `eval/mock-mcp/server.ts` plus its + `server.test.ts` contract; it must not retain the removed structured fields. +9. Update `docs/implementation/tools.md`, + `docs/implementation/mcp-cli-parity.md`, + `docs/implementation/cli-commands.md`, and stale repository-target references. + Update the canonical package skill and MCP quick-start/public-skill pair if their + routing or target grammar is incomplete; keep quick-start/skill text byte-identical. + Run plugin generation and inspect generated output rather than editing it. +10. Add `changes/.changed.md` with pending `minor` impact for both + `githits` and `@githits/mcp`. State the package-only breaking removals, compact + target conversions, exact-release behavior, and truthful empty-result fix. Do not + edit historical changelog sections. + +##### Tests and verification + +- Unit-test target parsing and request construction independently from service IO. + Cover scoped npm, representative registries, Go/Swift normalization, concrete + versions, one registry-compatible constraint, all five modes, mode/flag conflicts, + empty-ish inputs, malformed intervals, and zero service calls for repository/site + targets. +- Assert exact GraphQL variables and selections separately from timeline requests. + Exact mode must select only consumed `packageInfo.selectedVersion` changelog fields; + timeline mode must not send `repoUrl` or `gitRef`; body omission must set the + conditional selection variable in both modes. +- Test exact stable/prerelease/no-notes output, missing exact mapping, empty timeline + success without a source, constraint request versus resolved-release identity, body + omission, text previews, JSON losslessness, and CLI/MCP parity. Preserve existing + tests for operational errors. +- Run focused changelog/service/tool/command/parity tests, then `bun test`, + `bun run typecheck`, `bun run lint`, `bun run format:check`, `bun run build`, and + `bun run validate:packages`. +- Run `bun run plugins:generate` and `bun run plugins:check`; inspect every generated + diff and require each change to follow from canonical guidance. +- Run source `bun run smoke:mcp` and `bun run smoke:cli`. Against the development + backend, verify bare latest, exact `5.2.1`, exact prerelease `5.0.0-beta.3`, missing + `5.2.999`, closed `(4.21.2, 5.2.1]`, and an authoritative empty selection. Record + authentication or deployment limitations without adding retries or changing + timeouts. Built smoke is required only if smoke launch/CI validation changes. +- Re-run `bun scripts/agent-context-load.ts sizes`. Current `origin/main` baseline is + 13 stable tools, `catalog.full` 35,494 characters, `catalog.prefix80` 1,207, + `bootstrap.stable` 4,675, and `skill.file` 5,206. Record exact changed totals and + hashes without converting character savings into token, cost, or quality claims. +- Run targeted descriptor-only agent evals for both configured Codex and Claude: + existing `package-changelog-range.md` plus one exact-release case that requires + `npm:express@5.2.1`. Inspect actual calls/results, finals, metrics, and isolation + violations. Require compact package targets, no structured/repository fallback or + schema-driven futile retries, and no unexplained JSON increase. Do not claim answer + quality without grading. +- Run fresh internal technical review and one external Opus implementation reviewer + per round. Keep this plan through PR review and record actual evidence/status before + draft PR delivery. + +**Local implementation evidence (2026-09-18):** `bun scripts/agent-context-load.ts sizes` +reports 13 stable tools, `catalog.full` 34,351 (`sha256:8285069e…`), +`catalog.prefix80` 1,207 (`sha256:ff8592bc…`), `bootstrap.stable` 4,661, and +`skill.file` 5,192. Source `smoke:mcp` passed including compact +`pkg_changelog` latest calls. Source `smoke:cli` passed unauthenticated and skipped +the stable live CLI cohort (`AUTH_REQUIRED`); experimental live CLI passed. +Authenticated `scripts/mcp-call.ts` against development: latest `npm:express` +`mode:latest`; exact `npm:express@5.2.1` and `npm:express@5.0.0-beta.3` return +`mode:exact` with `hasChangelog:true`; missing `5.2.999` is `VERSION_NOT_FOUND`; +`npm:express@4.21.2..5.2.1` is `mode:range` with exclusive-start membership; +`npm:express@5.2.1..5.2.1` is empty success (`count: 0`, no `source`); +`github:expressjs/express` is client `INVALID_ARGUMENT`. Descriptor-only agent +evals are still outstanding. + +##### Phase 2b acceptance criteria + +1. Generated MCP schemas expose required string `target` and none of the six removed + coordinate/range fields; package-only calls remain self-sufficient. +2. Bare, exact, closed-range, lower-open, and upper-open package targets produce the + normalized service operations above; repository/site inputs and invalid conflicts + fail before network access. +3. Concrete pins return only the requested release or actionable + `VERSION_NOT_FOUND`; compatible constraints expose the resolved concrete release; + no-notes selected releases and empty timeline selections are successful and + explicit. +4. Exact and timeline GraphQL requests fetch only fields used by their selected + output modes, with body inclusion controlled on the wire. +5. CLI package calls retain useful range flags and JSON/text parity while repository + flags are absent from help, parsing, docs, skills, tests, and smoke fixtures. +6. Catalog size decreases from the recorded baseline, first-sentence/prefix contracts + pass, live development smoke covers the named backend cases, and targeted agent + traces use compact package targets without isolation violations. +7. Current docs, canonical skills, generated assets, and one independent minor/minor + change fragment agree. Required unit, package, smoke, build, and review gates pass. + +#### Phase 2c: compact upgrade-review coordinates + +**Status:** PENDING PRODUCT REORIENTATION + +**Expected outcome:** `pkg_upgrade_review` has one compact package addressing form +covering single and batch upgrades without duplicating structured fields. + +**Assumptions:** Existing CLI `@current..target` spelling remains the starting +grammar; package-only scope continues. + +**Unknowns or product decisions:** Settle one MCP representation for single and batch +calls after Phase 2b evidence is merged. + +**Dependencies:** Phase 2b merged; later `$next-steps` reorientation and user +discussion. + +**Acceptance criteria:** Single and batch package upgrades normalize equivalently, +invalid endpoints fail actionably, CLI behavior remains human-oriented, and the MCP +schema contains no duplicate coordinate form. Tactical detail is intentionally +deferred until Phase 2b merges. ### Phase 3: one MCP search-filter language @@ -1341,8 +1557,9 @@ language filtering or force agents to guess names. ### Phase 6: shared instruction ownership and concise tool copy -**Status:** IMPLEMENTED on `jlitola/compact-agent-instructions`, draft -[PR #403](https://github.com/githits-com/githits-cli/pull/403), awaiting merge. +**Status:** MERGED in +[PR #403](https://github.com/githits-com/githits-cli/pull/403), merge +`ce93eb1`. Initial internal technical review is clean; external Opus round 1 found only minor documentation issues, applied for a clean round under repository policy. Windows CI subsequently found a test-only LF assumption; corrected in the eighth dispatch with @@ -1807,6 +2024,19 @@ truth. ## Plan review record +- Phase 2b internal technical review (2026-09-18): accepted five initial findings. + Distinguished concrete exact pins from backend-compatible single-release + constraints; defined exact JSON placement for requested selector, resolved release, + `hasChangelog`, and detail-source provenance; named the independent eval MCP schema; + qualified repository migration guidance as code/discovery-only; and corrected + deployment status to development-only. Closure review found one remaining ambiguity + between requested constraints and resolved releases; `filter.version` now owns the + requested selector and `entries.items[0].version` owns the resolved release. Final + internal re-review returned no findings. +- Phase 2b external Fable plan review (2026-09-18): not completed. The configured + Claude account reported its Fable usage limit before reading the task. The stalled + dispatch was diagnosed once and stopped; no review result, validation, or clean-round + claim is inferred, and no substitute model was used. - Phase 6 internal technical review (2026-09-17): accepted the experimental-copy eval coverage gap, calibrated as a bounded verification gap rather than an existing blocking product defect. Added existing resolution/site-resolution/diff cases for diff --git a/eval/agentic/README.md b/eval/agentic/README.md index dcdac44a..1a1b358d 100644 --- a/eval/agentic/README.md +++ b/eval/agentic/README.md @@ -918,7 +918,7 @@ use at least one agent for quick iteration. | Package overview or vulnerability UX, `pkg_info`, `pkg_vulns` | `package-overview-vulnerabilities.md`; use `package-vulnerability-filter.md` for severity/version filtering behavior, `package-vulnerability-history.md` for historical/non-affecting advisory scope behavior, `package-vulnerability-transitive.md` for npm-audit-style resolved dependency evidence, and `package-vulnerability-rubygems.md` for non-npm descriptor routing | | `quick_start` catalog salience in the reported claude.ai layout | `probes/claude-ai-deferred-catalog.md`; inspect whether `quick_start` is the first GitHits call, exactly once, before package evidence tools | | Dependency graph UX, `pkg_deps` | `package-dependencies.md` | -| Release notes UX, `pkg_changelog` | `package-changelog.md`; use `package-changelog-range.md` for range/body-preview behavior | +| Release notes UX, `pkg_changelog` | `package-changelog.md`; use `package-changelog-range.md` for range/body-preview behavior and `package-changelog-exact.md` for a pinned selected-release call | | Upgrade evidence UX, `pkg_upgrade_review` | `package-upgrade-safety.md` | | Documentation browsing, `docs_list`, `read` | `docs-discovery.md`; use `docs-search-followup.md` for search-to-read handoff and `docs-search-noise.md` for noisy docs-result recovery; use `docs-fragment-read.md` for exact indexed section selection | | File listing / file read UX, `code_files`, `read` | `code-file-navigation.md`; use `code-files-listing.md` for focused listing behavior; use `code-read-window.md` for focused source-window behavior | diff --git a/eval/agentic/context-loading/routing-guide.ts b/eval/agentic/context-loading/routing-guide.ts index 2310b381..15ca4340 100644 --- a/eval/agentic/context-loading/routing-guide.ts +++ b/eval/agentic/context-loading/routing-guide.ts @@ -26,7 +26,7 @@ the routing decision; the selected tool supplies its argument details. | Assess a package's license, adoption, maintenance, or overall health | pkg_info | | Inspect vulnerabilities in a package or version | pkg_vulns | | Inspect direct dependencies or transitive footprint | pkg_deps | -| Find release notes for a package or repository | pkg_changelog | +| Find release notes for a package | pkg_changelog | | Compare current and target dependency versions for an upgrade | pkg_upgrade_review | | Find canonical implementation examples across projects | get_example | | Check progress of an earlier search reference | search_status | diff --git a/eval/agentic/workloads/package-changelog-exact.md b/eval/agentic/workloads/package-changelog-exact.md new file mode 100644 index 00000000..0b3713a4 --- /dev/null +++ b/eval/agentic/workloads/package-changelog-exact.md @@ -0,0 +1,7 @@ +# Workload: Package Changelog Exact Release + +You are checking what shipped in Express `5.2.1` before upgrading a Node.js +service that already runs Express 5. Summarize the release notes for that +exact version, including whether notes were available, and call out any +security, breaking, or operationally important changes. Prefer concise +evidence over copying raw notes. diff --git a/eval/mock-mcp/server.test.ts b/eval/mock-mcp/server.test.ts index 3de685af..959c5371 100644 --- a/eval/mock-mcp/server.test.ts +++ b/eval/mock-mcp/server.test.ts @@ -42,13 +42,17 @@ describe("security-eval mock MCP coordinate schemas", () => { try { await client.connect(transport); const listed = await client.listTools(); - for (const name of ["pkg_info", "pkg_vulns"] as const) { + for (const name of ["pkg_info", "pkg_vulns", "pkg_changelog"] as const) { const schema = schemaFor(listed.tools, name); expect(schema.properties?.target).toEqual({ type: "string" }); expect(schema.required).toContain("target"); expect(schema.properties ?? {}).not.toHaveProperty("registry"); expect(schema.properties ?? {}).not.toHaveProperty("package_name"); expect(schema.properties ?? {}).not.toHaveProperty("version"); + expect(schema.properties ?? {}).not.toHaveProperty("repo_url"); + expect(schema.properties ?? {}).not.toHaveProperty("git_ref"); + expect(schema.properties ?? {}).not.toHaveProperty("from_version"); + expect(schema.properties ?? {}).not.toHaveProperty("to_version"); } const info = await client.callTool({ @@ -72,6 +76,32 @@ describe("security-eval mock MCP coordinate schemas", () => { "fixture package vulnerabilities", ); + writeState(stateFile, { + ...state, + expectedTool: "pkg_changelog", + content: "fixture package changelog", + }); + const changelog = await client.callTool({ + name: "pkg_changelog", + arguments: { target: "npm:zod" }, + }); + expect(changelog.isError).not.toBe(true); + expect(JSON.stringify(changelog.content)).toContain( + "fixture package changelog", + ); + + const oldOnlyChangelog = await client.callTool({ + name: "pkg_changelog", + arguments: { registry: "npm", package_name: "zod" }, + }); + expect(oldOnlyChangelog.isError).toBe(true); + expect(JSON.stringify(oldOnlyChangelog.content)).toContain( + "Invalid arguments", + ); + expect(JSON.stringify(oldOnlyChangelog.content)).not.toContain( + "fixture package changelog", + ); + const oldOnlyInfo = await client.callTool({ name: "pkg_info", arguments: { registry: "npm", package_name: "zod" }, diff --git a/eval/mock-mcp/server.ts b/eval/mock-mcp/server.ts index cee5f7da..87d51b1f 100644 --- a/eval/mock-mcp/server.ts +++ b/eval/mock-mcp/server.ts @@ -5,7 +5,7 @@ * Mirrors the real `githits` MCP server's quick-start guide and * production tool descriptions so the agent under test sees the same * orientation it would see against the real CLI. - * `pkg_info` and `pkg_vulns` use the canonical string-target schemas; their + * `pkg_info`, `pkg_vulns`, and `pkg_changelog` use the canonical string-target schemas; their * fixture handlers deliberately ignore args, as before, and return only fixture state. * * Behavior: @@ -166,13 +166,11 @@ server.registerTool( includeToolAddenda, ), inputSchema: { - registry: z.string().optional(), - package_name: z.string().optional(), - repo_url: z.string().optional(), - from_version: z.string().optional(), - to_version: z.string().optional(), + target: z.string(), limit: z.number().int().optional(), omit_bodies: z.boolean().optional(), + verbose: z.boolean().optional(), + body_lines: z.number().optional(), format: z.enum(["json", "text", "text-v1"]).optional(), }, annotations: { readOnlyHint: true }, diff --git a/packages/core-internal/src/services/package-intelligence-service.test.ts b/packages/core-internal/src/services/package-intelligence-service.test.ts index df4b69d7..6b03fa61 100644 --- a/packages/core-internal/src/services/package-intelligence-service.test.ts +++ b/packages/core-internal/src/services/package-intelligence-service.test.ts @@ -8,7 +8,6 @@ import { MalformedPackageIntelligenceResponseError, PackageIntelligenceAccessError, PackageIntelligenceBackendError, - PackageIntelligenceChangelogSourceNotFoundError, PackageIntelligenceDocumentationSectionUnresolvedError, PackageIntelligenceFeatureFlagRequiredError, PackageIntelligenceNetworkError, @@ -2413,7 +2412,7 @@ describe("PackageIntelligenceServiceImpl.packageVulnerabilities", () => { describe("PackageIntelligenceServiceImpl — packageChangelog", () => { const ENDPOINT = "https://pkgseer.dev"; - it("treats an empty source as no changelog data", async () => { + it("treats an empty source with no entries as a successful empty selection", async () => { const fetchFn = mock(() => Promise.resolve( jsonResponse({ @@ -2433,9 +2432,12 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { asFetchFn(fetchFn), ); - await expect( - service.packageChangelog({ registry: "NPM", packageName: "express" }), - ).rejects.toBeInstanceOf(PackageIntelligenceChangelogSourceNotFoundError); + const result = await service.packageChangelog({ + registry: "NPM", + packageName: "express", + }); + expect(result.source).toBeUndefined(); + expect(result.entries).toEqual([]); }); it("sends includeBodies and omits unused metadata from changelog query", async () => { @@ -2474,9 +2476,17 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { }); const parsed = JSON.parse(capturedBody ?? "{}"); + expect(parsed.query).toContain("query PackageChangelog("); expect(parsed.query).toContain("body @include(if: $includeBodies)"); expect(parsed.query).not.toContain("metadata"); - expect(parsed.variables.includeBodies).toBe(false); + expect(parsed.query).not.toContain("repoUrl"); + expect(parsed.query).not.toContain("gitRef"); + expect(parsed.query).not.toContain("packageInfo"); + expect(parsed.variables).toEqual({ + registry: "NPM", + name: "express", + includeBodies: false, + }); }); it("accepts package version entries without a changelog source", async () => { @@ -2552,6 +2562,168 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { expect(result.source).toBeUndefined(); expect(result.entries).toHaveLength(1); }); + + it("selects exact packageInfo changelog fields and maps resolved identity", async () => { + let capturedBody: string | undefined; + const fetchFn = mock((_url: string, init?: RequestInit) => { + capturedBody = init?.body as string; + return Promise.resolve( + jsonResponse({ + data: { + packageInfo: { + selectedVersion: { + resolvedVersion: "5.2.1", + changelog: { + detailSource: "RELEASES", + hasChangelog: true, + entry: { + normalizedVersion: "5.2.1", + body: "## Patch", + htmlUrl: + "https://github.com/expressjs/express/releases/tag/5.2.1", + publishedAt: "2026-01-15T12:00:00Z", + }, + }, + }, + }, + }, + }), + ); + }); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + + const result = await service.packageChangelog({ + registry: "NPM", + packageName: "express", + version: "^5.0.0", + }); + + const parsed = JSON.parse(capturedBody ?? "{}"); + expect(parsed.query).toContain("query PackageChangelogExact("); + expect(parsed.query).toContain("packageInfo("); + expect(parsed.query).toContain("resolvedVersion"); + expect(parsed.query).toContain("detailSource"); + expect(parsed.query).toContain("hasChangelog"); + expect(parsed.query).toContain("body @include(if: $includeBodies)"); + expect(parsed.query).not.toContain("requestedVersion"); + expect(parsed.query).not.toContain("packageChangelog("); + expect(parsed.query).not.toContain("repoUrl"); + expect(parsed.query).not.toContain("isLatest"); + expect(parsed.variables).toEqual({ + registry: "NPM", + name: "express", + version: "^5.0.0", + includeBodies: true, + }); + expect(result.source).toBe("releases"); + expect(result.entries).toEqual([ + { + version: "5.2.1", + normalizedVersion: "5.2.1", + body: "## Patch", + htmlUrl: "https://github.com/expressjs/express/releases/tag/5.2.1", + publishedAt: "2026-01-15T12:00:00Z", + hasChangelog: true, + }, + ]); + }); + + it("returns a successful exact release without notes", async () => { + let capturedBody: string | undefined; + const fetchFn = mock((_url: string, init?: RequestInit) => { + capturedBody = init?.body as string; + return Promise.resolve( + jsonResponse({ + data: { + packageInfo: { + selectedVersion: { + resolvedVersion: "5.2.1", + changelog: { + detailSource: "PACKAGE_VERSION", + hasChangelog: false, + entry: { + normalizedVersion: "5.2.1", + body: null, + htmlUrl: null, + publishedAt: null, + }, + }, + }, + }, + }, + }), + ); + }); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + + const result = await service.packageChangelog({ + registry: "NPM", + packageName: "express", + version: "5.2.1", + includeBodies: false, + }); + const parsed = JSON.parse(capturedBody ?? "{}"); + expect(parsed.variables).toEqual({ + registry: "NPM", + name: "express", + version: "5.2.1", + includeBodies: false, + }); + expect(result.source).toBe("package_version"); + expect(result.entries[0]).toMatchObject({ + version: "5.2.1", + hasChangelog: false, + }); + expect(result.entries[0]?.body).toBeUndefined(); + }); + + it("accepts an exact no-notes payload with a null changelog entry", async () => { + const fetchFn = mock(() => + Promise.resolve( + jsonResponse({ + data: { + packageInfo: { + selectedVersion: { + resolvedVersion: "0.0.1", + changelog: { + detailSource: null, + hasChangelog: false, + entry: null, + }, + }, + }, + }, + }), + ), + ); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + + const result = await service.packageChangelog({ + registry: "NPM", + packageName: "tiny-empty", + version: "0.0.1", + }); + expect(result.source).toBeUndefined(); + expect(result.package?.registry).toBe("npm"); + expect(result.entries).toEqual([ + { + version: "0.0.1", + hasChangelog: false, + }, + ]); + }); }); describe("PackageIntelligenceServiceImpl — package docs targets", () => { diff --git a/packages/core-internal/src/services/package-intelligence-service.ts b/packages/core-internal/src/services/package-intelligence-service.ts index e209cff3..9dd9af44 100644 --- a/packages/core-internal/src/services/package-intelligence-service.ts +++ b/packages/core-internal/src/services/package-intelligence-service.ts @@ -23,7 +23,10 @@ import { PkgseerTransportError, postPkgseerGraphql, } from "../shared/pkgseer-graphql.js"; -import type { PkgseerRegistry } from "../shared/pkgseer-registry.js"; +import { + type PkgseerRegistry, + toPkgseerRegistryLowercase, +} from "../shared/pkgseer-registry.js"; import type { ClientHeaderBuilder } from "../shared/request-headers.js"; import { ClientUpdateRequiredError, @@ -625,20 +628,20 @@ export interface DependencyReport { } /** - * Inputs to `packageChangelog`. Addressing is "spec XOR repo-URL": - * either both `registry` and `packageName`, or `repoUrl` alone. - * The shared request builder enforces the XOR before reaching the - * service; the service layer trusts the contract. + * Inputs to `packageChangelog`. Package-only: `registry` + `packageName` + * are required. `version` selects one release via `packageInfo`; + * otherwise the timeline `packageChangelog` query is used. */ export interface PackageChangelogParams { - /** Uppercase GraphQL registry enum value. Required with `packageName`. */ - registry?: PkgseerRegistry; - /** Package name. Required with `registry`. */ - packageName?: string; - /** GitHub repo URL. Mutually exclusive with `registry` + `packageName`. */ - repoUrl?: string; - /** Branch or tag for CHANGELOG.md fetching. Ignored for GH Releases. */ - gitRef?: string; + /** Uppercase GraphQL registry enum value. */ + registry: PkgseerRegistry; + /** Package name. */ + packageName: string; + /** + * Exact selected-release selector. When set, the service queries + * `packageInfo.selectedVersion.changelog` and ignores range/limit. + */ + version?: string; /** * Exclusive start of version range. When set, the backend returns every * entry after `fromVersion` through `toVersion` (or latest); `limit` is @@ -661,7 +664,6 @@ export interface PackageChangelogParams { export interface ChangelogPackageInfo { name?: string; registry?: string; - repoUrl?: string; fromVersion?: string; toVersion?: string; limit?: number; @@ -674,6 +676,8 @@ export interface ChangelogEntryDetail { body?: string; htmlUrl?: string; publishedAt?: string; + /** Present for exact selected-release results. */ + hasChangelog?: boolean; } export interface ChangelogReport { @@ -910,23 +914,6 @@ export class MalformedPackageIntelligenceResponseError extends Error { } } -/** - * Raised when the backend confirmed the package / repo exists but - * could not resolve a changelog source for it (no GitHub Releases, - * no CHANGELOG.md, no HexDocs). Distinct from - * {@link PackageIntelligenceTargetNotFoundError} which signals the - * package itself is missing. The error-map routes this to the shared - * `NOT_FOUND` code so MCP / CLI error envelopes are consistent, but - * the distinct class lets the changelog executor attach a message - * naming the sources that were tried. - */ -export class PackageIntelligenceChangelogSourceNotFoundError extends Error { - constructor(message: string) { - super(message); - this.name = "PackageIntelligenceChangelogSourceNotFoundError"; - } -} - // -------------------------------------------------------------------- // Zod schema for the packageSummary response shape // -------------------------------------------------------------------- @@ -2293,7 +2280,6 @@ const changelogPackageInfoSchema = z .object({ name: z.string().nullable().optional(), registry: z.string().nullable().optional(), - repoUrl: z.string().nullable().optional(), fromVersion: z.string().nullable().optional(), toVersion: z.string().nullable().optional(), limit: z.number().int().nullable().optional(), @@ -2325,12 +2311,37 @@ const changelogGraphQLResponseSchema = z.object({ errors: z.array(graphQLErrorSchema).optional(), }); +const exactChangelogDetailSchema = z.object({ + detailSource: z.string().nullable().optional(), + hasChangelog: z.boolean(), + entry: changelogEntryDetailSchema.nullable().optional(), +}); + +const exactChangelogGraphQLResponseSchema = z.object({ + data: z + .object({ + packageInfo: z + .object({ + selectedVersion: z + .object({ + resolvedVersion: z.string(), + changelog: exactChangelogDetailSchema.nullable().optional(), + }) + .nullable() + .optional(), + }) + .nullable() + .optional(), + }) + .nullable() + .optional(), + errors: z.array(graphQLErrorSchema).optional(), +}); + const PACKAGE_CHANGELOG_QUERY = ` query PackageChangelog( - $registry: Registry - $name: String - $repoUrl: String - $gitRef: String + $registry: Registry! + $name: String! $fromVersion: String $toVersion: String $limit: Int @@ -2339,8 +2350,6 @@ query PackageChangelog( packageChangelog( registry: $registry name: $name - repoUrl: $repoUrl - gitRef: $gitRef fromVersion: $fromVersion toVersion: $toVersion limit: $limit @@ -2348,7 +2357,6 @@ query PackageChangelog( package { name registry - repoUrl fromVersion toVersion limit @@ -2364,6 +2372,38 @@ query PackageChangelog( } }`; +const PACKAGE_CHANGELOG_EXACT_QUERY = ` +query PackageChangelogExact( + $registry: Registry! + $name: String! + $version: String + $includeBodies: Boolean! = true +) { + packageInfo(registry: $registry, name: $name, version: $version) { + selectedVersion { + resolvedVersion + changelog { + detailSource + hasChangelog + entry { + normalizedVersion + body @include(if: $includeBodies) + htmlUrl + publishedAt + } + } + } + } +}`; + +function normaliseChangelogSource( + raw: string | null | undefined, +): string | undefined { + const trimmed = raw?.trim(); + if (!trimmed) return undefined; + return trimmed.toLowerCase(); +} + // -------------------------------------------------------------------- // Zod schema + queries for package docs // -------------------------------------------------------------------- @@ -3704,6 +3744,16 @@ export class PackageIntelligenceServiceImpl private async executePackageChangelog( token: string, params: PackageChangelogParams, + ): Promise { + if (params.version !== undefined) { + return this.executeExactPackageChangelog(token, params); + } + return this.executeTimelinePackageChangelog(token, params); + } + + private async executeTimelinePackageChangelog( + token: string, + params: PackageChangelogParams, ): Promise { let response: PkgseerGraphqlResponse; try { @@ -3714,8 +3764,6 @@ export class PackageIntelligenceServiceImpl variables: { registry: params.registry, name: params.packageName, - repoUrl: params.repoUrl, - gitRef: params.gitRef, fromVersion: params.fromVersion, toVersion: params.toVersion, limit: params.limit, @@ -3760,29 +3808,72 @@ export class PackageIntelligenceServiceImpl ); } - return this.normaliseChangelogReport(data, params); + return this.normaliseTimelineChangelogReport(data); } - private normaliseChangelogReport( - data: z.infer, + private async executeExactPackageChangelog( + token: string, params: PackageChangelogParams, - ): ChangelogReport { - // Backend returns source=null for package version entries that have no - // changelog entry. Treat no-source as NOT_FOUND only when no entries - // came back at all. - const source = data.source?.trim() ? data.source : undefined; - const rawEntries = data.entries ?? []; - if (!source && rawEntries.length === 0) { - const target = - params.repoUrl ?? - (params.registry && params.packageName - ? `${params.registry.toLowerCase()}:${params.packageName}` - : "package"); - throw new PackageIntelligenceChangelogSourceNotFoundError( - `No changelog source available for ${target} (tried GitHub Releases, CHANGELOG.md, and HexDocs).`, + ): Promise { + let response: PkgseerGraphqlResponse; + try { + response = await postPkgseerGraphql({ + endpointUrl: this.endpointUrl, + token, + query: PACKAGE_CHANGELOG_EXACT_QUERY, + variables: { + registry: params.registry, + name: params.packageName, + version: params.version, + includeBodies: params.includeBodies !== false, + }, + fetchFn: this.fetchFn, + clientHeaders: this.runtime.clientHeaders, + userAgent: this.runtime.userAgent, + diagnostics: this.runtime.diagnostics, + }); + } catch (cause) { + if (cause instanceof PkgseerTransportError) { + throw this.createTransportError(cause); + } + throw cause; + } + + if (response.status < 200 || response.status >= 300) { + throw this.createHttpError(response); + } + + const parsed = exactChangelogGraphQLResponseSchema.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 selected = parsed.data.data?.packageInfo?.selectedVersion; + if (!selected) { + throw new MalformedPackageIntelligenceResponseError( + "Empty response from the package-intelligence service.", ); } + return this.normaliseExactChangelogReport(selected, params); + } + + private normaliseTimelineChangelogReport( + data: z.infer, + ): ChangelogReport { + const source = normaliseChangelogSource(data.source); + const rawEntries = data.entries ?? []; const entries: ChangelogEntryDetail[] = rawEntries.map((entry) => ({ version: entry.version ?? undefined, normalizedVersion: entry.normalizedVersion ?? undefined, @@ -3795,7 +3886,6 @@ export class PackageIntelligenceServiceImpl ? { name: data.package.name ?? undefined, registry: data.package.registry ?? undefined, - repoUrl: data.package.repoUrl ?? undefined, fromVersion: data.package.fromVersion ?? undefined, toVersion: data.package.toVersion ?? undefined, limit: data.package.limit ?? undefined, @@ -3809,6 +3899,39 @@ export class PackageIntelligenceServiceImpl }; } + private normaliseExactChangelogReport( + selected: { + resolvedVersion: string; + changelog?: { + detailSource?: string | null; + hasChangelog: boolean; + entry?: z.infer | null; + } | null; + }, + params: PackageChangelogParams, + ): ChangelogReport { + const changelog = selected.changelog ?? undefined; + const entry = changelog?.entry ?? undefined; + const hasChangelog = changelog?.hasChangelog ?? false; + return { + package: { + name: params.packageName, + registry: toPkgseerRegistryLowercase(params.registry), + }, + source: normaliseChangelogSource(changelog?.detailSource), + entries: [ + { + version: selected.resolvedVersion, + normalizedVersion: entry?.normalizedVersion ?? undefined, + body: entry?.body ?? undefined, + htmlUrl: entry?.htmlUrl ?? undefined, + publishedAt: entry?.publishedAt ?? undefined, + hasChangelog, + }, + ], + }; + } + async listPackageDocs( params: ListPackageDocsParams, ): Promise { diff --git a/packages/core-internal/src/services/promote-version-not-found.ts b/packages/core-internal/src/services/promote-version-not-found.ts index 91f9cf76..089a5f08 100644 --- a/packages/core-internal/src/services/promote-version-not-found.ts +++ b/packages/core-internal/src/services/promote-version-not-found.ts @@ -29,8 +29,7 @@ * can only reflect an unrelated upstream condition. * - `details.package` is qualified with the lowercase registry prefix * (e.g. `"npm:lodash"`) when both `registry` and `packageName` are - * provided. In repo-URL addressing mode (`packageChangelog`) neither - * is available; `details.package` is omitted entirely. + * provided. Changelog requests are package-only. * - `details.requestedVersion` preference order when multiple are * set: `version` → `fromVersion` → `toVersion`. First non-null * wins. Range-mode requests typically set `fromVersion`, which is @@ -45,9 +44,8 @@ import { /** * Minimal shape shared by every versioned-query params type we route - * through this helper. All fields optional so repo-URL-addressed - * queries (`packageChangelog`) can also flow through — the helper - * omits any detail it can't synthesize. + * through this helper. All fields optional so callers can omit unused + * version bounds. */ export interface PromotableVersionedQueryParams { registry?: PkgseerRegistry; diff --git a/packages/mcp/src/internal.ts b/packages/mcp/src/internal.ts index 0586c520..a73b0152 100644 --- a/packages/mcp/src/internal.ts +++ b/packages/mcp/src/internal.ts @@ -35,6 +35,7 @@ export * from "./shared/list-package-docs-response.js"; export * from "./shared/list-package-docs-text.js"; export * from "./shared/package-changelog-request.js"; export * from "./shared/package-changelog-response.js"; +export * from "./shared/package-changelog-target.js"; export * from "./shared/package-dependencies-request.js"; export * from "./shared/package-dependencies-response.js"; export * from "./shared/package-intelligence-error-map.js"; diff --git a/packages/mcp/src/mcp/instructions.ts b/packages/mcp/src/mcp/instructions.ts index 5f4ffa3c..c2afce06 100644 --- a/packages/mcp/src/mcp/instructions.ts +++ b/packages/mcp/src/mcp/instructions.ts @@ -16,7 +16,7 @@ This guide owns shared policy; selected tools own call syntax and exceptions. | Assess a package's license, adoption, maintenance, or overall health | \`pkg_info\` | | Inspect vulnerabilities in a package or version | \`pkg_vulns\` | | Inspect direct dependencies or transitive footprint | \`pkg_deps\` | -| Find release notes for a package or repository | \`pkg_changelog\` | +| Find release notes for a package | \`pkg_changelog\` | | Compare current and target dependency versions for an upgrade | \`pkg_upgrade_review\` | | Find canonical implementation examples across projects | \`get_example\` | | Check progress of an earlier search reference | \`search_status\` | diff --git a/packages/mcp/src/mcp/server.test.ts b/packages/mcp/src/mcp/server.test.ts index 9d69afc5..cec9e7ab 100644 --- a/packages/mcp/src/mcp/server.test.ts +++ b/packages/mcp/src/mcp/server.test.ts @@ -168,12 +168,13 @@ const DESCRIPTION_ROUTING: Record< pkg_changelog: { prefix: /^Find release notes and changelog history/, exactPrefix: - "Find release notes and changelog history for a package or public repository. Def", + "Find release notes and changelog history for a package. Default latest mode retu", body: [ - "`(from_version, to_version]`", - "upper cap, not an exact-release lookup", + "`registry:name@version`", + "one selected release", + "Empty latest or range selections succeed", ], - absent: ["newest-first", "most recent", "one exact release"], + absent: ["newest-first", "most recent", "repo_url", "from_version"], }, pkg_upgrade_review: { prefix: /^Review a package upgrade/, @@ -397,6 +398,10 @@ describe("MCP compact target schemas", () => { "target", ], ], + [ + "pkg_changelog", + ["body_lines", "format", "limit", "omit_bodies", "target", "verbose"], + ], ] as const)("%s exposes the compact target schema", (name, properties) => { const descriptor = getMcpToolDescriptors().find( (candidate) => candidate.name === name, @@ -413,7 +418,15 @@ describe("MCP compact target schemas", () => { expect(schema.properties?.target, name).toMatchObject({ type: "string", }); - for (const coordinate of ["registry", "package_name", "version"]) { + for (const coordinate of [ + "registry", + "package_name", + "version", + "repo_url", + "git_ref", + "from_version", + "to_version", + ]) { expect( schema.properties?.[coordinate], `${name}: ${coordinate}`, diff --git a/packages/mcp/src/services/test-helpers.ts b/packages/mcp/src/services/test-helpers.ts index 35c84493..4b74924e 100644 --- a/packages/mcp/src/services/test-helpers.ts +++ b/packages/mcp/src/services/test-helpers.ts @@ -601,7 +601,6 @@ export const defaultChangelogReport: ChangelogReport = { package: { name: "express", registry: "npm", - repoUrl: undefined, fromVersion: undefined, toVersion: undefined, limit: 10, diff --git a/packages/mcp/src/shared/package-changelog-request.test.ts b/packages/mcp/src/shared/package-changelog-request.test.ts index 4d92300a..c94f4ada 100644 --- a/packages/mcp/src/shared/package-changelog-request.test.ts +++ b/packages/mcp/src/shared/package-changelog-request.test.ts @@ -1,161 +1,210 @@ import { describe, expect, it } from "bun:test"; import { buildPackageChangelogParams } from "./package-changelog-request.js"; -describe("buildPackageChangelogParams — addressing XOR", () => { - it("accepts spec-only input and produces uppercase registry", () => { - const { params, explicitFilterFields } = buildPackageChangelogParams({ - registry: "npm", - packageName: "express", +describe("buildPackageChangelogParams — package-only targets", () => { + it("accepts a bare latest target and produces uppercase registry", () => { + const { params, mode, explicitFilterFields } = buildPackageChangelogParams({ + target: "npm:express", }); + expect(mode).toBe("latest"); expect(params.registry).toBe("NPM"); expect(params.packageName).toBe("express"); - expect(params.repoUrl).toBeUndefined(); + expect(params.version).toBeUndefined(); + expect(params.fromVersion).toBeUndefined(); expect(explicitFilterFields.size).toBe(0); }); - it("accepts repo-url-only input and leaves registry/name empty", () => { - const { params } = buildPackageChangelogParams({ - repoUrl: "https://github.com/expressjs/express", + it("accepts an exact selected release", () => { + const { params, mode, explicitFilterFields } = buildPackageChangelogParams({ + target: "npm:express@5.2.1", }); - expect(params.repoUrl).toBe("https://github.com/expressjs/express"); - expect(params.registry).toBeUndefined(); - expect(params.packageName).toBeUndefined(); + expect(mode).toBe("exact"); + expect(params.version).toBe("5.2.1"); + expect(params.fromVersion).toBeUndefined(); + expect(params.toVersion).toBeUndefined(); + expect(params.limit).toBeUndefined(); + expect(explicitFilterFields.has("version")).toBe(true); }); - it("treats blank spec fields as absent for repo-url input", () => { - const { params } = buildPackageChangelogParams({ - registry: " ", - packageName: "\t", - repoUrl: "https://github.com/expressjs/express", + it("accepts a registry-compatible constraint as exact", () => { + const { params, mode } = buildPackageChangelogParams({ + target: "npm:express@^5.0.0", + }); + expect(mode).toBe("exact"); + expect(params.version).toBe("^5.0.0"); + }); + + it("accepts a closed interval as range", () => { + const { params, mode, explicitFilterFields } = buildPackageChangelogParams({ + target: "npm:express@4.21.2..5.2.1", + }); + expect(mode).toBe("range"); + expect(params.fromVersion).toBe("4.21.2"); + expect(params.toVersion).toBe("5.2.1"); + expect(explicitFilterFields.has("fromVersion")).toBe(true); + expect(explicitFilterFields.has("toVersion")).toBe(true); + }); + + it("accepts a lower-open interval as range to latest", () => { + const { params, mode } = buildPackageChangelogParams({ + target: "npm:express@4.21.2..", + }); + expect(mode).toBe("range"); + expect(params.fromVersion).toBe("4.21.2"); + expect(params.toVersion).toBeUndefined(); + }); + + it("accepts an upper-cap target as latest", () => { + const { params, mode, explicitFilterFields } = buildPackageChangelogParams({ + target: "npm:express@..5.2.1", }); + expect(mode).toBe("latest"); + expect(params.toVersion).toBe("5.2.1"); + expect(explicitFilterFields.has("toVersion")).toBe(true); + }); - expect(params.repoUrl).toBe("https://github.com/expressjs/express"); - expect(params.registry).toBeUndefined(); - expect(params.packageName).toBeUndefined(); + it("rejects a missing target", () => { + expect(() => buildPackageChangelogParams({})).toThrow(/package spec/); }); - it("rejects when both spec and repo-url are provided", () => { + it("rejects repository and site targets without producing params", () => { + expect(() => + buildPackageChangelogParams({ + target: "github:expressjs/express", + }), + ).toThrow(/package-only/); expect(() => buildPackageChangelogParams({ - registry: "npm", - packageName: "express", - repoUrl: "https://github.com/expressjs/express", + target: "https://github.com/expressjs/express", }), - ).toThrow(/not both/); + ).toThrow(/package-only/); + }); +}); + +describe("buildPackageChangelogParams — CLI flag adaptation", () => { + it("accepts flag-only --from / --to on a bare target", () => { + const { params, mode } = buildPackageChangelogParams({ + target: "npm:express", + fromVersion: "4.21.2", + toVersion: "5.2.1", + }); + expect(mode).toBe("range"); + expect(params.fromVersion).toBe("4.21.2"); + expect(params.toVersion).toBe("5.2.1"); }); - it("rejects when neither addressing form is provided", () => { - expect(() => buildPackageChangelogParams({})).toThrow(/spec/); + it("accepts --to as a latest-mode cap on a bare target", () => { + const { params, mode } = buildPackageChangelogParams({ + target: "npm:express", + toVersion: "5.2.1", + limit: 5, + }); + expect(mode).toBe("latest"); + expect(params.toVersion).toBe("5.2.1"); + expect(params.limit).toBe(5); }); - it("rejects a non-URL-shaped repo-url value", () => { - expect(() => buildPackageChangelogParams({ repoUrl: "not a url" })).toThrow( - /URL/, - ); + it("accepts --limit on an upper-cap target", () => { + const { params, mode } = buildPackageChangelogParams({ + target: "npm:express@..5.2.1", + limit: 3, + }); + expect(mode).toBe("latest"); + expect(params.limit).toBe(3); }); - it("rejects a spec with an unknown registry", () => { + it("rejects --from on an exact target", () => { expect(() => buildPackageChangelogParams({ - registry: "obscure", - packageName: "example", + target: "npm:express@5.2.1", + fromVersion: "4.0.0", }), - ).toThrow(/Unsupported registry/); + ).toThrow(/single-release/); }); -}); -describe("buildPackageChangelogParams — `@` rejection", () => { - it("rejects specVersion with a hint redirecting to --to / --from", () => { + it("rejects --to on an exact target", () => { expect(() => buildPackageChangelogParams({ - registry: "npm", - packageName: "express", - specVersion: "4.18.0", + target: "npm:express@5.2.1", + toVersion: "5.3.0", }), - ).toThrow(/--to|--from/); + ).toThrow(/single-release/); }); -}); -describe("buildPackageChangelogParams — mode mutual exclusion", () => { - it("rejects --from + --limit together", () => { + it("rejects --limit on an exact target", () => { expect(() => buildPackageChangelogParams({ - registry: "npm", - packageName: "express", - fromVersion: "4.0.0", - limit: 10, + target: "npm:express@5.2.1", + limit: 5, }), - ).toThrow(/latest-mode/); + ).toThrow(/drop `limit`/); + expect(() => + buildPackageChangelogParams({ + target: "npm:express@5.2.1", + limit: 5, + }), + ).not.toThrow(/--from/); }); - it("accepts --from alone (range mode)", () => { - const { params, explicitFilterFields } = buildPackageChangelogParams({ - registry: "npm", - packageName: "express", - fromVersion: "4.0.0", - }); - expect(params.fromVersion).toBe("4.0.0"); - expect(params.limit).toBeUndefined(); - expect(explicitFilterFields.has("fromVersion")).toBe(true); + it("rejects duplicate --from against an inline from bound", () => { + expect(() => + buildPackageChangelogParams({ + target: "npm:express@4.21.2..5.2.1", + fromVersion: "4.0.0", + }), + ).toThrow(/already contains a from version/); }); - it("accepts --limit alone (latest mode)", () => { - const { params, explicitFilterFields } = buildPackageChangelogParams({ - registry: "npm", - packageName: "express", - limit: 5, - }); - expect(params.limit).toBe(5); - expect(explicitFilterFields.has("limit")).toBe(true); + it("rejects duplicate --to against an inline to bound", () => { + expect(() => + buildPackageChangelogParams({ + target: "npm:express@4.21.2..5.2.1", + toVersion: "5.3.0", + }), + ).toThrow(/already contains a to version/); }); - it("accepts --to in either mode", () => { - const latest = buildPackageChangelogParams({ - registry: "npm", - packageName: "express", - toVersion: "5.0.0", - }); - expect(latest.params.toVersion).toBe("5.0.0"); - expect(latest.explicitFilterFields.has("toVersion")).toBe(true); + it("rejects --limit with a lower-bound interval", () => { + expect(() => + buildPackageChangelogParams({ + target: "npm:express@4.21.2..", + limit: 10, + }), + ).toThrow(/latest-mode/); + }); - const range = buildPackageChangelogParams({ - registry: "npm", - packageName: "express", - fromVersion: "4.0.0", - toVersion: "5.0.0", - }); - expect(range.params.fromVersion).toBe("4.0.0"); - expect(range.params.toVersion).toBe("5.0.0"); + it("rejects --from + --limit together", () => { + expect(() => + buildPackageChangelogParams({ + target: "npm:express", + fromVersion: "4.0.0", + limit: 10, + }), + ).toThrow(/latest-mode/); }); }); describe("buildPackageChangelogParams — version validation", () => { - it("rejects tag-style fromVersion", () => { + it("rejects tag-style fromVersion flags", () => { expect(() => buildPackageChangelogParams({ - registry: "npm", - packageName: "express", + target: "npm:express", fromVersion: "v4.18.0", }), - ).toThrow(/--from \/ from_version/); + ).toThrow(/--from/); }); - it("rejects tag-style toVersion", () => { + it("rejects tag-style exact versions", () => { expect(() => buildPackageChangelogParams({ - registry: "npm", - packageName: "express", - toVersion: "V5.0.0", + target: "npm:express@v5.2.1", }), - ).toThrow(/--to \/ to_version/); + ).toThrow(/git tag/); }); it("allows v-prefixed Swift versions", () => { const { params } = buildPackageChangelogParams({ - registry: "swift", - packageName: "github.com/apple/swift-crypto", - fromVersion: "v3.10.0", - toVersion: "v3.11.0", + target: "swift:github.com/apple/swift-crypto@v3.10.0..v3.11.0", }); expect(params.registry).toBe("SWIFT"); expect(params.fromVersion).toBe("v3.10.0"); @@ -164,28 +213,39 @@ describe("buildPackageChangelogParams — version validation", () => { it("sends canonical Go range bounds to the backend", () => { const { params } = buildPackageChangelogParams({ - registry: "go", - packageName: "golang.org/x/text", - fromVersion: "0.27.0", - toVersion: "v0.28.0", + target: "go:golang.org/x/text@0.27.0..v0.28.0", }); expect(params.fromVersion).toBe("v0.27.0"); expect(params.toVersion).toBe("v0.28.0"); }); + it("canonicalises Go exact pins", () => { + const { params } = buildPackageChangelogParams({ + target: "go:golang.org/x/text@0.28.0", + }); + expect(params.version).toBe("v0.28.0"); + }); + it.each([ "5.0.0-rc.1", "2.32.0.dev0", "1.7.0-rc.5", "4.0.0-alpha", "1.0.0+build.1", - ])("accepts pre-release / build version '%s' on --from", (version) => { + ])("accepts pre-release / build version '%s' as exact", (version) => { const { params } = buildPackageChangelogParams({ - registry: "npm", - packageName: "express", - fromVersion: version, + target: `npm:express@${version}`, }); - expect(params.fromVersion).toBe(version); + expect(params.version).toBe(version); + }); + + it("treats whitespace-only fromVersion as absent", () => { + const { params, explicitFilterFields } = buildPackageChangelogParams({ + target: "npm:express", + fromVersion: " ", + }); + expect(params.fromVersion).toBeUndefined(); + expect(explicitFilterFields.has("fromVersion")).toBe(false); }); }); @@ -193,8 +253,7 @@ describe("buildPackageChangelogParams — limit validation", () => { it.each([0, 51, 3.5, -1])("rejects out-of-range limit %s", (limit) => { expect(() => buildPackageChangelogParams({ - registry: "npm", - packageName: "express", + target: "npm:express", limit, }), ).toThrow(/1 and 50/); @@ -202,47 +261,14 @@ describe("buildPackageChangelogParams — limit validation", () => { it("accepts limit at boundaries (1 and 50)", () => { const low = buildPackageChangelogParams({ - registry: "npm", - packageName: "express", + target: "npm:express", limit: 1, }); expect(low.params.limit).toBe(1); const high = buildPackageChangelogParams({ - registry: "npm", - packageName: "express", + target: "npm:express", limit: 50, }); expect(high.params.limit).toBe(50); }); }); - -describe("buildPackageChangelogParams — filter tracking", () => { - it("tracks gitRef as explicit when set", () => { - const { explicitFilterFields } = buildPackageChangelogParams({ - registry: "npm", - packageName: "express", - gitRef: "main", - }); - expect(explicitFilterFields.has("gitRef")).toBe(true); - }); - - it("treats whitespace-only gitRef as absent", () => { - const { params, explicitFilterFields } = buildPackageChangelogParams({ - registry: "npm", - packageName: "express", - gitRef: " ", - }); - expect(params.gitRef).toBeUndefined(); - expect(explicitFilterFields.has("gitRef")).toBe(false); - }); - - it("treats whitespace-only fromVersion as absent", () => { - const { params, explicitFilterFields } = buildPackageChangelogParams({ - registry: "npm", - packageName: "express", - fromVersion: " ", - }); - expect(params.fromVersion).toBeUndefined(); - expect(explicitFilterFields.has("fromVersion")).toBe(false); - }); -}); diff --git a/packages/mcp/src/shared/package-changelog-request.ts b/packages/mcp/src/shared/package-changelog-request.ts index 97027c8f..8acd3f9a 100644 --- a/packages/mcp/src/shared/package-changelog-request.ts +++ b/packages/mcp/src/shared/package-changelog-request.ts @@ -1,61 +1,33 @@ /** - * Shared request builder for the `package_changelog` tool. The CLI - * command and the MCP tool normalise their inputs here so the two - * surfaces cannot diverge on addressing rules, version validation, - * or mode/limit mutual exclusion. + * Shared request builder for `pkg_changelog`. CLI and MCP normalise + * inputs here so addressing, interval classification, version + * validation, and limit/mode exclusion cannot diverge. * * Responsibilities: - * - Enforce addressing XOR: exactly one of (a) `` (registry + - * packageName) or (b) `repoUrl`. Reject both-present, none-present, - * and malformed ``. - * - Normalise exact Go `fromVersion` / `toVersion` bounds to their - * canonical `v`-prefixed form. Reject tag-style versions for other - * registries except Swift, where `v`-prefixed release tags are accepted. - * - Reject `@`: the `pkg changelog` family does not - * give `@version` a meaning (unlike `pkg vulns` and `pkg deps`). - * Redirect callers to `--to` / `to_version`. - * - Reject `fromVersion` + `limit` together (backend says range mode - * has no count cap; we catch it before the wire). - * - Enforce `limit` range (1–50). - * - Emit an `explicitFilterFields` set so the response envelope only - * echoes `filter.*` for caller-supplied fields, not backend - * defaults. + * - Require a package-only compact `target`. + * - Classify latest, exact, and interval suffixes. + * - Adapt legacy CLI `--from` / `--to` onto the same modes. + * - Reject exact/inline-endpoint conflicts and `limit` outside latest + * or upper-cap mode. + * - Normalise Go / Swift version spelling. + * - Emit `explicitFilterFields` so the envelope echoes only caller + * intent. */ import type { PackageChangelogParams } from "@githits/core-internal"; -import { - isKnownPkgseerRegistryArg, - PKGSEER_REGISTRY_LIST, - type PkgseerRegistryArg, - toPkgseerRegistry, -} from "@githits/core-internal"; -import { - InvalidPackageSpecError, - UnsupportedRegistryError, -} from "./package-spec.js"; +import { toPkgseerRegistry } from "@githits/core-internal"; +import { parsePackageChangelogTarget } from "./package-changelog-target.js"; +import { InvalidPackageSpecError } from "./package-spec.js"; import { normalisePackageVersion } from "./package-version.js"; -/** - * Raw inputs from either CLI or MCP, pre-normalisation. Keep every - * field optional so the builder is the single place enforcing the - * XOR + co-occurrence rules. - */ export interface PackageChangelogRequestInput { - /** Lowercase registry surface value (`npm`, `pypi`, …). */ - registry?: string; - /** Raw package name — trimmed before validation. */ - packageName?: string; - /** Full HTTPS repository URL. Mutex with `registry` + `packageName`. */ - repoUrl?: string; - /** Optional git branch/tag for CHANGELOG.md. */ - gitRef?: string; - /** Optional `@` captured from the spec parser. Always rejected here. */ - specVersion?: string; - /** Range-mode start version. */ + /** Compact package target. Required. */ + target?: string; + /** CLI `--from` exclusive start. Duplicate of an inline from bound is rejected. */ fromVersion?: string; - /** End-of-range / latest-mode cap. */ + /** CLI `--to` inclusive end / latest-mode cap. Duplicate of an inline to bound is rejected. */ toVersion?: string; - /** Latest-mode entry count cap. */ + /** Latest-mode entry count cap. Rejected for exact and lower-bound range targets. */ limit?: number; /** Include raw markdown bodies in entries. Defaults to true. */ includeBodies?: boolean; @@ -66,15 +38,17 @@ export type ExplicitFilterField = | "fromVersion" | "toVersion" | "limit" - | "gitRef"; + | "version"; + +export type PackageChangelogRequestMode = "latest" | "exact" | "range"; export interface PackageChangelogRequestBuildResult { params: PackageChangelogParams; + mode: PackageChangelogRequestMode; /** * Set of filter fields the caller explicitly supplied. The envelope - * consults this set instead of `params.*` to decide whether to - * echo a field under `filter.*`, so backend defaults (latest = 10) - * don't accidentally round-trip as caller intent. + * consults this set instead of `params.*` so backend defaults + * (latest = 10) don't round-trip as caller intent. */ explicitFilterFields: Set; } @@ -82,35 +56,64 @@ export interface PackageChangelogRequestBuildResult { export function buildPackageChangelogParams( input: PackageChangelogRequestInput, ): PackageChangelogRequestBuildResult { - if (input.specVersion !== undefined) { + const target = input.target?.trim() ?? ""; + if (target.length === 0) { throw new InvalidPackageSpecError( - "`@` isn't supported for pkg changelog — use `--to ` for entries up to a version, or `--from ` for a full range.", + "`pkg changelog` requires a package spec (e.g. `npm:express`).", ); } - const addressing = resolveAddressing(input); - const gitRef = normaliseGitRef(input.gitRef); - const fromVersion = normalisePackageVersion( - input.fromVersion, - addressing.registry, - { - rejectLeadingV: true, - fieldName: "--from / from_version", - }, - ); - const toVersion = normalisePackageVersion( - input.toVersion, - addressing.registry, - { - rejectLeadingV: true, - fieldName: "--to / to_version", - }, - ); + const parsed = parsePackageChangelogTarget(target); + const registry = toPkgseerRegistry(parsed.registry); + const flagFrom = normaliseBound(input.fromVersion, registry, "--from"); + const flagTo = normaliseBound(input.toVersion, registry, "--to"); const limit = normaliseLimit(input.limit); + if (parsed.mode === "exact") { + rejectExactConflicts(flagFrom, flagTo, limit); + const version = normaliseBound(parsed.version, registry, "version"); + if (version === undefined) { + throw new InvalidPackageSpecError( + "Selected-release target is missing a version.", + ); + } + return { + mode: "exact", + params: { + registry, + packageName: parsed.name, + version, + includeBodies: input.includeBodies, + }, + explicitFilterFields: new Set(["version"]), + }; + } + + const inlineFrom = + parsed.mode === "range" + ? normaliseBound(parsed.fromVersion, registry, "from version") + : undefined; + const inlineTo = normaliseBound(parsed.toVersion, registry, "to version"); + + if (inlineFrom !== undefined && flagFrom !== undefined) { + throw new InvalidPackageSpecError( + "Positional range already contains a from version. Drop `--from`, or use a bare package target with `--from`.", + ); + } + if (inlineTo !== undefined && flagTo !== undefined) { + throw new InvalidPackageSpecError( + "Positional target already contains a to version. Drop `--to`, or use a bare package target with `--to`.", + ); + } + + const fromVersion = inlineFrom ?? flagFrom; + const toVersion = inlineTo ?? flagTo; + const mode: PackageChangelogRequestMode = + fromVersion !== undefined ? "range" : "latest"; + if (fromVersion !== undefined && limit !== undefined) { throw new InvalidPackageSpecError( - "`--limit` / `limit` is a latest-mode input; drop `--limit` for range mode, or drop `--from` / `from_version` to cap by count instead.", + "`--limit` / `limit` is a latest-mode input; drop `--limit` for range mode, or drop `--from` / the from bound to cap by count instead.", ); } @@ -118,12 +121,12 @@ export function buildPackageChangelogParams( if (fromVersion !== undefined) explicit.add("fromVersion"); if (toVersion !== undefined) explicit.add("toVersion"); if (limit !== undefined) explicit.add("limit"); - if (gitRef !== undefined) explicit.add("gitRef"); return { + mode, params: { - ...addressing, - gitRef, + registry, + packageName: parsed.name, fromVersion, toVersion, limit, @@ -133,67 +136,36 @@ export function buildPackageChangelogParams( }; } -type ResolvedAddressing = - | { - registry: PackageChangelogParams["registry"]; - packageName: string; - repoUrl?: undefined; - } - | { repoUrl: string; registry?: undefined; packageName?: undefined }; - -function resolveAddressing( - input: PackageChangelogRequestInput, -): ResolvedAddressing { - const hasSpec = - hasNonBlankValue(input.registry) || hasNonBlankValue(input.packageName); - const hasRepoUrl = Boolean(input.repoUrl?.trim()); - - if (hasSpec && hasRepoUrl) { - throw new InvalidPackageSpecError( - "Provide either `` (registry + name) or `--repo-url` / `repo_url`, not both.", - ); - } - if (!hasSpec && !hasRepoUrl) { - throw new InvalidPackageSpecError( - "`pkg changelog` requires a package spec (e.g. `npm:express`) or `--repo-url` / `repo_url`.", - ); - } - - if (hasRepoUrl) { - const repoUrl = (input.repoUrl as string).trim(); - if (!isUrlShape(repoUrl)) { - throw new InvalidPackageSpecError( - `'${repoUrl}' does not look like a URL. Pass a full HTTPS repository URL (e.g. https://github.com/expressjs/express).`, - ); - } - return { repoUrl }; - } - - const packageName = input.packageName?.trim() ?? ""; - if (!packageName) { - throw new InvalidPackageSpecError("Package name is required."); - } - - const normalisedRegistryArg = input.registry?.trim().toLowerCase() ?? ""; - if (!isKnownPkgseerRegistryArg(normalisedRegistryArg)) { - throw new UnsupportedRegistryError( - `Unsupported registry '${input.registry}'. Supported: ${PKGSEER_REGISTRY_LIST}.`, - ); - } - const registry = toPkgseerRegistry( - normalisedRegistryArg as PkgseerRegistryArg, +function rejectExactConflicts( + fromVersion: string | undefined, + toVersion: string | undefined, + limit: number | undefined, +): void { + const extras: string[] = []; + if (fromVersion !== undefined) extras.push("`--from`"); + if (toVersion !== undefined) extras.push("`--to`"); + if (limit !== undefined) extras.push("`limit`"); + if (extras.length === 0) return; + throw new InvalidPackageSpecError( + `Inline single-release target already selects one release; drop ${joinList(extras)}.`, ); - return { registry, packageName }; } -function hasNonBlankValue(value: string | undefined): boolean { - return value !== undefined && value.trim().length > 0; +function joinList(items: string[]): string { + if (items.length === 1) return items[0]!; + if (items.length === 2) return `${items[0]} and ${items[1]}`; + return `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`; } -function normaliseGitRef(raw: string | undefined): string | undefined { - if (raw === undefined) return undefined; - const trimmed = raw.trim(); - return trimmed.length > 0 ? trimmed : undefined; +function normaliseBound( + raw: string | undefined, + registry: PackageChangelogParams["registry"], + fieldName: string, +): string | undefined { + return normalisePackageVersion(raw, registry, { + rejectLeadingV: true, + fieldName, + }); } function normaliseLimit(raw: number | undefined): number | undefined { @@ -205,18 +177,3 @@ function normaliseLimit(raw: number | undefined): number | undefined { } return raw; } - -/** - * Minimal URL-shape test. We want to reject obvious non-URLs like - * `"not a url"` client-side so agents get an actionable error instead - * of an opaque `BACKEND_ERROR`. Backend handles host-specific - * validation (supported repository hosts, for example). - */ -function isUrlShape(raw: string): boolean { - try { - const parsed = new URL(raw); - return parsed.protocol === "http:" || parsed.protocol === "https:"; - } catch { - return false; - } -} diff --git a/packages/mcp/src/shared/package-changelog-response.test.ts b/packages/mcp/src/shared/package-changelog-response.test.ts index 354f1821..3d23875a 100644 --- a/packages/mcp/src/shared/package-changelog-response.test.ts +++ b/packages/mcp/src/shared/package-changelog-response.test.ts @@ -68,16 +68,13 @@ describe("buildPackageChangelogSuccessPayload — envelope shape", () => { }); }); - it("emits the repo-URL addressing shape when no registry/name are set", () => { - const envelope = buildPackageChangelogSuccessPayload(baseReport, { - ...baseOptions, - registry: undefined, - name: undefined, - repoUrl: "https://github.com/expressjs/express", - }); - expect(envelope.registry).toBeUndefined(); - expect(envelope.name).toBeUndefined(); - expect(envelope.repoUrl).toBe("https://github.com/expressjs/express"); + it("emits the package addressing shape", () => { + const envelope = buildPackageChangelogSuccessPayload( + baseReport, + baseOptions, + ); + expect(envelope.registry).toBe("npm"); + expect(envelope.name).toBe("express"); }); }); @@ -174,6 +171,34 @@ describe("buildPackageChangelogSuccessPayload — mode derivation", () => { expect(envelope.filter?.fromVersion).toBe("5.0.0"); }); + it("emits mode: 'exact' with filter.version and hasChangelog", () => { + const report: ChangelogReport = { + ...baseReport, + source: "releases", + entries: [ + { + version: "5.2.1", + normalizedVersion: "5.2.1", + publishedAt: "2026-01-15T12:00:00Z", + htmlUrl: "https://github.com/expressjs/express/releases/tag/5.2.1", + body: "## Patch", + hasChangelog: true, + }, + ], + }; + const envelope = buildPackageChangelogSuccessPayload(report, { + ...baseOptions, + mode: "exact", + version: "^5.0.0", + explicitFilterFields: new Set(["version"]), + }); + expect(envelope.mode).toBe("exact"); + expect(envelope.filter?.version).toBe("^5.0.0"); + expect(envelope.entries.items).toHaveLength(1); + expect(envelope.entries.items[0]?.version).toBe("5.2.1"); + expect(envelope.entries.items[0]?.hasChangelog).toBe(true); + }); + it("emits mode: 'latest' otherwise", () => { const envelope = buildPackageChangelogSuccessPayload( baseReport, @@ -211,13 +236,14 @@ describe("buildPackageChangelogSuccessPayload — filter echo", () => { expect(envelope.filter).toBeUndefined(); }); - it("echoes gitRef when caller set it", () => { + it("echoes version when caller set an exact selector", () => { const envelope = buildPackageChangelogSuccessPayload(baseReport, { ...baseOptions, - gitRef: "develop", - explicitFilterFields: new Set(["gitRef"]), + mode: "exact", + version: "5.2.1", + explicitFilterFields: new Set(["version"]), }); - expect(envelope.filter?.gitRef).toBe("develop"); + expect(envelope.filter?.version).toBe("5.2.1"); }); }); @@ -501,17 +527,28 @@ describe("formatPackageChangelogTerminal", () => { expect(output).toContain("(empty release notes)"); }); - it("uses the repo URL as identity in repo-URL addressing", () => { - const envelope = buildPackageChangelogSuccessPayload(baseReport, { + it("says release notes are unavailable for exact no-notes results", () => { + const report: ChangelogReport = { + ...baseReport, + source: "package_version", + entries: [ + { + version: "5.2.1", + hasChangelog: false, + }, + ], + }; + const envelope = buildPackageChangelogSuccessPayload(report, { ...baseOptions, - registry: undefined, - name: undefined, - repoUrl: "https://github.com/expressjs/express", + mode: "exact", + version: "5.2.1", + explicitFilterFields: new Set(["version"]), }); const output = formatPackageChangelogTerminal(envelope, { verbose: false, useColors: false, }); - expect(output).toContain("https://github.com/expressjs/express"); + expect(output).toContain("exact 5.2.1"); + expect(output).toContain("Release notes are unavailable."); }); }); diff --git a/packages/mcp/src/shared/package-changelog-response.ts b/packages/mcp/src/shared/package-changelog-response.ts index 5c1c9f42..9ead88db 100644 --- a/packages/mcp/src/shared/package-changelog-response.ts +++ b/packages/mcp/src/shared/package-changelog-response.ts @@ -5,46 +5,23 @@ * Key design commitments: * * - **Data-first envelope.** Every top-level key is driven by what - * the backend returned and what the caller asked for, not by - * additional caller flags. `entries` is `{count, items}` whenever - * the backend returned entries. Package version responses may have - * entries with no concrete changelog source; only no-source + - * no-entry responses are promoted to `NOT_FOUND`. - * - **Mode derived from request.** `mode: "range"` iff `fromVersion` - * was non-null after normalisation; `"latest"` otherwise. Lowercase - * strings matching the backend's doc-comment convention. - * - **`entries.count` computed client-side** from `items.length`. No - * backend-count field is selected on the wire, so the invariant - * `entries.count === entries.items.length` always holds. + * the backend returned and what the caller asked for. + * - **Mode derived from request.** `latest`, `exact`, or `range`. + * - **`entries.count` computed client-side** from `items.length`. * - **`version` kept when null**, every other per-entry nullable - * field stripped *only when null/undefined*. Present-but-empty - * values (empty-string `body`, empty `htmlUrl`) are preserved so - * agents can distinguish "backend returned no content" from - * "backend didn't return this field". Rationale: `version` is the - * primary key agents index entries by, so keeping the slot - * present (even with `null`) makes it safe to write - * `entries.items.map(e => e.version)` without guarding. Other - * fields are metadata; their absence is signal, but empty values - * are their own signal. + * field stripped only when null/undefined. * - **`filter.*` emits only when caller explicitly supplied them.** - * Backend defaults (latest = 10, to = latest version) don't echo. - * The request builder produces an `explicitFilterFields` set that - * the envelope consults here. * - **Body omission lever.** When requested, each entry drops its - * `body` field. Other fields (`version`, `normalizedVersion`, - * `publishedAt`, `htmlUrl`) remain so the tool still produces the - * version / date / URL timeline. - * - **`metadata` dropped from envelope.** Source-specific opaque - * JSON; live-smoke observations will inform a typed passthrough - * later. TODO(backend) marker in the service types. + * `body` field. + * - Exact selected-release entries add `hasChangelog`. Timeline + * entries omit that field. */ import type { ChangelogReport } from "@githits/core-internal"; import { colorize, dim, highlight } from "./colors.js"; import type { ExplicitFilterField } from "./package-changelog-request.js"; -/** Two backend-documented modes, kept lowercase. */ -export type ChangelogMode = "latest" | "range"; +export type ChangelogMode = "latest" | "exact" | "range"; export interface LeanChangelogEntry { /** Present with a possibly-null value — the primary index key. */ @@ -57,6 +34,8 @@ export interface LeanChangelogEntry { htmlUrl?: string; /** Raw markdown. Stripped when null OR when bodies are omitted. */ body?: string; + /** Present only for exact selected-release results. */ + hasChangelog?: boolean; } export interface LeanEntriesBlock { @@ -75,7 +54,7 @@ export interface LeanChangelogFilter { fromVersion?: string; toVersion?: string; limit?: number; - gitRef?: string; + version?: string; } export interface LeanChangelogEnvelope { @@ -83,9 +62,10 @@ export interface LeanChangelogEnvelope { registry?: string; /** Present for spec addressing. */ name?: string; - /** Present for repo-URL addressing. */ - repoUrl?: string; - /** `"releases"` | `"changelog_file"` | `"hexdocs"` when resolved. Absent for package versions with no changelog entry. */ + /** + * Timeline source or exact `detailSource` normalised to lower snake + * case. Absent when the backend returned no concrete source. + */ source?: string; /** Derived from request params. */ mode: ChangelogMode; @@ -94,20 +74,16 @@ export interface LeanChangelogEnvelope { } export interface BuildChangelogPayloadOptions { - /** Caller's addressing echo; one of the two is present. */ registry?: string; name?: string; - repoUrl?: string; - /** Mode the caller requested. Built from `params.fromVersion`. */ mode: ChangelogMode; explicitFilterFields: Set; /** When false, drop each entry's `body` field. Default: true. */ includeBodies: boolean; - /** Caller's raw inputs, echoed under `filter.*` when explicit. */ fromVersion?: string; toVersion?: string; limit?: number; - gitRef?: string; + version?: string; } export function buildPackageChangelogSuccessPayload( @@ -118,12 +94,6 @@ export function buildPackageChangelogSuccessPayload( const lean: LeanChangelogEntry = { version: entry.version ?? null, }; - // Non-null-strip policy: present-but-null/undefined fields are - // stripped; present-but-empty values (empty-string body, empty - // URL) are preserved so agents can distinguish "backend returned - // no content" from "backend didn't return this field". The only - // mutation is `omit_bodies: true`, which explicitly drops - // `body` regardless of its value. if (entry.normalizedVersion != null) { lean.normalizedVersion = entry.normalizedVersion; } @@ -136,6 +106,9 @@ export function buildPackageChangelogSuccessPayload( if (options.includeBodies && entry.body != null) { lean.body = entry.body; } + if (options.mode === "exact" && entry.hasChangelog !== undefined) { + lean.hasChangelog = entry.hasChangelog; + } return lean; }); @@ -151,7 +124,6 @@ export function buildPackageChangelogSuccessPayload( if (options.registry) envelope.registry = options.registry; if (options.name) envelope.name = options.name; - if (options.repoUrl) envelope.repoUrl = options.repoUrl; const filter = buildFilterBlock(options); if (filter) envelope.filter = filter; @@ -174,8 +146,8 @@ function buildFilterBlock( if (explicitFilterFields.has("limit") && options.limit !== undefined) { filter.limit = options.limit; } - if (explicitFilterFields.has("gitRef") && options.gitRef) { - filter.gitRef = options.gitRef; + if (explicitFilterFields.has("version") && options.version) { + filter.version = options.version; } return Object.keys(filter).length > 0 ? filter : undefined; } @@ -191,35 +163,12 @@ export interface FormatChangelogTerminalOptions { bodyPreviewLines?: number; } -/** - * Default line cap applied to the body preview when `--verbose` is - * not set. Release notes run long (100+ lines on typescript / - * kubernetes); an unbounded default would dominate the terminal - * scrollback. A 10-line cap shows the first one or two sections - * plus any preamble, which is usually enough to answer "what - * shipped". `--verbose` lifts the cap; `--no-body` (which flows - * through the envelope builder as `body: undefined`) skips this - * branch entirely. - */ const DEFAULT_BODY_PREVIEW_LINES = 10; /** * Format an envelope for terminal display. The summary header leads * with the addressing + count + source; each entry renders as - * `version date url` plus an indented body preview. The preview - * is capped at {@link DEFAULT_BODY_PREVIEW_LINES} lines by default, - * expanded fully under `--verbose`, and skipped entirely when the - * caller passed `--no-body` (bodies are absent from the envelope - * on that path). - * - * Edge cases: - * - Empty entries: summary header + "No entries in this range.". - * - Missing `publishedAt`: `-` in the date column. - * - Missing `version`: `(unversioned)`; backend/source order is preserved - * (we don't re-sort). - * - Empty-string body: rendered with a neutral `(empty release - * notes)` sentinel so agents can tell it apart from - * `--no-body` / bodies-absent. + * `version date url` plus an indented body preview. */ export function formatPackageChangelogTerminal( envelope: LeanChangelogEnvelope, @@ -251,7 +200,12 @@ export function formatPackageChangelogTerminal( lines.push( `${highlight(`${padded} ${datePadded}`, options.useColors)} ${url}`, ); - if (entry.body != null) { + if (entry.hasChangelog === false) { + lines.push(""); + lines.push( + ` ${dim("Release notes are unavailable.", options.useColors)}`, + ); + } else if (entry.body != null) { appendBodyLines(lines, entry.body, options); } lines.push(""); @@ -293,24 +247,27 @@ function buildSummaryLine( const identity = envelope.registry && envelope.name ? `${envelope.name} | ${envelope.registry}` - : (envelope.repoUrl ?? "(unknown)"); + : "(unknown)"; const sourceLabel = envelope.source ? humanizeSource(envelope.source) : "package versions"; - const modeLabel = - envelope.mode === "range" ? rangeLabel(envelope) : latestLabel(envelope); + const modeLabel = modeSummary(envelope); const countLabel = `${envelope.entries.count} ${plural("entry", "entries", envelope.entries.count)}`; const parts = [identity, `source: ${sourceLabel}`, modeLabel, countLabel]; return colorize(parts.join(" | "), "bold", options.useColors); } -function rangeLabel(envelope: LeanChangelogEnvelope): string { - const from = envelope.filter?.fromVersion ?? "earliest"; - const to = envelope.filter?.toVersion ?? "latest"; - return `range (${from}, ${to}]`; -} - -function latestLabel(envelope: LeanChangelogEnvelope): string { +function modeSummary(envelope: LeanChangelogEnvelope): string { + if (envelope.mode === "exact") { + const version = + envelope.filter?.version ?? envelope.entries.items[0]?.version; + return version ? `exact ${version}` : "exact"; + } + if (envelope.mode === "range") { + const from = envelope.filter?.fromVersion ?? "earliest"; + const to = envelope.filter?.toVersion ?? "latest"; + return `range (${from}, ${to}]`; + } if (envelope.filter?.toVersion) { return `latest up to ${envelope.filter.toVersion}`; } @@ -325,6 +282,14 @@ function humanizeSource(source: string): string { return "CHANGELOG.md"; case "hexdocs": return "HexDocs"; + case "registry_release_notes": + return "registry release notes"; + case "registry_link": + return "registry link"; + case "generated_github_url": + return "generated GitHub URL"; + case "package_version": + return "package versions"; default: return source; } @@ -335,8 +300,6 @@ function plural(singular: string, pluralForm: string, count: number): string { } function formatDate(iso: string): string { - // Slice YYYY-MM-DD without turning this into a full Date parsing - // problem. Backend returns ISO8601; anything shorter stays verbatim. if (/^\d{4}-\d{2}-\d{2}/.test(iso)) return iso.slice(0, 10); return iso; } @@ -360,7 +323,5 @@ const ESC = String.fromCharCode(0x1b); const ANSI_SGR_PATTERN = new RegExp(`${ESC}\\[[0-9;]*m`, "g"); function stripAnsi(text: string): string { - // Minimal ANSI CSI stripper — the terminal formatter only uses - // SGR sequences produced by `colorize` / `dim`. return text.replace(ANSI_SGR_PATTERN, ""); } diff --git a/packages/mcp/src/shared/package-changelog-target.test.ts b/packages/mcp/src/shared/package-changelog-target.test.ts new file mode 100644 index 00000000..37961871 --- /dev/null +++ b/packages/mcp/src/shared/package-changelog-target.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "bun:test"; +import { parsePackageChangelogTarget } from "./package-changelog-target.js"; + +describe("parsePackageChangelogTarget", () => { + it("parses a bare latest package target", () => { + expect(parsePackageChangelogTarget("npm:express")).toEqual({ + mode: "latest", + registry: "npm", + name: "express", + }); + }); + + it("parses a scoped npm latest target", () => { + expect(parsePackageChangelogTarget("npm:@types/node")).toEqual({ + mode: "latest", + registry: "npm", + name: "@types/node", + }); + }); + + it("parses an exact selected release", () => { + expect(parsePackageChangelogTarget("npm:express@5.2.1")).toEqual({ + mode: "exact", + registry: "npm", + name: "express", + version: "5.2.1", + }); + }); + + it("parses a scoped npm exact pin", () => { + expect(parsePackageChangelogTarget("npm:@types/node@22.0.0")).toEqual({ + mode: "exact", + registry: "npm", + name: "@types/node", + version: "22.0.0", + }); + }); + + it("treats a registry-compatible constraint as one selected release", () => { + expect(parsePackageChangelogTarget("npm:express@^5.0.0")).toEqual({ + mode: "exact", + registry: "npm", + name: "express", + version: "^5.0.0", + }); + }); + + it("parses a closed interval", () => { + expect(parsePackageChangelogTarget("npm:express@4.21.2..5.2.1")).toEqual({ + mode: "range", + registry: "npm", + name: "express", + fromVersion: "4.21.2", + toVersion: "5.2.1", + }); + }); + + it("parses a lower-bound interval to latest", () => { + expect(parsePackageChangelogTarget("npm:express@4.21.2..")).toEqual({ + mode: "range", + registry: "npm", + name: "express", + fromVersion: "4.21.2", + }); + }); + + it("parses an upper-cap as latest mode", () => { + expect(parsePackageChangelogTarget("npm:express@..5.2.1")).toEqual({ + mode: "latest", + registry: "npm", + name: "express", + toVersion: "5.2.1", + }); + }); + + it("parses representative non-npm registries", () => { + expect(parsePackageChangelogTarget("pypi:requests@2.32.0")).toMatchObject({ + registry: "pypi", + name: "requests", + mode: "exact", + }); + expect( + parsePackageChangelogTarget("crates:serde@1.0.0..1.0.210"), + ).toMatchObject({ + registry: "crates", + name: "serde", + mode: "range", + }); + }); + + it("rejects an empty interval", () => { + expect(() => parsePackageChangelogTarget("npm:express@..")).toThrow( + /Empty changelog interval/, + ); + }); + + it("rejects more than one interval delimiter", () => { + expect(() => parsePackageChangelogTarget("npm:express@4..5..6")).toThrow( + /at most one '\.\.'/, + ); + }); + + it("rejects a three-dot interval", () => { + expect(() => + parsePackageChangelogTarget("npm:express@4.21.2...5.2.1"), + ).toThrow(/'\.\.\.' is not supported/); + }); + + it("rejects a missing registry prefix", () => { + expect(() => parsePackageChangelogTarget("express")).toThrow( + /registry prefix/, + ); + }); + + it("rejects an unsupported registry", () => { + expect(() => parsePackageChangelogTarget("obscure:example")).toThrow( + /Unsupported registry/, + ); + }); + + it("rejects repository and site targets before classifying a suffix", () => { + expect(() => + parsePackageChangelogTarget("github:expressjs/express"), + ).toThrow(/package-only/); + expect(() => + parsePackageChangelogTarget("https://github.com/expressjs/express"), + ).toThrow(/package-only/); + expect(() => parsePackageChangelogTarget("site:expressjs.com")).toThrow( + /package-only/, + ); + }); + + it("rejects empty and whitespace-only input", () => { + expect(() => parsePackageChangelogTarget("")).toThrow(/cannot be empty/); + expect(() => parsePackageChangelogTarget(" ")).toThrow(/cannot be empty/); + }); +}); diff --git a/packages/mcp/src/shared/package-changelog-target.ts b/packages/mcp/src/shared/package-changelog-target.ts new file mode 100644 index 00000000..fa1b58b0 --- /dev/null +++ b/packages/mcp/src/shared/package-changelog-target.ts @@ -0,0 +1,122 @@ +/** + * Package-only changelog target parser. Composes {@link parsePackageSpec} + * and classifies the optional `@` suffix as latest, one selected release, + * or an interval. Interval grammar matches upgrade-review (`..`, reject + * `...`) while also admitting open bounds. + */ + +import { + InvalidPackageSpecError, + type KnownRegistry, + parsePackageSpec, +} from "./package-spec.js"; +import { isRepositoryTargetSpec } from "./repository-target.js"; + +export type ChangelogTargetMode = "latest" | "exact" | "range"; + +export type ParsedPackageChangelogTarget = + | { + mode: "latest"; + registry: KnownRegistry; + name: string; + toVersion?: string; + } + | { + mode: "exact"; + registry: KnownRegistry; + name: string; + version: string; + } + | { + mode: "range"; + registry: KnownRegistry; + name: string; + fromVersion: string; + toVersion?: string; + }; + +const PACKAGE_ONLY_MESSAGE = + "`pkg_changelog` is package-only. Use a `registry:name` target such as `npm:express`, not a repository or site coordinate."; + +/** + * Parse a compact package changelog target. + * + * Accepted forms: + * - `npm:express` — latest + * - `npm:express@5.2.1` — one selected release + * - `npm:express@4.21.2..5.2.1` — closed range `(from, to]` + * - `npm:express@4.21.2..` — range to latest + * - `npm:express@..5.2.1` — latest up to an inclusive cap + */ +export function parsePackageChangelogTarget( + spec: string, +): ParsedPackageChangelogTarget { + const trimmed = spec.trim(); + if (trimmed.length === 0) { + throw new InvalidPackageSpecError( + "Package spec cannot be empty. Expected :[@ or @..].", + ); + } + if (isNonPackageChangelogTarget(trimmed)) { + throw new InvalidPackageSpecError(PACKAGE_ONLY_MESSAGE); + } + + const parsed = parsePackageSpec(trimmed); + if (parsed.version === undefined) { + return { + mode: "latest", + registry: parsed.registry, + name: parsed.name, + }; + } + return classifyVersionSuffix(parsed.registry, parsed.name, parsed.version); +} + +function classifyVersionSuffix( + registry: KnownRegistry, + name: string, + suffix: string, +): ParsedPackageChangelogTarget { + if (suffix.includes("...")) { + throw new InvalidPackageSpecError( + `Invalid changelog interval '${suffix}'. Use '..' between endpoints; '...' is not supported.`, + ); + } + if (!suffix.includes("..")) { + return { mode: "exact", registry, name, version: suffix }; + } + + const parts = suffix.split(".."); + if (parts.length !== 2) { + throw new InvalidPackageSpecError( + `Invalid changelog interval '${suffix}'. Expected at most one '..' between endpoints.`, + ); + } + const fromVersion = emptyToUndefined(parts[0]); + const toVersion = emptyToUndefined(parts[1]); + if (fromVersion === undefined && toVersion === undefined) { + throw new InvalidPackageSpecError( + "Empty changelog interval '@..' is not supported. Provide a from bound, a to bound, or both.", + ); + } + if (fromVersion === undefined) { + return { mode: "latest", registry, name, toVersion }; + } + return { mode: "range", registry, name, fromVersion, toVersion }; +} + +function emptyToUndefined(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function isNonPackageChangelogTarget(spec: string): boolean { + if (isRepositoryTargetSpec(spec)) return true; + const lower = spec.toLowerCase(); + return ( + lower.startsWith("site:") || + lower.startsWith("http:") || + lower.startsWith("https:") + ); +} diff --git a/packages/mcp/src/shared/package-intelligence-error-map.ts b/packages/mcp/src/shared/package-intelligence-error-map.ts index d4c2c4f1..68bb9065 100644 --- a/packages/mcp/src/shared/package-intelligence-error-map.ts +++ b/packages/mcp/src/shared/package-intelligence-error-map.ts @@ -13,7 +13,6 @@ import { MalformedPackageIntelligenceResponseError, PackageIntelligenceAccessError, PackageIntelligenceBackendError, - PackageIntelligenceChangelogSourceNotFoundError, PackageIntelligenceDocumentationSectionUnresolvedError, PackageIntelligenceFeatureFlagRequiredError, PackageIntelligenceGraphQLError, @@ -52,10 +51,7 @@ function classify(error: unknown): MappedError { if (error instanceof ClientUpdateRequiredError) { return buildUpdateRequiredError(error.reason, error.currentVersion); } - if ( - error instanceof PackageIntelligenceTargetNotFoundError || - error instanceof PackageIntelligenceChangelogSourceNotFoundError - ) { + if (error instanceof PackageIntelligenceTargetNotFoundError) { return { code: "NOT_FOUND", message: error.message, diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index 998ae6e2..02e594fe 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -900,6 +900,20 @@ function smokeResponse( name: string, args: Record, ): McpSmokeToolResult { + if ( + name === "pkg_changelog" && + typeof args.target === "string" && + (args.target.startsWith("github:") || args.target.startsWith("site:")) + ) { + return errorResult( + "INVALID_ARGUMENT", + JSON.stringify({ + error: "pkg_changelog is package-only", + code: "INVALID_ARGUMENT", + retryable: false, + }), + ); + } if (args.format === "json") return smokeJsonResponse(name, args); switch (name) { @@ -1069,6 +1083,14 @@ function smokeJsonResponse( }, }); case "pkg_changelog": + if (args.target === "npm:express@5.2.1") { + return jsonResult({ + mode: "exact", + entries: { + items: [{ version: "5.2.1", hasChangelog: true }], + }, + }); + } return jsonResult({ entries: {} }); case "pkg_upgrade_review": return jsonResult({ summary: {}, reviews: [{}] }); diff --git a/packages/mcp/src/smoke-test.ts b/packages/mcp/src/smoke-test.ts index 0c0e2ae1..88443656 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -915,8 +915,7 @@ async function runLiveSmoke(caller: McpSmokeCaller): Promise { const changelogText = assertDefaultText( await callTool(caller, "pkg_changelog", { - registry: "npm", - package_name: "express", + target: "npm:express", limit: 1, }), "pkg_changelog default", @@ -939,8 +938,7 @@ async function runLiveSmoke(caller: McpSmokeCaller): Promise { const changelogBodyLinesText = assertDefaultText( await callTool(caller, "pkg_changelog", { - registry: "npm", - package_name: "express", + target: "npm:express", limit: 2, body_lines: 3, }), @@ -959,8 +957,7 @@ async function runLiveSmoke(caller: McpSmokeCaller): Promise { const changelogJson = assertJsonResult( await callTool(caller, "pkg_changelog", { - registry: "npm", - package_name: "express", + target: "npm:express", limit: 1, format: "json", }), @@ -969,10 +966,43 @@ async function runLiveSmoke(caller: McpSmokeCaller): Promise { assertRecord(changelogJson, "pkg_changelog json"); assertRecord(changelogJson.entries, "pkg_changelog json entries"); + const changelogExact = assertJsonResult( + await callTool(caller, "pkg_changelog", { + target: "npm:express@5.2.1", + format: "json", + }), + "pkg_changelog exact json", + ); + assertRecord(changelogExact, "pkg_changelog exact json"); + assert(changelogExact.mode === "exact", "pkg_changelog exact json mode"); + const exactEntries = changelogExact.entries as + | { items?: Array<{ hasChangelog?: unknown; version?: unknown }> } + | undefined; + assert( + exactEntries?.items?.[0]?.version === "5.2.1", + "pkg_changelog exact json resolved version", + ); + assert( + typeof exactEntries?.items?.[0]?.hasChangelog === "boolean", + "pkg_changelog exact json missing hasChangelog", + ); + + const changelogRepo = await callTool(caller, "pkg_changelog", { + target: "github:expressjs/express", + format: "json", + }); + const changelogRepoEnvelope = assertCleanErrorEnvelope( + changelogRepo, + "pkg_changelog repository target", + ); + assert( + changelogRepoEnvelope.code === "INVALID_ARGUMENT", + `pkg_changelog repository target: expected INVALID_ARGUMENT, got ${changelogRepoEnvelope.code}`, + ); + const changelogTimeline = assertDefaultText( await callTool(caller, "pkg_changelog", { - registry: "npm", - package_name: "express", + target: "npm:express", limit: 2, omit_bodies: true, }), diff --git a/packages/mcp/src/tools/package-changelog.test.ts b/packages/mcp/src/tools/package-changelog.test.ts index 283099f2..54281b77 100644 --- a/packages/mcp/src/tools/package-changelog.test.ts +++ b/packages/mcp/src/tools/package-changelog.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it, mock } from "bun:test"; -import { - PackageIntelligenceChangelogSourceNotFoundError, - PackageIntelligenceTargetNotFoundError, -} from "@githits/core-internal"; +import { PackageIntelligenceTargetNotFoundError } from "@githits/core-internal"; import { createMockPackageIntelligenceService, defaultChangelogReport, @@ -20,66 +17,88 @@ describe("createPackageChangelogTool — metadata", () => { ); expect(tool.name).toBe("pkg_changelog"); expect(tool.description).toContain("latest mode"); - expect(tool.description).toContain("Range mode"); - expect(tool.description).toContain("`(from_version, to_version]`"); - expect(tool.description).toContain( - "upper cap, not an exact-release lookup", - ); - expect(tool.schema.to_version?.description).toContain( - "not an exact-release lookup", - ); - expect(tool.description).not.toContain("one exact release"); - expect(tool.schema.from_version?.description).not.toContain( - "one exact release", - ); + expect(tool.description).toContain("one selected release"); + expect(tool.description).toContain("`registry:name@from..to`"); expect(tool.description).toContain("markdown body previews"); expect(tool.description).toContain("body_lines"); + expect(tool.description).not.toContain("repo_url"); + expect(tool.description).not.toContain("from_version"); expect(tool.schema.format?.description).toContain( "Set `json` only when code consumes", ); expect(Object.keys(tool.schema).sort()).toEqual([ "body_lines", "format", - "from_version", - "git_ref", "limit", "omit_bodies", - "package_name", - "registry", - "repo_url", - "to_version", + "target", "verbose", ]); expect(tool.annotations?.readOnlyHint).toBe(true); }); + + it("keeps the discovery sentence and first 80 characters stable", () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService(), + ); + const firstSentence = tool.description.match(/^[^.]+\./)?.[0] ?? ""; + expect(firstSentence).toBe( + "Find release notes and changelog history for a package.", + ); + expect(firstSentence.length).toBeLessThanOrEqual(79); + expect(tool.description.slice(0, 80)).toBe( + "Find release notes and changelog history for a package. Default latest mode retu", + ); + }); }); describe("createPackageChangelogTool — happy path", () => { - it("normalises spec addressing and calls service.packageChangelog", async () => { + it("normalises a compact target and calls service.packageChangelog", async () => { const packageChangelog = mock(() => Promise.resolve(defaultChangelogReport), ); const service = createMockPackageIntelligenceService({ packageChangelog }); const tool = createPackageChangelogTool(service); - await tool.handler({ registry: "npm", package_name: "express" }, {}); + await tool.handler({ target: "npm:express" }, {}); const calls = packageChangelog.mock.calls as unknown as Array< - [{ registry?: string; packageName?: string; repoUrl?: string }] + [{ registry?: string; packageName?: string; version?: string }] >; expect(calls[0]?.[0]?.registry).toBe("NPM"); expect(calls[0]?.[0]?.packageName).toBe("express"); - expect(calls[0]?.[0]?.repoUrl).toBeUndefined(); + expect(calls[0]?.[0]?.version).toBeUndefined(); + }); + + it("routes an exact pin through version", async () => { + const packageChangelog = mock(() => + Promise.resolve({ + ...defaultChangelogReport, + entries: [ + { + ...defaultChangelogReport.entries[0]!, + hasChangelog: true, + }, + ], + }), + ); + const service = createMockPackageIntelligenceService({ packageChangelog }); + const tool = createPackageChangelogTool(service); + + await tool.handler({ target: "npm:express@5.2.1", format: "json" }, {}); + const calls = packageChangelog.mock.calls as unknown as Array< + [{ version?: string; fromVersion?: string; limit?: number }] + >; + expect(calls[0]?.[0]?.version).toBe("5.2.1"); + expect(calls[0]?.[0]?.fromVersion).toBeUndefined(); + expect(calls[0]?.[0]?.limit).toBeUndefined(); }); it("emits compact text by default", async () => { const tool = createPackageChangelogTool( createMockPackageIntelligenceService(), ); - const result = await tool.handler( - { registry: "npm", package_name: "express" }, - {}, - ); + const result = await tool.handler({ target: "npm:express" }, {}); expect(result.isError).toBeUndefined(); const text = result.content[0]?.text ?? ""; expect(text).toContain("express | npm"); @@ -107,10 +126,7 @@ describe("createPackageChangelogTool — happy path", () => { }), ); - const result = await tool.handler( - { registry: "npm", package_name: "express" }, - {}, - ); + const result = await tool.handler({ target: "npm:express" }, {}); const text = result.content[0]?.text ?? ""; expect(text).toContain( 'pass verbose=true, body_lines=, or format="json"', @@ -138,7 +154,7 @@ describe("createPackageChangelogTool — happy path", () => { ); const result = await tool.handler( - { registry: "npm", package_name: "express", body_lines: 3 }, + { target: "npm:express", body_lines: 3 }, {}, ); const text = result.content[0]?.text ?? ""; @@ -168,7 +184,7 @@ describe("createPackageChangelogTool — happy path", () => { ); const result = await tool.handler( - { registry: "npm", package_name: "express", verbose: true }, + { target: "npm:express", verbose: true }, {}, ); const text = result.content[0]?.text ?? ""; @@ -186,8 +202,7 @@ describe("createPackageChangelogTool — happy path", () => { const conflict = await tool.handler( { - registry: "npm", - package_name: "express", + target: "npm:express", omit_bodies: true, verbose: true, }, @@ -199,7 +214,7 @@ describe("createPackageChangelogTool — happy path", () => { ); const invalid = await tool.handler( - { registry: "npm", package_name: "express", body_lines: 0 }, + { target: "npm:express", body_lines: 0 }, {}, ); expect(invalid.isError).toBe(true); @@ -214,7 +229,7 @@ describe("createPackageChangelogTool — happy path", () => { createMockPackageIntelligenceService(), ); const result = await tool.handler( - { registry: "npm", package_name: "express", format: "json" }, + { target: "npm:express", format: "json" }, {}, ); const payload = parseText(result) as { @@ -246,7 +261,7 @@ describe("createPackageChangelogTool — happy path", () => { ); const result = await tool.handler( - { registry: "npm", package_name: "express", format: "json" }, + { target: "npm:express", format: "json" }, {}, ); @@ -259,43 +274,55 @@ describe("createPackageChangelogTool — happy path", () => { expect(payload.entries.count).toBe(1); }); - it("accepts repo_url addressing and emits repoUrl in the envelope", async () => { + it("emits exact JSON with hasChangelog and filter.version", async () => { const tool = createPackageChangelogTool( - createMockPackageIntelligenceService(), + createMockPackageIntelligenceService({ + packageChangelog: mock(() => + Promise.resolve({ + ...defaultChangelogReport, + entries: [ + { + ...defaultChangelogReport.entries[0]!, + hasChangelog: true, + }, + ], + }), + ), + }), ); const result = await tool.handler( - { repo_url: "https://github.com/expressjs/express", format: "json" }, + { target: "npm:express@5.2.1", format: "json" }, {}, ); const payload = parseText(result) as { - registry?: string; - name?: string; - repoUrl?: string; + mode: string; + filter?: { version?: string }; + entries: { items: Array<{ version?: string; hasChangelog?: boolean }> }; }; - expect(payload.repoUrl).toBe("https://github.com/expressjs/express"); - expect(payload.registry).toBeUndefined(); - expect(payload.name).toBeUndefined(); + expect(payload.mode).toBe("exact"); + expect(payload.filter?.version).toBe("5.2.1"); + expect(payload.entries.items).toHaveLength(1); + expect(payload.entries.items[0]?.hasChangelog).toBe(true); }); - it("emits mode: 'range' and filter.fromVersion when from_version is set", async () => { + it("emits mode: 'range' and filter.fromVersion for a closed interval", async () => { const tool = createPackageChangelogTool( createMockPackageIntelligenceService(), ); const result = await tool.handler( { - registry: "npm", - package_name: "express", - from_version: "5.0.0", + target: "npm:express@5.0.0..5.2.1", format: "json", }, {}, ); const payload = parseText(result) as { mode: string; - filter?: { fromVersion?: string }; + filter?: { fromVersion?: string; toVersion?: string }; }; expect(payload.mode).toBe("range"); expect(payload.filter?.fromVersion).toBe("5.0.0"); + expect(payload.filter?.toVersion).toBe("5.2.1"); }); it("drops body fields when omit_bodies is true", async () => { @@ -304,8 +331,7 @@ describe("createPackageChangelogTool — happy path", () => { ); const result = await tool.handler( { - registry: "npm", - package_name: "express", + target: "npm:express", omit_bodies: true, format: "json", }, @@ -324,13 +350,12 @@ describe("createPackageChangelogTool — happy path", () => { createMockPackageIntelligenceService(), ); const baseline = await tool.handler( - { registry: "npm", package_name: "express", format: "json" }, + { target: "npm:express", format: "json" }, {}, ); const withTextControls = await tool.handler( { - registry: "npm", - package_name: "express", + target: "npm:express", format: "json", body_lines: 3, verbose: true, @@ -345,7 +370,7 @@ describe("createPackageChangelogTool — happy path", () => { createMockPackageIntelligenceService(), ); const result = await tool.handler( - { registry: "npm", package_name: "express", format: "json" }, + { target: "npm:express", format: "json" }, {}, ); const payload = parseText(result) as { @@ -356,43 +381,69 @@ describe("createPackageChangelogTool — happy path", () => { }); describe("createPackageChangelogTool — validation errors via in-handler builder", () => { - it("returns INVALID_ARGUMENT when both spec and repo_url are provided", async () => { - const tool = createPackageChangelogTool( - createMockPackageIntelligenceService(), + it("returns INVALID_ARGUMENT when target is missing", async () => { + const packageChangelog = mock(() => + Promise.resolve(defaultChangelogReport), ); - const result = await tool.handler( - { - registry: "npm", - package_name: "express", - repo_url: "https://github.com/expressjs/express", - }, - {}, + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService({ packageChangelog }), ); + const result = await tool.handler({} as never, {}); expect(result.isError).toBe(true); - const payload = parseText(result) as { code: string; error: string }; + const payload = parseText(result) as { code: string }; expect(payload.code).toBe("INVALID_ARGUMENT"); - expect(payload.error).toContain("not both"); + expect(packageChangelog).not.toHaveBeenCalled(); }); - it("returns INVALID_ARGUMENT when neither addressing form is provided", async () => { + it("returns INVALID_ARGUMENT for a repository or site target before service access", async () => { + const packageChangelog = mock(() => + Promise.resolve(defaultChangelogReport), + ); const tool = createPackageChangelogTool( - createMockPackageIntelligenceService(), + createMockPackageIntelligenceService({ packageChangelog }), + ); + for (const target of [ + "github:expressjs/express", + "https://github.com/expressjs/express", + "site:expressjs.com", + ]) { + const result = await tool.handler({ target }, {}); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string; error: string }; + expect(payload.code).toBe("INVALID_ARGUMENT"); + expect(payload.error).toContain("package-only"); + } + expect(packageChangelog).not.toHaveBeenCalled(); + }); + + it("returns INVALID_ARGUMENT for limit on an exact target", async () => { + const packageChangelog = mock(() => + Promise.resolve(defaultChangelogReport), + ); + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService({ packageChangelog }), + ); + const result = await tool.handler( + { + target: "npm:express@5.2.1", + limit: 10, + }, + {}, ); - const result = await tool.handler({}, {}); expect(result.isError).toBe(true); - const payload = parseText(result) as { code: string }; + const payload = parseText(result) as { code: string; error: string }; expect(payload.code).toBe("INVALID_ARGUMENT"); + expect(payload.error).toContain("single-release"); + expect(packageChangelog).not.toHaveBeenCalled(); }); - it("returns INVALID_ARGUMENT for from_version + limit", async () => { + it("returns INVALID_ARGUMENT for limit on a lower-bound interval", async () => { const tool = createPackageChangelogTool( createMockPackageIntelligenceService(), ); const result = await tool.handler( { - registry: "npm", - package_name: "express", - from_version: "5.0.0", + target: "npm:express@5.0.0..", limit: 10, }, {}, @@ -403,15 +454,13 @@ describe("createPackageChangelogTool — validation errors via in-handler builde expect(payload.error).toContain("latest-mode"); }); - it("returns INVALID_ARGUMENT for tag-style from_version", async () => { + it("returns INVALID_ARGUMENT for a tag-style exact version", async () => { const tool = createPackageChangelogTool( createMockPackageIntelligenceService(), ); const result = await tool.handler( { - registry: "npm", - package_name: "express", - from_version: "v4.18.0", + target: "npm:express@v4.18.0", }, {}, ); @@ -425,13 +474,7 @@ describe("createPackageChangelogTool — validation errors via in-handler builde const tool = createPackageChangelogTool( createMockPackageIntelligenceService(), ); - // MCP schema is permissive; the shared builder enforces bounds. - // This guarantees agents always see the shared envelope rather - // than a raw Zod rejection from the SDK. - const result = await tool.handler( - { registry: "npm", package_name: "express", limit: 51 }, - {}, - ); + const result = await tool.handler({ target: "npm:express", limit: 51 }, {}); expect(result.isError).toBe(true); const payload = parseText(result) as { code: string; error: string }; expect(payload.code).toBe("INVALID_ARGUMENT"); @@ -443,7 +486,7 @@ describe("createPackageChangelogTool — validation errors via in-handler builde createMockPackageIntelligenceService(), ); const result = await tool.handler( - { registry: "npm", package_name: "express", limit: 3.5 }, + { target: "npm:express", limit: 3.5 }, {}, ); expect(result.isError).toBe(true); @@ -451,37 +494,47 @@ describe("createPackageChangelogTool — validation errors via in-handler builde expect(payload.code).toBe("INVALID_ARGUMENT"); }); - it("returns INVALID_ARGUMENT for a non-URL repo_url value", async () => { + it("returns INVALID_ARGUMENT for an empty interval", async () => { + const packageChangelog = mock(() => + Promise.resolve(defaultChangelogReport), + ); const tool = createPackageChangelogTool( - createMockPackageIntelligenceService(), + createMockPackageIntelligenceService({ packageChangelog }), ); - const result = await tool.handler({ repo_url: "not a url" }, {}); + const result = await tool.handler({ target: "npm:express@.." }, {}); expect(result.isError).toBe(true); - const payload = parseText(result) as { code: string; error: string }; - expect(payload.code).toBe("INVALID_ARGUMENT"); - expect(payload.error).toContain("URL"); + expect((parseText(result) as { code: string }).code).toBe( + "INVALID_ARGUMENT", + ); + expect(packageChangelog).not.toHaveBeenCalled(); }); }); describe("createPackageChangelogTool — service errors", () => { - it("classifies PackageIntelligenceChangelogSourceNotFoundError as NOT_FOUND", async () => { - const service = createMockPackageIntelligenceService({ - packageChangelog: mock(() => - Promise.reject( - new PackageIntelligenceChangelogSourceNotFoundError( - "No changelog source available for npm:ghost.", - ), + it("returns empty timeline selections as success", async () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService({ + packageChangelog: mock(() => + Promise.resolve({ + ...defaultChangelogReport, + source: undefined, + entries: [], + }), ), - ), - }); - const tool = createPackageChangelogTool(service); + }), + ); const result = await tool.handler( - { registry: "npm", package_name: "ghost" }, + { target: "npm:express@9.0.0..9.1.0", format: "json" }, {}, ); - expect(result.isError).toBe(true); - const payload = parseText(result) as { code: string }; - expect(payload.code).toBe("NOT_FOUND"); + expect(result.isError).toBeUndefined(); + const payload = parseText(result) as { + source?: string; + entries: { count: number; items: unknown[] }; + }; + expect(payload.source).toBeUndefined(); + expect(payload.entries.count).toBe(0); + expect(payload.entries.items).toEqual([]); }); it("classifies PackageIntelligenceTargetNotFoundError as NOT_FOUND (package missing)", async () => { @@ -493,10 +546,7 @@ describe("createPackageChangelogTool — service errors", () => { ), }); const tool = createPackageChangelogTool(service); - const result = await tool.handler( - { registry: "npm", package_name: "does-not-exist" }, - {}, - ); + const result = await tool.handler({ target: "npm:does-not-exist" }, {}); expect(result.isError).toBe(true); const payload = parseText(result) as { code: string }; expect(payload.code).toBe("NOT_FOUND"); @@ -507,10 +557,7 @@ describe("createPackageChangelogTool — service errors", () => { packageChangelog: mock(() => Promise.reject(new Error("boom"))), }); const tool = createPackageChangelogTool(service); - const result = await tool.handler( - { registry: "npm", package_name: "express" }, - {}, - ); + const result = await tool.handler({ target: "npm:express" }, {}); expect(result.isError).toBe(true); const payload = parseText(result) as { code: string }; expect(payload.code).toBe("UNKNOWN"); diff --git a/packages/mcp/src/tools/package-changelog.ts b/packages/mcp/src/tools/package-changelog.ts index 603f5a9f..9bc9efdd 100644 --- a/packages/mcp/src/tools/package-changelog.ts +++ b/packages/mcp/src/tools/package-changelog.ts @@ -21,12 +21,7 @@ import { } from "./types.js"; export interface PackageChangelogArgs { - registry?: string; - package_name?: string; - repo_url?: string; - git_ref?: string; - from_version?: string; - to_version?: string; + target: string; limit?: number; omit_bodies?: boolean; verbose?: boolean; @@ -35,62 +30,20 @@ export interface PackageChangelogArgs { } /** - * Permissive schema — the shared `buildPackageChangelogParams` builder - * is the single validation path. Raw Zod errors never surface to - * agents. Matches the pattern established by the other pkg-intel - * tools (`package_summary`, `package_vulnerabilities`, - * `package_dependencies`). - * - * `package_changelog` is the first pkg-intel MCP tool with dual - * addressing (`registry` + `package_name` XOR `repo_url`). The - * underlying `packageChangelog` query is intrinsically repo-level - * (sources: GitHub Releases / CHANGELOG.md / HexDocs), so exposing - * `repo_url` isn't a bolt-on — it's a peer addressing mode on the - * schema. The other pkg-intel tools omit it because their queries - * are registry-metadata APIs with no repo-URL alternative. + * Strings remain permissive so package parsing and request validation + * return mapped domain errors. Missing or non-string targets fail SDK validation. */ const schema: ZodRawShape = { - registry: z + target: z .string() - .optional() - .describe( - `Package registry (with \`package_name\`). Mutually exclusive with \`repo_url\`. Supported: ${PKGSEER_REGISTRY_LIST}.`, - ), - package_name: z - .string() - .optional() - .describe( - "Package name (with `registry`). Scoped names ok (`@types/node`). Mutually exclusive with `repo_url`.", - ), - repo_url: z - .string() - .optional() .describe( - "Full HTTPS repository URL (GitHub, Codeberg, or GitLab). Mutually exclusive with `registry` + `package_name`. Use when agents have a repo URL without a registry mapping.", - ), - from_version: z - .string() - .optional() - .describe( - "Exclusive start of version range. Returns every entry after `from_version` through `to_version` (or latest) with no count cap. Mutually exclusive with `limit`. Go accepts versions with or without its canonical `v` prefix; tag-style `v` prefixes are rejected for other registries except Swift.", - ), - to_version: z - .string() - .optional() - .describe( - "Inclusive range end or latest-mode upper cap, not an exact-release lookup. Defaults to latest on the wire. Go accepts versions with or without its canonical `v` prefix; tag-style `v` prefixes are rejected for other registries except Swift.", + `Package registry:name, optional @version or @from..to interval, for example npm:express@5.2.1. Registries: ${PKGSEER_REGISTRY_LIST}.`, ), limit: z .number() .optional() .describe( - "Latest-mode cap on entry count (1–50, default 10). Rejected with `INVALID_ARGUMENT` when `from_version` is also set or when out of range.", - ), - git_ref: z - .string() - .optional() - .describe( - "Git branch or tag for CHANGELOG.md source (no effect on GitHub Releases or HexDocs). Defaults to the repository's default branch.", + "Latest-mode and upper-cap entry count (1-50, default 10). Rejected with `INVALID_ARGUMENT` for a single-release or lower-bound range target.", ), omit_bodies: z .boolean() @@ -119,19 +72,18 @@ const schema: ZodRawShape = { }; export const DESCRIPTION_BASE: string = - "Find release notes and changelog history for a package or public repository. Default " + + "Find release notes and changelog history for a package. Default " + "latest mode returns up to ten entries; source ordering may interleave maintained release lines. " + - "Range mode returns every entry in `(from_version, to_version]` with no count cap. " + - "`to_version` is an upper cap, not an exact-release lookup. " + - "Address via `registry` + `package_name` or `repo_url` (mutually " + - "exclusive). Entries include markdown body previews. Example: " + - '`{"registry":"npm","package_name":"express","limit":2}`. ' + + "Pass `target` as `registry:name` for latest, `registry:name@version` for one selected release, " + + "or `registry:name@from..to` for a closed interval. Open bounds `from..` and `..to` are accepted. " + + "`limit` applies only to latest and upper-cap targets. " + + "A selected release without notes succeeds with `hasChangelog: false`. " + + "Empty latest or range selections succeed with no entries. " + + "Entries include markdown body previews. Example: " + + '`{"target":"npm:express@5.2.1"}`. ' + "Text output previews 10 body lines by default; use `body_lines` " + "to tune the preview or `verbose:true` for full text bodies. " + - "Package-version entries without changelog " + - "text succeed with `source` omitted; no-source plus no entries " + - "returns `NOT_FOUND`. Supports npm, PyPI, Hex, Crates, NuGet, " + - "Maven, Zig, vcpkg, Packagist, RubyGems, Go, and Swift."; + "Supports npm, PyPI, Hex, Crates, NuGet, Maven, Zig, vcpkg, Packagist, RubyGems, Go, and Swift."; export const DESCRIPTION: string = `${DESCRIPTION_BASE}\n\n${PKG_CHANGELOG_GUARDRAIL}`; @@ -149,30 +101,23 @@ export function createPackageChangelogTool( const bodyPreviewLines = textFormat ? validateTextOptions(args) : undefined; - const { params, explicitFilterFields } = buildPackageChangelogParams({ - registry: args.registry, - packageName: args.package_name, - repoUrl: args.repo_url, - gitRef: args.git_ref, - fromVersion: args.from_version, - toVersion: args.to_version, - limit: args.limit, - includeBodies: args.omit_bodies !== true, - }); + const { params, mode, explicitFilterFields } = + buildPackageChangelogParams({ + target: args.target, + limit: args.limit, + includeBodies: args.omit_bodies !== true, + }); const report = await service.packageChangelog(params); const payload = buildPackageChangelogSuccessPayload(report, { - registry: params.registry - ? toPkgseerRegistryLowercase(params.registry) - : undefined, + registry: toPkgseerRegistryLowercase(params.registry), name: params.packageName, - repoUrl: params.repoUrl, - mode: params.fromVersion ? "range" : "latest", + mode, explicitFilterFields, includeBodies: args.omit_bodies !== true, fromVersion: params.fromVersion, toVersion: params.toVersion, limit: params.limit, - gitRef: params.gitRef, + version: params.version, }); if (textFormat) { return textResult( diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index 82677eb8..0e07ce89 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -130,8 +130,7 @@ export const JSON_PARITY_FIXTURES: JsonParityFixture[] = [ cliArgs: ["pkg", "changelog", "npm:express", "--limit", "1", "--json"], mcpTool: "pkg_changelog", mcpArgs: { - registry: "npm", - package_name: "express", + target: "npm:express", limit: 1, format: "json", }, @@ -1763,6 +1762,24 @@ async function runLiveSmoke(env: Record): Promise { assertRecord(changelogJson, "pkg changelog json"); assertRecord(changelogJson.entries, "pkg changelog json entries"); + const changelogExact = assertJsonOutput( + await runCli(["pkg", "changelog", "npm:express@5.2.1", "--json"]), + "pkg changelog exact json", + ); + assertRecord(changelogExact, "pkg changelog exact json"); + assert(changelogExact.mode === "exact", "pkg changelog exact json mode"); + const exactEntries = changelogExact.entries as + | { items?: Array<{ hasChangelog?: unknown; version?: unknown }> } + | undefined; + assert( + exactEntries?.items?.[0]?.version === "5.2.1", + "pkg changelog exact json resolved version", + ); + assert( + typeof exactEntries?.items?.[0]?.hasChangelog === "boolean", + "pkg changelog exact json missing hasChangelog", + ); + const upgradeReviewText = assertTerminalOutput( await runCli([ "pkg", diff --git a/scripts/smoke-scripts.test.ts b/scripts/smoke-scripts.test.ts index f74a5b59..a989fadc 100644 --- a/scripts/smoke-scripts.test.ts +++ b/scripts/smoke-scripts.test.ts @@ -619,8 +619,7 @@ describe("smoke script options", () => { JSON_PARITY_FIXTURES.find(({ name }) => name === "pkg_changelog") ?.mcpArgs, ).toEqual({ - registry: "npm", - package_name: "express", + target: "npm:express", limit: 1, format: "json", }); diff --git a/skills/githits-mcp/SKILL.md b/skills/githits-mcp/SKILL.md index a7524bf4..7b044877 100644 --- a/skills/githits-mcp/SKILL.md +++ b/skills/githits-mcp/SKILL.md @@ -26,7 +26,7 @@ This guide owns shared policy; selected tools own call syntax and exceptions. | Assess a package's license, adoption, maintenance, or overall health | `pkg_info` | | Inspect vulnerabilities in a package or version | `pkg_vulns` | | Inspect direct dependencies or transitive footprint | `pkg_deps` | -| Find release notes for a package or repository | `pkg_changelog` | +| Find release notes for a package | `pkg_changelog` | | Compare current and target dependency versions for an upgrade | `pkg_upgrade_review` | | Find canonical implementation examples across projects | `get_example` | | Check progress of an earlier search reference | `search_status` | diff --git a/skills/githits-package/SKILL.md b/skills/githits-package/SKILL.md index 6749defa..bc0cde0c 100644 --- a/skills/githits-package/SKILL.md +++ b/skills/githits-package/SKILL.md @@ -21,7 +21,7 @@ Use GitHits package intelligence before making dependency claims from memory. - Most package commands use `:[@]`, for example `npm:lodash@4.17.20` or `pypi:requests`. - `pkg info` always reports the latest published version and does not accept a version pin. -- `pkg changelog` accepts `:` or `--repo-url `; do not pass `@` to changelog. Use `--to ` instead. +- `pkg changelog` is package-only: `:`, `:@`, or `:@..`. `--from`/`--to` remain range flags on a bare spec. Repository and site targets are rejected. ## Core Commands @@ -39,8 +39,8 @@ githits pkg deps npm:express --lifecycle all githits pkg deps npm:express --depth 3 githits pkg changelog npm:express --limit 3 +githits pkg changelog npm:express@5.2.1 githits pkg changelog npm:express --from 4.18.0 --to 4.19.0 -githits pkg changelog --repo-url https://github.com/expressjs/express --limit 2 --no-body githits pkg upgrade-review npm:zod@4.3.6 --to 4.4.3 githits pkg upgrade-review --package npm:zod@4.3.6..4.4.3 --package npm:lint-staged@16.2.7..16.4.0 @@ -54,7 +54,7 @@ githits pkg upgrade-review --package npm:zod@4.3.6..4.4.3 --package npm:lint-sta - Need historical advisories that do not affect the inspected version: use `pkg vulns --scope non_affecting`; use `--scope all` for affected plus historical rows. - Need dependency footprint: start with `pkg deps`; add `--lifecycle all` for non-runtime groups and `--depth ` for aggregate transitive graph data. - Need upgrade evidence for dependency updates, outdated package bumps, or lockfile changes: prefer `pkg upgrade-review` because it compares current vs target vulnerabilities, changelog range evidence, deprecation metadata, peer changes, dependency changes, and transitive security evidence by default. It reports facts only; you still own the final assessment. -- Need release notes without a current-to-target comparison: use `pkg changelog`; use `--from`/`--to` for ranges and `--no-body` for compact timelines. +- Need release notes without a current-to-target comparison: use `pkg changelog`; pin `@version` for one selected release, use `--from`/`--to` or `@from..to` for ranges, and `--no-body` for compact timelines. ## Gotchas diff --git a/skills/githits-package/references/package.md b/skills/githits-package/references/package.md index cd6250c7..7fad058e 100644 --- a/skills/githits-package/references/package.md +++ b/skills/githits-package/references/package.md @@ -31,13 +31,11 @@ Use `--depth` to request capped transitive output. Without it, output is direct ## Changelog -`githits pkg changelog ` returns recent release notes. `--limit` caps latest mode. `--from` is the exclusive lower bound for range mode, which returns entries after `--from` through `--to` (or latest). +`githits pkg changelog ` returns release notes for a package. Bare targets use latest mode. Pin `@version` for one selected release. Use `@from..to`, `@from..`, or `@..to` for interval and upper-cap forms. -Flags: `--repo-url `, `--from `, `--to `, `--limit 1-50`, `--git-ref `, `--verbose`, `--no-body`, `--json`. +Flags: `--from `, `--to `, `--limit 1-50`, `--verbose`, `--no-body`, `--json`. -Do not use `registry:name@version` for changelog. `--to ` is an upper cap, not an exact-release lookup. - -For repository changelogs, pass a full HTTPS URL on github.com, codeberg.org, or gitlab.com to `--repo-url`; use `--git-ref` for a branch or tag. Codeberg requires owner/repo; GitLab permits nested namespaces. Do not pass compact `github:`, `codeberg:`, or `gitlab:` targets to this URL field. +`--from` and `--to` remain package range flags on a bare spec. Inline single-release targets reject those flags and `--limit`. Repository and site targets are not supported. ## Upgrade Review diff --git a/src/commands/pkg/changelog.test.ts b/src/commands/pkg/changelog.test.ts index a45f6619..512a9ae1 100644 --- a/src/commands/pkg/changelog.test.ts +++ b/src/commands/pkg/changelog.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, mock, spyOn } from "bun:test"; import { - PackageIntelligenceChangelogSourceNotFoundError, PackageIntelligenceTargetNotFoundError, PackageIntelligenceVersionNotFoundError, } from "@githits/core-internal"; @@ -22,6 +21,9 @@ describe("pkg changelog help", () => { expect(help).toContain("up to ten latest-mode entries"); expect(help).toContain("Exclusive start of version range"); + expect(help).toContain("npm:express@5.2.1"); + expect(help).not.toContain("--repo-url"); + expect(help).not.toContain("--git-ref"); expect(help).not.toContain("ten most recent entries"); }); }); @@ -248,25 +250,30 @@ describe("pkgChangelogAction", () => { ); const calls = packageChangelog.mock.calls as unknown as Array< - [{ registry?: string; packageName?: string; repoUrl?: string }] + [{ registry?: string; packageName?: string; version?: string }] >; expect(calls[0]?.[0]?.registry).toBe("NPM"); expect(calls[0]?.[0]?.packageName).toBe("express"); - expect(calls[0]?.[0]?.repoUrl).toBeUndefined(); + expect(calls[0]?.[0]?.version).toBeUndefined(); writeSpy.mockRestore(); }); - it("sends repo-url addressing when --repo-url is set", async () => { + it("sends an exact pin as version", async () => { const packageChangelog = mock(() => - Promise.resolve(defaultChangelogReport), + Promise.resolve({ + ...defaultChangelogReport, + entries: [ + { ...defaultChangelogReport.entries[0]!, hasChangelog: true }, + ], + }), ); const writeSpy = spyOn(process.stdout, "write").mockImplementation( (() => true) as typeof process.stdout.write, ); await pkgChangelogAction( - undefined, - { repoUrl: "https://github.com/expressjs/express" }, + "npm:express@5.2.1", + {}, createDeps({ packageIntelligenceService: createMockPackageIntelligenceService({ packageChangelog, @@ -275,29 +282,39 @@ describe("pkgChangelogAction", () => { ); const calls = packageChangelog.mock.calls as unknown as Array< - [{ registry?: string; packageName?: string; repoUrl?: string }] + [{ version?: string; fromVersion?: string }] >; - expect(calls[0]?.[0]?.registry).toBeUndefined(); - expect(calls[0]?.[0]?.packageName).toBeUndefined(); - expect(calls[0]?.[0]?.repoUrl).toBe("https://github.com/expressjs/express"); + expect(calls[0]?.[0]?.version).toBe("5.2.1"); + expect(calls[0]?.[0]?.fromVersion).toBeUndefined(); writeSpy.mockRestore(); }); - it("rejects @ with a hint pointing to --to / --from", async () => { + it("rejects a repository target before service access", async () => { const errorSpy = spyOn(console, "error").mockImplementation(() => {}); const exitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); }); + const packageChangelog = mock(() => + Promise.resolve(defaultChangelogReport), + ); try { - await pkgChangelogAction("npm:express@5.0.0", {}, createDeps()); + await pkgChangelogAction( + "github:expressjs/express", + {}, + createDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog, + }), + }), + ); } catch { /* expected */ } const msg = errorSpy.mock.calls[0]?.[0] as string; - expect(msg).toContain("--to"); - expect(msg).toContain("--from"); + expect(msg).toContain("package-only"); + expect(packageChangelog).not.toHaveBeenCalled(); errorSpy.mockRestore(); exitSpy.mockRestore(); }); @@ -384,36 +401,36 @@ describe("pkgChangelogAction", () => { exitSpy.mockRestore(); }); - it("routes NOT_FOUND (no changelog source) through the error envelope", async () => { - const errorSpy = spyOn(console, "error").mockImplementation(() => {}); - const exitSpy = spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit"); - }); + it("renders empty timeline selections as success", 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); - try { - await pkgChangelogAction( - "npm:obscure-pkg", - { json: true }, - createDeps({ - packageIntelligenceService: createMockPackageIntelligenceService({ - packageChangelog: mock(() => - Promise.reject( - new PackageIntelligenceChangelogSourceNotFoundError( - "No changelog source available for npm:obscure-pkg.", - ), - ), - ), - }), + await pkgChangelogAction( + "npm:express@9.0.0..9.1.0", + {}, + createDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: mock(() => + Promise.resolve({ + ...defaultChangelogReport, + source: undefined, + entries: [], + }), + ), }), - ); - } catch { - /* expected */ - } + }), + ); - const payload = JSON.parse(errorSpy.mock.calls[0]?.[0] as string); - expect(payload.code).toBe("NOT_FOUND"); - errorSpy.mockRestore(); - exitSpy.mockRestore(); + const combined = writes.join(""); + expect(combined).toContain("No entries in this range."); + writeSpy.mockRestore(); }); it("renders VERSION_NOT_FOUND with structured detail lines", async () => { diff --git a/src/commands/pkg/changelog.ts b/src/commands/pkg/changelog.ts index 632eae79..083911b1 100644 --- a/src/commands/pkg/changelog.ts +++ b/src/commands/pkg/changelog.ts @@ -9,7 +9,6 @@ import { formatPackageChangelogTerminal, InvalidPackageSpecError, type MappedError, - parsePackageSpec, requireAuth, shouldUseColors, } from "@githits/mcp/internal"; @@ -22,8 +21,6 @@ import { } from "../format-mapped-error.js"; export interface PkgChangelogCommandOptions { - repoUrl?: string; - gitRef?: string; from?: string; to?: string; limit?: string; @@ -43,13 +40,12 @@ export interface PkgChangelogCommandDependencies { } /** - * Core `pkg changelog` action. Accepts either `` (same parser - * as `pkg info` / `pkg vulns` / `pkg deps`) or `--repo-url `, - * mutually exclusive. `@` is rejected at the shared - * builder boundary — use `--to ` to cap by version. + * Core `pkg changelog` action. Accepts a compact package target + * (`registry:name`, `@version`, or `@from..to`) plus compatible + * package range flags. */ export async function pkgChangelogAction( - spec: string | undefined, + spec: string, options: PkgChangelogCommandOptions, deps: PkgChangelogCommandDependencies, ): Promise { @@ -67,7 +63,6 @@ export async function pkgChangelogAction( ); } - const parsed = spec !== undefined ? parsePackageSpec(spec) : undefined; const limit = resolveLimit(options); const includeBodies = options.body !== false; if (!includeBodies && options.verbose) { @@ -76,12 +71,8 @@ export async function pkgChangelogAction( ); } - const { params, explicitFilterFields } = buildPackageChangelogParams({ - registry: parsed?.registry, - packageName: parsed?.name, - specVersion: parsed?.version, - repoUrl: options.repoUrl, - gitRef: options.gitRef, + const { params, mode, explicitFilterFields } = buildPackageChangelogParams({ + target: spec, fromVersion: options.from, toVersion: options.to, limit, @@ -92,18 +83,15 @@ export async function pkgChangelogAction( await deps.packageIntelligenceService.packageChangelog(params); const payload = buildPackageChangelogSuccessPayload(report, { - registry: params.registry - ? toPkgseerRegistryLowercase(params.registry) - : undefined, + registry: toPkgseerRegistryLowercase(params.registry), name: params.packageName, - repoUrl: params.repoUrl, - mode: params.fromVersion ? "range" : "latest", + mode, explicitFilterFields, includeBodies, fromVersion: params.fromVersion, toVersion: params.toVersion, limit: params.limit, - gitRef: params.gitRef, + version: params.version, }); if (options.json) { @@ -146,8 +134,7 @@ function handlePkgChangelogCommandError(error: unknown, json: boolean): never { /** * Mirrors `pkg vulns` / `pkg deps` — enriches VERSION_NOT_FOUND with - * the package and requested version (the shared helper populated - * these from `fromVersion` / `toVersion` when `version` wasn't set). + * the package and requested version. */ function formatChangelogTerminalError(mapped: MappedError): string { if (mapped.code === "UPDATE_REQUIRED") { @@ -184,19 +171,16 @@ function formatChangelogTerminalError(mapped: MappedError): string { return lines.join("\n"); } -const PKG_CHANGELOG_DESCRIPTION = `Fetch recent release notes or changelog entries for a package or -repository. By default shows up to ten latest-mode entries with the -first 10 lines of each entry's body. Use --from for a full version -range, --limit to change the latest-mode count (1-50), --verbose to -uncap the body preview, and --no-body to drop bodies entirely. +const PKG_CHANGELOG_DESCRIPTION = `Fetch recent release notes or changelog entries for a package. +By default shows up to ten latest-mode entries with the first 10 +lines of each entry's body. Pin a version for one selected release, +or use @from..to for a closed interval. --from/--to remain as +package range flags on a bare spec. --limit changes the latest-mode +count (1-50). --verbose uncaps the body preview; --no-body drops +bodies entirely. -Addressing: (registry:name) OR --repo-url . Source -(GitHub Releases, CHANGELOG.md, or HexDocs) is shown on the summary -line. - -Package spec: :. Supported registries: ${PKGSEER_REGISTRY_LIST}. \`@\` -is NOT accepted here — use --to for "entries up to this -version".`; +Package spec: :[@ | @..]. +Supported registries: ${PKGSEER_REGISTRY_LIST}.`; export function registerPkgChangelogCommand(pkgCommand: Command): Command { return pkgCommand @@ -204,12 +188,8 @@ export function registerPkgChangelogCommand(pkgCommand: Command): Command { .summary("Fetch release notes / changelog entries for a package") .description(PKG_CHANGELOG_DESCRIPTION) .argument( - "[spec]", - "Package spec, e.g. npm:express (mutually exclusive with --repo-url)", - ) - .option( - "--repo-url ", - "Repository URL addressing (mutually exclusive with )", + "", + "Package spec, e.g. npm:express, npm:express@5.2.1, or npm:express@4.21.2..5.2.1", ) .option( "--from ", @@ -217,10 +197,6 @@ export function registerPkgChangelogCommand(pkgCommand: Command): Command { ) .option("--to ", "End of range / latest-mode cap") .option("--limit ", "Latest-mode entry count (1-50, default 10)") - .option( - "--git-ref ", - "Git branch/tag for CHANGELOG.md source (ignored for GitHub Releases / HexDocs)", - ) .option( "-v, --verbose", "Uncap the markdown body preview (default cap: 10 lines per entry)", @@ -230,15 +206,13 @@ export function registerPkgChangelogCommand(pkgCommand: Command): Command { "Drop body fields from entries (affects terminal + JSON)", ) .option("--json", "Emit the JSON envelope") - .action( - async (spec: string | undefined, options: PkgChangelogCommandOptions) => { - const deps = await createContainer(); - await pkgChangelogAction(spec, options, { - packageIntelligenceService: deps.packageIntelligenceService, - codeNavigationUrl: deps.codeNavigationUrl, - hasValidToken: deps.hasValidToken, - mcpUrl: deps.mcpUrl, - }); - }, - ); + .action(async (spec: string, options: PkgChangelogCommandOptions) => { + const deps = await createContainer(); + await pkgChangelogAction(spec, options, { + packageIntelligenceService: deps.packageIntelligenceService, + codeNavigationUrl: deps.codeNavigationUrl, + hasValidToken: deps.hasValidToken, + mcpUrl: deps.mcpUrl, + }); + }); } diff --git a/src/services/test-helpers.ts b/src/services/test-helpers.ts index 31f30c39..00370933 100644 --- a/src/services/test-helpers.ts +++ b/src/services/test-helpers.ts @@ -813,7 +813,6 @@ export const defaultChangelogReport: ChangelogReport = { package: { name: "express", registry: "npm", - repoUrl: undefined, fromVersion: undefined, toVersion: undefined, limit: 10, diff --git a/src/tools/package-changelog-parity.test.ts b/src/tools/package-changelog-parity.test.ts index c053905a..7abdd697 100644 --- a/src/tools/package-changelog-parity.test.ts +++ b/src/tools/package-changelog-parity.test.ts @@ -9,16 +9,13 @@ // - Service-sourced success / 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 -// `buildPackageChangelogParams` after `parsePackageSpec`; MCP -// rejects in the same builder. Same envelope shape, surface- -// specific error text. +// - `INVALID_ARGUMENT` fixtures use `toMatchObject` when surface +// wording can differ; identical compact targets use `toEqual`. import { describe, expect, it, mock, spyOn } from "bun:test"; import { type ChangelogReport, PackageIntelligenceBackendError, - PackageIntelligenceChangelogSourceNotFoundError, PackageIntelligenceTargetNotFoundError, PackageIntelligenceVersionNotFoundError, } from "@githits/core-internal"; @@ -48,7 +45,7 @@ function cliDeps( } async function cliJson( - spec: string | undefined, + spec: string, options: Parameters[1] = {}, deps: PkgChangelogCommandDependencies = cliDeps(), ): Promise { @@ -75,13 +72,8 @@ async function cliJson( } interface McpArgs { - registry?: string; - package_name?: string; - repo_url?: string; - from_version?: string; - to_version?: string; + target: string; limit?: number; - git_ref?: string; omit_bodies?: boolean; } @@ -115,7 +107,7 @@ describe("package_changelog parity", () => { }), ); const { json, isError } = await mcpJson( - { registry: "npm", package_name: "express" }, + { target: "npm:express" }, fn as never, ); expect(isError).toBeUndefined(); @@ -134,15 +126,46 @@ describe("package_changelog parity", () => { expect(envelope.entries.count).toBe(2); }); - it("PARITY-JSON-KEYS: range mode (--from / from_version) echoes filter and flips mode", async () => { + it("PARITY-JSON-KEYS: exact pin CLI === MCP", async () => { + const exactReport: ChangelogReport = { + ...defaultChangelogReport, + entries: [{ ...defaultChangelogReport.entries[0]!, hasChangelog: true }], + }; + const fn = mock(() => Promise.resolve(exactReport)); + const cli = await cliJson( + "npm:express@5.2.1", + {}, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { target: "npm:express@5.2.1" }, + fn as never, + ); + expect(cli).toEqual(json); + const envelope = cli as { + mode: string; + filter?: { version?: string }; + entries: { items: Array<{ hasChangelog?: boolean; version?: string }> }; + }; + expect(envelope.mode).toBe("exact"); + expect(envelope.filter?.version).toBe("5.2.1"); + expect(envelope.entries.items[0]?.hasChangelog).toBe(true); + expect(envelope.entries.items[0]?.version).toBe("5.2.1"); + }); + + it("PARITY-JSON-KEYS: range mode echoes filter and flips mode", async () => { const rangeReport: ChangelogReport = { ...defaultChangelogReport, entries: [defaultChangelogReport.entries[0]!], }; const fn = mock(() => Promise.resolve(rangeReport)); const cli = await cliJson( - "npm:express", - { from: "5.0.0" }, + "npm:express@5.0.0..", + {}, cliDeps({ packageIntelligenceService: createMockPackageIntelligenceService({ packageChangelog: fn as never, @@ -150,7 +173,7 @@ describe("package_changelog parity", () => { }), ); const { json } = await mcpJson( - { registry: "npm", package_name: "express", from_version: "5.0.0" }, + { target: "npm:express@5.0.0.." }, fn as never, ); expect(cli).toEqual(json); @@ -162,36 +185,6 @@ describe("package_changelog parity", () => { expect(envelope.filter?.fromVersion).toBe("5.0.0"); }); - it.each([ - "https://github.com/expressjs/express", - "https://codeberg.org/zigil/decimal", - "https://gitlab.com/group/subgroup/project", - ])( - "PARITY-JSON-KEYS: repo-URL addressing (CLI --repo-url === MCP repo_url) %s", - async (repoUrl) => { - const fn = mock(() => Promise.resolve(defaultChangelogReport)); - const cli = await cliJson( - undefined, - { repoUrl: repoUrl }, - cliDeps({ - packageIntelligenceService: createMockPackageIntelligenceService({ - packageChangelog: fn as never, - }), - }), - ); - const { json } = await mcpJson({ repo_url: repoUrl }, fn as never); - expect(cli).toEqual(json); - const envelope = cli as { - repoUrl?: string; - registry?: string; - name?: string; - }; - expect(envelope.repoUrl).toBe(repoUrl); - expect(envelope.registry).toBeUndefined(); - expect(envelope.name).toBeUndefined(); - }, - ); - it("PARITY-JSON-KEYS: no-body (CLI --no-body === MCP omit_bodies: true)", async () => { const fn = mock(() => Promise.resolve(defaultChangelogReport)); const cli = await cliJson( @@ -204,7 +197,7 @@ describe("package_changelog parity", () => { }), ); const { json } = await mcpJson( - { registry: "npm", package_name: "express", omit_bodies: true }, + { target: "npm:express", omit_bodies: true }, fn as never, ); expect(cli).toEqual(json); @@ -227,10 +220,7 @@ describe("package_changelog parity", () => { }), }), ); - const { json } = await mcpJson( - { registry: "npm", package_name: "express" }, - fn as never, - ); + const { json } = await mcpJson({ target: "npm:express" }, fn as never); expect(cli).toEqual(json); const envelope = cli as { entries: { items: Array<{ body?: string }> }; @@ -241,6 +231,7 @@ describe("package_changelog parity", () => { it("PARITY-JSON-KEYS: empty entries lossless on both surfaces", async () => { const emptyReport: ChangelogReport = { ...defaultChangelogReport, + source: undefined, entries: [], }; const fn = mock(() => Promise.resolve(emptyReport)); @@ -253,12 +244,13 @@ describe("package_changelog parity", () => { }), }), ); - const { json } = await mcpJson( - { registry: "npm", package_name: "express" }, - fn as never, - ); + const { json } = await mcpJson({ target: "npm:express" }, fn as never); expect(cli).toEqual(json); - const envelope = cli as { entries: { count: number; items: unknown[] } }; + const envelope = cli as { + source?: string; + entries: { count: number; items: unknown[] }; + }; + expect(envelope.source).toBeUndefined(); expect(envelope.entries.count).toBe(0); expect(envelope.entries.items).toEqual([]); }); @@ -280,7 +272,7 @@ describe("package_changelog parity", () => { }), ); const { json, isError } = await mcpJson( - { registry: "npm", package_name: "express" }, + { target: "npm:express" }, fn as never, ); expect(isError).toBeUndefined(); @@ -294,31 +286,6 @@ describe("package_changelog parity", () => { expect(envelope.entries.items[0]?.version).toBe("5.2.1"); }); - it("PARITY-ERROR-ENVELOPE: NOT_FOUND (no changelog source) identical on both surfaces", async () => { - const fn = mock(() => - Promise.reject( - new PackageIntelligenceChangelogSourceNotFoundError( - "No changelog source available for npm:obscure (tried GitHub Releases, CHANGELOG.md, and HexDocs).", - ), - ), - ); - const cli = await cliJson( - "npm:obscure", - {}, - cliDeps({ - packageIntelligenceService: createMockPackageIntelligenceService({ - packageChangelog: fn as never, - }), - }), - ); - const { json } = await mcpJson( - { registry: "npm", package_name: "obscure" }, - fn as never, - ); - expect(cli).toEqual(json); - expect((cli as { code: string }).code).toBe("NOT_FOUND"); - }); - it("PARITY-ERROR-ENVELOPE: PackageIntelligenceTargetNotFoundError (package missing) identical", async () => { const fn = mock(() => Promise.reject( @@ -335,7 +302,7 @@ describe("package_changelog parity", () => { }), ); const { json } = await mcpJson( - { registry: "npm", package_name: "does-not-exist" }, + { target: "npm:does-not-exist" }, fn as never, ); expect(cli).toEqual(json); @@ -348,14 +315,14 @@ describe("package_changelog parity", () => { new PackageIntelligenceVersionNotFoundError( "No matching version found", "npm:express", - "99.0.0", + "5.2.999", undefined, ), ), ); const cli = await cliJson( - "npm:express", - { from: "99.0.0" }, + "npm:express@5.2.999", + {}, cliDeps({ packageIntelligenceService: createMockPackageIntelligenceService({ packageChangelog: fn as never, @@ -363,7 +330,7 @@ describe("package_changelog parity", () => { }), ); const { json } = await mcpJson( - { registry: "npm", package_name: "express", from_version: "99.0.0" }, + { target: "npm:express@5.2.999" }, fn as never, ); expect(cli).toEqual(json); @@ -373,7 +340,7 @@ describe("package_changelog parity", () => { }; expect(envelope.code).toBe("VERSION_NOT_FOUND"); expect(envelope.details?.package).toBe("npm:express"); - expect(envelope.details?.requestedVersion).toBe("99.0.0"); + expect(envelope.details?.requestedVersion).toBe("5.2.999"); }); it("PARITY-ERROR-ENVELOPE: BACKEND_ERROR identical on both surfaces", async () => { @@ -396,59 +363,29 @@ describe("package_changelog parity", () => { }), }), ); - const { json } = await mcpJson( - { registry: "npm", package_name: "express" }, - fn as never, - ); + const { json } = await mcpJson({ target: "npm:express" }, fn as never); expect(cli).toEqual(json); expect((cli as { code: string }).code).toBe("BACKEND_ERROR"); expect((cli as { retryable: boolean }).retryable).toBe(true); }); - it("PARITY-ERROR-ENVELOPE: INVALID_ARGUMENT for @ matches shape", async () => { - const cli = await cliJson("npm:express@5.0.0", {}); - const { json } = await mcpJson({ - registry: "npm", - package_name: "express", - // The MCP surface has no `@` channel; we test - // the equivalent rule from the other direction — a different - // builder rule (from + limit). The shape is the concern here, - // not identical text. - }); - // CLI envelope is an error; MCP hits the default service mock - // happy path, so shapes differ by design. Instead assert CLI - // matches the shared envelope contract. + it("PARITY-ERROR-ENVELOPE: INVALID_ARGUMENT for repository targets matches on both surfaces", async () => { + const cli = await cliJson("github:expressjs/express", {}); + const { json } = await mcpJson({ target: "github:expressjs/express" }); + expect(cli).toEqual(json); expect(cli).toMatchObject({ code: "INVALID_ARGUMENT", - error: expect.any(String), - retryable: false, - }); - // Sanity: MCP rejects from + limit with INVALID_ARGUMENT too. - const mcpReject = await mcpJson({ - registry: "npm", - package_name: "express", - from_version: "5.0.0", - limit: 10, - }); - expect(mcpReject.json).toMatchObject({ - code: "INVALID_ARGUMENT", - error: expect.any(String), + error: expect.stringContaining("package-only"), retryable: false, }); - // Suppress unused-var warning on `json` — we don't compare it. - void json; }); it("PARITY-ERROR-ENVELOPE: INVALID_ARGUMENT for from + limit matches on both surfaces", async () => { const cli = await cliJson("npm:express", { from: "5.0.0", limit: "10" }); const { json } = await mcpJson({ - registry: "npm", - package_name: "express", - from_version: "5.0.0", + target: "npm:express@5.0.0..", limit: 10, }); - // Shape parity — message text differs (CLI gets the Node error - // surface, MCP the JSON payload). toMatchObject covers both. expect(cli).toMatchObject({ code: "INVALID_ARGUMENT", error: expect.stringContaining("latest-mode"), From 93514527c1aa503b89681507a991bdc51cf18ae4 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Fri, 18 Sep 2026 13:52:09 +0300 Subject: [PATCH 2/6] docs: compact package-only changelog discovery copy Keep the first sentence and first 80 characters, drop repeated grammar from the tool description, and align routing and skills with package-only targets. Co-authored-by: Cursor --- eval/agentic/context-loading/routing-guide.ts | 2 +- eval/agentic/probes/claude-ai-deferred-catalog.md | 2 +- packages/mcp/src/mcp/instructions.ts | 2 +- packages/mcp/src/tools/package-changelog.test.ts | 7 ++++++- packages/mcp/src/tools/package-changelog.ts | 15 +++++---------- skills/githits-mcp/SKILL.md | 2 +- skills/githits-package/SKILL.md | 4 ++-- src/commands/pkg/changelog.test.ts | 1 + src/commands/pkg/changelog.ts | 9 +++++---- 9 files changed, 23 insertions(+), 21 deletions(-) diff --git a/eval/agentic/context-loading/routing-guide.ts b/eval/agentic/context-loading/routing-guide.ts index 15ca4340..8a9fdf0d 100644 --- a/eval/agentic/context-loading/routing-guide.ts +++ b/eval/agentic/context-loading/routing-guide.ts @@ -26,7 +26,7 @@ the routing decision; the selected tool supplies its argument details. | Assess a package's license, adoption, maintenance, or overall health | pkg_info | | Inspect vulnerabilities in a package or version | pkg_vulns | | Inspect direct dependencies or transitive footprint | pkg_deps | -| Find release notes for a package | pkg_changelog | +| Find release notes and changelog history for a package | pkg_changelog | | Compare current and target dependency versions for an upgrade | pkg_upgrade_review | | Find canonical implementation examples across projects | get_example | | Check progress of an earlier search reference | search_status | diff --git a/eval/agentic/probes/claude-ai-deferred-catalog.md b/eval/agentic/probes/claude-ai-deferred-catalog.md index 7ac0bf97..4dc8d095 100644 --- a/eval/agentic/probes/claude-ai-deferred-catalog.md +++ b/eval/agentic/probes/claude-ai-deferred-catalog.md @@ -13,7 +13,7 @@ GitHits (16): - GitHits:docs_read — Read a package documentation page by ID; use `docs_list` to browse and `search`… - GitHits:feedback — Submit feedback when a GitHits result or the overall experience was helpful, un… - GitHits:get_example — Find canonical cross-project examples when no single target is the answer, or t… -- GitHits:pkg_changelog — Find release notes and changelog history for a package or public GitHub repo. +- GitHits:pkg_changelog — Find release notes and changelog history for a package. - GitHits:pkg_deps — Inspect what a package depends on, directly or transitively. - GitHits:pkg_info — Assess latest package health and adoption: license, downloads, and activity. - GitHits:pkg_upgrade_review — Review a package upgrade: vulnerabilities, releases, peers, dependency changes. diff --git a/packages/mcp/src/mcp/instructions.ts b/packages/mcp/src/mcp/instructions.ts index c2afce06..68ba9782 100644 --- a/packages/mcp/src/mcp/instructions.ts +++ b/packages/mcp/src/mcp/instructions.ts @@ -16,7 +16,7 @@ This guide owns shared policy; selected tools own call syntax and exceptions. | Assess a package's license, adoption, maintenance, or overall health | \`pkg_info\` | | Inspect vulnerabilities in a package or version | \`pkg_vulns\` | | Inspect direct dependencies or transitive footprint | \`pkg_deps\` | -| Find release notes for a package | \`pkg_changelog\` | +| Find release notes and changelog history for a package | \`pkg_changelog\` | | Compare current and target dependency versions for an upgrade | \`pkg_upgrade_review\` | | Find canonical implementation examples across projects | \`get_example\` | | Check progress of an earlier search reference | \`search_status\` | diff --git a/packages/mcp/src/tools/package-changelog.test.ts b/packages/mcp/src/tools/package-changelog.test.ts index 54281b77..daf84c15 100644 --- a/packages/mcp/src/tools/package-changelog.test.ts +++ b/packages/mcp/src/tools/package-changelog.test.ts @@ -19,10 +19,15 @@ describe("createPackageChangelogTool — metadata", () => { expect(tool.description).toContain("latest mode"); expect(tool.description).toContain("one selected release"); expect(tool.description).toContain("`registry:name@from..to`"); - expect(tool.description).toContain("markdown body previews"); expect(tool.description).toContain("body_lines"); + expect(tool.description).not.toContain("markdown body previews"); + expect(tool.description).not.toContain("Supports npm"); expect(tool.description).not.toContain("repo_url"); expect(tool.description).not.toContain("from_version"); + expect(tool.schema.target?.description).toContain( + "registry:name[@version|@from..to]", + ); + expect(tool.schema.target?.description).toContain("Package-only"); expect(tool.schema.format?.description).toContain( "Set `json` only when code consumes", ); diff --git a/packages/mcp/src/tools/package-changelog.ts b/packages/mcp/src/tools/package-changelog.ts index 9bc9efdd..a07df55f 100644 --- a/packages/mcp/src/tools/package-changelog.ts +++ b/packages/mcp/src/tools/package-changelog.ts @@ -37,19 +37,19 @@ const schema: ZodRawShape = { target: z .string() .describe( - `Package registry:name, optional @version or @from..to interval, for example npm:express@5.2.1. Registries: ${PKGSEER_REGISTRY_LIST}.`, + `Package registry:name[@version|@from..to], for example npm:express@5.2.1; omit the version for latest. Open bounds from.. and ..to are accepted. Package-only; repository and site targets are rejected. Registries: ${PKGSEER_REGISTRY_LIST}.`, ), limit: z .number() .optional() .describe( - "Latest-mode and upper-cap entry count (1-50, default 10). Rejected with `INVALID_ARGUMENT` for a single-release or lower-bound range target.", + "Latest-mode and upper-cap count (1-50, default 10). Rejected for a selected-release or lower-bound range target.", ), omit_bodies: z .boolean() .optional() .describe( - "When true, each entry in `entries.items[]` omits its `body` field. Default false. Use when you only need the version / date / URL timeline — drops 10 KB+ per entry on large release notes.", + "Omit each entry body (default false). Use for version/date/URL timelines; large notes drop 10 KB+ per entry.", ), verbose: z .boolean() @@ -74,16 +74,11 @@ const schema: ZodRawShape = { export const DESCRIPTION_BASE: string = "Find release notes and changelog history for a package. Default " + "latest mode returns up to ten entries; source ordering may interleave maintained release lines. " + - "Pass `target` as `registry:name` for latest, `registry:name@version` for one selected release, " + - "or `registry:name@from..to` for a closed interval. Open bounds `from..` and `..to` are accepted. " + + "Pin `registry:name@version` for one selected release, or `registry:name@from..to` for a closed interval. " + "`limit` applies only to latest and upper-cap targets. " + "A selected release without notes succeeds with `hasChangelog: false`. " + "Empty latest or range selections succeed with no entries. " + - "Entries include markdown body previews. Example: " + - '`{"target":"npm:express@5.2.1"}`. ' + - "Text output previews 10 body lines by default; use `body_lines` " + - "to tune the preview or `verbose:true` for full text bodies. " + - "Supports npm, PyPI, Hex, Crates, NuGet, Maven, Zig, vcpkg, Packagist, RubyGems, Go, and Swift."; + "Text previews 10 body lines; use `body_lines` or `verbose:true` for more."; export const DESCRIPTION: string = `${DESCRIPTION_BASE}\n\n${PKG_CHANGELOG_GUARDRAIL}`; diff --git a/skills/githits-mcp/SKILL.md b/skills/githits-mcp/SKILL.md index 7b044877..5819e1f7 100644 --- a/skills/githits-mcp/SKILL.md +++ b/skills/githits-mcp/SKILL.md @@ -26,7 +26,7 @@ This guide owns shared policy; selected tools own call syntax and exceptions. | Assess a package's license, adoption, maintenance, or overall health | `pkg_info` | | Inspect vulnerabilities in a package or version | `pkg_vulns` | | Inspect direct dependencies or transitive footprint | `pkg_deps` | -| Find release notes for a package | `pkg_changelog` | +| Find release notes and changelog history for a package | `pkg_changelog` | | Compare current and target dependency versions for an upgrade | `pkg_upgrade_review` | | Find canonical implementation examples across projects | `get_example` | | Check progress of an earlier search reference | `search_status` | diff --git a/skills/githits-package/SKILL.md b/skills/githits-package/SKILL.md index bc0cde0c..c174f1d3 100644 --- a/skills/githits-package/SKILL.md +++ b/skills/githits-package/SKILL.md @@ -21,7 +21,7 @@ Use GitHits package intelligence before making dependency claims from memory. - Most package commands use `:[@]`, for example `npm:lodash@4.17.20` or `pypi:requests`. - `pkg info` always reports the latest published version and does not accept a version pin. -- `pkg changelog` is package-only: `:`, `:@`, or `:@..`. `--from`/`--to` remain range flags on a bare spec. Repository and site targets are rejected. +- `pkg changelog` is package-only. Pin `@version` for one release; use `@from..to` or `--from`/`--to` for ranges. Repository and site targets are rejected. ## Core Commands @@ -54,7 +54,7 @@ githits pkg upgrade-review --package npm:zod@4.3.6..4.4.3 --package npm:lint-sta - Need historical advisories that do not affect the inspected version: use `pkg vulns --scope non_affecting`; use `--scope all` for affected plus historical rows. - Need dependency footprint: start with `pkg deps`; add `--lifecycle all` for non-runtime groups and `--depth ` for aggregate transitive graph data. - Need upgrade evidence for dependency updates, outdated package bumps, or lockfile changes: prefer `pkg upgrade-review` because it compares current vs target vulnerabilities, changelog range evidence, deprecation metadata, peer changes, dependency changes, and transitive security evidence by default. It reports facts only; you still own the final assessment. -- Need release notes without a current-to-target comparison: use `pkg changelog`; pin `@version` for one selected release, use `--from`/`--to` or `@from..to` for ranges, and `--no-body` for compact timelines. +- Need release notes without a current-to-target comparison: use `pkg changelog`; `--no-body` for compact timelines. ## Gotchas diff --git a/src/commands/pkg/changelog.test.ts b/src/commands/pkg/changelog.test.ts index 512a9ae1..68f003fe 100644 --- a/src/commands/pkg/changelog.test.ts +++ b/src/commands/pkg/changelog.test.ts @@ -20,6 +20,7 @@ describe("pkg changelog help", () => { const help = command.helpInformation().replace(/\s+/g, " "); expect(help).toContain("up to ten latest-mode entries"); + expect(help).toContain("Package-only"); expect(help).toContain("Exclusive start of version range"); expect(help).toContain("npm:express@5.2.1"); expect(help).not.toContain("--repo-url"); diff --git a/src/commands/pkg/changelog.ts b/src/commands/pkg/changelog.ts index 083911b1..98476120 100644 --- a/src/commands/pkg/changelog.ts +++ b/src/commands/pkg/changelog.ts @@ -171,7 +171,7 @@ function formatChangelogTerminalError(mapped: MappedError): string { return lines.join("\n"); } -const PKG_CHANGELOG_DESCRIPTION = `Fetch recent release notes or changelog entries for a package. +const PKG_CHANGELOG_DESCRIPTION = `Find release notes and changelog history for a package. By default shows up to ten latest-mode entries with the first 10 lines of each entry's body. Pin a version for one selected release, or use @from..to for a closed interval. --from/--to remain as @@ -179,13 +179,14 @@ package range flags on a bare spec. --limit changes the latest-mode count (1-50). --verbose uncaps the body preview; --no-body drops bodies entirely. -Package spec: :[@ | @..]. -Supported registries: ${PKGSEER_REGISTRY_LIST}.`; +Package spec: :[@ | @..]. Package-only; +repository and site targets are rejected. Supported registries: +${PKGSEER_REGISTRY_LIST}.`; export function registerPkgChangelogCommand(pkgCommand: Command): Command { return pkgCommand .command("changelog") - .summary("Fetch release notes / changelog entries for a package") + .summary("Find release notes and changelog history for a package") .description(PKG_CHANGELOG_DESCRIPTION) .argument( "", From a1cdba859d0fa4671a1d7d61bff07cd2c59a324f Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Fri, 18 Sep 2026 14:15:30 +0300 Subject: [PATCH 3/6] fix: label exact changelog text with the resolved release Exact mode text was echoing the requested selector in the summary header. Keep filter.version as the selector and show the concrete entry version. Co-authored-by: Cursor --- docs/implementation/cli-commands.md | 2 +- .../shared/package-changelog-response.test.ts | 30 +++++++++++++++++++ .../src/shared/package-changelog-response.ts | 2 +- packages/mcp/src/smoke-test.test.ts | 1 + 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index c758eada..a3f433d6 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -714,7 +714,7 @@ Fetches release notes or changelog entries for a package. Output preserves sourc **Pre-release versions.** Normalised versions flow through unchanged (`5.0.0-rc.1`, `2.32.0.dev0`, `1.7.0-rc.5`). Tag-style `v`-prefixed inputs are rejected except for Go canonicalisation and Swift. -**Default terminal output.** Summary header (`name | registry | source | mode | entry count`) followed by each entry's `version date url` header plus the first 10 lines of its markdown body, indented and dimmed. Bodies longer than the cap show a footer `... (+N more lines - use --verbose for the full body)`. Missing dates render as `-`; missing versions render as `(unversioned)`. Exact no-notes results say `Release notes are unavailable.` +**Default terminal output.** Summary header (`name | registry | source | mode | entry count`) followed by each entry's `version date url` header plus the first 10 lines of its markdown body, indented and dimmed. Bodies longer than the cap show a footer `... (+N more lines - use --verbose for the full body)`. Missing dates render as `-`; missing versions render as `(unversioned)`. Exact mode labels the resolved release, not the requested selector. Exact no-notes results say `Release notes are unavailable.` **`--verbose`.** Uncaps the body preview — every entry's full markdown body renders, indented and dimmed, with no truncation footer. Terminal-only — does not change `--json` output. diff --git a/packages/mcp/src/shared/package-changelog-response.test.ts b/packages/mcp/src/shared/package-changelog-response.test.ts index 3d23875a..e8eabbe3 100644 --- a/packages/mcp/src/shared/package-changelog-response.test.ts +++ b/packages/mcp/src/shared/package-changelog-response.test.ts @@ -551,4 +551,34 @@ describe("formatPackageChangelogTerminal", () => { expect(output).toContain("exact 5.2.1"); expect(output).toContain("Release notes are unavailable."); }); + + it("labels exact text with the resolved release, not the requested selector", () => { + const report: ChangelogReport = { + ...baseReport, + source: "releases", + entries: [ + { + version: "5.2.1", + normalizedVersion: "5.2.1", + publishedAt: "2026-01-15T12:00:00Z", + htmlUrl: "https://github.com/expressjs/express/releases/tag/5.2.1", + body: "## Patch", + hasChangelog: true, + }, + ], + }; + const envelope = buildPackageChangelogSuccessPayload(report, { + ...baseOptions, + mode: "exact", + version: "^5.0.0", + explicitFilterFields: new Set(["version"]), + }); + const output = formatPackageChangelogTerminal(envelope, { + verbose: false, + useColors: false, + }); + expect(envelope.filter?.version).toBe("^5.0.0"); + expect(output).toContain("exact 5.2.1"); + expect(output).not.toContain("exact ^5.0.0"); + }); }); diff --git a/packages/mcp/src/shared/package-changelog-response.ts b/packages/mcp/src/shared/package-changelog-response.ts index 9ead88db..4fe5130d 100644 --- a/packages/mcp/src/shared/package-changelog-response.ts +++ b/packages/mcp/src/shared/package-changelog-response.ts @@ -260,7 +260,7 @@ function buildSummaryLine( function modeSummary(envelope: LeanChangelogEnvelope): string { if (envelope.mode === "exact") { const version = - envelope.filter?.version ?? envelope.entries.items[0]?.version; + envelope.entries.items[0]?.version ?? envelope.filter?.version; return version ? `exact ${version}` : "exact"; } if (envelope.mode === "range") { diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index 02e594fe..5ea4b84b 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -275,6 +275,7 @@ describe("runMcpSmoke", () => { "pkg_info", "pkg_vulns", "pkg_deps", + "pkg_changelog", ]); const compactPackageCalls = calls.filter(({ name }) => compactPackageNames.has(name), From 642cd45b9feb38280a7e90c146ec38310a44caa1 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Fri, 18 Sep 2026 14:19:34 +0300 Subject: [PATCH 4/6] chore: rename changelog fragment after 0.20.0 rebase Keep the package-only changelog note as a new 0.21 fragment instead of editing the fragment that 0.20.0 already consumed. Co-authored-by: Cursor --- ...s.changed.md => 407-package-only-changelog-targets.changed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changes/{package-only-changelog-targets.changed.md => 407-package-only-changelog-targets.changed.md} (100%) diff --git a/changes/package-only-changelog-targets.changed.md b/changes/407-package-only-changelog-targets.changed.md similarity index 100% rename from changes/package-only-changelog-targets.changed.md rename to changes/407-package-only-changelog-targets.changed.md From b541f5b16362e8bdb859ad58c0f718c7eb7d5cb1 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Fri, 18 Sep 2026 14:52:18 +0300 Subject: [PATCH 5/6] test: promote exact changelog version misses at the service layer Cover executeExactPackageChangelog GraphQL 'No matching version found' the same way packageDependencies already does, so a missing pin stays VERSION_NOT_FOUND. Co-authored-by: Cursor --- .../package-intelligence-service.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/core-internal/src/services/package-intelligence-service.test.ts b/packages/core-internal/src/services/package-intelligence-service.test.ts index 6b03fa61..76121652 100644 --- a/packages/core-internal/src/services/package-intelligence-service.test.ts +++ b/packages/core-internal/src/services/package-intelligence-service.test.ts @@ -2724,6 +2724,32 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { }, ]); }); + + it("promotes a generic 'no matching version' error to VERSION_NOT_FOUND for an exact pin", async () => { + const fetchFn = mock(() => + Promise.resolve( + jsonResponse({ errors: [{ message: "No matching version found" }] }), + ), + ); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + try { + await service.packageChangelog({ + 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"); + } + }); }); describe("PackageIntelligenceServiceImpl — package docs targets", () => { From 7d1d299a21edf447fbd0bc511c74c7b5a0e4756a Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Sat, 19 Sep 2026 09:08:35 +0300 Subject: [PATCH 6/6] test: register package-changelog-exact in the suite inventory The new workload file was unclassified, so the checked-in suite manifest contract failed on CI. Co-authored-by: Cursor --- eval/agentic/suites.json | 6 ++++++ scripts/agent-eval-suite.test.ts | 5 +++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/eval/agentic/suites.json b/eval/agentic/suites.json index 1aff15c6..748f1583 100644 --- a/eval/agentic/suites.json +++ b/eval/agentic/suites.json @@ -109,6 +109,12 @@ "safety": "stable", "suites": ["stable-full"] }, + { + "id": "package-changelog-exact", + "path": "eval/agentic/workloads/package-changelog-exact.md", + "safety": "stable", + "suites": ["stable-full"] + }, { "id": "package-changelog-range", "path": "eval/agentic/workloads/package-changelog-range.md", diff --git a/scripts/agent-eval-suite.test.ts b/scripts/agent-eval-suite.test.ts index b5066cc5..ec7d100a 100644 --- a/scripts/agent-eval-suite.test.ts +++ b/scripts/agent-eval-suite.test.ts @@ -393,10 +393,10 @@ describe("agent eval suites", () => { it("loads the checked-in manifest with the exact workload inventory", () => { const manifest = loadSuiteManifest(); expect(manifest.schemaVersion).toBe(1); - expect(manifest.workloads).toHaveLength(30); + expect(manifest.workloads).toHaveLength(31); expect( manifest.workloads.filter((workload) => workload.safety === "stable"), - ).toHaveLength(24); + ).toHaveLength(25); expect( manifest.workloads.filter((workload) => workload.safety === "stateful"), ).toHaveLength(1); @@ -434,6 +434,7 @@ describe("agent eval suites", () => { "global-example", "opencode-compaction", "package-changelog", + "package-changelog-exact", "package-changelog-range", "package-dependencies", "package-overview-vulnerabilities",