diff --git a/changes/407-package-only-changelog-targets.changed.md b/changes/407-package-only-changelog-targets.changed.md new file mode 100644 index 00000000..80d8f3f4 --- /dev/null +++ b/changes/407-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..a3f433d6 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 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. -**`--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..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 or repository | 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/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/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..76121652 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,194 @@ 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, + }, + ]); + }); + + 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", () => { 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..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 or repository | \`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/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..e8eabbe3 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,58 @@ 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, + mode: "exact", + version: "5.2.1", + explicitFilterFields: new Set(["version"]), + }); + const output = formatPackageChangelogTerminal(envelope, { + verbose: false, + useColors: false, + }); + 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, - registry: undefined, - name: undefined, - repoUrl: "https://github.com/expressjs/express", + mode: "exact", + version: "^5.0.0", + explicitFilterFields: new Set(["version"]), }); const output = formatPackageChangelogTerminal(envelope, { verbose: false, useColors: false, }); - expect(output).toContain("https://github.com/expressjs/express"); + 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 5c1c9f42..4fe5130d 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.entries.items[0]?.version ?? envelope.filter?.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..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), @@ -900,6 +901,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 +1084,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..daf84c15 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,93 @@ 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("markdown body previews"); + expect(tool.description).toContain("one selected release"); + expect(tool.description).toContain("`registry:name@from..to`"); 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", ); 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 +131,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 +159,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 +189,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 +207,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 +219,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 +234,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 +266,7 @@ describe("createPackageChangelogTool — happy path", () => { ); const result = await tool.handler( - { registry: "npm", package_name: "express", format: "json" }, + { target: "npm:express", format: "json" }, {}, ); @@ -259,43 +279,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 +336,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 +355,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 +375,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 +386,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 +459,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 +479,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 +491,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 +499,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 +551,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 +562,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..a07df55f 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,68 +30,26 @@ 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[@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 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 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() @@ -119,19 +72,13 @@ 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}`. ' + - "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."; + "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. " + + "Text previews 10 body lines; use `body_lines` or `verbose:true` for more."; export const DESCRIPTION: string = `${DESCRIPTION_BASE}\n\n${PKG_CHANGELOG_GUARDRAIL}`; @@ -149,30 +96,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/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", 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..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 or repository | `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 6749defa..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` accepts `:` or `--repo-url `; do not pass `@` to changelog. Use `--to ` instead. +- `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 @@ -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`; `--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..68f003fe 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"; @@ -21,7 +20,11 @@ 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"); + expect(help).not.toContain("--git-ref"); expect(help).not.toContain("ten most recent entries"); }); }); @@ -248,25 +251,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 +283,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 +402,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..98476120 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,32 +171,26 @@ 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 = `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 +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: :[@ | @..]. 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( - "[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 +198,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 +207,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"),