Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions docs/implementation/mcp-cli-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions docs/implementation/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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.

Expand All @@ -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`. `<spec>@<version>` 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

Expand Down
27 changes: 27 additions & 0 deletions src/commands/pkg/changelog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});

Expand Down
76 changes: 76 additions & 0 deletions src/services/package-intelligence-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
16 changes: 7 additions & 9 deletions src/services/package-intelligence-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -2023,13 +2023,12 @@ export class PackageIntelligenceServiceImpl
data: z.infer<typeof changelogReportResponseSchema>,
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
Expand All @@ -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,
Expand Down
18 changes: 14 additions & 4 deletions src/shared/package-changelog-response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading