diff --git a/changes/changelog-classified-statements.changed.md b/changes/changelog-classified-statements.changed.md new file mode 100644 index 00000000..15717445 --- /dev/null +++ b/changes/changelog-classified-statements.changed.md @@ -0,0 +1,6 @@ +--- +"githits": minor +"@githits/mcp": minor +--- + +- **Complete changelog statements** - Package changelogs now default to all parsed bullets with available agent classifications, compact kind tags with bracketed action markers, uncertainty, coverage and nearby sources; no-impact statements stay plain bullets. `changes` filters actionable, important or all statements before pagination. `verbatim` shows full original notes instead; `verbose` is its deprecated alias. Timeline-only omission skips parsing/classification. Default JSON replaces raw bodies and risk-only fields with complete statement evidence; use `--verbatim --json` / `verbatim:true,format:"json"` for original bodies. diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index a283bae5..7682f74a 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -55,7 +55,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 ` | 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 changelog ` | package spec (`registry:name[@version\|@from..to]`) | `--from`, `--to`, `--limit`, `--changes`, `--no-body`, `--verbatim`, `--verbose`, `--json` | All parsed statements with available classifications. `--changes` filters; `--verbatim` shows original notes instead, `--verbose` is its deprecated alias; `--no-body` shows only the timeline. | | `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, release-note quotes grouped by version, agent classification, sources and coverage, deprecation metadata, peer changes, dependency changes, and transitive security evidence by default. Reports evidence without a package verdict. | | `docs list ` *(legacy compatibility)* | package spec (optional `@version`) | `--limit`, `--after`, `--verbose`, `--json` | Help points hosted-site browsing to `githits list site:` and package-local docs to the package target. Existing execution remains unchanged: text emits target-based read commands; JSON retains `docsReadTarget`, stable `pageId`, provenance `sourceUrl`, and exact repo-file metadata when available. | | `list [paths...]` | package, repository, or `site:` target; optional literal paths/globs | `-R, --recursive`, `-s, --silent`, repeatable `--file-type`, `--language`, `--intent`, `--limit`, `--after`, `--wait`, `--json` | List one package/repository source inventory, including package-local documentation files, or one explicitly targeted hosted site. Text is one path per line with `/` on directories; the header reuses backend-authored read targets for follow-up, while `--silent` emits only paths for piping. JSON carries exact actions, cursors, and metadata. | @@ -721,39 +721,33 @@ Compact issue evidence also stays within the resolved terminal width, using ASCI ### `githits pkg changelog` -``` -githits pkg changelog npm:express -githits pkg changelog npm:express@5.2.1 -githits pkg changelog npm:express@4.0.0..5.2.1 +```bash +githits pkg changelog npm:express # all parsed statements +githits pkg changelog npm:express@5.2.1 # one selected release +githits pkg changelog npm:express@4.0.0..5.2.1 # start excluded, end included githits pkg changelog npm:express --from 4.0.0 githits pkg changelog npm:express --to 4.18.0 --limit 5 -githits pkg changelog npm:express --json -githits pkg changelog pypi:requests --no-body --json # lean timeline +githits pkg changelog npm:express --changes important # action + should know +githits pkg changelog npm:express --changes actionable +githits pkg changelog npm:express --verbatim # full original notes +githits pkg changelog npm:express --verbatim --json # original bodies in JSON +githits pkg changelog npm:express --json # full statement evidence +githits pkg changelog pypi:requests --no-body --json # lean timeline ``` -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.** Required `` in `registry:name`, `registry:name@version`, or `registry:name@from..to` form. Repository and site targets are rejected. - -**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. - -**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`). Tag-style `v`-prefixed inputs are rejected except for Go canonicalisation and Swift. - -**Default terminal output.** Summary header (`name | registry | source | mode | entry count`) followed by each entry's `version date url` header plus the first 10 lines of its markdown body, indented and dimmed. Bodies longer than the cap show a footer `... (+N more lines - use --verbose for the full body)`. Missing dates render as `-`; missing versions render as `(unversioned)`. Exact 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. - -**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?, 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 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. +Default output shows all parsed statements with available agent classifications, +including no-impact and unassessed units; no original-note previews. Source links +appear beside each release/source group. `--changes actionable|important|all` +filters statements before pagination; all is default. Every matching page is +retrieved. Pending and missing-note coverage stays visible; no package verdict. + +`--verbatim` shows full original notes instead, skipping classification. +`--verbose` is a deprecated alias in text and JSON. `--no-body` skips parsing, +notes and classification and cannot combine with either original-note flag. +`--changes` is ignored outside statement mode. Default JSON omits raw bodies and +carries statements with uncut text and per-item provenance; use `--verbatim --json` +for original bodies. See [changelog evidence](pkg-changelog.md) for coverage, +projection, paging and compatibility details. ### `githits pkg upgrade-review` diff --git a/docs/implementation/pkg-changelog.md b/docs/implementation/pkg-changelog.md new file mode 100644 index 00000000..47e88df9 --- /dev/null +++ b/docs/implementation/pkg-changelog.md @@ -0,0 +1,214 @@ +# Package changelog evidence + +`pkg changelog` and MCP `pkg_changelog` show every parsed release-note statement +in source order, with available agent classifications. Raw notes are a separate +view, avoiding duplicate previews. Statements and source links are the evidence; +classifications never assign a package-level verdict. Upgrade review additionally +compares security and dependencies between installed and target versions. + +## Selection and views + +Targets remain latest `registry:name`, exact `registry:name@version`, or range +`registry:name@from..to`. The lower bound is excluded, the upper included. Latest +uses a 1-50 entry limit (default 10); ranges return all selected entries. Exact +uses the backend-selected release, including prereleases, without inventing a +previous version. Statement joins use raw `entry.version`, not normalizedVersion. + +| CLI / MCP | View | +| --- | --- | +| Default | All parsed statements, including NO_IMPACT, oversize and unassessed | +| `--changes actionable` / `changes: "actionable"` | MUST_ACT, including visibly uncertain items | +| `--changes important` / `changes: "important"` | MUST_ACT and SHOULD_KNOW, including uncertain items | +| `--changes all` / `changes: "all"` | Default complete view | +| `--verbatim` / `verbatim: true` | Full original notes instead of statements; no classification work | +| `--verbose` / `verbose: true` | Deprecated alias for verbatim in text and JSON | +| `--no-body` / `omit_bodies: true` | Version/date/URL timeline; no bodies, statement parsing or classification | + +Empty optional filter strings mean all. Unknown active filters fail before +service access. `changes` is ignored, including validation and envelope echo, +outside statement mode. Timeline and an explicit original-note flag conflict +before service access. Explicit false alone preserves the default view. + +MCP `body_lines` remains a deprecated text-only original-note preview request +and line cap (1-50). It selects that legacy view even with `verbatim:false`; +`verbatim:true`/`verbose:true` show full notes and ignore its cap. Timeline wins +over `body_lines` alone. JSON ignores this text-only knob. There is no separate +verbose classification-detail mode or implicit default note preview. + +## Evidence, coverage and sources + +Action kinds use compact tags such as `[!behavior]`; should-know kinds use +`[deprecation]` or `[security fix]`. Action without a kind uses `[!]`, and +should-know without a kind uses `[should know]`. One short legend explains +these tags and plain no-impact bullets. Red/yellow highlighting is optional; +the action marker keeps meaning visible without color. NO_IMPACT stays untagged. +Uncertain statements retain `(uncertain)` with muted highlighting, based only on +the backend `ambiguous` boolean; confidence values remain JSON evidence. +Unassessed statements retain `[not assessed]`; oversize UNCLASSIFIED statements +retain `[too long to classify - read it]`. Model/formulation remain per-item JSON +provenance. Omitting no-impact tags changes presentation only, not classification +or coverage data. + +The backend owns parsing, headings, source order and conservative alternate-source +restatement suppression. The client preserves nested wording/code and predecessor +`context` when suppression would otherwise remove needed context. It performs +no extraction, local deduplication or generated summarization. Text shows the +bounded backend `text` without additional local excerpt limits; backend cuts +are marked. JSON includes uncut statement/context fullText. + +Source URLs appear once at each release/source block, close to their statements. +Embedded Markdown links retain their inline URLs. There is no response-wide +Sources table or invented locator. Missing URLs are stated. Entry-level htmlUrl +is a fallback for releases with no displayed statements, not proof of every +statement's source. Terminal controls are removed from all untrusted text at +presentation; JSON preserves evidence with lowercase enum spelling and the +existing absent-null convention, except classification:null is preserved. + +Coverage names classified/not-assessed versions, versions without notes and +versions without parsed statements. Missing notes never establish no risk. +Filtered text explicitly reports pending and oversize statements excluded by +the filter, including zero matches. Coverage in JSON includes every selected +release, regardless of filtering. Pending may mean a background job is still +running, failed or exceeded the request budget; rerun in a few seconds to a +minute to read completed stored labels. No client polling or automatic reruns. + +## Transport, pagination and ownership + +Backend #3228 exposes `statements(first, offset, tiers)` on PackageChangelogResult +and PackageVersionChangelog, in dev and production. Its 100-item maximum page +size replaces the old risk-only 50-item cap for changelog views. Filters apply +before pagination. The upgrade-review risk API remains unchanged. + +The core service retrieves every matching page. Original-note/timeline and up +to 100 matching statements need one query; larger results need one query/page. +Follow-up pages reuse the same release/range selector and omit first-page release +metadata. No product-side second query is added for optional details. + +| Projection | Selected data | +| --- | --- | +| Statement text | First-page version/date/URL; every-page text, position/sourceIndex, heading/source/URL, context, tier/ambiguous/kind and page controls | +| Statement JSON | Above plus normalizedVersion, sourcePosition, fullText/context fullText, confidences, model/formulation and aggregate source; no entry.body | +| All statements | Coverage version/status/statementsTotal; assessment counts derived from collected answers | +| Filtered statements | Full backend assessment coverage to detect shifting filtered offsets | +| Original notes | Entry.body and timeline fields; JSON adds normalizedVersion/source; no statements/risk/provenance | +| Timeline | Timeline fields only; JSON adds normalizedVersion/source; no bodies/statements/risk | +| Follow-up page | Statement projection/controls/coverage only; no entries, source summary, dates, normalized versions or body | + +Offsets address current evidence, not immutable snapshots. In all mode, arriving +labels cannot shift parsed positions: compare parse coverage/totals, validate +contiguous ordered positions and derive final assessment coverage from collected +items. Some earlier items may still be null when later pages carry new answers; +coverage honestly describes the returned evidence. Zero-unit note statuses remain +authoritative. Filtered mode compares full coverage and matching counts; observable +changes return a retryable rerun error because labels can shift filtered offsets. +All modes validate forward offsets, page counts and nonduplicate ordered keys. +Same-count source edits cannot reliably be detected; no snapshot guarantee. + +Core package-intelligence service owns transport/page validation. Shared request +resolution, JSON shaping and the per-tool formatter live in packages/mcp/src/shared; +CLI and MCP supply host-native expansion hints. The small kind-label vocabulary +is shared with upgrade review, whose classification/provenance contract is retained. + +## JSON compatibility + +The envelope retains registry/name, optional source, mode, entries:{count,items} +and explicit filter. Statement mode adds statements:{count,totalCount,matchingCount, +items,coverage}; count equals items.length after all pages are collected. Entry +version remains present (possibly null), hasChangelog is present for exact JSON. +Default JSON no longer includes raw entry.body or deprecated top-level riskItems/ +riskCoverage. Callers needing original bodies use `--verbatim --json` or +`verbatim:true,format:"json"`. Verbose now selects the same original-note JSON +view; it is no longer a text-only expansion option. Both artifacts have minor +pending impact during 0.x. Empty selections and exact releases without notes +remain successful; actual missing packages/versions keep mapped errors. + +## Validation and measurements + +Verified on 2026-10-14 against dev, using normal CLI authentication. Maximal +filtered JSON operations measured **85 exact / 88 latest-50 / 113 range**, below +500. Admission-only probes add a separately verified 510-cost control (51 fixed +packageVulnerabilities aliases); rejected totals595/598/623 minus510 give those +costs. Resolvers do not execute the rejection probes. Text, unfiltered JSON and +follow-up projections select fewer fields than these maxima. + +Built Node CLI and local stdio MCP matched text/JSON for exact/latest/range, all +three filters, empty ranges, missing notes, original-note aliases and timelines. +Observed selected evidence: + +| Selection | Parsed / assessed | No impact identified | All pages | Actionable / important | +| --- | --- | --- | --- | --- | +| Express latest10 | 335 / 335 | 284 | 4 | Not measured | +| Express4.20.0 exact | 40 / 40 | 32 | 1 | 6 / 8 | +| Express4.18.2..5.1.0 | 271 / 271 | 228 | 3 | 30 / 43 | +| Biome2.4.2..2.4.15 | 875 / 875 | 813 | 9 | 40 / 62 | + +No pending, oversize or unparseable units were observed in these live ranges. +Missing-note is-even1.0.0 succeeded with one WITHOUT_NOTES version and zero units. +Pending/oversize/unparseable and labels arriving between pages are tested with +fixtures. Stored formulations included s-hier-v2, s-hier-v3 and s-hier-v5. + +These are complete-output size/timing observations, not a speedup benchmark. +Old built preview+risk text versus new all-statements bytes: + +| Selection | Before bytes | After bytes | New built text time | +| --- | --- | --- | --- | +| Express latest10 | 20,039 | 57,029 | 5.907s | +| Express4.20.0 | 2,501 | 6,482 | 2.174s | +| Express4.18.2..5.1.0 | 16,861 | 40,760 | 3.030s | +| Biome2.4.2..2.4.15 | 39,824 | 260,050 | 14.853s | + +Initial Biome text runs before the wrapping fix took10.672s and10.218s with +identical259,575bytes; JSON was772,829bytes and took11.570s. Final text above +wraps nested prose and labels predecessor context clearly. These individual +observations include concurrent smoke-validation load, CLI startup/auth and all +required page requests; they do not isolate rendering cost. +Pages are100-unit backend pages (collection and selectors verified in wire tests), +not model spend. The API does not reveal whether labels were initially stored; +no cold-run claim. Complete all output is larger because it includes no-impact +statements and uncapped source evidence; filters reduce the same Biome range to +40 actionable or62 important statements in one page. + +Focused core/shared/CLI/MCP/parity/server/smoke, smoke-script and upgrade regression tests passed +567 cases/2,855 assertions; after the wrapping/context correction, the affected +formatter/tool/CLI/parity/upgrade checks passed162 cases/992 assertions. Build, +typecheck and public-package validation passed. Source unauthenticated +CLI/MCP and built Node smoke modes validate registration/auth handling. External +Opus review round2 was clean after fixing continuation wrapping/context wording; +its one independent final review also found no issues. Both reviewed commits +passed Linux/Windows, build, Bun/Node20/22/24/26 and package-validation CI checks. Full live +smoke harnesses could not use macOS keychain authentication under their isolated +HOME (system-keychain-unavailable), without exporting credentials; the affected +feature's live matrix above uses ordinary CLI/local stdio authentication instead. + +The descriptor-intent Codex exact-release workload used quick_start, +pkg_changelog(default), pkg_vulns and pkg_changelog(verbatim:true), four logical +calls in25.154s. Its answer cited the rollback and rejected-CVE evidence. Trace +validation had zero isolation violations. This is an inspected trace, not a +quality grade. No new package verdict was inferred from model labels. + +The final compact tags use `[!kind]` for action and `[kind]` for should-know, +with `[!]` / `[should know]` when a kind is missing. No-impact statements stay +plain bullets, explained once in the legend; uncertainty stays explicit. +Focused formatter/CLI/MCP/parity/upgrade tests pass165 cases/1,013 assertions, +with build, typecheck and all four source/built credential-free smoke modes +passing. Authenticated dev built CLI/local MCP text parity passes Express4.22.0, +4.20.0 and5.2.1. Express4.20.0 text is5,687bytes versus6,482before shortening +labels; this is output size, not rendering speed. JSON and query selections, +and their measured complexity, are unchanged. The final descriptor-intent +workload uses three logical calls in19.1s and cites rollback/rejected-CVE evidence +with no isolation violations; this is an inspected trace, not a quality grade. +External Opus bracket-tag review and its independent final check have no code +findings. The final check's stale documentation assertion count is corrected. +No unresolved findings or deferred work remain. + +## Release guidance staging + +Published skills/githits-package stays unchanged until this behavior reaches its +release branch, following .agents/skills/githits-release/SKILL.md. At release, +update changelog examples/flags and routing to describe all parsed bullets with +available classifications, `--changes actionable|important|all`, explicit +`--verbatim` original notes and timeline `--no-body`. Verbose is a deprecated +verbatim alias; changes is ignored outside statement mode. Raw bodies require +verbatim even in JSON. Preserve exact/range boundaries and upgrade-review routing; +never add a package verdict. Generate/check plugin assets and skill packaging +when applying those canonical release edits. diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 050415b8..c165ce26 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -175,7 +175,7 @@ callable. | `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` | `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` excludes `from` and includes `to`. Empty selections succeed with no entries. | +| `pkg_changelog` | `target`, `limit?`, `changes?`, `omit_bodies?`, `verbatim?`, `verbose?`, `body_lines?`, `format?` | All parsed statements with available classifications; `changes` filters before pagination. `verbatim` shows original notes instead; `verbose` is its deprecated alias. Body omission shows only the timeline. Latest mode caps entries; pins select one release; `@from..to` excludes `from` and includes `to`. Empty selections succeed. | | `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. | | `read` | `target` (string), `path?`, `selector?`, `start_line?`, `end_line?`, `wait_timeout_ms?`, `format?` | Pass a code file target + path, an explicit `site:` target + target-relative page path, a compact `target#symbol` or selector, or another emitted docs target to unified backend read; the returned type determines code/docs presentation. Preserve emitted site action values exactly; do not repeat the target's scope in the path. `/` reads the site's landing page. HTTP(S) docs fragments select sections unless explicit bounds override them. Text displays 150/300 lines; exact-file code caps before fetching, while docs JSON keeps the backend selection. The backend applies wait where relevant. See [unified read](unified-read.md). | | `grep` | `targets` (ordered `{target, corpus?, path_selectors?}` objects), `pattern`, `pattern_type?`, `ignore_case?`, `context_lines_before?`, `context_lines_after?`, `max_matches?`, `cursor?`, `wait_timeout_ms?`, `format?` | Search known regex or literal matches across package, repository, and explicit site targets. Regex, case-sensitive matching, zero context, all indexed repository files, 100 matches, and a 30,000 ms first-page preparation wait are the defaults. Package targets also include selected hosted docs. Follow exact read locators and replay partial pages with the same targets and controls. Legacy repository-file filters remain on CLI `githits code grep`. See [unified grep](unified-grep.md). | @@ -529,17 +529,31 @@ JSON retains backend order and multiplicity. ### `pkg_changelog` response shape +Package changelogs default to all parsed statements with available classifications, +including no-impact, oversize and unassessed units. `changes:"actionable"|"important"|"all"` +filters before pagination; every matching page is retrieved. `ambiguous` alone +controls uncertainty. See [changelog evidence](pkg-changelog.md) for the complete +contract, coverage, projections and release guidance staging. + **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?, 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 effective body option omits bodies (`omit_bodies: true`, unless text `verbose:true` overrides it). The backend's opaque per-entry `metadata` GenericJSON is deliberately dropped from the envelope. +**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 present only in original-note mode (`verbatim:true` or deprecated `verbose:true`). Default JSON adds `statements:{count,totalCount,matchingCount,items,coverage}` with full statement/context text and per-item classification provenance, preserving classification:null. Deprecated top-level riskItems/riskCoverage are no longer selected for changelog; upgrade review is unchanged. The backend's opaque per-entry `metadata` GenericJSON is deliberately dropped from the envelope. **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 releases after `from` through `to` (starting version excluded, ending version included). Open bounds `from..` and `..to` are accepted. Repository and site targets are rejected before network access. Use `registry:name@from` separately for the starting release's own notes. Exact pins query `packageInfo.selectedVersion.changelog`; latest and interval targets query `packageChangelog`. **Mode selection.** A from bound triggers range mode (every entry in `(fromVersion, toVersion]` with no cap). The lower bound is excluded and the upper bound is included, for inline targets and CLI flags alike. Text spells out these boundaries beside the interval notation. 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 compact 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` overrides `body_lines` and `omit_bodies:true` in text, fetching and displaying full bodies instead of rejecting conflicting preview controls. Ignored `body_lines` values skip range validation, including when bodies are omitted. JSON still honors `omit_bodies:true` because `verbose` is text-only. 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." +**Views and aliases.** `verbatim:true` shows full original notes instead of +statements, in text and JSON. `verbose:true` is a deprecated alias. +`omit_bodies:true` shows only the version/date/URL timeline and conflicts with +an explicit original-note flag. Both views skip classification and ignore changes. +MCP `body_lines` is a deprecated text-only original-note preview selector and cap +(1-50); explicit raw-note flags ignore it, omission wins over it alone, and JSON +ignores it. Explicit `verbatim:false` alone preserves default; body_lines still +selects its legacy preview when present. Default text never adds raw-note previews. +Source links stay next to release/source groups, without a global Sources table. -**`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. +**`filter.*` echo.** `filter` is emitted only when the caller explicitly supplied at least one of `fromVersion`, `toVersion`, `limit`, `version`, or an effective `changes` view. 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 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. @@ -547,7 +561,7 @@ JSON retains backend order and multiplicity. **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"` and asserts CLI/MCP equality for latest, exact, range, omitted bodies, empty selections, and mapped errors. +`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 parsed statements with native `format="json"` hints for bounded cuts and `verbatim=true` for original notes. 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 @@ -799,7 +813,7 @@ cost savings. Captures and reproduction scripts are under ignored **Example-search anatomy.** `get_example` text mode returns markdown with source provenance, followed by `solution_id: ` when the response includes an app URL. This avoids JSON-wrapped markdown while preserving result identity. Omit `language` to infer it; if GitHits cannot match it, the error lists languages to retry with. -**Package metadata anatomy.** `pkg_info`, `pkg_vulns`, `pkg_deps`, and `pkg_changelog` text mode reuse their shared no-color terminal formatters and inject surface-native hints where needed. `pkg_upgrade_review` uses one shared CLI/MCP formatter with caller width and ANSI as inputs. `pkg_deps` hides non-runtime groups by default and says `pass lifecycle="all"` when groups exist. `pkg_changelog` caps body previews and says `pass verbose=true`, `body_lines=`, or `format="json"` when text omitted lines. Package errors respect the selected format: default/text explains the failure and useful recovery; explicit JSON retains `{error, code, retryable, details?}` for programmatic callers. +**Package metadata anatomy.** `pkg_info`, `pkg_vulns`, `pkg_deps`, and `pkg_changelog` text mode reuse their shared no-color terminal formatters and inject surface-native hints where needed. `pkg_upgrade_review` uses one shared CLI/MCP formatter with caller width and ANSI as inputs. `pkg_deps` hides non-runtime groups by default and says `pass lifecycle="all"` when groups exist. `pkg_changelog` defaults to all parsed bullets; its deprecated explicit preview cap says `pass verbatim=true`, while backend statement cuts say `pass format="json"`. Package errors respect the selected format: default/text explains the failure and useful recovery; explicit JSON retains `{error, code, retryable, details?}` for programmatic callers. **Unified search outcome-first anatomy** (CLI human search/search-status and MCP `search` / `search_status` text-v1). One shared presentation model owns target diff --git a/packages/core-internal/src/services/package-changelog-statements.test.ts b/packages/core-internal/src/services/package-changelog-statements.test.ts new file mode 100644 index 00000000..b4607499 --- /dev/null +++ b/packages/core-internal/src/services/package-changelog-statements.test.ts @@ -0,0 +1,408 @@ +import { describe, expect, it, mock } from "bun:test"; +import { + MalformedPackageIntelligenceResponseError, + type PackageChangelogParams, + PackageIntelligenceBackendError, + PackageIntelligenceServiceImpl, +} from "./package-intelligence-service.js"; +import { createMockTokenProvider } from "./test-helpers.js"; + +function statement(position = 0, classified = true, json = false) { + return { + version: "1.0.0", + position, + sourceIndex: 0, + heading: null, + source: "RELEASES", + url: "https://example.com/1.0.0", + text: `* Statement ${position}`, + textTruncated: false, + context: null as { + heading: string; + text: string; + textTruncated: boolean; + fullText: string; + } | null, + ...(json + ? { sourcePosition: position, fullText: `* Full statement ${position}` } + : {}), + classification: classified + ? { + tier: "MUST_ACT", + ambiguous: true, + kind: null, + ...(json + ? { + tierConfidence: 0.95, + kindConfidence: null, + model: "jev", + formulation: "s-hier-v5", + } + : {}), + } + : null, + }; +} +function coverage(total = 1, assessed = total) { + return [ + { + version: "1.0.0", + status: assessed === total ? "CLASSIFIED" : "NOT_ASSESSED", + statementsTotal: total, + statementsAssessed: assessed, + statementsNotAssessed: total - assessed, + statementsNoImpact: 0, + statementsUnclassified: 0, + }, + ]; +} +function page( + items = [statement()], + total = items.length, + offset = 0, + cov = coverage(total), +) { + const remainingCount = total - offset - items.length; + return { + items, + totalCount: total, + matchingCount: total, + remainingCount, + hasMore: remainingCount > 0, + nextOffset: remainingCount > 0 ? offset + items.length : null, + coverage: cov, + }; +} +function setup(pages: unknown[], params: Partial = {}) { + const requests: { query: string; variables: Record }[] = []; + const fetchFn = mock( + async (_url: unknown, init?: RequestInit): Promise => { + const request = JSON.parse(String(init?.body)); + requests.push(request); + const index = requests.length - 1; + const metadata = + index === 0 + ? { + source: "releases", + entries: [ + { + version: "1.0.0", + body: "raw notes", + htmlUrl: "https://example.com/1.0.0", + }, + ], + detailSource: "RELEASES", + hasChangelog: true, + entry: { + body: "raw notes", + htmlUrl: "https://example.com/1.0.0", + }, + } + : {}; + const block = { + ...metadata, + ...(pages[index] === undefined ? {} : { statements: pages[index] }), + }; + return new Response( + JSON.stringify({ + data: + params.version !== undefined + ? { + packageInfo: { + selectedVersion: { + ...(index === 0 ? { resolvedVersion: "1.0.0" } : {}), + changelog: block, + }, + }, + } + : { packageChangelog: block }, + }), + ); + }, + ); + const service = new PackageIntelligenceServiceImpl( + "https://pkgseer.dev", + createMockTokenProvider(), + fetchFn as unknown as typeof fetch, + ); + return { + requests, + fetchFn, + call: () => + service.packageChangelog({ + registry: "NPM", + packageName: "example", + ...params, + }), + }; +} + +describe("changelog view projections", () => { + for (const version of [undefined, "1.0.0"]) { + for (const view of ["statements", "verbatim", "timeline"] as const) { + for (const includeJsonFields of [false, true]) { + for (const statementTiers of [undefined, ["MUST_ACT"] as const]) { + it(`${version ? "exact" : "range"} ${view} json=${includeJsonFields} filter=${Boolean(statementTiers)}`, async () => { + const run = setup([page([statement(0, true, includeJsonFields)])], { + version, + view, + includeJsonFields, + statementTiers: statementTiers ? [...statementTiers] : undefined, + ...(!version ? { fromVersion: "0.9.0", toVersion: "1.0.0" } : {}), + }); + const result = await run.call(); + const wire = run.requests[0]!; + expect(run.fetchFn).toHaveBeenCalledTimes(1); + expect(wire.query).not.toContain("riskItems"); + expect(wire.query).not.toContain("riskCoverage"); + expect(wire.query).not.toMatch(/package\s*\{/); + expect(wire.query.includes("source entries")).toBe( + version === undefined && includeJsonFields, + ); + expect(wire.query.includes("detailSource")).toBe( + version !== undefined && includeJsonFields, + ); + expect(wire.query.includes("hasChangelog")).toBe( + version !== undefined && + (includeJsonFields || view !== "statements"), + ); + expect(wire.query.includes("normalizedVersion")).toBe( + includeJsonFields, + ); + expect(wire.query.match(/\bbody\b/) !== null).toBe( + view === "verbatim", + ); + expect(wire.query.includes("statements(first: 100")).toBe( + view === "statements", + ); + expect(wire.query.includes("fullText")).toBe( + view === "statements" && includeJsonFields, + ); + expect(wire.query.includes("model formulation")).toBe( + view === "statements" && includeJsonFields, + ); + expect(wire.query.includes("statementsAssessed")).toBe( + view === "statements" && Boolean(statementTiers), + ); + expect(wire.variables.offset).toBe( + view === "statements" ? 0 : undefined, + ); + expect(wire.variables.tiers).toEqual( + view === "statements" ? statementTiers : undefined, + ); + expect(result.entries[0]?.body).toBe( + view === "verbatim" ? "raw notes" : undefined, + ); + if (view === "statements") { + expect( + result.statements?.items[0]?.classification?.ambiguous, + ).toBe(true); + expect(result.statements?.items[0]?.classification?.model).toBe( + includeJsonFields ? "jev" : undefined, + ); + expect( + result.statements?.items[0]?.classification?.tierConfidence, + ).toBe(includeJsonFields ? 0.95 : undefined); + } else expect(result.statements).toBeUndefined(); + }); + } + } + } + } +}); + +describe("complete changelog pagination", () => { + it.each([undefined, "1.0.0"])( + "collects over100 statements for selector %s, omitting repeat metadata", + async (version) => { + const run = setup( + [ + page( + Array.from({ length: 100 }, (_, i) => statement(i)), + 101, + ), + page([statement(100)], 101, 100), + ], + { version }, + ); + const result = await run.call(); + expect(result.statements?.items).toHaveLength(101); + expect(result.statements?.matchingCount).toBe(101); + expect(run.requests).toHaveLength(2); + expect(run.requests[1]?.variables).toMatchObject({ + offset: 100, + ...(version ? { version } : {}), + }); + for (const field of [ + "entries", + "entry {", + "detailSource", + "hasChangelog", + "resolvedVersion", + "publishedAt", + "normalizedVersion", + "body", + "source entries", + ]) + expect(run.requests[1]?.query).not.toContain(field); + }, + ); + + it("allows answers arriving between all-mode pages and reports only collected assessment", async () => { + const run = setup([ + page( + Array.from({ length: 100 }, (_, i) => statement(i, false)), + 101, + 0, + coverage(101, 0), + ), + page([statement(100)], 101, 100, coverage(101)), + ]); + const result = await run.call(); + expect(result.statements?.coverage[0]).toMatchObject({ + status: "NOT_ASSESSED", + statementsAssessed: 1, + statementsNotAssessed: 100, + }); + expect(result.statements?.items[0]?.classification).toBeNull(); + expect(result.statements?.items[100]?.classification).not.toBeNull(); + }); + + it("fails explicitly when filtered offsets change during assessment, with no automatic retry", async () => { + // First response total is all parsed units, matching is filtered units. + const first = page( + Array.from({ length: 100 }, (_, i) => statement(i)), + 101, + 0, + coverage(103, 101), + ); + first.totalCount = 103; + const stableRun = setup( + [ + first, + { + ...page([statement(100)], 102, 100, coverage(103, 102)), + totalCount: 103, + }, + ], + { statementTiers: ["MUST_ACT"] }, + ); + await expect(stableRun.call()).rejects.toThrow( + PackageIntelligenceBackendError, + ); + expect(stableRun.fetchFn).toHaveBeenCalledTimes(2); + // No third request even when the second response advertises another page. + }); + + it("filtered pages allow position gaps and preserve full coverage", async () => { + const p = page([statement(3), statement(7)], 2, 0, coverage(10, 9)); + p.totalCount = 10; + const result = await setup([p], { statementTiers: ["MUST_ACT"] }).call(); + expect(result.statements?.items.map((item) => item.position)).toEqual([ + 3, 7, + ]); + expect(result.statements?.coverage[0]?.statementsNotAssessed).toBe(1); + }); + + it("keeps no-impact, oversize, missing and unparseable evidence distinct", async () => { + const p = page([statement(), statement(1), statement(2, false)]); + p.items[0]!.classification!.tier = "NO_IMPACT"; + p.items[1]!.classification!.tier = "UNCLASSIFIED"; + const result = await setup([p]).call(); + expect(result.statements?.coverage[0]).toMatchObject({ + statementsAssessed: 2, + statementsNotAssessed: 1, + statementsNoImpact: 1, + statementsUnclassified: 1, + }); + for (const status of ["WITHOUT_NOTES", "UNPARSEABLE"] as const) { + const empty = page([], 0, 0, [{ ...coverage(0)[0]!, status }]); + expect( + (await setup([empty]).call()).statements?.coverage[0]?.status, + ).toBe(status); + } + }); + + it.each([{ nextOffset: 0 }, { hasMore: false }, { remainingCount: 0 }])( + "rejects malformed progress %j", + async (patch) => { + const run = setup([{ ...page([statement()], 2), ...patch }]); + await expect(run.call()).rejects.toThrow( + MalformedPackageIntelligenceResponseError, + ); + expect(run.fetchFn).toHaveBeenCalledTimes(1); + }, + ); + it.each([ + { items: [statement(0), statement(0)] }, + { items: [statement(1)] }, + { items: [statement(0), { ...statement(1), version: "unknown" }] }, + ])( + "rejects duplicate, skipped or unknown all-mode positions", + async ({ items }) => { + await expect(setup([page([...items])]).call()).rejects.toThrow( + MalformedPackageIntelligenceResponseError, + ); + }, + ); + it("rejects coverage for releases absent from the displayed timeline", async () => { + const p = page([{ ...statement(), version: "2.0.0" }], 1, 0, [ + { ...coverage()[0]!, version: "2.0.0" }, + ]); + await expect(setup([p]).call()).rejects.toThrow( + MalformedPackageIntelligenceResponseError, + ); + }); + it("rejects missing selected fields instead of inventing coverage", async () => { + await expect(setup([undefined]).call()).rejects.toThrow( + MalformedPackageIntelligenceResponseError, + ); + await expect( + setup([page()], { includeJsonFields: true }).call(), + ).rejects.toThrow(MalformedPackageIntelligenceResponseError); + const p = page(); + await expect( + setup( + [ + { + ...p, + coverage: [ + { version: "1.0.0", status: "CLASSIFIED", statementsTotal: 1 }, + ], + }, + ], + { statementTiers: ["MUST_ACT"] }, + ).call(), + ).rejects.toThrow(MalformedPackageIntelligenceResponseError); + }); + it("retains fullText, context, mixed formulation and confidence only as evidence", async () => { + const first = statement(0, true, true); + const second = { + ...statement(1, true, true), + classification: { + ...statement(1, true, true).classification!, + formulation: "s-hier-v3", + ambiguous: false, + tierConfidence: 0.1, + }, + context: { + heading: "Context heading", + text: "bounded", + textTruncated: true, + fullText: "uncut predecessor", + }, + }; + const result = await setup([page([first, second])], { + includeJsonFields: true, + }).call(); + expect(result.statements?.items[0]?.fullText).toBe("* Full statement 0"); + expect(result.statements?.items[1]?.context?.fullText).toBe( + "uncut predecessor", + ); + expect(result.statements?.items[1]?.classification).toMatchObject({ + formulation: "s-hier-v3", + ambiguous: false, + tierConfidence: 0.1, + }); + }); +}); 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 90f84144..a7813e2f 100644 --- a/packages/core-internal/src/services/package-intelligence-service.test.ts +++ b/packages/core-internal/src/services/package-intelligence-service.test.ts @@ -2445,13 +2445,15 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { const result = await service.packageChangelog({ registry: "NPM", + view: "verbatim", + includeJsonFields: true, packageName: "express", }); expect(result.source).toBeUndefined(); expect(result.entries).toEqual([]); }); - it("sends includeBodies and omits unused metadata from changelog query", async () => { + it("selects timeline fields and omits unused metadata", async () => { let capturedBody: string | undefined; const fetchFn = mock((_url: string, init?: RequestInit) => { capturedBody = init?.body as string; @@ -2483,12 +2485,12 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { await service.packageChangelog({ registry: "NPM", packageName: "express", - includeBodies: false, + view: "timeline", }); const parsed = JSON.parse(capturedBody ?? "{}"); expect(parsed.query).toContain("query PackageChangelog("); - expect(parsed.query).toContain("body @include(if: $includeBodies)"); + expect(parsed.query).not.toContain("statements("); expect(parsed.query).not.toContain("metadata"); expect(parsed.query).not.toContain("repoUrl"); expect(parsed.query).not.toContain("gitRef"); @@ -2496,7 +2498,6 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { expect(parsed.variables).toEqual({ registry: "NPM", name: "express", - includeBodies: false, }); }); @@ -2530,6 +2531,8 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { const result = await service.packageChangelog({ registry: "NPM", + view: "verbatim", + includeJsonFields: true, packageName: "react", }); @@ -2567,6 +2570,8 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { const result = await service.packageChangelog({ registry: "NPM", + view: "verbatim", + includeJsonFields: true, packageName: "react", }); @@ -2609,6 +2614,8 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { const result = await service.packageChangelog({ registry: "NPM", + view: "verbatim", + includeJsonFields: true, packageName: "express", version: "^5.0.0", }); @@ -2619,7 +2626,7 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { 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("statements("); expect(parsed.query).not.toContain("requestedVersion"); expect(parsed.query).not.toContain("packageChangelog("); expect(parsed.query).not.toContain("repoUrl"); @@ -2628,7 +2635,6 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { registry: "NPM", name: "express", version: "^5.0.0", - includeBodies: true, }); expect(result.source).toBe("releases"); expect(result.entries).toEqual([ @@ -2679,14 +2685,13 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { registry: "NPM", packageName: "express", version: "5.2.1", - includeBodies: false, + view: "timeline", }); 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({ @@ -2723,11 +2728,12 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { const result = await service.packageChangelog({ registry: "NPM", + view: "verbatim", + includeJsonFields: true, 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", @@ -2750,6 +2756,8 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { try { await service.packageChangelog({ registry: "NPM", + view: "verbatim", + includeJsonFields: true, packageName: "express", version: "99.99.99", }); @@ -3437,6 +3445,36 @@ describe("PackageIntelligenceServiceImpl — packageUpgradeReview", () => { }); } + for (const field of ["model", "formulation"] as const) { + it(`retains strict upgrade-review ${field} validation`, async () => { + const body = structuredClone(upgradeReviewBody); + const item = + body.data.packageUpgradeReview.reviews[0]!.changelog.riskItems[0]!; + Reflect.deleteProperty(item, field); + const fetchFn = mock(() => Promise.resolve(jsonResponse(body))); + const service = new PackageIntelligenceServiceImpl( + ENDPOINT, + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + await expect( + service.packageUpgradeReview({ + packages: [ + { + registry: "NPM", + name: "express", + currentVersion: "4.0.0", + targetVersion: "5.0.0", + }, + ], + includeTransitiveSecurity: false, + includeDependencyIssues: false, + changelogLimit: 1, + }), + ).rejects.toThrow(MalformedPackageIntelligenceResponseError); + }); + } + it("rejects null fullText because the selected backend field is non-null", async () => { const body = structuredClone(upgradeReviewBody); const item = body.data.packageUpgradeReview.reviews[0]!.changelog diff --git a/packages/core-internal/src/services/package-intelligence-service.ts b/packages/core-internal/src/services/package-intelligence-service.ts index 8c6b76b8..c0f1734f 100644 --- a/packages/core-internal/src/services/package-intelligence-service.ts +++ b/packages/core-internal/src/services/package-intelligence-service.ts @@ -24,10 +24,7 @@ import { PkgseerTransportError, postPkgseerGraphql, } from "../shared/pkgseer-graphql.js"; -import { - type PkgseerRegistry, - toPkgseerRegistryLowercase, -} from "../shared/pkgseer-registry.js"; +import type { PkgseerRegistry } from "../shared/pkgseer-registry.js"; import type { ClientHeaderBuilder } from "../shared/request-headers.js"; import { ClientUpdateRequiredError, @@ -324,7 +321,7 @@ export interface PackageUpgradeChangelogEntry { } /** Backend model labels for one release-note statement, never a package verdict. */ -export interface PackageUpgradeChangelogRiskItem { +export interface ChangelogRiskItem { version: string; tier: "MUST_ACT" | "SHOULD_KNOW" | "UNCLASSIFIED"; /** Backend ambiguity policy; confidence is evidence, not a client threshold. */ @@ -340,11 +337,17 @@ export interface PackageUpgradeChangelogRiskItem { url?: string; heading?: string; source?: string; + model?: string; + formulation?: string; +} + +/** Upgrade reviews always select classifier provenance. */ +export interface PackageUpgradeChangelogRiskItem extends ChangelogRiskItem { model: string; formulation: string; } -export interface PackageUpgradeChangelogRiskCoverage { +export interface ChangelogRiskCoverage { versionsClassified: number; versionsNotAssessed: number; versionsWithoutNotes: number; @@ -359,6 +362,9 @@ export interface PackageUpgradeChangelogRiskCoverage { itemsOmitted: number; } +export interface PackageUpgradeChangelogRiskCoverage + extends ChangelogRiskCoverage {} + export interface PackageUpgradeChangelog { riskItems: PackageUpgradeChangelogRiskItem[]; riskCoverage: PackageUpgradeChangelogRiskCoverage; @@ -700,8 +706,11 @@ export interface PackageChangelogParams { toVersion?: string; /** Latest-mode cap (1–50). Rejected client-side when `fromVersion` is set. */ limit?: number; - /** Include raw markdown bodies in entries. Defaults to true. */ - includeBodies?: boolean; + /** Default: complete parsed statements. Original notes and timeline do no label work. */ + view?: PackageChangelogView; + statementTiers?: ChangelogStatementTier[]; + /** Select full statement text and classification provenance for machine consumers. */ + includeJsonFields?: boolean; } /** @@ -728,12 +737,74 @@ export interface ChangelogEntryDetail { hasChangelog?: boolean; } +export type PackageChangelogView = "statements" | "verbatim" | "timeline"; +export type ChangelogStatementTier = + | "MUST_ACT" + | "SHOULD_KNOW" + | "NO_IMPACT" + | "UNCLASSIFIED"; +export type ChangelogStatementAssessmentStatus = + | "CLASSIFIED" + | "NOT_ASSESSED" + | "WITHOUT_NOTES" + | "UNPARSEABLE"; + +export interface ChangelogStatementClassification { + tier: ChangelogStatementTier; + ambiguous: boolean; + kind?: ChangelogRiskItem["kind"]; + tierConfidence?: number; + kindConfidence?: number; + model?: string; + formulation?: string; +} + +export interface ChangelogStatementContext { + heading?: string; + text: string; + textTruncated: boolean; + fullText?: string; +} + +export interface ChangelogStatement { + version: string; + position: number; + sourceIndex: number; + sourcePosition?: number; + heading?: string; + source?: string; + url?: string; + text: string; + textTruncated: boolean; + fullText?: string; + context?: ChangelogStatementContext; + /** Null is missing assessment, never a no-impact classification. */ + classification: ChangelogStatementClassification | null; +} + +export interface ChangelogStatementCoverage { + version: string; + status: ChangelogStatementAssessmentStatus; + statementsTotal: number; + statementsAssessed: number; + statementsNotAssessed: number; + statementsNoImpact: number; + statementsUnclassified: number; +} + +export interface ChangelogStatements { + totalCount: number; + matchingCount: number; + items: ChangelogStatement[]; + coverage: ChangelogStatementCoverage[]; +} + export interface ChangelogReport { - /** Echo of addressing + filter as the backend saw it. */ + statements?: ChangelogStatements; + /** Optional legacy addressing echo; hosts already know the request identity. */ package?: ChangelogPackageInfo; - /** `"releases"` | `"changelog_file"` | `"hexdocs"` when resolved; absent for package versions with no changelog entry. */ source?: string; - /** Entries in backend/source order. Empty array = resolved source but nothing in range. */ + /** Entries in backend/source order. */ entries: ChangelogEntryDetail[]; } @@ -2026,11 +2097,10 @@ const packageUpgradeChangelogEntrySchema = z.object({ signals: z.array(z.string()), }); -const packageUpgradeChangelogRiskItemSchema = z.object({ +const changelogRiskItemBaseSchema = z.object({ version: z.string(), tier: z.enum(["MUST_ACT", "SHOULD_KNOW", "UNCLASSIFIED"]), ambiguous: z.boolean(), - tierConfidence: z.number().min(0).max(1).nullable(), kind: z .enum([ "REMOVES_OR_RENAMES_API", @@ -2042,17 +2112,22 @@ const packageUpgradeChangelogRiskItemSchema = z.object({ "NOTABLE_CHANGE", ]) .nullable(), - kindConfidence: z.number().min(0).max(1).nullable(), text: z.string(), textTruncated: z.boolean(), fullText: z.string().optional(), url: z.string().nullable(), - heading: z.string().nullable(), source: z.string().nullable(), - model: z.string(), - formulation: z.string(), }); +const changelogRiskDetailFields = { + tierConfidence: z.number().min(0).max(1).nullable(), + kindConfidence: z.number().min(0).max(1).nullable(), + heading: z.string().nullable(), + model: z.string(), + formulation: z.string(), +}; +const packageUpgradeChangelogRiskItemSchema = + changelogRiskItemBaseSchema.extend(changelogRiskDetailFields); const packageUpgradeChangelogRiskCoverageSchema = z.object({ versionsClassified: z.number().int(), versionsNotAssessed: z.number().int(), @@ -2402,17 +2477,6 @@ fragment PackageUpgradeDependencyChangeGroupFields on PackageUpgradeDependencyCh // Zod schema + query for packageChangelog // -------------------------------------------------------------------- -const changelogPackageInfoSchema = z - .object({ - name: z.string().nullable().optional(), - registry: z.string().nullable().optional(), - fromVersion: z.string().nullable().optional(), - toVersion: z.string().nullable().optional(), - limit: z.number().int().nullable().optional(), - }) - .nullable() - .optional(); - const changelogEntryDetailSchema = z.object({ version: z.string().nullable().optional(), normalizedVersion: z.string().nullable().optional(), @@ -2421,12 +2485,70 @@ const changelogEntryDetailSchema = z.object({ publishedAt: z.string().nullable().optional(), }); +const changelogStatementClassificationSchema = z.object({ + tier: z.enum(["MUST_ACT", "SHOULD_KNOW", "NO_IMPACT", "UNCLASSIFIED"]), + ambiguous: z.boolean(), + kind: changelogRiskItemBaseSchema.shape.kind, + tierConfidence: z.number().min(0).max(1).nullable().optional(), + kindConfidence: z.number().min(0).max(1).nullable().optional(), + model: z.string().optional(), + formulation: z.string().optional(), +}); +const changelogStatementContextSchema = z.object({ + heading: z.string().nullable(), + text: z.string(), + textTruncated: z.boolean(), + fullText: z.string().optional(), +}); +const changelogStatementSchema = z.object({ + version: z.string(), + position: z.number().int().nonnegative(), + sourceIndex: z.number().int().nonnegative(), + sourcePosition: z.number().int().nonnegative().optional(), + heading: z.string().nullable(), + source: z.string().nullable(), + url: z.string().nullable(), + text: z.string(), + textTruncated: z.boolean(), + fullText: z.string().optional(), + context: changelogStatementContextSchema.nullable(), + classification: changelogStatementClassificationSchema.nullable(), +}); +const changelogStatementCoverageSchema = z.object({ + version: z.string(), + status: z.enum([ + "CLASSIFIED", + "NOT_ASSESSED", + "WITHOUT_NOTES", + "UNPARSEABLE", + ]), + statementsTotal: z.number().int().nonnegative(), + statementsAssessed: z.number().int().nonnegative().optional(), + statementsNotAssessed: z.number().int().nonnegative().optional(), + statementsNoImpact: z.number().int().nonnegative().optional(), + statementsUnclassified: z.number().int().nonnegative().optional(), +}); +const changelogStatementPageSchema = z.object({ + items: z.array(changelogStatementSchema), + coverage: z.array(changelogStatementCoverageSchema), + totalCount: z.number().int().nonnegative(), + matchingCount: z.number().int().nonnegative(), + remainingCount: z.number().int().nonnegative(), + nextOffset: z.number().int().nonnegative().nullable(), + hasMore: z.boolean(), +}); +type ChangelogStatementPage = z.infer; const changelogReportResponseSchema = z.object({ - package: changelogPackageInfoSchema, source: z.string().nullable().optional(), entries: z.array(changelogEntryDetailSchema).nullable().optional(), + statements: changelogStatementPageSchema.optional(), +}); +const exactChangelogDetailSchema = z.object({ + detailSource: z.string().nullable().optional(), + hasChangelog: z.boolean().optional(), + entry: changelogEntryDetailSchema.nullable().optional(), + statements: changelogStatementPageSchema.optional(), }); - const changelogGraphQLResponseSchema = z.object({ data: z .object({ @@ -2436,13 +2558,6 @@ const changelogGraphQLResponseSchema = z.object({ .optional(), 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({ @@ -2450,7 +2565,7 @@ const exactChangelogGraphQLResponseSchema = z.object({ .object({ selectedVersion: z .object({ - resolvedVersion: z.string(), + resolvedVersion: z.string().optional(), changelog: exactChangelogDetailSchema.nullable().optional(), }) .nullable() @@ -2464,63 +2579,157 @@ const exactChangelogGraphQLResponseSchema = z.object({ errors: z.array(graphQLErrorSchema).optional(), }); -const PACKAGE_CHANGELOG_QUERY = ` -query PackageChangelog( - $registry: Registry! - $name: String! - $fromVersion: String - $toVersion: String - $limit: Int - $includeBodies: Boolean! = true -) { - packageChangelog( - registry: $registry - name: $name - fromVersion: $fromVersion - toVersion: $toVersion - limit: $limit - ) { - package { - name - registry - fromVersion - toVersion - limit +/** Build only the projection consumed by the selected host view and page. */ +function packageChangelogQuery( + params: PackageChangelogParams, + offset: number, +): string { + const statementView = (params.view ?? "statements") === "statements"; + const json = params.includeJsonFields === true; + const firstPage = offset === 0; + const entry = `${params.version === undefined ? "version" : ""} ${json ? "normalizedVersion" : ""} htmlUrl publishedAt ${params.view === "verbatim" ? "body" : ""}`; + const statements = statementView + ? `statements(first: 100, offset: $offset, tiers: $tiers) { + totalCount matchingCount remainingCount nextOffset hasMore + coverage { + version status statementsTotal + ${params.statementTiers?.length ? "statementsAssessed statementsNotAssessed statementsNoImpact statementsUnclassified" : ""} } - source - entries { - version - normalizedVersion - body @include(if: $includeBodies) - htmlUrl - publishedAt + items { + version position sourceIndex heading source url text textTruncated + ${json ? "sourcePosition fullText" : ""} + context { heading text textTruncated ${json ? "fullText" : ""} } + classification { tier ambiguous kind ${json ? "tierConfidence kindConfidence model formulation" : ""} } } - } -}`; + }` + : ""; + const pageVars = statementView + ? "$offset: Int! $tiers: [ChangelogStatementTier!]" + : ""; + if (params.version !== undefined) + return `query PackageChangelogExact( + $registry: Registry! $name: String! $version: String ${pageVars} + ) { + packageInfo(registry: $registry, name: $name, version: $version) { selectedVersion { + ${firstPage ? "resolvedVersion" : ""} + changelog { ${firstPage ? `${json ? "detailSource" : ""} ${json || !statementView ? "hasChangelog" : ""} entry { ${entry} }` : ""} ${statements} } + } } + }`; + return `query PackageChangelog( + $registry: Registry! $name: String! $fromVersion: String $toVersion: String $limit: Int ${pageVars} + ) { + packageChangelog(registry: $registry, name: $name, fromVersion: $fromVersion, toVersion: $toVersion, limit: $limit) { + ${firstPage ? `${json ? "source" : ""} entries { ${entry} }` : ""} ${statements} + } + }`; +} -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 normaliseChangelogStatement( + item: ChangelogStatementPage["items"][number], +): ChangelogStatement { + // The shared null stripper deliberately omits optional evidence, but a missing + // classification must remain distinguishable from a no-impact answer. + return { + ...(stripNullProperties(item) as Omit< + ChangelogStatement, + "classification" + >), + classification: + item.classification === null + ? null + : stripNullProperties(item.classification), + } as ChangelogStatement; +} + +/** Collect current evidence, without treating arriving labels as parse changes. */ +function collectChangelogStatements( + pages: ChangelogStatementPage[], + filtered: boolean, +): ChangelogStatements { + const first = pages[0]; + if (!first) + throw new MalformedPackageIntelligenceResponseError( + "Missing selected changelog statements.", + ); + const coverage = first.coverage; + const items: ChangelogStatement[] = []; + const positions = new Map(); + let lastVersionIndex = -1; + for (const page of pages) { + for (const item of page.items) { + const index = coverage.findIndex((c) => c.version === item.version); + const previous = positions.get(item.version) ?? -1; + if ( + index < lastVersionIndex || + index < 0 || + item.position <= previous || + (!filtered && item.position !== previous + 1) + ) + throw new MalformedPackageIntelligenceResponseError( + "Invalid changelog statement ordering or duplicate positions.", + ); + lastVersionIndex = index; + positions.set(item.version, item.position); + items.push(normaliseChangelogStatement(item)); } } -}`; + if ( + items.length !== first.matchingCount || + coverage.reduce((sum, c) => sum + c.statementsTotal, 0) !== + first.totalCount || + (!filtered && first.matchingCount !== first.totalCount) + ) + throw new MalformedPackageIntelligenceResponseError( + "Inconsistent changelog statement counts.", + ); + const finalCoverage: ChangelogStatementCoverage[] = coverage.map((c) => { + if (filtered) { + if ( + c.statementsAssessed === undefined || + c.statementsNotAssessed === undefined || + c.statementsNoImpact === undefined || + c.statementsUnclassified === undefined || + c.statementsAssessed + c.statementsNotAssessed !== c.statementsTotal + ) + throw new MalformedPackageIntelligenceResponseError( + "Missing or inconsistent selected changelog coverage counts.", + ); + return c as ChangelogStatementCoverage; + } + const releaseItems = items.filter((item) => item.version === c.version); + if (releaseItems.length !== c.statementsTotal) + throw new MalformedPackageIntelligenceResponseError( + "Incomplete changelog release statements.", + ); + const assessed = releaseItems.filter( + (item) => item.classification !== null, + ).length; + return { + version: c.version, + statementsTotal: c.statementsTotal, + status: + c.statementsTotal === 0 + ? c.status + : assessed === c.statementsTotal + ? "CLASSIFIED" + : "NOT_ASSESSED", + statementsAssessed: assessed, + statementsNotAssessed: c.statementsTotal - assessed, + statementsNoImpact: releaseItems.filter( + (item) => item.classification?.tier === "NO_IMPACT", + ).length, + statementsUnclassified: releaseItems.filter( + (item) => item.classification?.tier === "UNCLASSIFIED", + ).length, + }; + }); + return { + items, + totalCount: first.totalCount, + matchingCount: first.matchingCount, + coverage: finalCoverage, + }; +} function normaliseChangelogSource( raw: string | null | undefined, @@ -3874,87 +4083,124 @@ export class PackageIntelligenceServiceImpl 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 { - response = await postPkgseerGraphql({ - endpointUrl: this.endpointUrl, - token, - query: PACKAGE_CHANGELOG_QUERY, - variables: { - registry: params.registry, - name: params.packageName, - fromVersion: params.fromVersion, - toVersion: params.toVersion, - limit: params.limit, - 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); + const pages: ChangelogStatementPage[] = []; + let nextOffset: number | null = 0; + let report: ChangelogReport | undefined; + while (nextOffset !== null) { + const offset: number = nextOffset; + const page = await this.fetchPackageChangelogPage(token, params, offset); + if (offset === 0) report = page.report; + if ((params.view ?? "statements") !== "statements") break; + const statements = page.statements; + if (!statements) + throw new MalformedPackageIntelligenceResponseError( + "Missing selected changelog statements.", + ); + if ( + params.includeJsonFields && + statements.items.some( + (item) => + item.fullText === undefined || + item.sourcePosition === undefined || + (item.context && item.context.fullText === undefined) || + (item.classification && + (item.classification.model === undefined || + item.classification.formulation === undefined || + item.classification.tierConfidence === undefined || + item.classification.kindConfidence === undefined)), + ) + ) + throw new MalformedPackageIntelligenceResponseError( + "Missing selected changelog statement evidence fields.", + ); + const next = offset + statements.items.length; + if ( + statements.items.length > 100 || + statements.remainingCount !== statements.matchingCount - next || + statements.hasMore !== statements.remainingCount > 0 || + (statements.hasMore + ? statements.nextOffset !== next || next <= offset + : statements.nextOffset !== null) + ) + throw new MalformedPackageIntelligenceResponseError( + "Invalid changelog statement page progress.", + ); + pages.push(statements); + // Validate comparable evidence before making another request. Final counts + // and ordering are checked by collection once all pages have arrived. + if (pages.length > 1) { + const first = pages[0]; + const signature = (p: ChangelogStatementPage): string => + JSON.stringify( + params.statementTiers?.length + ? p.coverage + : p.coverage.map((c) => [ + c.version, + c.statementsTotal, + c.statementsTotal === 0 ? c.status : null, + ]), + ); + if ( + first && + (first.totalCount !== statements.totalCount || + first.matchingCount !== statements.matchingCount || + signature(first) !== signature(statements)) + ) + throw new PackageIntelligenceBackendError( + "Changelog evidence changed during retrieval; classification may still be in progress. Rerun to retrieve the current view.", + undefined, + undefined, + true, + ); } - throw cause; - } - - if (response.status < 200 || response.status >= 300) { - throw this.createHttpError(response); + if (!statements.hasMore) break; + nextOffset = statements.nextOffset; } - - const parsed = changelogGraphQLResponseSchema.safeParse( - response.parsedBody, - ); - if (!parsed.success) { + if (!report) 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, + "Empty changelog response.", ); - } - - const data = parsed.data.data?.packageChangelog; - if (!data) { - throw new MalformedPackageIntelligenceResponseError( - "Empty response from the package-intelligence service.", + if ((params.view ?? "statements") === "statements") { + report.statements = collectChangelogStatements( + pages, + Boolean(params.statementTiers?.length), ); + const versions = report.entries.map((entry) => entry.version); + if ( + JSON.stringify(versions) !== + JSON.stringify(report.statements.coverage.map((c) => c.version)) + ) + throw new MalformedPackageIntelligenceResponseError( + "Changelog coverage does not match the selected releases.", + ); } - - return this.normaliseTimelineChangelogReport(data); + return report; } - private async executeExactPackageChangelog( + private async fetchPackageChangelogPage( token: string, params: PackageChangelogParams, - ): Promise { + offset: number, + ): Promise<{ report: ChangelogReport; statements?: ChangelogStatementPage }> { + const exact = params.version !== undefined; + const statementView = (params.view ?? "statements") === "statements"; let response: PkgseerGraphqlResponse; try { response = await postPkgseerGraphql({ endpointUrl: this.endpointUrl, token, - query: PACKAGE_CHANGELOG_EXACT_QUERY, + query: packageChangelogQuery(params, offset), variables: { registry: params.registry, name: params.packageName, - version: params.version, - includeBodies: params.includeBodies !== false, + ...(exact + ? { version: params.version } + : { + fromVersion: params.fromVersion, + toVersion: params.toVersion, + limit: params.limit, + }), + ...(statementView ? { offset, tiers: params.statementTiers } : {}), }, fetchFn: this.fetchFn, clientHeaders: this.runtime.clientHeaders, @@ -3962,102 +4208,85 @@ export class PackageIntelligenceServiceImpl diagnostics: this.runtime.diagnostics, }); } catch (cause) { - if (cause instanceof PkgseerTransportError) { + if (cause instanceof PkgseerTransportError) throw this.createTransportError(cause); - } throw cause; } - - if (response.status < 200 || response.status >= 300) { + if (response.status < 200 || response.status >= 300) throw this.createHttpError(response); - } - - const parsed = exactChangelogGraphQLResponseSchema.safeParse( - response.parsedBody, - ); - if (!parsed.success) { + const parsed = ( + exact + ? exactChangelogGraphQLResponseSchema + : changelogGraphQLResponseSchema + ).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) { + if (parsed.data.errors?.length) throw promoteGenericVersionNotFound( this.createGraphQLError(parsed.data.errors), params, ); + const data = parsed.data.data; + if (exact) { + const selected = + data && "packageInfo" in data + ? data.packageInfo?.selectedVersion + : undefined; + if ( + !selected?.changelog || + (offset === 0 && + (selected.resolvedVersion === undefined || + ((params.includeJsonFields || !statementView) && + selected.changelog.hasChangelog === undefined))) + ) + throw new MalformedPackageIntelligenceResponseError( + "Empty selected-release changelog response.", + ); + const c = selected.changelog; + return { + statements: c.statements, + report: { + source: normaliseChangelogSource(c.detailSource), + entries: + offset === 0 + ? [ + { + version: selected.resolvedVersion, + normalizedVersion: c.entry?.normalizedVersion ?? undefined, + htmlUrl: c.entry?.htmlUrl ?? undefined, + publishedAt: c.entry?.publishedAt ?? undefined, + body: + params.view === "verbatim" + ? (c.entry?.body ?? undefined) + : undefined, + hasChangelog: c.hasChangelog, + }, + ] + : [], + }, + }; } - - const selected = parsed.data.data?.packageInfo?.selectedVersion; - if (!selected) { + const changelog = + data && "packageChangelog" in data ? data.packageChangelog : undefined; + if (!changelog || (offset === 0 && changelog.entries === undefined)) throw new MalformedPackageIntelligenceResponseError( - "Empty response from the package-intelligence service.", + "Empty changelog response.", ); - } - - 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, - body: entry.body ?? undefined, - htmlUrl: entry.htmlUrl ?? undefined, - publishedAt: entry.publishedAt ?? undefined, - })); - - const packageInfo: ChangelogPackageInfo | undefined = data.package - ? { - name: data.package.name ?? undefined, - registry: data.package.registry ?? undefined, - fromVersion: data.package.fromVersion ?? undefined, - toVersion: data.package.toVersion ?? undefined, - limit: data.package.limit ?? undefined, - } - : undefined; - return { - package: packageInfo, - source, - entries, - }; - } - - 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), + statements: changelog.statements, + report: { + source: normaliseChangelogSource(changelog.source), + entries: (changelog.entries ?? []).map((entry) => ({ + version: entry.version ?? undefined, + normalizedVersion: entry.normalizedVersion ?? undefined, + htmlUrl: entry.htmlUrl ?? undefined, + publishedAt: entry.publishedAt ?? undefined, + body: + params.view === "verbatim" ? (entry.body ?? undefined) : undefined, + })), }, - 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, - }, - ], }; } diff --git a/packages/mcp/src/mcp/server.test.ts b/packages/mcp/src/mcp/server.test.ts index d8f50727..eab8abc0 100644 --- a/packages/mcp/src/mcp/server.test.ts +++ b/packages/mcp/src/mcp/server.test.ts @@ -578,7 +578,16 @@ describe("MCP compact target schemas", () => { ], [ "pkg_changelog", - ["body_lines", "format", "limit", "omit_bodies", "target", "verbose"], + [ + "body_lines", + "changes", + "format", + "limit", + "omit_bodies", + "target", + "verbatim", + "verbose", + ], ], ] as const)("%s exposes the compact target schema", (name, properties) => { const descriptor = getMcpToolDescriptors().find( diff --git a/packages/mcp/src/services/test-helpers.ts b/packages/mcp/src/services/test-helpers.ts index 8d487d19..4be4f425 100644 --- a/packages/mcp/src/services/test-helpers.ts +++ b/packages/mcp/src/services/test-helpers.ts @@ -604,6 +604,38 @@ export const cratesFeatureDependencyReport: DependencyReport = { * markdown, ISO date, and a normalisedVersion that equals the raw. */ export const defaultChangelogReport: ChangelogReport = { + statements: { + totalCount: 2, + matchingCount: 2, + items: ["5.2.1", "5.2.0"].map((version, index) => ({ + version, + position: 0, + sourceIndex: 0, + sourcePosition: 0, + text: index === 0 ? "- fixed a thing" : "- added WebSocket support", + fullText: index === 0 ? "- fixed a thing" : "- added WebSocket support", + textTruncated: false, + heading: index === 0 ? "Patch" : "Minor", + source: "RELEASES", + url: `https://github.com/expressjs/express/releases/tag/${version}`, + classification: { + tier: "NO_IMPACT", + ambiguous: false, + tierConfidence: 0.9, + model: "jev-1.13.0", + formulation: "s-hier-v5", + }, + })), + coverage: ["5.2.1", "5.2.0"].map((version) => ({ + version, + status: "CLASSIFIED", + statementsTotal: 1, + statementsAssessed: 1, + statementsNotAssessed: 0, + statementsNoImpact: 1, + statementsUnclassified: 0, + })), + }, package: { name: "express", registry: "npm", diff --git a/packages/mcp/src/shared/changelog-risk-presentation.ts b/packages/mcp/src/shared/changelog-risk-presentation.ts new file mode 100644 index 00000000..012832cf --- /dev/null +++ b/packages/mcp/src/shared/changelog-risk-presentation.ts @@ -0,0 +1,113 @@ +import type { + ChangelogRiskCoverage, + ChangelogRiskItem, +} from "@githits/core-internal"; +import { sanitizeTerminalText } from "./terminal-text.js"; + +/** Shared statement presentation; policy and counts remain backend-owned. */ +export interface ClassifiedChangelogItem + extends Omit { + tier: "must_act" | "should_know" | "unclassified"; +} + +export function normaliseChangelogRiskItems( + items: ChangelogRiskItem[], +): ClassifiedChangelogItem[] { + return items.map((item) => ({ + ...item, + tier: item.tier.toLowerCase() as ClassifiedChangelogItem["tier"], + kind: item.kind?.toLowerCase(), + source: item.source?.toLowerCase(), + })); +} + +export function changelogRiskSections( + items: Item[], +): Array<{ label: string; items: Item[] }> { + return [ + { + label: "Requires action", + items: items.filter( + (item) => item.tier === "must_act" && !item.ambiguous, + ), + }, + { + label: "Possibly requires action", + items: items.filter((item) => item.tier === "must_act" && item.ambiguous), + }, + { + label: "Should know", + items: items + .filter((item) => item.tier === "should_know") + .sort((a, b) => Number(a.ambiguous) - Number(b.ambiguous)), + }, + { + label: "Too long to classify - read it", + items: items.filter((item) => item.tier === "unclassified"), + }, + ]; +} + +export function changelogRiskSummary(c: ChangelogRiskCoverage): string { + return `${c.itemsMustActConfident} require action${c.itemsMustActAmbiguous ? ` (+${c.itemsMustActAmbiguous} uncertain)` : ""} | ${c.itemsShouldKnowConfident} should know${c.itemsShouldKnowAmbiguous ? ` (+${c.itemsShouldKnowAmbiguous} uncertain)` : ""} | ${c.itemsUnclassified} too long to classify`; +} + +export function changelogRiskCoverageText(c: ChangelogRiskCoverage): string { + return `${c.versionsClassified} classified | ${c.versionsNotAssessed} not assessed | ${c.versionsWithoutNotes} without notes${c.versionsUnparseable ? ` | ${c.versionsUnparseable} unparseable` : ""} | ${c.unitsNoImpact} statements labeled no impact`; +} + +/** Render the observed release-note Markdown as visible words and source references. */ +export function releaseNoteText( + text: string, + reference: (url: string) => string, +): string { + const plain = safeRiskText( + text + .split(/\r?\n/) + .map((line) => + line + .replace(/^\s*(?:>\s*)+/, "") + .replace(/^\s*(?:[-*+]\s+|\d+[.)]\s+)/, "") + .replace(/\[!([A-Z]+)\]/g, "$1:"), + ) + .join(" "), + ) + .replace(/`([^`]+)`/g, "$1") + .replace(/\[([^\]]+)\]\[[^\]]*\]/g, "$1") + // Note-authored numbers must not impersonate our source citations. + .replace(/\[(S?\d+)\](?!\()/g, "($1)"); + return plain + .replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<>]+)/g, + ( + _match: string, + label: string | undefined, + linkedUrl: string | undefined, + bareUrl: string | undefined, + ): string => { + if (linkedUrl) return `${label ?? ""} ${reference(linkedUrl)}`.trim(); + const rawUrl = bareUrl ?? ""; + const url = rawUrl.replace(/[.,;:!?)]*$/, ""); + return `${reference(url)}${rawUrl.slice(url.length)}`; + }, + ) + .replace(/\s+/g, " ") + .trim(); +} + +export const RISK_KIND_LABELS: Record = { + removes_or_renames_api: "removal", + changes_behavior_or_default: "behavior", + raises_runtime_or_platform_requirement: "runtime/platform", + changes_packaging_or_module_format: "packaging/modules", + deprecates_without_removal: "deprecation", + security_fix: "security fix", + notable_change: "notable change", +}; + +export const PENDING_CLASSIFICATION_MESSAGE = + "Classification is still running or may have failed. Rerun in a few seconds to a minute to fill not-assessed versions from completed stored labels, without rerunning the model."; + +export function safeRiskText(value: string): string { + return sanitizeTerminalText(value.replace(/\s+/g, " ")).trim(); +} diff --git a/packages/mcp/src/shared/package-changelog-request.test.ts b/packages/mcp/src/shared/package-changelog-request.test.ts index 5c50d5a1..35e111d4 100644 --- a/packages/mcp/src/shared/package-changelog-request.test.ts +++ b/packages/mcp/src/shared/package-changelog-request.test.ts @@ -272,3 +272,67 @@ describe("buildPackageChangelogParams — limit validation", () => { expect(high.params.limit).toBe(50); }); }); + +describe("classified statement views", () => { + for (const target of [ + "npm:express", + "npm:express@5.2.1", + "npm:express@5.0.0..5.2.1", + ]) { + for (const [changes, expected] of [ + ["actionable", ["MUST_ACT"]], + ["important", ["MUST_ACT", "SHOULD_KNOW"]], + ["all", undefined], + ] as const) { + it(`maps ${changes} to backend tiers for ${target}`, () => { + const result = buildPackageChangelogParams({ target, changes }); + expect(result.params.statementTiers).toEqual( + expected ? [...expected] : undefined, + ); + expect(result.changes).toBe(changes); + expect(result.explicitFilterFields.has("changes")).toBe(true); + }); + } + } + it("defaults empty agent strings to all without an explicit filter echo", () => { + for (const changes of [undefined, "", " "]) { + const result = buildPackageChangelogParams({ + target: "npm:express", + changes, + }); + expect(result.changes).toBe("all"); + expect(result.params.statementTiers).toBeUndefined(); + expect(result.explicitFilterFields.has("changes")).toBe(false); + } + }); + it("ignores the changes knob entirely for timeline-only requests", () => { + const result = buildPackageChangelogParams({ + target: "npm:express", + view: "timeline", + changes: "unknown", + }); + expect(result.changes).toBeUndefined(); + expect(result.params.statementTiers).toBeUndefined(); + expect(result.explicitFilterFields.has("changes")).toBe(false); + }); + it("rejects unknown active views locally", () => { + expect(() => + buildPackageChangelogParams({ target: "npm:express", changes: "safe" }), + ).toThrow("changes must be actionable"); + }); +}); + +describe("view resolution", () => { + it("ignores changes for both raw notes and timeline", () => { + for (const view of ["verbatim", "timeline"] as const) { + const result = buildPackageChangelogParams({ + target: "npm:express", + view, + changes: "ignored", + }); + expect(result.changes).toBeUndefined(); + expect(result.params.statementTiers).toBeUndefined(); + expect(result.explicitFilterFields.has("changes")).toBe(false); + } + }); +}); diff --git a/packages/mcp/src/shared/package-changelog-request.ts b/packages/mcp/src/shared/package-changelog-request.ts index 8acd3f9a..08634ab8 100644 --- a/packages/mcp/src/shared/package-changelog-request.ts +++ b/packages/mcp/src/shared/package-changelog-request.ts @@ -14,7 +14,10 @@ * intent. */ -import type { PackageChangelogParams } from "@githits/core-internal"; +import type { + PackageChangelogParams, + PackageChangelogView, +} from "@githits/core-internal"; import { toPkgseerRegistry } from "@githits/core-internal"; import { parsePackageChangelogTarget } from "./package-changelog-target.js"; import { InvalidPackageSpecError } from "./package-spec.js"; @@ -29,8 +32,9 @@ export interface PackageChangelogRequestInput { toVersion?: string; /** 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; + view?: PackageChangelogView; + /** Cumulative statement filter; ignored outside statement mode. */ + changes?: string; } /** Fields whose *explicit* presence the envelope echoes under `filter.*`. */ @@ -38,12 +42,16 @@ export type ExplicitFilterField = | "fromVersion" | "toVersion" | "limit" - | "version"; + | "version" + | "changes"; + +export type ChangelogChangesView = "actionable" | "important" | "all"; export type PackageChangelogRequestMode = "latest" | "exact" | "range"; export interface PackageChangelogRequestBuildResult { params: PackageChangelogParams; + changes?: ChangelogChangesView; mode: PackageChangelogRequestMode; /** * Set of filter fields the caller explicitly supplied. The envelope @@ -68,6 +76,17 @@ export function buildPackageChangelogParams( const flagFrom = normaliseBound(input.fromVersion, registry, "--from"); const flagTo = normaliseBound(input.toVersion, registry, "--to"); const limit = normaliseLimit(input.limit); + const view = input.view ?? "statements"; + const changes = + view === "statements" ? normaliseChanges(input.changes) : undefined; + const statementTiers: PackageChangelogParams["statementTiers"] = + changes === "actionable" + ? ["MUST_ACT"] + : changes === "important" + ? ["MUST_ACT", "SHOULD_KNOW"] + : undefined; + const explicitChanges = + changes !== undefined && Boolean(input.changes?.trim()); if (parsed.mode === "exact") { rejectExactConflicts(flagFrom, flagTo, limit); @@ -79,13 +98,17 @@ export function buildPackageChangelogParams( } return { mode: "exact", + changes, params: { registry, packageName: parsed.name, version, - includeBodies: input.includeBodies, + view, + statementTiers, }, - explicitFilterFields: new Set(["version"]), + explicitFilterFields: new Set( + explicitChanges ? ["version", "changes"] : ["version"], + ), }; } @@ -118,19 +141,22 @@ export function buildPackageChangelogParams( } const explicit = new Set(); + if (explicitChanges) explicit.add("changes"); if (fromVersion !== undefined) explicit.add("fromVersion"); if (toVersion !== undefined) explicit.add("toVersion"); if (limit !== undefined) explicit.add("limit"); return { mode, + changes, params: { registry, packageName: parsed.name, fromVersion, toVersion, limit, - includeBodies: input.includeBodies, + view, + statementTiers, }, explicitFilterFields: explicit, }; @@ -152,7 +178,7 @@ function rejectExactConflicts( } function joinList(items: string[]): string { - if (items.length === 1) return items[0]!; + if (items.length === 1) return items.join(""); if (items.length === 2) return `${items[0]} and ${items[1]}`; return `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`; } @@ -177,3 +203,48 @@ function normaliseLimit(raw: number | undefined): number | undefined { } return raw; } + +/** Empty agent strings mean the default; omission mode does not validate this knob. */ +function normaliseChanges(raw: string | undefined): ChangelogChangesView { + const value = raw?.trim().toLowerCase() || "all"; + if (value !== "actionable" && value !== "important" && value !== "all") { + throw new InvalidPackageSpecError( + "changes must be actionable, important (actionable + should know), or all.", + ); + } + return value; +} + +export interface PackageChangelogViewInput { + omitBodies?: boolean; + verbatim?: boolean; + verbose?: boolean; + bodyLines?: number; + json?: boolean; +} + +/** Resolve the view once; explicit raw-note switches cannot coexist with timeline. */ +export function resolvePackageChangelogView(input: PackageChangelogViewInput): { + view: PackageChangelogView; + bodyPreviewLines?: number; +} { + const fullNotes = input.verbatim === true || input.verbose === true; + if (input.omitBodies && fullNotes) + throw new InvalidPackageSpecError( + "--no-body / omit_bodies cannot be combined with --verbatim / verbatim or its --verbose / verbose alias.", + ); + if (input.omitBodies) return { view: "timeline" }; + if (fullNotes) return { view: "verbatim" }; + if (!input.json && input.bodyLines !== undefined) { + if ( + !Number.isInteger(input.bodyLines) || + input.bodyLines < 1 || + input.bodyLines > 50 + ) + throw new InvalidPackageSpecError( + `body_lines must be an integer between 1 and 50. Got ${input.bodyLines}.`, + ); + return { view: "verbatim", bodyPreviewLines: input.bodyLines }; + } + return { view: "statements" }; +} diff --git a/packages/mcp/src/shared/package-changelog-response.test.ts b/packages/mcp/src/shared/package-changelog-response.test.ts index 6ab5a7e3..b84c03b1 100644 --- a/packages/mcp/src/shared/package-changelog-response.test.ts +++ b/packages/mcp/src/shared/package-changelog-response.test.ts @@ -36,7 +36,7 @@ const baseOptions = { name: "express", mode: "latest" as const, explicitFilterFields: new Set(), - includeBodies: true, + view: "verbatim" as const, }; describe("buildPackageChangelogSuccessPayload — envelope shape", () => { @@ -144,10 +144,10 @@ describe("buildPackageChangelogSuccessPayload — version null handling", () => }); describe("buildPackageChangelogSuccessPayload — body omission lever", () => { - it("drops body fields from every entry when includeBodies is false", () => { + it("drops body fields from every timeline entry", () => { const envelope = buildPackageChangelogSuccessPayload(baseReport, { ...baseOptions, - includeBodies: false, + view: "timeline", }); for (const entry of envelope.entries.items) { expect(entry.body).toBeUndefined(); @@ -260,327 +260,298 @@ describe("buildPackageChangelogSuccessPayload — empty entries", () => { }); }); -describe("formatPackageChangelogTerminal", () => { - it("renders a summary header + one-line entries by default", () => { - const envelope = buildPackageChangelogSuccessPayload( - baseReport, - baseOptions, - ); - const output = formatPackageChangelogTerminal(envelope, { - verbose: false, - useColors: false, - }); - expect(output).toContain("express | npm"); - expect(output).toContain("source: GitHub Releases"); - expect(output).toContain("2 entries"); - expect(output).toContain("5.2.1"); - expect(output).toContain("2026-01-15"); - expect(output).toContain("/releases/tag/5.2.1"); - // Fixture bodies are well under the 10-line cap, so they render - // in full without a truncation footer. - expect(output).toContain("## Patch"); - expect(output).toContain("- fixed a thing"); - 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", - ); - const report: ChangelogReport = { - ...baseReport, - entries: [ - { - version: "1.0.0", - publishedAt: "2026-01-01T00:00:00Z", - htmlUrl: "https://example.com", - body: longBody, - }, - ], +describe("complete statement evidence view", () => { + function evidence(): ChangelogReport { + return { + ...structuredClone(baseReport), + statements: { + totalCount: 5, + matchingCount: 5, + items: [ + "MUST_ACT", + "SHOULD_KNOW", + "NO_IMPACT", + "UNCLASSIFIED", + null, + ].map((tier, position) => ({ + version: "5.2.1", + position, + sourceIndex: position < 3 ? 0 : 1, + sourcePosition: position, + heading: "Patch", + source: position < 3 ? "CHANGELOG_FILE" : "RELEASES", + url: + position < 3 + ? "https://example.com/history#L1" + : "https://example.com/releases/5.2.1", + text: `* Quoted statement ${position}${position === 0 ? "\n * nested wording\n\n ```js\n foo( 1)\n ```" : ""}`, + fullText: "Full uncut statement", + textTruncated: position === 3, + ...(position === 3 + ? { + context: { + heading: "Earlier", + text: "Earlier quote", + textTruncated: false, + fullText: "Earlier uncut quote", + }, + } + : {}), + classification: + tier === null + ? null + : { + tier: tier as "MUST_ACT", + ambiguous: position < 2, + tierConfidence: 0.95, + kind: + position === 0 ? "CHANGES_BEHAVIOR_OR_DEFAULT" : undefined, + model: "jev", + formulation: position < 3 ? "s-hier-v5" : "s-hier-v3", + }, + })), + coverage: [ + { + version: "5.2.1", + status: "NOT_ASSESSED", + statementsTotal: 5, + statementsAssessed: 4, + statementsNotAssessed: 1, + statementsNoImpact: 1, + statementsUnclassified: 1, + }, + ], + }, }; - const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); - const output = formatPackageChangelogTerminal(envelope, { - verbose: false, + } + function payload(report = evidence(), changes?: "actionable" | "important") { + return buildPackageChangelogSuccessPayload(report, { + ...baseOptions, + view: "statements", + changes, + explicitFilterFields: new Set(changes ? ["changes"] : []), + }); + } + it("renders all parsed units once, source URLs nearby, without raw-note duplication", () => { + const text = formatPackageChangelogTerminal(payload(), { useColors: false, + terminalWidth: 200, }); - expect(output).toContain("line 1"); - expect(output).toContain("line 10"); - expect(output).not.toContain("line 11"); - expect(output).toContain("+15 more lines"); - expect(output).toContain("use --verbose for the full body"); - expect(output).toContain( - "... (+15 more lines - use --verbose for the full body)", + expect(text).not.toContain("## Patch"); + expect(text).not.toContain("Sources"); + expect(text.split("https://example.com/history#L1")).toHaveLength(2); + expect(text.indexOf("https://example.com/history#L1")).toBeLessThan( + text.indexOf("Quoted statement 0"), ); + expect(text).toContain("! requires action | kind tags: should know"); + expect(text).toContain("* [!behavior] (uncertain) Quoted statement 0"); + expect(text).toContain("* [should know] (uncertain) Quoted statement 1"); + expect(text).toContain("* Quoted statement 2"); + expect(text).toContain("[too long to classify - read it]"); + expect(text).toContain("[not assessed]"); + expect(text).not.toContain("[no impact identified]"); + expect(text).not.toContain("[requires action"); + expect(text).not.toContain("* i "); + expect(text.match(/\(uncertain\)/g)).toHaveLength(2); + expect(text).toContain("nested wording"); + expect(text).toContain("foo( 1)"); + expect(text).toContain("Earlier quote"); + expect(text).toContain("Statement truncated"); + expect(text).toContain("still in progress or failed"); + expect(text).not.toContain("jev"); + expect(text).not.toContain("Full uncut statement"); }); - - it("uses caller-supplied body preview line cap", () => { - const longBody = Array.from({ length: 8 }, (_, i) => `line ${i + 1}`).join( - "\n", - ); - const report: ChangelogReport = { - ...baseReport, - entries: [ - { - version: "1.0.0", - publishedAt: "2026-01-01T00:00:00Z", - htmlUrl: "https://example.com", - body: longBody, - }, - ], - }; - const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); - const output = formatPackageChangelogTerminal(envelope, { - verbose: false, + it("leaves no-impact bullets untagged, preserving uncertainty and JSON evidence", () => { + const report = evidence(); + const item = report.statements!.items[2]!; + item.classification!.kind = "NOTABLE_CHANGE"; + item.classification!.ambiguous = true; + report.statements!.items = [item]; + const text = formatPackageChangelogTerminal(payload(report), { useColors: false, - bodyPreviewLines: 3, - fullBodyHint: "pass verbose=true", }); - expect(output).toContain("line 3"); - expect(output).not.toContain("line 4"); - expect(output).toContain("... (+5 more lines - pass verbose=true)"); - }); - - it("lifts the body cap under --verbose and omits the truncation footer", () => { - const longBody = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join( - "\n", + expect(text).toContain("* (uncertain) Quoted statement 2"); + expect(text).not.toContain("[no impact identified]"); + expect(text).not.toContain("[notable change]"); + expect(text).toContain( + "! requires action | kind tags: should know | plain: no impact identified", ); - const report: ChangelogReport = { - ...baseReport, - entries: [ - { - version: "1.0.0", - publishedAt: "2026-01-01T00:00:00Z", - htmlUrl: "https://example.com", - body: longBody, - }, - ], - }; - const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); - const output = formatPackageChangelogTerminal(envelope, { - verbose: true, - useColors: false, + expect(payload(report).statements?.items[0]?.classification).toMatchObject({ + tier: "no_impact", + kind: "notable_change", + ambiguous: true, }); - expect(output).toContain("line 1"); - expect(output).toContain("line 25"); - expect(output).not.toContain("use --verbose"); - expect(output).not.toContain("more line"); }); - - it("uses singular wording in the truncation footer when exactly one line is hidden", () => { - const elevenLines = Array.from( - { length: 11 }, - (_, i) => `line ${i + 1}`, - ).join("\n"); - const report: ChangelogReport = { - ...baseReport, - entries: [ - { - version: "1.0.0", - publishedAt: "2026-01-01T00:00:00Z", - htmlUrl: "https://example.com", - body: elevenLines, - }, - ], - }; - const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); - const output = formatPackageChangelogTerminal(envelope, { - verbose: false, + it("keeps severity markers when the kind is absent, without inventing tags", () => { + const report = evidence(); + report.statements!.items[0]!.classification!.kind = undefined; + report.statements!.items[0]!.classification!.ambiguous = false; + const text = formatPackageChangelogTerminal(payload(report), { useColors: false, }); - expect(output).toContain("+1 more line "); - expect(output).not.toContain("+1 more lines"); + expect(text).toContain("* [!] Quoted statement 0"); + expect(text).toContain("* [should know] (uncertain) Quoted statement 1"); + expect(text).not.toContain("[requires action]"); + expect(text).toContain("[should know]"); }); - - it("renders '(unversioned)' for entries with no version", () => { - const report: ChangelogReport = { - ...baseReport, - entries: [ - { - version: undefined, - publishedAt: "2026-01-01T00:00:00Z", - htmlUrl: "https://example.com", - body: "raw", - }, - ], - }; - const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); - const output = formatPackageChangelogTerminal(envelope, { - verbose: false, - useColors: false, - }); - expect(output).toContain("(unversioned)"); + it("colors confident action and should-know prefixes while keeping the same no-color meaning", () => { + const report = evidence(); + report.statements!.items[0]!.classification!.ambiguous = false; + report.statements!.items[1]!.classification!.ambiguous = false; + report.statements!.items[1]!.classification!.kind = + "DEPRECATES_WITHOUT_REMOVAL"; + const p = payload(report); + const plain = formatPackageChangelogTerminal(p, { useColors: false }); + const colored = formatPackageChangelogTerminal(p, { useColors: true }); + expect(colored).toContain("\x1b[31m[!behavior]"); + expect(colored).toContain("\x1b[33m[deprecation]"); + expect(plain).toContain("* [deprecation] Quoted statement 1"); + expect(Bun.stripANSI(colored)).toBe(plain); }); - - it("renders a dash for missing publishedAt", () => { - const report: ChangelogReport = { - ...baseReport, - entries: [ - { - version: "1.0.0", - publishedAt: undefined, - htmlUrl: "https://example.com", - }, - ], - }; - const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); - const output = formatPackageChangelogTerminal(envelope, { - verbose: false, - useColors: false, + it("preserves structured evidence and null classification without mutating the service result", () => { + const report = evidence(); + const p = payload(report); + expect(p.entries.items[0]?.body).toBeUndefined(); + expect(p.statements?.count).toBe(5); + expect(p.statements?.items[0]?.classification).toMatchObject({ + tier: "must_act", + kind: "changes_behavior_or_default", + ambiguous: true, + tierConfidence: 0.95, }); - expect(output).toContain("-"); + expect(p.statements?.items[4]?.classification).toBeNull(); + expect(p.statements?.items[3]?.context?.fullText).toBe( + "Earlier uncut quote", + ); + expect(report.statements?.items[0]?.classification?.tier).toBe("MUST_ACT"); + expect(p.statements?.coverage[0]?.status).toBe("not_assessed"); }); - - it("renders 'No entries in this range.' on zero-entry success", () => { - const report: ChangelogReport = { ...baseReport, entries: [] }; - const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); - const output = formatPackageChangelogTerminal(envelope, { - verbose: false, + it.each(["actionable", "important"] as const)( + "reports filtered pending and oversize exclusions with zero matches: %s", + (changes) => { + const report = evidence(); + report.statements!.items = []; + report.statements!.matchingCount = 0; + const text = formatPackageChangelogTerminal(payload(report, changes), { + useColors: false, + }); + expect(text).toContain("0 matching statements of 5 parsed"); + expect(text).toContain( + "excludes 1 not-assessed and 1 too-long-to-classify", + ); + expect(text).toContain("No statements match this filter"); + expect(text).toContain("Rerun"); + }, + ); + it("shows missing/unparseable coverage with zero units, never implying safe", () => { + const report = evidence(); + report.statements!.items = []; + report.statements!.totalCount = 0; + report.statements!.matchingCount = 0; + report.statements!.coverage = baseReport.entries.map((entry, index) => ({ + version: entry.version!, + status: index === 0 ? "WITHOUT_NOTES" : "UNPARSEABLE", + statementsTotal: 0, + statementsAssessed: 0, + statementsNotAssessed: 0, + statementsNoImpact: 0, + statementsUnclassified: 0, + })); + const text = formatPackageChangelogTerminal(payload(report), { useColors: false, }); - expect(output).toContain("No entries in this range."); + expect(text.replace(/\s+/g, " ")).toContain("1 without notes"); + expect(text).toContain("1 without parsed statements"); + expect(text).toContain("No release notes available"); + expect(text).toContain("Notes contain no parsed statements"); + expect(text).toContain("not evidence of no risk"); }); - - it("labels range mode with its exclusive lower bound in the summary", () => { - const envelope = buildPackageChangelogSuccessPayload(baseReport, { - ...baseOptions, - mode: "range", - fromVersion: "4.0.0", - toVersion: "5.0.0", - explicitFilterFields: new Set(["fromVersion", "toVersion"]), + it("sanitizes every untrusted text field while leaving JSON evidence alone", () => { + const report = evidence(); + report.entries[0]!.version = "5.2.1\x1b[31m"; + report.statements!.items.forEach((item) => { + item.version = report.entries[0]!.version!; + item.text += "\x1b[31mInjected\x1b[0m"; + item.heading = "Header\x1b[2J"; + item.url += "\x1b]0;evil\x07"; }); - const output = formatPackageChangelogTerminal(envelope, { - verbose: false, + const p = payload(report); + const text = formatPackageChangelogTerminal(p, { useColors: false, + terminalWidth: 72, }); - expect(output).toContain( - "range (4.0.0, 5.0.0] (start excluded; end included)", - ); + expect(text).not.toContain("\x1b"); + expect(text).toContain("Injected"); + expect(p.statements?.items[0]?.text).toContain("\x1b"); }); - - it("renders printable ASCII punctuation", () => { - const longBody = Array.from({ length: 12 }, (_, i) => `line ${i + 1}`).join( - "\n", - ); - const report: ChangelogReport = { - ...baseReport, - entries: [ - { - version: "1.0.0", - publishedAt: undefined, - htmlUrl: "https://example.com", - body: longBody, - }, - ], - }; - const envelope = buildPackageChangelogSuccessPayload(report, { - ...baseOptions, - mode: "range", - fromVersion: "1.0.0", - toVersion: "2.0.0", - explicitFilterFields: new Set(["fromVersion", "toVersion"]), - }); - const output = formatPackageChangelogTerminal(envelope, { - verbose: false, + it("wraps Express-style four-space continuations and nested bullets while preserving indented code", () => { + const report = evidence(); + report.statements!.items[0]!.text = `- Fixed HTTP header conflict + Fixed the behavior of res.send() ${"with explicit response headers ".repeat(15)} + * IMPORTANT: ${"this default changes existing usage ".repeat(8)} + + example( 1, ${"argument, ".repeat(10)});`; + const text = formatPackageChangelogTerminal(payload(report), { useColors: false, + terminalWidth: 72, }); - expect(output).not.toMatch(/[·…—–→]/); + expect(text).toContain("Fixed the behavior of res.send()"); + expect(text).toContain("* IMPORTANT:"); + expect(text).toContain(`example( 1, ${"argument, ".repeat(10)});`); + for (const line of text.split("\n")) { + if (!line.includes("example(")) + expect(line.length).toBeLessThanOrEqual(72); + } + expect(text).toContain("Context for next statement - Earlier"); }); - - it("labels latest mode with 'latest up to X' when toVersion is set", () => { - const envelope = buildPackageChangelogSuccessPayload(baseReport, { - ...baseOptions, - toVersion: "5.2.1", - explicitFilterFields: new Set(["toVersion"]), - }); - const output = formatPackageChangelogTerminal(envelope, { - verbose: false, + it("preserves backend Unicode while formatter punctuation stays ASCII", () => { + const report = evidence(); + report.statements!.items[0]!.text = "* α — 保留 …"; + const text = formatPackageChangelogTerminal(payload(report), { useColors: false, }); - expect(output).toContain("latest up to 5.2.1"); + expect(text).toContain("α — 保留 …"); + expect(text).toContain("[!behavior]"); }); - - it("renders '(empty release notes)' sentinel for an empty-string body under --verbose", () => { - const report: ChangelogReport = { - ...baseReport, - entries: [ - { - version: "1.0.0", - publishedAt: "2026-01-01T00:00:00Z", - htmlUrl: "https://example.com", - body: "", - }, - ], - }; - const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); - const output = formatPackageChangelogTerminal(envelope, { - verbose: true, + it("wraps prose to caller width without truncating ordinary statements", () => { + const report = evidence(); + report.statements!.items[0]!.text = `${"word ".repeat(110)}last word`; + const text = formatPackageChangelogTerminal(payload(report), { useColors: false, + terminalWidth: 72, }); - expect(output).toContain("(empty release notes)"); + expect(text).toContain("last word"); + for (const line of text.split("\n")) + expect(line.length).toBeLessThanOrEqual(72); }); - - 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, + it("uses color only as an aid, and mutes uncertain labels", () => { + const plain = formatPackageChangelogTerminal(payload(), { useColors: false, }); - expect(output).toContain("exact 5.2.1"); - expect(output).toContain("Release notes are unavailable."); + const colored = formatPackageChangelogTerminal(payload(), { + useColors: true, + }); + expect(Bun.stripANSI(colored)).toBe(plain); + expect(colored).toContain("\x1b[2m[!behavior]"); }); - - it("labels exact text with the resolved release, not the requested selector", () => { - const report: ChangelogReport = { + it("original-note view shows all lines unless the deprecated explicit cap is present", () => { + const report = { ...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, - }, - ], + entries: [{ version: "1", body: "first\nsecond\nlast" }], }; - const envelope = buildPackageChangelogSuccessPayload(report, { + const p = buildPackageChangelogSuccessPayload(report, { ...baseOptions, - mode: "exact", - version: "^5.0.0", - explicitFilterFields: new Set(["version"]), - }); - const output = formatPackageChangelogTerminal(envelope, { - verbose: false, - useColors: false, + view: "verbatim", }); - expect(envelope.filter?.version).toBe("^5.0.0"); - expect(output).toContain("exact 5.2.1"); - expect(output).not.toContain("exact ^5.0.0"); + expect(formatPackageChangelogTerminal(p, { useColors: false })).toContain( + "last", + ); + expect( + formatPackageChangelogTerminal(p, { + useColors: false, + bodyPreviewLines: 1, + }), + ).toContain("+2 more lines - use --verbatim"); + expect(p.statements).toBeUndefined(); }); }); diff --git a/packages/mcp/src/shared/package-changelog-response.ts b/packages/mcp/src/shared/package-changelog-response.ts index bdf5bb0c..8b57ebc2 100644 --- a/packages/mcp/src/shared/package-changelog-response.ts +++ b/packages/mcp/src/shared/package-changelog-response.ts @@ -11,15 +11,25 @@ * - **`version` kept when null**, every other per-entry nullable * field stripped only when null/undefined. * - **`filter.*` emits only when caller explicitly supplied them.** - * - **Body omission lever.** When requested, each entry drops its - * `body` field. + * - Original bodies appear only in the explicit original-note view. + * - Complete statements preserve missing classifications as null. * - Exact selected-release entries add `hasChangelog`. Timeline * entries omit that field. */ -import type { ChangelogReport } from "@githits/core-internal"; +import type { + ChangelogReport, + ChangelogStatement, + ChangelogStatements, + PackageChangelogView, +} from "@githits/core-internal"; +import { RISK_KIND_LABELS } from "./changelog-risk-presentation.js"; import { colorize, dim, highlight } from "./colors.js"; -import type { ExplicitFilterField } from "./package-changelog-request.js"; +import type { + ChangelogChangesView, + ExplicitFilterField, +} from "./package-changelog-request.js"; +import { sanitizeTerminalText, wrapTerminalProse } from "./terminal-text.js"; export type ChangelogMode = "latest" | "exact" | "range"; @@ -51,6 +61,7 @@ export interface LeanEntriesBlock { * intent. */ export interface LeanChangelogFilter { + changes?: ChangelogChangesView; fromVersion?: string; toVersion?: string; limit?: number; @@ -58,6 +69,7 @@ export interface LeanChangelogFilter { } export interface LeanChangelogEnvelope { + statements?: LeanChangelogStatements; /** Present for spec addressing. */ registry?: string; /** Present for spec addressing. */ @@ -74,12 +86,12 @@ export interface LeanChangelogEnvelope { } export interface BuildChangelogPayloadOptions { + changes?: ChangelogChangesView; registry?: string; name?: string; mode: ChangelogMode; explicitFilterFields: Set; - /** When false, drop each entry's `body` field. Default: true. */ - includeBodies: boolean; + view: PackageChangelogView; fromVersion?: string; toVersion?: string; limit?: number; @@ -103,7 +115,7 @@ export function buildPackageChangelogSuccessPayload( if (entry.htmlUrl != null) { lean.htmlUrl = entry.htmlUrl; } - if (options.includeBodies && entry.body != null) { + if (options.view === "verbatim" && entry.body != null) { lean.body = entry.body; } if (options.mode === "exact" && entry.hasChangelog !== undefined) { @@ -120,6 +132,30 @@ export function buildPackageChangelogSuccessPayload( }, }; + if (options.view === "statements" && report.statements) { + envelope.statements = { + ...report.statements, + count: report.statements.items.length, + items: report.statements.items.map((item) => ({ + ...item, + ...(item.source ? { source: item.source.toLowerCase() } : {}), + classification: + item.classification === null + ? null + : { + ...item.classification, + tier: item.classification.tier.toLowerCase(), + ...(item.classification.kind + ? { kind: item.classification.kind.toLowerCase() } + : {}), + }, + })), + coverage: report.statements.coverage.map((c) => ({ + ...c, + status: c.status.toLowerCase(), + })), + }; + } if (report.source) envelope.source = report.source; if (options.registry) envelope.registry = options.registry; @@ -137,6 +173,8 @@ function buildFilterBlock( const { explicitFilterFields } = options; if (explicitFilterFields.size === 0) return undefined; const filter: LeanChangelogFilter = {}; + if (options.explicitFilterFields.has("changes") && options.changes) + filter.changes = options.changes; if (explicitFilterFields.has("fromVersion") && options.fromVersion) { filter.fromVersion = options.fromVersion; } @@ -152,176 +190,295 @@ function buildFilterBlock( return Object.keys(filter).length > 0 ? filter : undefined; } -// -------------------------------------------------------------------- -// Shared terminal/text formatter used by both CLI and MCP. -// -------------------------------------------------------------------- +/** Structured JSON retains evidence; only enum spelling is normalised. */ +export interface LeanChangelogStatement + extends Omit { + classification: + | (Omit< + NonNullable, + "tier" | "kind" + > & { tier: string; kind?: string }) + | null; +} +export interface LeanChangelogStatements + extends Omit { + count: number; + items: LeanChangelogStatement[]; + coverage: (Omit & { + status: string; + })[]; +} export interface FormatChangelogTerminalOptions { - verbose?: boolean; useColors: boolean; fullBodyHint?: string; + fullStatementHint?: string; bodyPreviewLines?: number; + terminalWidth?: number; } -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. - */ +/** One source-ordered evidence view shared by CLI and MCP. */ export function formatPackageChangelogTerminal( envelope: LeanChangelogEnvelope, options: FormatChangelogTerminalOptions, ): string { + const width = Number.isFinite(options.terminalWidth) + ? Math.max(20, Math.floor(options.terminalWidth as number)) + : 80; const lines: string[] = []; - lines.push(buildSummaryLine(envelope, options)); - lines.push(""); - - if (envelope.entries.items.length === 0) { - lines.push(dim("No entries in this range.", options.useColors)); - lines.push(""); - return lines.join("\n"); + const prose = (text: string, indent = ""): void => { + lines.push(...wrapTerminalProse(`${indent}${text}`, width, indent)); + }; + const identity = + envelope.registry && envelope.name + ? `${envelope.registry}:${envelope.name}` + : "(unknown package)"; + lines.push( + ...wrapTerminalProse( + sanitizeTerminalText( + `${identity} | ${modeSummary(envelope)} | ${envelope.entries.count} release${envelope.entries.count === 1 ? "" : "s"}`, + ), + width, + ).map((line) => colorize(line, "bold", options.useColors)), + ); + const statements = envelope.statements; + if (statements) { + lines.push( + "", + highlight("Changes (classified by agent)", options.useColors), + ); + const c = statements.coverage; + prose( + `${statements.count} ${envelope.filter?.changes && envelope.filter.changes !== "all" ? "matching " : ""}statements of ${statements.totalCount} parsed | versions: ${c.filter((v) => v.status === "classified").length} classified | ${c.filter((v) => v.status === "not_assessed").length} not assessed | ${c.filter((v) => v.status === "without_notes").length} without notes | ${c.filter((v) => v.status === "unparseable").length} without parsed statements`, + " ", + ); + if ( + statements.items.some( + (item) => + item.classification?.tier === "must_act" || + item.classification?.tier === "should_know" || + item.classification?.tier === "no_impact", + ) + ) + prose( + "! requires action | kind tags: should know | plain: no impact identified", + " ", + ); + const pending = c.reduce((sum, v) => sum + v.statementsNotAssessed, 0); + const oversize = c.reduce((sum, v) => sum + v.statementsUnclassified, 0); + if (envelope.filter?.changes && envelope.filter.changes !== "all") + prose( + `Filter excludes ${pending} not-assessed and ${oversize} too-long-to-classify statements.`, + " ", + ); + if (pending) + prose( + "Classification is still in progress or failed. Rerun in a few seconds to a minute to fill stored answers.", + " ", + ); + if ( + c.some((v) => v.status === "without_notes" || v.status === "unparseable") + ) + prose("Missing or unparseable notes are not evidence of no risk.", " "); } - - const versionWidth = computeVersionColumnWidth(envelope.entries.items); - const dateWidth = 10; // YYYY-MM-DD - + if (!envelope.entries.items.length) + lines.push("", "No releases in this selection."); for (const entry of envelope.entries.items) { - const version = entry.version ?? "(unversioned)"; - const date = entry.publishedAt - ? formatDate(entry.publishedAt) - : dim("-", options.useColors); - const url = entry.htmlUrl - ? dim(entry.htmlUrl, options.useColors) - : dim("(no link)", options.useColors); - const padded = padColumn(version, versionWidth); - const datePadded = padColumn(date, dateWidth); + lines.push(""); lines.push( - `${highlight(`${padded} ${datePadded}`, options.useColors)} ${url}`, + ...wrapTerminalProse( + sanitizeTerminalText( + `${entry.version ?? "(unversioned)"}${entry.publishedAt ? ` (${formatDate(entry.publishedAt)})` : ""}`, + ), + width, + ).map((line) => highlight(line, options.useColors)), ); - if (entry.hasChangelog === false) { - lines.push(""); - lines.push( - ` ${dim("Release notes are unavailable.", options.useColors)}`, + if (!statements) { + if (entry.htmlUrl) prose(sanitizeTerminalText(entry.htmlUrl), " "); + if (entry.body !== undefined) + renderOriginalNotes(lines, entry.body, options); + else if (entry.hasChangelog === false) + lines.push(" No release notes available."); + continue; + } + const items = statements.items.filter( + (item) => item.version === entry.version, + ); + if (!items.length) { + if (entry.htmlUrl) prose(sanitizeTerminalText(entry.htmlUrl), " "); + const coverage = statements.coverage.find( + (v) => v.version === entry.version, ); - } else if (entry.body != null) { - appendBodyLines(lines, entry.body, options); + prose( + coverage?.status === "without_notes" + ? "No release notes available." + : coverage?.status === "unparseable" + ? "Notes contain no parsed statements." + : "No statements match this filter.", + " ", + ); + continue; + } + let sourceIndex: number | undefined; + let heading: string | undefined; + for (const item of items) { + if (sourceIndex !== item.sourceIndex) { + sourceIndex = item.sourceIndex; + heading = undefined; + const source = noteSourceLabel(item.source); + prose( + `${source}${item.url ? `: ${sanitizeTerminalText(item.url)}` : " (source URL unavailable)"}`, + " ", + ); + } + if (item.heading !== heading) { + heading = item.heading; + if (heading) prose(sanitizeTerminalText(heading), " "); + } + if (item.context) { + if (item.context.heading && item.context.heading !== heading) + prose( + `Context for next statement - ${sanitizeTerminalText(item.context.heading)}`, + " ", + ); + else prose("Context for next statement", " "); + renderStatementText(lines, item.context.text, " ", width); + if (item.context.textTruncated) + prose( + `Context truncated; ${options.fullStatementHint ?? "use --json for full text"}.`, + " ", + ); + } + const classification = item.classification; + const tier = classification?.tier; + const kind = classification?.kind + ? RISK_KIND_LABELS[classification.kind as keyof typeof RISK_KIND_LABELS] + : undefined; + const annotation = + tier === "no_impact" + ? undefined + : tier === "unclassified" + ? "[too long to classify - read it]" + : !classification + ? "[not assessed]" + : kind + ? `[${tier === "must_act" ? "!" : ""}${kind}]` + : tier === "must_act" + ? "[!]" + : "[should know]"; + const start = lines.length; + renderStatementText( + lines, + item.text, + ` * ${annotation ? `${annotation} ` : ""}${classification?.ambiguous ? "(uncertain) " : ""}`, + width, + ); + // Style only the trusted prefix after sanitation and wrapping. Missing + // kinds retain explicit tier meaning; plain bullets cannot style a quote. + if (annotation && lines[start]?.startsWith(` * ${annotation}`)) { + const styled = + classification?.ambiguous || !classification + ? dim(annotation, options.useColors) + : colorize( + annotation, + tier === "must_act" + ? "red" + : tier === "should_know" + ? "yellow" + : "cyan", + options.useColors, + ); + lines[start] = + ` * ${styled}${lines[start].slice(6 + annotation.length)}`; + } + if (classification?.ambiguous && lines[start]) + lines[start] = lines[start].replace( + "(uncertain)", + dim("(uncertain)", options.useColors), + ); + if (item.textTruncated) + prose( + `Statement truncated; ${options.fullStatementHint ?? "use --json for full text"}.`, + " ", + ); } - lines.push(""); } + return `${lines.join("\n")}\n`; +} - return lines.join("\n"); +/** Preserve nested lines and code, wrap prose without flattening the unit. */ +function renderStatementText( + lines: string[], + text: string, + prefix: string, + width: number, +): void { + const raw = text.split("\n"); + const first = sanitizeTerminalText((raw[0] ?? "").replace(/\t/g, " ")); + const contentIndent = first.match(/^ *(?:[-*+]|\d+[.)]) +/)?.[0].length ?? 0; + let fenced = false; + raw.forEach((line, index) => { + const safe = sanitizeTerminalText(line.replace(/\t/g, " ")); + const content = + index === 0 ? safe.replace(/^\s*(?:[-*+] |\d+[.)] )/, "") : safe; + if (!content.trim()) { + lines.push(""); + return; + } + const leading = safe.match(/^ */)?.[0] ?? ""; + const indent = index === 0 ? prefix : ` ${leading}`; + const value = index === 0 ? content : content.trimStart(); + const isFence = value.startsWith("```") || value.startsWith("~~~"); + const nestedBullet = /^(?:[-*+]|\d+[.)]) +/.test(value.trimStart()); + const indentedCode = leading.length >= contentIndent + 4 && !nestedBullet; + if (fenced || isFence || indentedCode) lines.push(`${indent}${value}`); + else + lines.push( + ...wrapTerminalProse( + `${indent}${value}`, + width, + index === 0 ? " " : `${indent}${nestedBullet ? " " : ""}`, + ), + ); + if (isFence) fenced = !fenced; + }); } -function appendBodyLines( +function renderOriginalNotes( lines: string[], body: string, options: FormatChangelogTerminalOptions, ): void { - lines.push(""); - if (body.length === 0) { - lines.push(` ${dim("(empty release notes)", options.useColors)}`); + if (!body.trim()) { + lines.push(" (empty release notes)"); return; } const bodyLines = body.split("\n"); - const cap = options.verbose - ? bodyLines.length - : (options.bodyPreviewLines ?? DEFAULT_BODY_PREVIEW_LINES); - const visible = bodyLines.slice(0, cap); - for (const bodyLine of visible) { - lines.push(` ${bodyLine}`); - } - const hidden = bodyLines.length - visible.length; - if (hidden > 0) { + const cap = options.bodyPreviewLines ?? bodyLines.length; + for (const line of bodyLines.slice(0, cap)) + lines.push(` ${sanitizeTerminalText(line.replace(/\t/g, " "))}`); + if (bodyLines.length > cap) lines.push( - ` ${dim(`... (+${hidden} more line${hidden === 1 ? "" : "s"} - ${options.fullBodyHint ?? "use --verbose for the full body"})`, options.useColors)}`, + ` ... (+${bodyLines.length - cap} more lines - ${options.fullBodyHint ?? "use --verbatim for full original notes"})`, ); - } -} - -function buildSummaryLine( - envelope: LeanChangelogEnvelope, - options: FormatChangelogTerminalOptions, -): string { - const identity = - envelope.registry && envelope.name - ? `${envelope.name} | ${envelope.registry}` - : "(unknown)"; - const sourceLabel = envelope.source - ? humanizeSource(envelope.source) - : "package versions"; - 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 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}] (start excluded; end included)`; - } - if (envelope.filter?.toVersion) { - return `latest up to ${envelope.filter.toVersion}`; - } - return "latest"; -} - -function humanizeSource(source: string): string { - switch (source) { - case "releases": - return "GitHub Releases"; - case "changelog_file": - 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; - } -} - -function plural(singular: string, pluralForm: string, count: number): string { - return count === 1 ? singular : pluralForm; + if (envelope.mode === "exact") + return `exact ${envelope.entries.items[0]?.version ?? envelope.filter?.version ?? ""}`.trim(); + if (envelope.mode === "range") + return `range (${envelope.filter?.fromVersion ?? "earliest"}, ${envelope.filter?.toVersion ?? "latest"}] (start excluded; end included)`; + return envelope.filter?.toVersion + ? `latest up to ${envelope.filter.toVersion}` + : "latest"; } - function formatDate(iso: string): string { - if (/^\d{4}-\d{2}-\d{2}/.test(iso)) return iso.slice(0, 10); - return iso; + return /^\d{4}-\d{2}-\d{2}/.test(iso) ? iso.slice(0, 10) : iso; } - -function computeVersionColumnWidth(items: LeanChangelogEntry[]): number { - let width = 0; - for (const item of items) { - const label = item.version ?? "(unversioned)"; - if (label.length > width) width = label.length; - } - return width; -} - -function padColumn(text: string, width: number): string { - const visible = stripAnsi(text); - if (visible.length >= width) return text; - return text + " ".repeat(width - visible.length); -} - -const ESC = String.fromCharCode(0x1b); -const ANSI_SGR_PATTERN = new RegExp(`${ESC}\\[[0-9;]*m`, "g"); - -function stripAnsi(text: string): string { - return text.replace(ANSI_SGR_PATTERN, ""); +function noteSourceLabel(source?: string): string { + if (source === "releases") return "Release notes"; + if (source === "changelog_file") return "Changelog"; + if (source === "hexdocs") return "HexDocs"; + if (source === "registry_release_notes") return "Registry notes"; + return source ? sanitizeTerminalText(source) : "Notes"; } diff --git a/packages/mcp/src/shared/package-upgrade-review-response.test.ts b/packages/mcp/src/shared/package-upgrade-review-response.test.ts index f453b425..444f9071 100644 --- a/packages/mcp/src/shared/package-upgrade-review-response.test.ts +++ b/packages/mcp/src/shared/package-upgrade-review-response.test.ts @@ -1509,7 +1509,7 @@ describe("upgrade review model statement evidence", () => { terminalWidth: 200, }); expect(plain).toContain( - "Classification versions: 1 classified | 0 not assessed | 0 without notes | 56 statements labeled no impact\n", + "Classification coverage: 1 classified | 0 not assessed | 0 without notes | 56 statements labeled no impact\n", ); expect(plain).toContain('* [security fix] "Fixed a vulnerability."'); expect(plain).toContain('* "Added a warning."'); @@ -1894,6 +1894,21 @@ describe("upgrade review model statement evidence", () => { expect(empty).toContain("0 statements labeled no impact"); }); + it("does not admit hidden quote URLs through relative Markdown link labels", () => { + const review = riskReview([ + riskItem({ + tier: "should_know", + text: `Migration [2](../MIGRATION.md). ${"Long details ".repeat(30)} https://example.com/hidden`, + }), + ]); + const text = formatPackageUpgradeReviewTerminal( + formatterResponse([review]), + ); + expect(text).toContain("[2](../MIGRATION.md)"); + expect(text).not.toContain("https://example.com/hidden"); + expect(text).toContain("https://example.com/release"); + }); + it("distinguishes omitted items and backend-truncated quotes from local excerpts", () => { const review = riskReview([ riskItem({ diff --git a/packages/mcp/src/shared/package-upgrade-review-response.ts b/packages/mcp/src/shared/package-upgrade-review-response.ts index 80be9ad1..78f480dd 100644 --- a/packages/mcp/src/shared/package-upgrade-review-response.ts +++ b/packages/mcp/src/shared/package-upgrade-review-response.ts @@ -3,12 +3,19 @@ import type { PackageIntelligenceService, PackageUpgradeChangelogRiskCoverage, } from "@githits/core-internal"; +import { + type ClassifiedChangelogItem, + changelogRiskSections, + PENDING_CLASSIFICATION_MESSAGE, + RISK_KIND_LABELS, + releaseNoteText, + safeRiskText, +} from "./changelog-risk-presentation.js"; import { colorize, colors, highlight } from "./colors.js"; import type { PackageUpgradeReviewOptions, UpgradeReviewPackageRequest, } from "./package-upgrade-review-request.js"; -import { sanitizeTerminalText } from "./terminal-text.js"; export type VersionDelta = | "patch" @@ -90,19 +97,7 @@ export interface UpgradeChangelogEntry { signals?: string[]; } -export interface UpgradeChangelogRiskItem { - version: string; - tier: "must_act" | "should_know" | "unclassified"; - ambiguous: boolean; - tierConfidence?: number; - kind?: string; - kindConfidence?: number; - text: string; - textTruncated: boolean; - fullText?: string; - url?: string; - heading?: string; - source?: string; +export interface UpgradeChangelogRiskItem extends ClassifiedChangelogItem { model: string; formulation: string; } @@ -912,7 +907,7 @@ function formatChangesSection( ); appendWrappedText( lines, - " Classification versions: ", + " Classification coverage: ", `${riskCoverageText(changelog)} | ${changelog.riskCoverage.unitsNoImpact} statements labeled no impact`, width, " ", @@ -1095,33 +1090,7 @@ function formatChangesSection( width, " ", ); - const sections = [ - { - label: "Requires action", - items: group.items.filter( - (item) => item.tier === "must_act" && !isUncertainRiskItem(item), - ), - }, - { - label: "Possibly requires action", - items: group.items.filter( - (item) => item.tier === "must_act" && isUncertainRiskItem(item), - ), - }, - { - label: "Should know", - items: group.items - .filter((item) => item.tier === "should_know") - .sort( - (a, b) => - Number(isUncertainRiskItem(a)) - Number(isUncertainRiskItem(b)), - ), - }, - { - label: "Too long to classify - read it", - items: group.items.filter((item) => item.tier === "unclassified"), - }, - ]; + const sections = changelogRiskSections(group.items); for (const { label, items } of sections) { if (items.length === 0) continue; lines.push(` ${label}`); @@ -1247,7 +1216,7 @@ function formatChangesSection( ); // Excerpting may remove an in-note link: list only references still visible. const cited = new Set( - [...lines.join("\n").matchAll(/\[(\d+)\]/g)].map((match) => + [...lines.join("\n").matchAll(/\[(\d+)\](?!\()/g)].map((match) => Number(match[1]), ), ); @@ -1270,58 +1239,6 @@ function formatChangesSection( return lines; } -/** Render the observed release-note Markdown as visible words and source references. */ -function releaseNoteText( - text: string, - reference: (url: string) => string, -): string { - const plain = safeRiskText( - text - .split(/\r?\n/) - .map((line) => - line - .replace(/^\s*(?:>\s*)+/, "") - .replace(/^\s*(?:[-*+]\s+|\d+[.)]\s+)/, "") - .replace(/\[!([A-Z]+)\]/g, "$1:"), - ) - .join(" "), - ) - .replace(/`([^`]+)`/g, "$1") - .replace(/\[([^\]]+)\]\[[^\]]*\]/g, "$1") - // Note-authored numbers must not impersonate our source citations. - .replace(/\[(\d+)\](?!\()/g, "($1)"); - return plain - .replace( - /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<>]+)/g, - ( - _match: string, - label: string | undefined, - linkedUrl: string | undefined, - bareUrl: string | undefined, - ): string => { - if (linkedUrl) return `${label ?? ""} ${reference(linkedUrl)}`.trim(); - const rawUrl = bareUrl ?? ""; - const url = rawUrl.replace(/[.,;:!?)]*$/, ""); - return `${reference(url)}${rawUrl.slice(url.length)}`; - }, - ) - .replace(/\s+/g, " ") - .trim(); -} - -const RISK_KIND_LABELS: Record = { - removes_or_renames_api: "removal", - changes_behavior_or_default: "behavior", - raises_runtime_or_platform_requirement: "runtime/platform", - changes_packaging_or_module_format: "packaging/modules", - deprecates_without_removal: "deprecation", - security_fix: "security fix", - notable_change: "notable change", -}; - -const PENDING_CLASSIFICATION_MESSAGE = - "Classification is still running or may have failed. Rerun in a few seconds to a minute to fill not-assessed versions from completed stored labels, without rerunning the model."; - function isUncertainRiskItem(item: UpgradeChangelogRiskItem): boolean { return item.ambiguous; } @@ -1348,10 +1265,6 @@ function riskCoverageText(changelog: UpgradeChangelog): string { return `${c.versionsClassified} classified | ${c.versionsNotAssessed} not assessed | ${c.versionsWithoutNotes} without notes${c.versionsUnparseable > 0 ? ` | ${c.versionsUnparseable} unparseable` : ""}`; } -function safeRiskText(value: string): string { - return sanitizeTerminalText(value.replace(/\s+/g, " ")).trim(); -} - function formatBatchTriage( reviews: UpgradeReview[], options: FormatPackageUpgradeReviewTerminalOptions, diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index 28795dc9..1a71de00 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -1380,8 +1380,8 @@ function smokeResponse( return textResult("express vulnerabilities"); case "pkg_changelog": return textResult( - args.body_lines === 3 && args.verbose !== true - ? 'truncated; pass verbose=true, body_lines=, or format="json"' + args.body_lines === 3 && args.verbose !== true && args.verbatim !== true + ? "truncated; pass verbatim=true" : "compact changelog timeline", ); case "pkg_upgrade_review": @@ -1545,15 +1545,20 @@ function smokeJsonResponse( }, }); case "pkg_changelog": + if (args.omit_bodies === true) return jsonResult({ entries: {} }); if (args.target === "npm:express@5.2.1") { return jsonResult({ mode: "exact", + statements: { items: [], coverage: [] }, entries: { items: [{ version: "5.2.1", hasChangelog: true }], }, }); } - return jsonResult({ entries: {} }); + return jsonResult({ + entries: { items: [] }, + statements: { items: [], coverage: [] }, + }); case "pkg_upgrade_review": return jsonResult({ summary: {}, diff --git a/packages/mcp/src/smoke-test.ts b/packages/mcp/src/smoke-test.ts index 820a1586..4cfcdb90 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -1253,13 +1253,11 @@ async function runLiveSmoke(caller: McpSmokeCaller): Promise { "pkg_changelog default leaked CLI verbose flag", ); if ( - changelogText.includes("truncated") || + changelogText.includes("more lines") || changelogText.includes("full bodies") ) { assert( - changelogText.includes( - 'pass verbose=true, body_lines=, or format="json"', - ), + changelogText.includes("pass verbatim=true"), "pkg_changelog truncation hint is not MCP-native", ); } @@ -1274,13 +1272,11 @@ async function runLiveSmoke(caller: McpSmokeCaller): Promise { ); // Live entries may fit within the requested body limit without truncation. if ( - changelogBodyLinesText.includes("truncated") || + changelogBodyLinesText.includes("more lines") || changelogBodyLinesText.includes("full bodies") ) { assert( - changelogBodyLinesText.includes( - 'pass verbose=true, body_lines=, or format="json"', - ), + changelogBodyLinesText.includes("pass verbatim=true"), "pkg_changelog body_lines missing MCP-native truncation hint", ); } @@ -1294,15 +1290,12 @@ async function runLiveSmoke(caller: McpSmokeCaller): Promise { target: "npm:express", limit: 2, body_lines: 3, - verbose: true, - omit_bodies: true, + verbatim: true, }), "pkg_changelog verbose overrides preview controls", ); assert( - !changelogFullBodiesText.includes( - 'pass verbose=true, body_lines=, or format="json"', - ), + !changelogFullBodiesText.includes("pass verbatim=true"), "pkg_changelog verbose still truncated body previews", ); @@ -1316,6 +1309,65 @@ async function runLiveSmoke(caller: McpSmokeCaller): Promise { ); assertRecord(changelogJson, "pkg_changelog json"); assertRecord(changelogJson.entries, "pkg_changelog json entries"); + assertRecord(changelogJson.statements, "pkg_changelog json statements"); + assert( + Array.isArray(changelogJson.statements.items), + "pkg_changelog json missing parsed statements", + ); + assert( + Array.isArray(changelogJson.statements.coverage), + "pkg_changelog json missing coverage", + ); + assert( + Array.isArray(changelogJson.entries.items) && + changelogJson.entries.items.every( + (entry: unknown) => + typeof entry === "object" && entry !== null && !("body" in entry), + ), + "pkg_changelog default JSON over-fetched note bodies", + ); + const filteredChangelog = assertJsonResult( + await callTool(caller, "pkg_changelog", { + target: "npm:express@5.2.1", + changes: "actionable", + format: "json", + }), + "pkg_changelog actionable", + ); + assertRecord(filteredChangelog, "pkg_changelog actionable"); + assertRecord( + filteredChangelog.statements, + "pkg_changelog filtered statements", + ); + assert( + Array.isArray(filteredChangelog.statements.items) && + filteredChangelog.statements.items.every( + (item: unknown) => + typeof item === "object" && + item !== null && + "classification" in item && + typeof item.classification === "object" && + item.classification !== null && + "tier" in item.classification && + item.classification.tier === "must_act", + ), + "pkg_changelog actionable includes another tier", + ); + const timelineChangelog = assertJsonResult( + await callTool(caller, "pkg_changelog", { + target: "npm:express", + limit: 1, + omit_bodies: true, + changes: "ignored", + format: "json", + }), + "pkg_changelog timeline-only", + ); + assertRecord(timelineChangelog, "pkg_changelog timeline-only"); + assert( + !("statements" in timelineChangelog), + "pkg_changelog timeline-only classified notes", + ); const changelogExact = assertJsonResult( await callTool(caller, "pkg_changelog", { diff --git a/packages/mcp/src/tools/package-changelog.test.ts b/packages/mcp/src/tools/package-changelog.test.ts index 7c8b47a7..ec70a8d6 100644 --- a/packages/mcp/src/tools/package-changelog.test.ts +++ b/packages/mcp/src/tools/package-changelog.test.ts @@ -43,14 +43,16 @@ describe("createPackageChangelogTool — metadata", () => { "Set `json` only when code consumes", ); expect(tool.schema.verbose?.description).toContain( - "overriding body_lines and omit_bodies:true", + "cannot combine with omit_bodies:true", ); expect(Object.keys(tool.schema).sort()).toEqual([ "body_lines", + "changes", "format", "limit", "omit_bodies", "target", + "verbatim", "verbose", ]); expect(tool.annotations?.readOnlyHint).toBe(true); @@ -120,39 +122,11 @@ describe("createPackageChangelogTool — happy path", () => { const result = await tool.handler({ target: "npm:express" }, {}); expect(result.isError).toBeUndefined(); const text = result.content[0]?.text ?? ""; - expect(text).toContain("express | npm"); - expect(text).toContain("2 entries"); + expect(text).toContain("npm:express"); + expect(text).toContain("2 releases"); expect(() => JSON.parse(text)).toThrow(); }); - it("uses MCP-native hint when compact changelog text truncates bodies", async () => { - const tool = createPackageChangelogTool( - createMockPackageIntelligenceService({ - packageChangelog: mock(() => - Promise.resolve({ - ...defaultChangelogReport, - entries: [ - { - ...defaultChangelogReport.entries[0]!, - body: Array.from( - { length: 12 }, - (_, i) => `line ${i + 1}`, - ).join("\n"), - }, - ], - }), - ), - }), - ); - - 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"', - ); - expect(text).not.toContain("--verbose"); - }); - it("uses body_lines to cap MCP text previews", async () => { const tool = createPackageChangelogTool( createMockPackageIntelligenceService({ @@ -201,7 +175,7 @@ describe("createPackageChangelogTool — happy path", () => { const tool = createPackageChangelogTool( createMockPackageIntelligenceService({ packageChangelog }), ); - for (const omit_bodies of [undefined, false, true]) { + for (const omit_bodies of [undefined, false]) { const result = await tool.handler( { target: "npm:express", verbose: true, body_lines, omit_bodies }, {}, @@ -212,9 +186,9 @@ describe("createPackageChangelogTool — happy path", () => { expect(text).not.toContain("more line"); } for (const [params] of packageChangelog.mock.calls as unknown as Array< - [{ includeBodies: boolean }] + [{ view: string }] >) { - expect(params.includeBodies).toBe(true); + expect(params.view).toBe("verbatim"); } }, ); @@ -253,7 +227,7 @@ describe("createPackageChangelogTool — happy path", () => { ); expect(result.isError).toBeUndefined(); expect(packageChangelog).toHaveBeenCalledWith( - expect.objectContaining({ includeBodies: false }), + expect.objectContaining({ view: "timeline" }), ); expect(result.content[0]?.text).not.toContain( defaultChangelogReport.entries[0]!.body!, @@ -361,7 +335,7 @@ describe("createPackageChangelogTool — happy path", () => { expect(payload.filter?.toVersion).toBe("5.2.1"); }); - it.each([undefined, false, true])( + it.each([undefined, false])( "JSON omission is preserved with verbose=%s", async (verbose) => { const packageChangelog = mock(() => @@ -384,7 +358,7 @@ describe("createPackageChangelogTool — happy path", () => { entries: { items: Array<{ body?: string }> }; }; expect(packageChangelog).toHaveBeenCalledWith( - expect.objectContaining({ includeBodies: false }), + expect.objectContaining({ view: "timeline" }), ); for (const item of payload.entries.items) { expect(item.body).toBeUndefined(); @@ -405,14 +379,13 @@ describe("createPackageChangelogTool — happy path", () => { target: "npm:express", format: "json", body_lines: 3, - verbose: true, }, {}, ); expect(parseText(withTextControls)).toEqual(parseText(baseline)); }); - it("keeps body fields by default", async () => { + it("omits body fields by default", async () => { const tool = createPackageChangelogTool( createMockPackageIntelligenceService(), ); @@ -423,7 +396,7 @@ describe("createPackageChangelogTool — happy path", () => { const payload = parseText(result) as { entries: { items: Array<{ body?: string }> }; }; - expect(payload.entries.items[0]?.body).toBeTruthy(); + expect(payload.entries.items[0]?.body).toBeUndefined(); }); }); @@ -625,3 +598,74 @@ describe("createPackageChangelogTool — service errors", () => { expect(payload.code).toBe("UNKNOWN"); }); }); + +describe("changelog view precedence", () => { + for (const format of ["text", "json"] as const) { + for (const alias of ["verbatim", "verbose"] as const) { + it(`${alias} selects full original notes for ${format}, ignoring filter/cap`, async () => { + const fn = mock(() => Promise.resolve(defaultChangelogReport)); + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService({ packageChangelog: fn }), + ); + const result = await tool.handler( + { + target: "npm:express", + [alias]: true, + changes: "ignored", + body_lines: 0, + format, + }, + {}, + ); + expect(result.isError).toBeUndefined(); + expect(fn).toHaveBeenCalledWith( + expect.objectContaining({ + view: "verbatim", + statementTiers: undefined, + }), + ); + expect(result.content[0]?.text).toContain("fixed a thing"); + expect(result.content[0]?.text).not.toContain("no impact identified"); + }); + it(`${alias} conflicts with omission in ${format} before service access`, async () => { + const fn = mock(() => Promise.resolve(defaultChangelogReport)); + const result = await createPackageChangelogTool( + createMockPackageIntelligenceService({ packageChangelog: fn }), + ).handler( + { target: "npm:express", [alias]: true, omit_bodies: true, format }, + {}, + ); + expect(result.isError).toBe(true); + expect(fn).not.toHaveBeenCalled(); + }); + } + } + it("explicit false allows an explicit legacy preview request", async () => { + const fn = mock(() => Promise.resolve(defaultChangelogReport)); + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService({ packageChangelog: fn }), + ); + const result = await tool.handler( + { target: "npm:express", verbatim: false, body_lines: 1 }, + {}, + ); + expect(fn).toHaveBeenCalledWith( + expect.objectContaining({ view: "verbatim" }), + ); + expect(result.content[0]?.text).toContain("pass verbatim=true"); + expect(result.content[0]?.text).not.toContain("--verbatim"); + }); + it("uses MCP-native full-statement hint for backend cuts", async () => { + const report = structuredClone(defaultChangelogReport); + report.statements!.items[0]!.textTruncated = true; + const result = await createPackageChangelogTool( + createMockPackageIntelligenceService({ + packageChangelog: mock(() => Promise.resolve(report)), + }), + ).handler({ target: "npm:express" }, {}); + expect(result.content[0]?.text).toContain( + 'pass format="json" for full text', + ); + expect(result.content[0]?.text).not.toContain("--json"); + }); +}); diff --git a/packages/mcp/src/tools/package-changelog.ts b/packages/mcp/src/tools/package-changelog.ts index e64da221..1a8aaf0d 100644 --- a/packages/mcp/src/tools/package-changelog.ts +++ b/packages/mcp/src/tools/package-changelog.ts @@ -4,13 +4,15 @@ import { toPkgseerRegistryLowercase, } from "@githits/core-internal"; import { z } from "zod"; -import { buildPackageChangelogParams } from "../shared/package-changelog-request.js"; +import { + buildPackageChangelogParams, + resolvePackageChangelogView, +} from "../shared/package-changelog-request.js"; import { buildPackageChangelogSuccessPayload, formatPackageChangelogTerminal, } from "../shared/package-changelog-response.js"; import { mapPackageIntelligenceError } from "../shared/package-intelligence-error-map.js"; -import { InvalidPackageSpecError } from "../shared/package-spec.js"; import { PKG_CHANGELOG_GUARDRAIL } from "./guardrails.js"; import { mcpMappedErrorResult, throwIfCallerCancellation } from "./shared.js"; import { @@ -25,8 +27,10 @@ export interface PackageChangelogArgs { limit?: number; omit_bodies?: boolean; verbose?: boolean; + verbatim?: boolean; body_lines?: number; format?: "text" | "json"; + changes?: string; } /** @@ -45,29 +49,41 @@ const schema: ZodRawShape = { .describe( "Latest-mode and upper-cap (`@..to`) count (1-50, default 10). Do not pass with exact `@version` or lower-bound range targets (`@from..to`, `@from..`).", ), + changes: z + .string() + .optional() + .describe( + "Statements: actionable (MUST_ACT, including uncertain), important (actionable + SHOULD_KNOW), or all (default, including no impact identified, too-long and unassessed statements). Filters before pagination. Coverage includes every selected release. Ignored outside the default statement view.", + ), omit_bodies: z .boolean() .optional() .describe( - "Omit each entry body (default false). Use for version/date/URL timelines; large notes drop 10 KB+ per entry. Text verbose:true overrides omission.", + "Show only the version/date/URL timeline, skipping parsing and classification. Cannot combine with verbatim:true or verbose:true.", + ), + verbatim: z + .boolean() + .optional() + .describe( + "Show full original notes instead of parsed statements, including bodies in JSON. Skips classification. Cannot combine with omit_bodies:true.", ), verbose: z .boolean() .optional() .describe( - "Text output only. Show full bodies, overriding body_lines and omit_bodies:true.", + "Deprecated alias for verbatim:true, in text and JSON. Overrides body_lines; cannot combine with omit_bodies:true.", ), body_lines: z .number() .optional() .describe( - "Text output only. Number of body lines to preview per entry (1-50, default 10). Ignored for format=json, omit_bodies:true, and verbose:true.", + "Deprecated text-only original-note preview request and line cap (1-50). Ignored for format=json, omit_bodies:true, verbatim:true and verbose:true. Omit for the default statement view.", ), format: z .enum(["text", "json"]) .default("text") .describe( - "Omit `format` to use token-efficient text when the model reads the result or chooses follow-up tools. Set `json` only when code consumes the raw response instead of the model, or a required field is absent from text. JSON includes full markdown bodies.", + "Omit `format` to use token-efficient text when the model reads the result or chooses follow-up tools. Set `json` only when code consumes the raw response instead of the model, or a required field is absent from text. JSON includes full statement text and per-item provenance by default; verbatim:true includes original markdown bodies.", ), }; @@ -78,7 +94,7 @@ export const DESCRIPTION_BASE: string = "`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."; + "Default text shows all parsed statements with available agent classifications, including no-impact and unassessed statements, in source order. `changes` filters statements; all matching pages are retrieved. `verbatim:true` shows original full notes instead; `verbose` is its deprecated alias and `body_lines` requests a legacy original-note preview. `omit_bodies:true` shows only the timeline. Original-note and timeline views skip classification and ignore `changes`."; export const DESCRIPTION: string = `${DESCRIPTION_BASE}\n\n${PKG_CHANGELOG_GUARDRAIL}`; @@ -93,24 +109,31 @@ export function createPackageChangelogTool( handler: async (args, context) => { try { const textFormat = isTextFormat(args.format); - const includeBodies = - args.omit_bodies !== true || (textFormat && args.verbose === true); - const bodyPreviewLines = textFormat - ? validateTextOptions(args) - : undefined; - const { params, mode, explicitFilterFields } = + const { view, bodyPreviewLines } = resolvePackageChangelogView({ + omitBodies: args.omit_bodies, + verbatim: args.verbatim, + verbose: args.verbose, + bodyLines: args.body_lines, + json: !textFormat, + }); + const { params, mode, explicitFilterFields, changes } = buildPackageChangelogParams({ target: args.target, limit: args.limit, - includeBodies, + view, + changes: args.changes, }); - const report = await service.packageChangelog(params); + const report = await service.packageChangelog({ + ...params, + includeJsonFields: !textFormat, + }); const payload = buildPackageChangelogSuccessPayload(report, { registry: toPkgseerRegistryLowercase(params.registry), name: params.packageName, mode, + changes, explicitFilterFields, - includeBodies, + view, fromVersion: params.fromVersion, toVersion: params.toVersion, limit: params.limit, @@ -120,10 +143,9 @@ export function createPackageChangelogTool( return textResult( formatPackageChangelogTerminal(payload, { useColors: false, - verbose: args.verbose ?? false, bodyPreviewLines, - fullBodyHint: - 'pass verbose=true, body_lines=, or format="json" for full bodies', + fullStatementHint: 'pass format="json" for full text', + fullBodyHint: "pass verbatim=true for full original notes", }).trimEnd(), ); } @@ -137,21 +159,6 @@ export function createPackageChangelogTool( }; } -function validateTextOptions(args: PackageChangelogArgs): number | undefined { - if (args.verbose === true || args.omit_bodies === true) return undefined; - if (args.body_lines === undefined) return undefined; - if ( - !Number.isInteger(args.body_lines) || - args.body_lines < 1 || - args.body_lines > 50 - ) { - throw new InvalidPackageSpecError( - `body_lines must be an integer between 1 and 50. Got ${args.body_lines}.`, - ); - } - return args.body_lines; -} - function isTextFormat(format: PackageChangelogArgs["format"]): boolean { return format === undefined || format === "text"; } diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index 7def88de..a4e58677 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -1947,8 +1947,8 @@ async function runLiveSmoke(env: Record): Promise { "pkg changelog terminal missing context", ); assert( - !/[·…—–→]/.test(changelogText), - "pkg changelog terminal contains non-ASCII punctuation", + !/[·…—–→]/.test(changelogText.split("\n")[0] ?? ""), + "pkg changelog summary contains non-ASCII punctuation", ); const changelogJson = assertJsonOutput( @@ -1957,6 +1957,20 @@ async function runLiveSmoke(env: Record): Promise { ); assertRecord(changelogJson, "pkg changelog json"); assertRecord(changelogJson.entries, "pkg changelog json entries"); + assertRecord(changelogJson.statements, "pkg changelog json statements"); + assert( + Array.isArray(changelogJson.statements.items) && + Array.isArray(changelogJson.statements.coverage), + "pkg changelog missing statements or coverage", + ); + assert( + Array.isArray(changelogJson.entries.items) && + changelogJson.entries.items.every( + (entry: unknown) => + typeof entry === "object" && entry !== null && !("body" in entry), + ), + "pkg changelog default JSON over-fetched raw bodies", + ); const changelogExact = assertJsonOutput( await runCli(["pkg", "changelog", "npm:express@5.2.1", "--json"]), diff --git a/src/commands/pkg/changelog.test.ts b/src/commands/pkg/changelog.test.ts index f287b8eb..9faac44c 100644 --- a/src/commands/pkg/changelog.test.ts +++ b/src/commands/pkg/changelog.test.ts @@ -19,7 +19,10 @@ describe("pkg changelog help", () => { const command = registerPkgChangelogCommand(new Command().command("pkg")); const help = command.helpInformation().replace(/\s+/g, " "); - expect(help).toContain("up to ten latest-mode entries"); + expect(help).toContain("all parsed release-note statements"); + expect(help).toContain("--changes "); + expect(help).toContain("important (actionable + should know)"); + expect(help).toContain("ignored with --verbatim and --no-body"); expect(help).toContain("Package-only"); expect(help).toContain("Exclusive start of version range"); expect(help).toContain("Inclusive end of range / latest-mode cap"); @@ -62,69 +65,18 @@ describe("pkgChangelogAction", () => { await pkgChangelogAction("npm:express", {}, createDeps()); const combined = writes.join(""); - expect(combined).toContain("express | npm"); - expect(combined).toContain("source: GitHub Releases"); - expect(combined).toContain("2 entries"); + expect(combined).toContain("npm:express"); + expect(combined).toContain("Changes (classified by agent)"); + expect(combined).toContain("2 releases"); expect(combined).toContain("5.2.1"); - // Default now shows bodies (capped at 10 lines). Fixture bodies - // are well under the cap, so full content appears without the - // "... more lines" footer. - expect(combined).toContain("## Patch"); - expect(combined).toContain("- fixed a thing"); + // The default retains statement wording without raw note duplication. + expect(combined).not.toContain("## Patch"); + expect(combined).toContain("* fixed a thing"); + expect(combined).not.toContain("[no impact identified]"); expect(combined).not.toContain("use --verbose"); writeSpy.mockRestore(); }); - it("renders the truncation footer when a body exceeds the default cap", 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); - - // Service returns a single entry with 25 body lines — exceeds - // the 10-line default cap. Expect the first 10 lines + a footer. - const longBody = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join( - "\n", - ); - const longReport = { - package: { name: "big-pkg", registry: "npm", limit: 10 }, - source: "releases", - entries: [ - { - version: "1.0.0", - normalizedVersion: "1.0.0", - publishedAt: "2026-01-01T00:00:00Z", - htmlUrl: "https://example.com", - body: longBody, - }, - ], - }; - await pkgChangelogAction( - "npm:big-pkg", - {}, - createDeps({ - packageIntelligenceService: createMockPackageIntelligenceService({ - packageChangelog: mock(() => Promise.resolve(longReport)), - }), - }), - ); - - const combined = writes.join(""); - expect(combined).toContain("line 1"); - expect(combined).toContain("line 10"); - expect(combined).not.toContain("line 11"); - expect(combined).toContain("+15 more lines"); - expect(combined).toContain( - "... (+15 more lines - use --verbose for the full body)", - ); - writeSpy.mockRestore(); - }); - it("expands bodies fully under --verbose (no truncation footer)", async () => { const writes: string[] = []; const writeSpy = spyOn(process.stdout, "write").mockImplementation((( @@ -184,7 +136,8 @@ describe("pkgChangelogAction", () => { expect(payload.source).toBe("releases"); expect(payload.mode).toBe("latest"); expect(payload.entries.count).toBe(2); - expect(payload.entries.items[0].body).toBe("## Patch\n- fixed a thing"); + expect(payload.entries.items[0].body).toBeUndefined(); + expect(payload.statements.items[0].fullText).toBe("- fixed a thing"); logSpy.mockRestore(); }); @@ -383,7 +336,7 @@ describe("pkgChangelogAction", () => { const msg = errorSpy.mock.calls[0]?.[0] as string; expect(msg).toContain("--no-body"); expect(msg).toContain("--verbose"); - expect(msg).toContain("uncaps"); + expect(msg).toContain("cannot be combined"); errorSpy.mockRestore(); exitSpy.mockRestore(); }); @@ -434,7 +387,7 @@ describe("pkgChangelogAction", () => { ); const combined = writes.join(""); - expect(combined).toContain("No entries in this range."); + expect(combined).toContain("No releases in this selection."); writeSpy.mockRestore(); }); diff --git a/src/commands/pkg/changelog.ts b/src/commands/pkg/changelog.ts index f516d8dd..70111926 100644 --- a/src/commands/pkg/changelog.ts +++ b/src/commands/pkg/changelog.ts @@ -10,6 +10,7 @@ import { InvalidPackageSpecError, type MappedError, requireAuth, + resolvePackageChangelogView, shouldUseColors, } from "@githits/mcp/internal"; import type { Command } from "commander"; @@ -25,11 +26,13 @@ export interface PkgChangelogCommandOptions { to?: string; limit?: string; verbose?: boolean; + verbatim?: boolean; noBody?: boolean; // Commander's `--no-body` flag is surfaced as `body: false` in - // options; we map it to `includeBodies` in the builder. + // options; shared view resolution maps it to timeline mode. body?: boolean; json?: boolean; + changes?: string; } export interface PkgChangelogCommandDependencies { @@ -64,30 +67,35 @@ export async function pkgChangelogAction( } const limit = resolveLimit(options); - const includeBodies = options.body !== false; - if (!includeBodies && options.verbose) { - throw new InvalidPackageSpecError( - "--no-body drops the bodies that --verbose uncaps — pass only one of the two flags.", - ); - } - - const { params, mode, explicitFilterFields } = buildPackageChangelogParams({ - target: spec, - fromVersion: options.from, - toVersion: options.to, - limit, - includeBodies, + const { view } = resolvePackageChangelogView({ + omitBodies: options.body === false || options.noBody === true, + verbatim: options.verbatim, + verbose: options.verbose, + json: options.json, }); - const report = - await deps.packageIntelligenceService.packageChangelog(params); + const { params, mode, explicitFilterFields, changes } = + buildPackageChangelogParams({ + target: spec, + fromVersion: options.from, + toVersion: options.to, + limit, + view, + changes: options.changes, + }); + + const report = await deps.packageIntelligenceService.packageChangelog({ + ...params, + includeJsonFields: options.json === true, + }); const payload = buildPackageChangelogSuccessPayload(report, { registry: toPkgseerRegistryLowercase(params.registry), name: params.packageName, mode, + changes, explicitFilterFields, - includeBodies, + view, fromVersion: params.fromVersion, toVersion: params.toVersion, limit: params.limit, @@ -100,8 +108,8 @@ export async function pkgChangelogAction( } const output = formatPackageChangelogTerminal(payload, { - verbose: options.verbose ?? false, useColors: shouldUseColors(), + terminalWidth: process.stdout.columns, }); process.stdout.write(output); } catch (error) { @@ -172,13 +180,14 @@ function formatChangelogTerminalError(mapped: MappedError): string { } 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, +By default shows all parsed release-note statements, with available agent +classifications and nearby sources. Pin a version for one selected release, or use @from..to for releases after from through to (from excluded, -to included). --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. +to included). --from/--to remain package range flags. --limit changes +latest-mode entry count (1-50). --changes actionable|important|all limits +statements; all is the default. --verbatim shows full original notes instead; +--verbose is a deprecated alias. --no-body shows a version/date/URL timeline. +JSON includes full statement evidence; --verbatim --json includes original bodies. Package spec: :[@ | @..]. Package-only; repository and site targets are rejected. Supported registries: @@ -202,13 +211,18 @@ export function registerPkgChangelogCommand(pkgCommand: Command): Command { "--limit ", "Latest-mode or upper-cap entry count (1-50, default 10)", ) + .option("-v, --verbose", "Deprecated alias for --verbatim") .option( - "-v, --verbose", - "Uncap the markdown body preview (default cap: 10 lines per entry)", + "--verbatim", + "Show original full notes instead of parsed statements", ) .option( "--no-body", - "Drop body fields from entries (affects terminal + JSON)", + "Show only the version/date/URL timeline; skip parsing and classification", + ) + .option( + "--changes ", + "Statements: actionable, important (actionable + should know), all (default); ignored with --verbatim and --no-body", ) .option("--json", "Emit the JSON envelope") .action(async (spec: string, options: PkgChangelogCommandOptions) => { diff --git a/src/services/test-helpers.ts b/src/services/test-helpers.ts index a52aa5c1..d37d1db2 100644 --- a/src/services/test-helpers.ts +++ b/src/services/test-helpers.ts @@ -822,6 +822,38 @@ export const cratesFeatureDependencyReport: DependencyReport = { * markdown, ISO date, and a normalisedVersion that equals the raw. */ export const defaultChangelogReport: ChangelogReport = { + statements: { + totalCount: 2, + matchingCount: 2, + items: ["5.2.1", "5.2.0"].map((version, index) => ({ + version, + position: 0, + sourceIndex: 0, + sourcePosition: 0, + text: index === 0 ? "- fixed a thing" : "- added WebSocket support", + fullText: index === 0 ? "- fixed a thing" : "- added WebSocket support", + textTruncated: false, + heading: index === 0 ? "Patch" : "Minor", + source: "RELEASES", + url: `https://github.com/expressjs/express/releases/tag/${version}`, + classification: { + tier: "NO_IMPACT", + ambiguous: false, + tierConfidence: 0.9, + model: "jev-1.13.0", + formulation: "s-hier-v5", + }, + })), + coverage: ["5.2.1", "5.2.0"].map((version) => ({ + version, + status: "CLASSIFIED", + statementsTotal: 1, + statementsAssessed: 1, + statementsNotAssessed: 0, + statementsNoImpact: 1, + statementsUnclassified: 0, + })), + }, package: { name: "express", registry: "npm", diff --git a/src/tools/package-changelog-parity.test.ts b/src/tools/package-changelog-parity.test.ts index 7abdd697..7d153a66 100644 --- a/src/tools/package-changelog-parity.test.ts +++ b/src/tools/package-changelog-parity.test.ts @@ -72,9 +72,12 @@ async function cliJson( } interface McpArgs { + changes?: string; target: string; limit?: number; omit_bodies?: boolean; + verbatim?: boolean; + verbose?: boolean; } async function mcpJson( @@ -209,7 +212,7 @@ describe("package_changelog parity", () => { } }); - it("PARITY-JSON-KEYS: default includes bodies on both surfaces", async () => { + it("PARITY-JSON-KEYS: default omits bodies on both surfaces", async () => { const fn = mock(() => Promise.resolve(defaultChangelogReport)); const cli = await cliJson( "npm:express", @@ -225,7 +228,7 @@ describe("package_changelog parity", () => { const envelope = cli as { entries: { items: Array<{ body?: string }> }; }; - expect(envelope.entries.items[0]?.body).toBe("## Patch\n- fixed a thing"); + expect(envelope.entries.items[0]?.body).toBeUndefined(); }); it("PARITY-JSON-KEYS: empty entries lossless on both surfaces", async () => { @@ -398,3 +401,92 @@ describe("package_changelog parity", () => { }); }); }); + +describe("changelog statement and view parity", () => { + for (const target of [ + "npm:express", + "npm:express@5.2.1", + "npm:express@5.0.0..5.2.1", + ]) { + for (const changes of ["actionable", "important", "all"]) { + it(`${target} ${changes}: JSON evidence and wire request match`, async () => { + const fn = mock(() => Promise.resolve(defaultChangelogReport)); + const cli = await cliJson( + target, + { changes }, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: fn, + }), + }), + ); + const mcp = await mcpJson({ target, changes }, fn); + expect(cli).toEqual(mcp.json); + expect(cli).not.toHaveProperty("entries.items.0.body"); + expect(cli).toHaveProperty("statements.items.0.fullText"); + const calls = fn.mock.calls as unknown as Array< + [Record] + >; + expect(calls[0]?.[0]).toEqual(calls[1]?.[0]); + expect(calls[0]?.[0]?.statementTiers).toEqual( + changes === "actionable" + ? ["MUST_ACT"] + : changes === "important" + ? ["MUST_ACT", "SHOULD_KNOW"] + : undefined, + ); + expect(calls[0]?.[0]?.includeJsonFields).toBe(true); + }); + } + } + for (const alias of ["verbatim", "verbose"] as const) { + it(`original-note JSON via ${alias} matches, with changes ignored`, async () => { + const cli = await cliJson("npm:express", { + [alias]: true, + changes: "ignored", + }); + const mcp = await mcpJson({ + target: "npm:express", + [alias]: true, + changes: "ignored", + }); + expect(cli).toEqual(mcp.json); + expect(cli).toHaveProperty("entries.items.0.body"); + expect(cli).not.toHaveProperty("statements"); + expect(cli).not.toHaveProperty("filter.changes"); + }); + it(`${alias} conflicts with timeline identically`, async () => { + const cli = await cliJson("npm:express", { [alias]: true, body: false }); + const mcp = await mcpJson({ + target: "npm:express", + [alias]: true, + omit_bodies: true, + }); + expect(cli).toEqual(mcp.json); + expect(cli).toHaveProperty("code", "INVALID_ARGUMENT"); + }); + } + it("shares the same complete statement text", 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:express", {}, cliDeps()); + } finally { + writeSpy.mockRestore(); + } + const tool = createParityMcpTool("pkg_changelog", { + packageIntelligenceService: createMockPackageIntelligenceService(), + }); + const result = await tool.handler({ target: "npm:express" }, {}); + expect(result.content[0]?.text).toBe( + Bun.stripANSI(writes.join("")).trimEnd(), + ); + }); +});