diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index 88932561..f947ce1f 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -253,10 +253,13 @@ When a new tool lands with both MCP and CLI surfaces: values never round-trip as caller intent. - **`entries: { count, items }` shape.** Mirrors `runtime: {count, items}` from `pkg_deps`. -- **Missing source promoted to `NOT_FOUND`.** The service layer - promotes null or empty `source` to a typed - `PackageIntelligenceChangelogSourceNotFoundError` with a message - naming the sources tried (GitHub Releases, CHANGELOG.md, HexDocs). +- **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). - **`--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/tools.md b/docs/implementation/tools.md index 8b7c297f..a63b498d 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -27,7 +27,7 @@ The CLI mirrors the production MCP tool contract where equivalent tools exist. C | `pkg_info` | `registry`, `package_name` | Package overview: latest version, license, description, repository, downloads, GitHub metadata, install command, and known vulnerabilities. Always returns the latest published version. | | `pkg_vulns` | `registry`, `package_name`, `version?`, `min_severity?`, `include_withdrawn?` | Known vulnerabilities for a package on npm, PyPI, Hex, or Crates. Count summary, per-advisory OSV ID + severity + affected/fix ranges, and upgrade paths. Malware is surfaced in a disjoint bucket. | | `pkg_deps` | `registry`, `package_name`, `version?`, `lifecycle?`, `include_transitive?`, `include_importers?`, `max_depth?` | Direct runtime dependency list (each `{name, version, constraint}` — the backend resolves each constraint to a concrete version) plus, when the backend has them, structured groups for dev / peer / build / optional with registry-specific condition metadata (PyPI extras, Crates features). Optional transitive block with aggregate edge counts, the preprocessed install footprint as `{name, version}`, typed conflicts and circular-dependency cycles; opt into per-package importer provenance with `include_importers`. | -| `pkg_changelog` | `registry?`, `package_name?`, `repo_url?`, `from_version?`, `to_version?`, `limit?`, `git_ref?`, `include_bodies?` | Release notes or changelog entries for a package or GitHub repo. Default latest mode returns the ten most recent entries; `from_version` switches to range mode (no count cap). Dual addressing (spec vs repo URL) mutually exclusive. Response always includes `source` (`"releases"` / `"changelog_file"` / `"hexdocs"`), `mode` (`"latest"` / `"range"`), and `entries: { count, items }` with full markdown bodies by default; set `include_bodies: false` for a lean version / date / URL timeline. | +| `pkg_changelog` | `registry?`, `package_name?`, `repo_url?`, `from_version?`, `to_version?`, `limit?`, `git_ref?`, `include_bodies?` | Release notes or changelog entries for a package or GitHub repo. Default latest mode returns the ten most recent entries; `from_version` switches to range mode (no count cap). Dual addressing (spec vs repo URL) mutually exclusive. Response includes optional `source` (`"releases"` / `"changelog_file"` / `"hexdocs"`) when a concrete changelog source exists, `mode` (`"latest"` / `"range"`), and `entries: { count, items }` with full markdown bodies by default; set `include_bodies: false` for a lean version / date / URL timeline. Package-version entries without changelog text succeed with `source` omitted. | | `code_files` | `target`, `path_prefix?`, `limit?`, `wait_timeout_ms?`, `format?` | List files in an indexed dependency. Returns `{total, hasMore, files: [{path, name, language, fileType, byteSize}], resolution, indexedVersion}` in JSON mode. Dual addressing via `target.registry + target.package_name` (spec) or `target.repo_url + target.git_ref` (repo). `path_prefix` is a literal directory prefix — NOT a glob (`*.ts` won't match); glob / pattern filtering is an upstream enhancement. Emits an `INDEXING` error envelope when the dependency is being indexed on-demand — retry with a longer `wait_timeout_ms` or pick a version from `details.availableVersions`. `format` defaults to `text-v1` (paths-only listing); pass `format: "json"` for the structured envelope. | | `code_read` | `target`, `path`, `start_line?`, `end_line?`, `wait_timeout_ms?` | Read a file from an indexed dependency. **MCP per-call span cap: 150 lines** — broader requests (or no range) are silently truncated to the first 150 lines from the caller's start, with a `hint` field explaining the cap and the original request. Use `start_line` / `end_line` to pick a focused window — typical 80-150 lines around a known symbol from `search` / `code_grep`. Binary files set `isBinary: true` and omit `content`. On `NOT_FOUND` / `FILE_NOT_FOUND` call `code_files` to discover the actual path. The cap is MCP-only; the CLI command `githits code read` honors arbitrary ranges. | | `code_grep` | `target`, `pattern`, `path?`, `path_prefix?`, `globs?`, `extensions?`, `pattern_type?`, `case_sensitive?`, `exclude_doc_files?`, `exclude_test_files?`, `context_lines?`, `context_lines_before?`, `context_lines_after?`, `max_matches?`, `max_matches_per_file?`, `cursor?`, `symbol_fields?`, `wait_timeout_ms?`, `format?` | Deterministic text grep over indexed dependency or repository source. Defaults to literal, ASCII case-insensitive matching across the whole target; non-ASCII letters match case-sensitively. Narrow with `path`, `path_prefix`, `globs`, or `extensions`. `pattern_type: "regex"` uses RE2 syntax; whole-target regexes must include at least one literal substring for index pre-filtering. Returns matches plus pagination and scan counters; `symbol_fields` hydrates enclosing symbol metadata on each match. `format` defaults to `text-v1` (matches grouped by file, grep -A/-B notation for context); pass `format: "json"` for the structured envelope. | @@ -90,7 +90,7 @@ The CLI mirrors the production MCP tool contract where equivalent tools exist. C ### `pkg_changelog` response shape -**Data-first envelope.** The top level carries addressing (`registry` + `name` for spec addressing, or `repoUrl` for repo-URL addressing), `source` (`"releases"` / `"changelog_file"` / `"hexdocs"`), 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` 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. **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 `include_bodies: false`. The backend's opaque per-entry `metadata` GenericJSON is deliberately dropped from the envelope in v1 — revisit via agent feedback. @@ -104,11 +104,11 @@ The CLI mirrors the production MCP tool contract where equivalent tools exist. C **Version validation.** Same rule as `pkg_vulns` / `pkg_deps`: tag-style `v`-prefixed inputs on `from_version` / `to_version` are rejected client-side with `INVALID_ARGUMENT`. `@` is also 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`. -**NOT_FOUND semantics.** Backend `source === null` or `source === ""` is promoted to a typed `PackageIntelligenceChangelogSourceNotFoundError` at the service boundary, 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 a success, not an error — "no entries in this range" is a legitimate neutral outcome. +**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. **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, `include_bodies`-toggleable changelog with `--no-body` timeline mode and repo-URL addressing, use `pkg_changelog`. -`pkg_changelog` shares its envelope builder with the CLI `githits pkg changelog` command via `src/shared/package-changelog-request.ts` and `src/shared/package-changelog-response.ts`. The terminal formatter is CLI-only. The parity test (`src/tools/package-changelog-parity.test.ts`) asserts `toEqual` across every service-sourced success / error fixture (happy latest, range mode, repo-URL addressing, `--no-body` / `include_bodies: false`, default bodies, empty entries, NOT_FOUND, PackageIntelligenceTargetNotFoundError, VERSION_NOT_FOUND, BACKEND_ERROR) and `toMatchObject` for builder-sourced `INVALID_ARGUMENT`. +`pkg_changelog` shares its envelope builder with the CLI `githits pkg changelog` command via `src/shared/package-changelog-request.ts` and `src/shared/package-changelog-response.ts`. The terminal formatter is CLI-only. The parity test (`src/tools/package-changelog-parity.test.ts`) asserts `toEqual` across every service-sourced success / error fixture (happy latest, range mode, repo-URL addressing, no-source package-version entries, `--no-body` / `include_bodies: false`, default bodies, empty entries, NOT_FOUND, PackageIntelligenceTargetNotFoundError, VERSION_NOT_FOUND, BACKEND_ERROR) and `toMatchObject` for builder-sourced `INVALID_ARGUMENT`. ### `code_files` / `code_read` / `code_grep` response shapes diff --git a/src/commands/pkg/changelog.test.ts b/src/commands/pkg/changelog.test.ts index 29f3633d..1cc4f1d8 100644 --- a/src/commands/pkg/changelog.test.ts +++ b/src/commands/pkg/changelog.test.ts @@ -166,6 +166,33 @@ describe("pkgChangelogAction", () => { logSpy.mockRestore(); }); + it("emits JSON version entries without source when no changelog source is available", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await pkgChangelogAction( + "npm:express", + { json: true }, + createDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: mock(() => + Promise.resolve({ + ...defaultChangelogReport, + source: undefined, + entries: [defaultChangelogReport.entries[0]!], + }), + ), + }), + }), + ); + + const output = logSpy.mock.calls[0]?.[0] as string; + const payload = JSON.parse(output); + expect(payload.source).toBeUndefined(); + expect(payload.entries.count).toBe(1); + expect(payload.entries.items[0].version).toBe("5.2.1"); + logSpy.mockRestore(); + }); + it("drops body fields in JSON when --no-body is set", async () => { const logSpy = spyOn(console, "log").mockImplementation(() => {}); diff --git a/src/services/package-intelligence-service.test.ts b/src/services/package-intelligence-service.test.ts index 4f28f2c6..5fc983e5 100644 --- a/src/services/package-intelligence-service.test.ts +++ b/src/services/package-intelligence-service.test.ts @@ -1319,6 +1319,82 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { service.packageChangelog({ registry: "NPM", packageName: "express" }), ).rejects.toBeInstanceOf(PackageIntelligenceChangelogSourceNotFoundError); }); + + it("accepts package version entries without a changelog source", async () => { + const fetchFn = mock(() => + Promise.resolve( + jsonResponse({ + data: { + packageChangelog: { + package: { name: "react", registry: "NPM" }, + source: null, + entries: [ + { + version: "19.2.5", + normalizedVersion: "19.2.5", + body: "React Server Components", + htmlUrl: null, + publishedAt: "2026-04-08T00:00:00Z", + metadata: null, + }, + ], + }, + }, + }), + ), + ); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + + const result = await service.packageChangelog({ + registry: "NPM", + packageName: "react", + }); + + expect(result.source).toBeUndefined(); + expect(result.entries[0]?.version).toBe("19.2.5"); + }); + + it("normalizes an empty changelog source to absent when entries are present", async () => { + const fetchFn = mock(() => + Promise.resolve( + jsonResponse({ + data: { + packageChangelog: { + package: { name: "react", registry: "NPM" }, + source: "", + entries: [ + { + version: "19.2.5", + normalizedVersion: "19.2.5", + body: null, + htmlUrl: null, + publishedAt: "2026-04-08T00:00:00Z", + metadata: null, + }, + ], + }, + }, + }), + ), + ); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + + const result = await service.packageChangelog({ + registry: "NPM", + packageName: "react", + }); + + expect(result.source).toBeUndefined(); + expect(result.entries).toHaveLength(1); + }); }); describe("PackageIntelligenceServiceImpl — packageDependencies", () => { diff --git a/src/services/package-intelligence-service.ts b/src/services/package-intelligence-service.ts index 827cc807..798eafcf 100644 --- a/src/services/package-intelligence-service.ts +++ b/src/services/package-intelligence-service.ts @@ -358,7 +358,7 @@ export interface ChangelogEntryDetail { export interface ChangelogReport { /** Echo of addressing + filter as the backend saw it. */ package?: ChangelogPackageInfo; - /** `"releases"` | `"changelog_file"` | `"hexdocs"` when resolved; null/empty otherwise. */ + /** `"releases"` | `"changelog_file"` | `"hexdocs"` when resolved; absent for package versions with no changelog entry. */ source?: string; /** Entries, newest-first. Empty array = resolved source but nothing in range. */ entries: ChangelogEntryDetail[]; @@ -2023,13 +2023,12 @@ export class PackageIntelligenceServiceImpl data: z.infer, params: PackageChangelogParams, ): ChangelogReport { - // Backend returns a null or empty source when no changelog data could - // be resolved for the package/repo. Distinct from `entries: []` with - // a valid source, which means "source resolved but produced no entries - // in this range". Promote the no-source case to a typed error at the - // service boundary so the envelope builder never has to think about it. - const source = data.source ?? undefined; - if (!source) { + // 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 && data.source.trim() ? data.source : undefined; + const rawEntries = data.entries ?? []; + if (!source && rawEntries.length === 0) { const target = params.repoUrl ?? (params.registry && params.packageName @@ -2040,7 +2039,6 @@ export class PackageIntelligenceServiceImpl ); } - const rawEntries = data.entries ?? []; const entries: ChangelogEntryDetail[] = rawEntries.map((entry) => ({ version: entry.version ?? undefined, normalizedVersion: entry.normalizedVersion ?? undefined, diff --git a/src/shared/package-changelog-response.test.ts b/src/shared/package-changelog-response.test.ts index 56d4ee2e..9ed5528c 100644 --- a/src/shared/package-changelog-response.test.ts +++ b/src/shared/package-changelog-response.test.ts @@ -135,14 +135,14 @@ describe("buildPackageChangelogSuccessPayload — version null handling", () => expect(envelope.entries.items[0]?.body).toBe(""); }); - it("throws if the service layer leaks source=null into the envelope builder (defence in depth)", () => { + it("omits source when package version entries have no changelog entry", () => { const report: ChangelogReport = { ...baseReport, source: undefined, }; - expect(() => - buildPackageChangelogSuccessPayload(report, baseOptions), - ).toThrow(/null source/); + const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); + expect(envelope.source).toBeUndefined(); + expect(envelope.entries.count).toBe(2); }); }); @@ -257,6 +257,16 @@ describe("formatPackageChangelogTerminal", () => { expect(output).not.toContain("use --verbose"); }); + it("labels no-source package entries as package versions", () => { + const report: ChangelogReport = { ...baseReport, source: undefined }; + const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); + const output = formatPackageChangelogTerminal(envelope, { + verbose: false, + useColors: false, + }); + expect(output).toContain("source: package versions"); + }); + it("caps the body at 10 lines by default with a truncation footer", () => { const longBody = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join( "\n", diff --git a/src/shared/package-changelog-response.ts b/src/shared/package-changelog-response.ts index 79835c50..b5067dbb 100644 --- a/src/shared/package-changelog-response.ts +++ b/src/shared/package-changelog-response.ts @@ -9,10 +9,9 @@ * - **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 resolved a `source` (even `{count: 0, items: []}` - * for an empty range); null/empty source is promoted to a - * `NOT_FOUND` error at the service boundary and never reaches the - * envelope builder. + * 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. @@ -88,9 +87,8 @@ export interface LeanChangelogEnvelope { name?: string; /** Present for repo-URL addressing. */ repoUrl?: string; - /** `"releases"` | `"changelog_file"` | `"hexdocs"`. Never null/empty here - * (no-source responses are promoted to NOT_FOUND at the service boundary). */ - source: string; + /** `"releases"` | `"changelog_file"` | `"hexdocs"` when resolved. Absent for package versions with no changelog entry. */ + source?: string; /** Derived from request params. */ mode: ChangelogMode; entries: LeanEntriesBlock; @@ -143,18 +141,7 @@ export function buildPackageChangelogSuccessPayload( return lean; }); - if (report.source == null) { - // Defence in depth — the service promotes null-source to - // NOT_FOUND before we get here, so this path is unreachable in - // practice. Throw rather than emit an envelope that would - // violate the `source: string` type contract. - throw new Error( - "Changelog envelope builder received a null source — should have been promoted to NOT_FOUND at the service boundary.", - ); - } - const envelope: LeanChangelogEnvelope = { - source: report.source, mode: options.mode, entries: { count: items.length, @@ -162,6 +149,8 @@ export function buildPackageChangelogSuccessPayload( }, }; + if (report.source) envelope.source = report.source; + if (options.registry) envelope.registry = options.registry; if (options.name) envelope.name = options.name; if (options.repoUrl) envelope.repoUrl = options.repoUrl; @@ -303,7 +292,9 @@ function buildSummaryLine( envelope.registry && envelope.name ? `${envelope.name} · ${envelope.registry}` : (envelope.repoUrl ?? "(unknown)"); - const sourceLabel = humanizeSource(envelope.source); + const sourceLabel = envelope.source + ? humanizeSource(envelope.source) + : "package versions"; const modeLabel = envelope.mode === "range" ? rangeLabel(envelope) : latestLabel(envelope); const countLabel = `${envelope.entries.count} ${plural("entry", "entries", envelope.entries.count)}`; diff --git a/src/tools/package-changelog-parity.test.ts b/src/tools/package-changelog-parity.test.ts index f2a41131..691ac76f 100644 --- a/src/tools/package-changelog-parity.test.ts +++ b/src/tools/package-changelog-parity.test.ts @@ -254,6 +254,37 @@ describe("package_changelog parity", () => { expect(envelope.entries.items).toEqual([]); }); + it("PARITY-JSON-KEYS: package version entries without source succeed on both surfaces", async () => { + const noSourceReport: ChangelogReport = { + ...defaultChangelogReport, + source: undefined, + entries: [defaultChangelogReport.entries[0]!], + }; + const fn = mock(() => Promise.resolve(noSourceReport)); + const cli = await cliJson( + "npm:express", + {}, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: fn as never, + }), + }), + ); + const { json, isError } = await mcpJson( + { registry: "npm", package_name: "express" }, + fn as never, + ); + expect(isError).toBeUndefined(); + expect(cli).toEqual(json); + const envelope = cli as { + source?: string; + entries: { count: number; items: Array<{ version?: string }> }; + }; + expect(envelope.source).toBeUndefined(); + expect(envelope.entries.count).toBe(1); + 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( diff --git a/src/tools/package-changelog.test.ts b/src/tools/package-changelog.test.ts index 10476db6..5d1878a9 100644 --- a/src/tools/package-changelog.test.ts +++ b/src/tools/package-changelog.test.ts @@ -77,6 +77,33 @@ describe("createPackageChangelogTool — happy path", () => { expect(payload.entries.items.length).toBe(2); }); + it("omits source when package version entries have no changelog source", async () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService({ + packageChangelog: mock(() => + Promise.resolve({ + ...defaultChangelogReport, + source: undefined, + entries: [defaultChangelogReport.entries[0]!], + }), + ), + }), + ); + + const result = await tool.handler( + { registry: "npm", package_name: "express" }, + {}, + ); + + 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(1); + }); + it("accepts repo_url addressing and emits repoUrl in the envelope", async () => { const tool = createPackageChangelogTool( createMockPackageIntelligenceService(), diff --git a/src/tools/package-changelog.ts b/src/tools/package-changelog.ts index ee71df10..04771e75 100644 --- a/src/tools/package-changelog.ts +++ b/src/tools/package-changelog.ts @@ -89,13 +89,14 @@ const DESCRIPTION = "With `from_version`, returns every entry in the " + "`[from_version, to_version]` range (range mode, no count cap). " + "Address via `registry` + `package_name` or `repo_url` (mutually " + - 'exclusive). Response: `source` (`"releases"` / `"changelog_file"` ' + - '/ `"hexdocs"`), `mode` (`"latest"` or `"range"`), ' + + 'exclusive). Response includes optional `source` (`"releases"` / ' + + '`"changelog_file"` / `"hexdocs"`) when a concrete changelog source ' + + 'exists, `mode` (`"latest"` or `"range"`), ' + "`entries: { count, items }` with full markdown bodies. Set " + "`include_bodies: false` for a version / date / URL timeline only. " + - "Supports npm, PyPI, Hex, Crates, vcpkg, Zig, NuGet, Maven, " + - "Packagist; returns `NOT_FOUND` when a package has no changelog " + - "source."; + "Package-version entries without changelog text succeed with `source` " + + "omitted; no-source plus no entries returns `NOT_FOUND`. Supports npm, " + + "PyPI, Hex, Crates, vcpkg, Zig, NuGet, Maven, Packagist."; export function createPackageChangelogTool( service: PackageIntelligenceService,