From bc457bda7075f3eac82e7f3dbf8eed141f377884 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Tue, 13 Oct 2026 12:51:41 +0300 Subject: [PATCH 01/14] feat: add classified statements to package changelogs Preserve release-note previews while exposing backend classifications, uncertainty, source locators and coverage in CLI and MCP text and JSON. Add server-side cumulative change filters before the cap, keep timeline-only requests free of classifier work, and verify mode selections with tests and live dev parity. --- .../changelog-classified-statements.added.md | 6 + docs/implementation/cli-commands.md | 11 +- docs/implementation/pkg-changelog.md | 127 ++++++++ docs/implementation/tools.md | 6 +- docs/plans/changelog-classifications.md | 260 +++++++++++++++++ .../package-intelligence-service.test.ts | 212 ++++++++++++++ .../services/package-intelligence-service.ts | 154 +++++++++- packages/mcp/src/services/test-helpers.ts | 14 + .../src/shared/changelog-risk-presentation.ts | 113 ++++++++ .../shared/package-changelog-request.test.ts | 49 ++++ .../src/shared/package-changelog-request.ts | 40 ++- .../shared/package-changelog-response.test.ts | 271 ++++++++++++++++++ .../src/shared/package-changelog-response.ts | 196 ++++++++++++- .../shared/package-upgrade-review-response.ts | 107 +------ packages/mcp/src/smoke-test.test.ts | 5 +- packages/mcp/src/smoke-test.ts | 45 ++- .../mcp/src/tools/package-changelog.test.ts | 52 ++++ packages/mcp/src/tools/package-changelog.ts | 21 +- src/commands/pkg/changelog.test.ts | 3 + src/commands/pkg/changelog.ts | 35 ++- src/services/test-helpers.ts | 14 + src/tools/package-changelog-parity.test.ts | 171 +++++++++++ 22 files changed, 1766 insertions(+), 146 deletions(-) create mode 100644 changes/changelog-classified-statements.added.md create mode 100644 docs/implementation/pkg-changelog.md create mode 100644 docs/plans/changelog-classifications.md create mode 100644 packages/mcp/src/shared/changelog-risk-presentation.ts diff --git a/changes/changelog-classified-statements.added.md b/changes/changelog-classified-statements.added.md new file mode 100644 index 00000000..b0fade50 --- /dev/null +++ b/changes/changelog-classified-statements.added.md @@ -0,0 +1,6 @@ +--- +"githits": minor +"@githits/mcp": minor +--- + +- **Classified changelog statements** - Package changelogs retain release-note previews and add source-linked agent classifications, uncertainty and coverage, with `changes` views for actionable, important or all statements. Timeline-only body omission skips classification; JSON preserves complete statement evidence and provenance. diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index a283bae5..79ea484f 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`, `--verbose`, `--json` | Release notes / changelog entries for a package. Default shows each entry with a 10-line body preview; pin a version for one selected release; `--verbose` uncaps, `--no-body` drops. | | `pkg upgrade-review [spec]` | single package spec with current version plus `--to`, positional package range, OR repeatable `--package` ranges | `--to`, repeatable `--package`, `--no-transitive-security`, `--dependency-issues`, `--min-severity`, `--verbose`, `--json` | Compare current and target versions for upgrade evidence: vulnerabilities, 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. | @@ -727,6 +727,7 @@ githits pkg changelog npm:express@5.2.1 githits pkg changelog npm:express@4.0.0..5.2.1 githits pkg changelog npm:express --from 4.0.0 githits pkg changelog npm:express --to 4.18.0 --limit 5 +githits pkg changelog npm:express --changes important githits pkg changelog npm:express --json githits pkg changelog pypi:requests --no-body --json # lean timeline ``` @@ -741,13 +742,15 @@ Fetches release notes or changelog entries for a package. Output preserves sourc **Pre-release versions.** Normalised versions flow through unchanged (`5.0.0-rc.1`, `2.32.0.dev0`, `1.7.0-rc.5`). Tag-style `v`-prefixed inputs are rejected except for Go canonicalisation and Swift. -**Default terminal output.** Summary header (`name | registry | source | mode | entry count`) followed by each entry's `version date url` header plus the first 10 lines of its markdown body, indented and dimmed. Bodies longer than the cap show a footer `... (+N more lines - use --verbose for the full body)`. Missing dates render as `-`; missing versions render as `(unversioned)`. Exact mode labels the resolved release, not the requested selector. Exact no-notes results say `Release notes are unavailable.` +**Default terminal output.** Summary header (`name | registry | source | mode | entry count`), one classification summary/coverage block, then each entry's `version date source references` 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. Classified statements follow each preview, with kind labels and uncertain items separated from confident action statements. Source references resolve once below the entries. 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. +**`--changes `.** Selects classified statements: `all` (default), `actionable` (MUST_ACT including uncertain items), or `important` (MUST_ACT plus SHOULD_KNOW). Backend filtering precedes the 50-item cap; note previews and all-tier coverage totals remain unchanged. See [changelog evidence](pkg-changelog.md) for uncertainty, coverage and source semantics. -**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. +**`--no-body`.** Drops body fields from entries and skips classification. `--changes` is ignored in this mode, including validation and filter echo. 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}, riskItems?, riskCoverage?, filter?}`. Classification fields are present unless bodies are omitted; JSON includes complete statement text and per-item provenance. `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`, an effective `--changes`, 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. diff --git a/docs/implementation/pkg-changelog.md b/docs/implementation/pkg-changelog.md new file mode 100644 index 00000000..69175354 --- /dev/null +++ b/docs/implementation/pkg-changelog.md @@ -0,0 +1,127 @@ +# Package changelog evidence + +`pkg changelog` and MCP `pkg_changelog` show release-note previews alongside +agent-classified statements. They answer what shipped in selected releases; +upgrade review additionally compares security and dependencies between versions. +Statements and their source links are the evidence. Classification does not +establish an application's compatibility or assign a package-level risk. + +## Selection and views + +Existing package targets remain unchanged: latest `registry:name`, exact +`registry:name@version`, or range `registry:name@from..to`. The lower bound is +excluded and the upper bound included. Latest uses a 1–50 entry limit; ranges +return all selected entries. Exact uses the backend-selected release, including +prereleases, without inventing a prior version. + +Default output retains the first ten logical lines of each note body and adds +classified statements grouped under that release's raw `version`. The normalized +version is available in JSON but is not the statement join key. Per-version +sections separate confident action from possible action, should-know statements +and statements too long to classify. A missing kind still retains its tier. +Uncertainty comes only from the backend `ambiguous` boolean; confidence numbers +are evidence, never a client policy. Labels remain understandable without color. + +| CLI `--changes` / MCP `changes` | Statements returned | +| --- | --- | +| `all` (default) | Actionable, should know and too-long statements | +| `actionable` | MUST_ACT, including visibly uncertain MUST_ACT | +| `important` | MUST_ACT and SHOULD_KNOW, including uncertain items | + +The backend filters before its 50-item cap. Filtering does not filter release +entries or their note previews. A filter therefore retains context and may still +show other changes in the original notes. Empty optional agent strings mean +`all`; unknown active values fail before service access. + +`--no-body` / `omit_bodies:true` preserves the version/date/link timeline and +skips both bodies and classification. `changes` is ignored, including validation +and echo, in that mode. MCP text `verbose:true` keeps its existing override: +notes and classifications are enabled even when `omit_bodies:true`. CLI still +rejects `--no-body` combined with `--verbose`. JSON honors omission despite +verbose because verbose is text-only. + +Verbose expands note bodies and locally abbreviated quotes, plus headings and +confidence evidence. Text uses the backend-bounded `text`; a backend cut is +explicitly marked and the complete `fullText` is available in JSON. No-impact +statements appear only as a count. Model and formulation stay per-item JSON +provenance, allowing stored labels from different classifier revisions. + +## Coverage and locators + +The summary uses backend counts before filtering and capping, never counts of +returned quotes. Confident and uncertain counts remain separate. Version +coverage and no-impact counts likewise cover all selected releases. Only +`itemsOmitted` describes the selected filter: matching statements cut by the cap. +The text labels those scopes explicitly. + +Pending versions may still be running or their job may have failed; rerun in a +few seconds to a minute to read completed stored labels. Current backend jobs +share a six-second wait budget and allow at most ten classification batches; +there is no twenty-version ceiling. Missing notes and unparseable statements +are never evidence of no risk. Empty selections and exact releases without +notes remain successful. + +Each item uses its own backend `url`, including alternate note sources. Version +headings collect available note locators and the Sources table lists URLs once. +Timeline-level `source` describes the aggregate and cannot identify every +entry's source. Use an item's matching URL/source or exact-release source when +available; otherwise label an entry link generically as Notes. Do not invent +links or infer sources from URL patterns. The original markdown previews remain +available and may themselves contain links. + +Terminal sanitation runs at presentation only, on notes, quotes, headings, +versions and locators. JSON retains raw evidence with the established lowercase +enum and absent-null conventions. + +## Transport and envelope + +Backend #3204 added `riskItems` and `riskCoverage` to `PackageChangelogResult` +and `PackageVersionChangelog`, with optional `riskTiers` on `packageChangelog` +and `selectedVersion.changelog`. Its production deployment was confirmed by the +owner on 2026-10-13. Both surfaces reuse existing stored labels and alternate +bodies. Risk computation is lazy when selected, and sibling fields share work. + +The client makes one query. Body and risk fields use `includeBodies` directives +so timeline-only calls do not trigger classification. Compact text selects only +consumed statement fields; verbose adds confidence/heading fields. JSON adds +`fullText`, model and formulation. The shared risk schema keeps upgrade-review +provenance required while permitting omitted compact changelog fields. Missing +selected fields are protocol errors; there is no old-schema fallback. + +The existing envelope retains `registry`, `name`, optional `source`, `mode`, +`entries: {count, items}` and explicit `filter`. When classification is selected, +add top-level `riskItems` and `riskCoverage`. `filter.changes` echoes only an +explicit, effective view. Timeline-only envelopes retain their earlier shape. + +Core transport lives in `package-intelligence-service.ts`; shared request, +envelope and per-tool formatter live under `packages/mcp/src/shared`. +`changelog-risk-presentation.ts` owns the small statement presentation primitives +also consumed by upgrade review. CLI and MCP handlers select modes and supply +host-native note expansion hints. + +## Verified complexity + +Authenticated dev admission measurements on 2026-10-13: exact56, latest50=65, +range90 for the final maximal client JSON selections, below the500 limit. +A verified510-cost control operation (51fixed-cost packageVulnerabilities aliases) +was added only to admission probes, which were rejected before resolvers; +subtracting510 from566/575/600 gives the client costs. Product calls remain +a single query. Runtime directives can still count statically. + +## Release guidance staging + +Keep published `skills/githits-package` unchanged until this behavior is in its +release branch, following `.agents/skills/githits-release/SKILL.md`. At release: + +- In SKILL.md, add an example `githits pkg changelog npm:express --changes important`. +- Expand the changelog routing sentence: "Release-note previews include + agent-classified statements by default. Use `--changes actionable` for action + statements or `--changes important` for action plus should-know statements; + these filter classifications, not note previews. `--no-body` remains a compact + timeline and skips classification, so `--changes` is ignored there." +- In references/package.md, add `--changes actionable|important|all` to changelog + flags and the same filtering/body-omission distinction. Retain range boundaries, + exact selection and upgrade-review routing. Do not add a package verdict. + +Run plugin generation/check and skill packaging tests when applying those +canonical release edits. No generated plugin input changes in this feature PR. diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 050415b8..c221d7c1 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -529,6 +529,8 @@ JSON retains backend order and multiplicity. ### `pkg_changelog` response shape +Package changelogs now add agent-classified statements to existing note previews. See [changelog evidence](pkg-changelog.md) for the complete contract and release guidance staging. `changes: "actionable" | "important" | "all"` (default all) filters statements before the backend cap, without filtering notes or all-tier coverage totals. `ambiguous` alone controls uncertainty. Body omission skips both notes and risk fields; `changes` is ignored unless text verbose overrides omission. JSON adds per-item evidence/provenance and all selected-release coverage under top-level `riskItems` and `riskCoverage`. + **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. @@ -537,9 +539,9 @@ JSON retains backend order and multiplicity. **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." +**`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. Classification selections are also omitted, so no classifier work runs and `changes` is ignored. 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." -**`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. diff --git a/docs/plans/changelog-classifications.md b/docs/plans/changelog-classifications.md new file mode 100644 index 00000000..bf553866 --- /dev/null +++ b/docs/plans/changelog-classifications.md @@ -0,0 +1,260 @@ +# Classified statements in package changelogs + +## Outcome and status + +Status: IMPLEMENTED, external code review pending. The owner confirmed production deployment on 2026-10-13. +Backend #3204 is merged; current SDL and changelog match the reviewed contract. +Plan review is clean after the round 3 wording fix. `pkg changelog` and MCP `pkg_changelog` retain their release-note +previews and additionally show the backend's classified statements per version. +Users can select actionable statements, actionable plus should-know statements, +or all classified statements. JSON retains evidence, provenance and honest +coverage. Classifications never become a package-level compatibility verdict. + +The user confirmed note previews remain the default on 2026-10-12 and requested +the three cumulative filters. After review, the user confirmed `all` as the +default and keeping `--no-body` as the existing timeline-only mode: it skips +classification, and `--changes` is a no-op there. No fourth `none` value. Backend changes are explicitly authorized. PR #463 +has merged; this work started from CLI main 6b35537, now refreshed to5531953 in a separate feature PR. + +## Verified evidence + +- Latest backend main 4fac1441178082398eb28419389448d7f35987be was read through + `gh api`; the local canonical checkout bf74b72f0 is stale. Latest SDL exposes + `riskItems` and `riskCoverage` only on `PackageUpgradeChangelog`, not on + `PackageChangelogResult` or `PackageVersionChangelog`. +- Latest/range requests already use `packageChangelog`. Exact pins use + `packageInfo.selectedVersion.changelog`, preserving the authoritative selected + version, including prereleases. No invented previous version is necessary. +- Backend `PackageChangelog` carries displayed and alternate note bodies, with + source URLs, internally. `PackageUpgradeReview.risk_entries/1` adapts these to + the reusable `Changelog.Risk` classifier. Stored fingerprints incorporate the + note bodies, so changing caller must not change classification inputs. +- `ChangelogRiskItem` already has `ambiguous`, bounded `text`, optional selected + `fullText`, `url`, and per-item model/formulation. Coverage has five pre-cap + confident/ambiguous tier counters, pending/missing/unparseable version counts, + no-impact count and `itemsOmitted`. +- Upgrade review has no classification filter; `--min-severity` is advisory-only. +- Current changelog text previews ten body lines, with explicit body omission and + verbose expansion. CLI and MCP share request/envelope/formatter modules. JSON + uses `entries: {count, items}` and echoes only explicit filters. + +## Ownership and boundaries + +The backend owns classification, stored labels, source aggregation, policy, +coverage, filtering before the cap and job budgets. The service client owns +typed GraphQL transport/validation. The shared MCP presentation layer owns +envelopes and terminal rendering used by both hosts. Classification presentation +primitives reused with upgrade review belong beside those shared formatters; +changelog must not manufacture an aggregate upgrade review to obtain labels. + +Backend work is handed off to its own Orca worktree per backend AGENTS: +`/Users/jpl/orca/workspaces/pkgseer-backend/changelog-classifications`. +The receiving agent owns backend planning, implementation, tests, docs and its +draft PR. This coordinator edits only githits-cli and reads the shared contract +artifact `/tmp/pkg-changelog-backend-contract.md`. + +No new classifier, queue, cache, storage, public dependency, package verdict, +repository-addressed CLI tool, extra GraphQL query, or upgrade-review filter. +Backend jobs and storage are existing infrastructure. Neither PR is merged or +deployed by this task. Deployment needs the owner's separate authorization. + +## Contract and behavior + +Backend contract confirmed by the receiving agent in +`/tmp/pkg-changelog-backend-contract.md`; final SDL is checked before client +queries are finalized: + +- Add `riskItems: [ChangelogRiskItem!]!` and + `riskCoverage: ChangelogRiskCoverage!` to both changelog result objects. +- Optional `riskTiers: [ChangelogRiskTier!]` on the timeline root and exact + changelog field filters statements before the 50-item cap. Omitted/all tiers + means all. Version selection and note previews remain unchanged. +- Coverage and the five tier counters describe all selected release versions, + before filtering/capping; `itemsOmitted` counts matching filtered statements + omitted by the cap. Document those different scopes explicitly. +- Risk selection is lazy and sibling fields share one computation. Body-only + legacy callers neither schedule classification nor incur classification waits. +- Selected versions' displayed plus alternate bodies are classified using the + same inputs/fingerprints and current job budget as upgrade review: six seconds + and at most ten batches, not the stale twenty-version ceiling. Latest main + `Changelog.Risk` lines55-58/129-158 confirm this. +- Empty selections and exact versions without notes remain successful. A missing + note is never evidence of no risk. Repo-only backend addressing must have an + explicit supported contract; CLI never invokes it. + +CLI `--changes ` and MCP `changes` have identical values: + +| View | Classified statements shown | +| --- | --- | +| `actionable` | MUST_ACT, including visibly uncertain MUST_ACT | +| `important` | MUST_ACT and SHOULD_KNOW, including uncertain items | +| `all` (default) | All three tiers, including statements too long to classify | + +Empty optional MCP strings normalize to the default; unknown non-empty views +fail locally with mapped INVALID_ARGUMENT when classification is enabled. The +explicit nonblank view is echoed in `filter.changes` only when effective. +Filtering affects classifications, not note previews or the release timeline. +Existing `--no-body` / `omit_bodies` skips note bodies AND risk selections, +preserving a fast timeline-only mode and its existing JSON envelope. `changes` +is ignored there (including validation/echo, like MCP ignored `body_lines`). +Existing verbose overrides for bodies remain unchanged: MCP text verbose enables +bodies/classifications even with omit_bodies, whereas CLI rejects no-body with +verbose as before. Risk field @include directives reuse includeBodies. + +Text retains version/date/source headings and note previews. Add adjacent +classification sections: Requires action, Possibly requires action, Should know, +Too long to classify - read it. Do not put subsection counts after titles. +Show kind labels when present; missing kind still has a tier. Uncertain derives +only from `ambiguous`, never tierConfidence. Confident MUST_ACT quotes are full +within the bounded wire text; lower-priority quotes are brief, verbose expands. +Model/formulation/confidences remain machine evidence, not default text clutter. +Keep source URLs available once through references when multiple sources apply. +Show whole-selection tier counters and classification coverage once, with the +pending rerun message, no-impact count and explicit matching-item omissions. + +JSON adds top-level `riskItems` and `riskCoverage` to the existing lean envelope +when classification is selected, with established lowercase enum/null-stripping +conventions. `fullText` is JSON-only, matching upgrade review; verbose expands +bounded quotes and existing full note bodies. Compact selection omits unconsumed +provenance/confidence/heading fields using conditional selections: model and +formulation for JSON, confidence/heading for JSON or verbose. Bodies and risk +fields share includeBodies selection, so omitted bodies produce the old timeline +envelope without classifier work. Do not silently accept missing risk fields +once selected; report schema/protocol errors. Join items to entries by `version`, +not `normalizedVersion`; keep source ownership separate from version spelling. + +## Ordered delivery + +### Backend slice — COMPLETE (#3204, owner-confirmed production deployment) + +Outcome: exact/latest/range APIs expose lazy filtered classification evidence. +Assumptions: existing Risk jobs and stored labels are reusable (verified). +Unknowns: none. Delivered handoff confirms exact/timeline riskTiers argument, +repo-only risk-selection validation and maximal backend complexity73/71/96 +(exact/latest50/range, including contentSafety). Measure final client documents. +Dependencies: none beyond existing backend services. +Acceptance: focused backend tests cover lazy selection, both sibling fields, +empty/no-notes, selected-version scopes, alternate-source locators, server +ambiguity/counters, filtered-before-cap ordering and existing six-second/ten-batch job budget. Draft +PR contains current SDL/docs and complexity evidence for exact/latest50/range. + +### Client slice — IMPLEMENTED, review pending + +Outcome: both hosts show useful per-version classifications and identical JSON, +with the three cumulative filters and existing note controls preserved. +Assumptions: backend contract above is delivered; no production fallback or +parallel aggregate query is introduced. Public deployment precedes release. +Unknowns/product decisions: none. Live dev validation passed. Stored labels meant +no live pending sample; pending-only and truncated/oversize edge cases are tested. +Dependencies: backend gate satisfied. SDL riskTiers/riskItems/riskCoverage and +filter-before-cap/all-tier-counter semantics verified on 2026-10-13. Source +branch fast-forwarded to current CLI main5531953 before implementation. + +Implementation order: + +1. Review this plan, then verify exact SDL and arguments from the backend handoff. +2. Extend core changelog params/report/query/Zod normalization. Factor a shared + risk item schema base, with an upgrade-review extension retaining REQUIRED + model/formulation/confidence/heading keys and a changelog extension allowing + the omitted compact fields. Add a regression proving upgrade review rejects + missing model/formulation. Reuse neutral risk types and factor presentation + primitives only where both formatters consume them. Preserve upgrade review. +3. Extend shared request validation, both host options and lean envelope. Add + per-version classification rendering and one coverage summary, with source + references, wrapping, color-independent labels and terminal sanitation. +4. Update tools/CLI/implementation docs and a dual-artifact minor fragment. + CLI --changes help and MCP changes description explicitly state it is ignored + when bodies are omitted; MCP text verbose re-enables it. + Stage exact edits for canonical skills/githits-package/SKILL.md and its + package reference in durable implementation docs, with timeline-only no-body + semantics and filter guidance. Leave the published package skill unchanged in + this feature PR to avoid advertising unreleased flags, following the explicit + CLI code/package release-branch guidance rule in + .agents/skills/githits-release/SKILL.md (read by the reviewer). The onboarding + section alone is not the authority for this package guidance decision. Its current no-body timeline advice remains true. Test + descriptor prefix contracts; generate/check plugin assets only if a canonical + plugin input actually changes. +5. Verify focused tests, build and both smoke surfaces, then internal pre-flight + and one external reviewer per round. Open a separate draft client PR. + +Acceptance tests: + +- CLI/MCP JSON and text parity for exact/latest/range and all three filters. +- Wire selections/variables prove backend tier filtering, no-body skipping risk/body + fields with ignored changes, MCP verbose override and CLI conflict rejection, + JSON-only fullText and provenance versus compact/verbose selections, and one query per call. +- Pending-only, zero risk items, missing/unparseable notes, omitted matching + items, oversize/truncated quotes, mixed provenance, and kindless items. +- Backend ambiguity overrides any numeric confidence (test opposite numeric + values); no client-side confidence policy. +- Version matching uses entry.version even when normalizedVersion differs. +- Existing notes, latest limits, excluded lower bound, exact prereleases, empty + results and mapped errors keep working. All terminal strings sanitize control + sequences; JSON stays unchanged. Narrow-width text wraps prose. +- Upgrade-review regression tests pass after any shared extraction. +- Dev source CLI/local stdio MCP verify exact express@5.2.1, latest express and + express@5.0.0..5.2.1, plus a many-item Biome range with each filter; report + cold/warm coverage and operation complexity below 500. Auth uses normal CLI. +- Targeted changelog descriptor/agent workloads run only after the backend is + available, with trace/final/metrics/isolation artifacts inspected. Do not + describe harness completion as graded quality. + +Baseline verification: five existing changelog test files pass (129 tests, +423 assertions, 446 ms). Authenticated dev exact `npm:express@5.2.1` returned its +release body and locator through normal CLI auth. Internal technical plan review +found no findings; static query complexity remains a measurement requirement. +External round 1: direction sound. Accepted the default/timeline clarification +(user confirmed all + timeline-only no-body, no fourth view), guidance/descriptor +coverage, JSON-only fullText, and explicit version join. No rejected findings. +Round 2 accepted schema-base/required-upgrade/optional-changelog extensions +with a strict upgrade-provenance regression; corrected remaining no-body +wording and pinned package-guidance release authority. Round 3 confirmed technical closure; its only finding was this explicit help/ +description requirement, now added. The round is clean under the minor-wording +rule. No fresh-context external subagent or validation was run during planning. + +No performance optimization is proposed. Measure GraphQL complexity on the exact +documents before/after; preserve bounded backend classification wait. Rendering +adds a bounded 50 statements and does not require a performance benchmark claim. + +## Reorientation, completion and cleanup + +After receiving the backend contract, compare it against every assumption above; +update the plan and review any material change before implementation. After +owner-authorized deployment, verify live coverage and schema using normal auth. +Do not merge/release/deploy. If deployment is pending, deliver draft PRs and state +the remaining verification explicitly; keep this plan current until complete. + +When implementation and reviews are complete and live acceptance passes, transfer +durable contracts/semantics to implementation docs, then delete this plan in the +final commit of the client PR. Major deferred work belongs in the repository +backlog; none is currently proposed. + + +## Client implementation evidence (2026-10-13) + +- Implemented note-preserving classifications and views, no-body selection gates, + raw-version joins, mode-specific field selection/validation and shared quote + presentation. Source labels use matching item evidence, not aggregate source. +- Focused8files:440pass/0fail/1800 assertions. Source/built CLI and MCP smoke + suites passed; build and typecheck passed after two typed test assertions fixed. +- Authenticated dev CLI/localstdioMCP exact/latest/range and all views, Biome, + latest50, empty, no-notes and timeline-only JSON parity passed. Range text is + equal except intended host-specific body expansion hint. +- Expressrange4classified/0pending/125no-impact,0confident+8uncertain action, + 6confident+2uncertain know; warm unchanged. Biome13classified/0pending, + 13confident+27uncertain action,12confident+10uncertain know,813no-impact: + all50returned/12omitted versus actionable40returned/0omitted. +- Latest50 selects50classified/0pending,41action+42know precap,597no-impact, + all50returned/33omitted versus actionable41returned/0omitted. Counter scopes + stable acrossfilters; stored formulationsv2/v3/v5 preserved peritem. +- Complexity maximalactualclientJSON: exact56/latest50=65/range90. Verified + rejected-control510 plus documents566/575/600, before resolver execution. +- Internal codepreflight: direction sound, no findings after typed assertions. + External code review pending. Descriptor-only Codex exact-release eval: neutral + run completed with zero tool calls (not tool-use evidence); explicit GitHits + intent run completed with3logicalcalls (quick_start, pkg_changelog default and + verbose), no isolation violations, high self-reported confidence,20.8seconds. + Final answer quoted the rollback/security context and preserved uncertainty. + No quality grading ran. Initial missing dedicated CODEX_HOME preflight did not + execute a model; retry used the existing dedicated eval home without reading + auth material. Last touched request/CLI/parity files:89pass/266 assertions. 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..c689655a 100644 --- a/packages/core-internal/src/services/package-intelligence-service.test.ts +++ b/packages/core-internal/src/services/package-intelligence-service.test.ts @@ -2422,6 +2422,22 @@ describe("PackageIntelligenceServiceImpl.packageVulnerabilities", () => { describe("PackageIntelligenceServiceImpl — packageChangelog", () => { const ENDPOINT = "https://pkgseer.dev"; + const emptyRisk = { + riskItems: [], + riskCoverage: { + versionsClassified: 2, + versionsNotAssessed: 0, + versionsWithoutNotes: 0, + versionsUnparseable: 0, + unitsNoImpact: 2, + itemsMustActConfident: 0, + itemsMustActAmbiguous: 0, + itemsShouldKnowConfident: 0, + itemsShouldKnowAmbiguous: 0, + itemsUnclassified: 0, + itemsOmitted: 0, + }, + }; it("treats an empty source with no entries as a successful empty selection", async () => { const fetchFn = mock(() => @@ -2429,6 +2445,7 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { jsonResponse({ data: { packageChangelog: { + ...emptyRisk, package: { name: "express", registry: "NPM" }, source: "", entries: [], @@ -2459,6 +2476,7 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { jsonResponse({ data: { packageChangelog: { + ...emptyRisk, package: { name: "express", registry: "NPM" }, source: "releases", entries: [ @@ -2497,6 +2515,8 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { registry: "NPM", name: "express", includeBodies: false, + includeRiskJsonFields: false, + includeRiskDetails: false, }); }); @@ -2506,6 +2526,7 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { jsonResponse({ data: { packageChangelog: { + ...emptyRisk, package: { name: "react", registry: "NPM" }, source: null, entries: [ @@ -2543,6 +2564,7 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { jsonResponse({ data: { packageChangelog: { + ...emptyRisk, package: { name: "react", registry: "NPM" }, source: "", entries: [ @@ -2585,6 +2607,7 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { selectedVersion: { resolvedVersion: "5.2.1", changelog: { + ...emptyRisk, detailSource: "RELEASES", hasChangelog: true, entry: { @@ -2629,6 +2652,8 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { name: "express", version: "^5.0.0", includeBodies: true, + includeRiskJsonFields: false, + includeRiskDetails: false, }); expect(result.source).toBe("releases"); expect(result.entries).toEqual([ @@ -2654,6 +2679,7 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { selectedVersion: { resolvedVersion: "5.2.1", changelog: { + ...emptyRisk, detailSource: "PACKAGE_VERSION", hasChangelog: false, entry: { @@ -2687,6 +2713,8 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { name: "express", version: "5.2.1", includeBodies: false, + includeRiskJsonFields: false, + includeRiskDetails: false, }); expect(result.source).toBe("package_version"); expect(result.entries[0]).toMatchObject({ @@ -2705,6 +2733,7 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { selectedVersion: { resolvedVersion: "0.0.1", changelog: { + ...emptyRisk, detailSource: null, hasChangelog: false, entry: null, @@ -3437,6 +3466,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 @@ -4286,3 +4345,156 @@ describe("PackageIntelligenceServiceImpl — packageDependencies", () => { ).rejects.toBeInstanceOf(MalformedPackageIntelligenceResponseError); }); }); + +describe("changelog classification wire selection", () => { + const c = { + versionsClassified: 1, + versionsNotAssessed: 0, + versionsWithoutNotes: 0, + versionsUnparseable: 0, + unitsNoImpact: 20, + itemsMustActConfident: 0, + itemsMustActAmbiguous: 1, + itemsShouldKnowConfident: 3, + itemsShouldKnowAmbiguous: 0, + itemsUnclassified: 0, + itemsOmitted: 0, + }; + const compact = { + version: "5.2.1", + tier: "MUST_ACT", + ambiguous: true, + kind: "CHANGES_BEHAVIOR_OR_DEFAULT", + text: "Changed a default.", + textTruncated: false, + url: "https://example.com/v5.2.1", + source: "RELEASES", + }; + const details = { tierConfidence: 0.95, kindConfidence: 0.9, heading: null }; + const provenance = { + model: "jev-1.13.0", + formulation: "s-hier-v5", + fullText: "Changed a default.", + }; + for (const version of [undefined, "5.2.1"]) { + for (const mode of ["compact", "verbose", "json", "timeline"] as const) { + it(`requests only consumed fields for ${version ? "exact" : "timeline/range"} ${mode}`, async () => { + let request: + | { query: string; variables: Record } + | undefined; + const includeBodies = mode !== "timeline"; + const includeRiskDetails = mode === "verbose" || mode === "json"; + const includeRiskJsonFields = mode === "json"; + const selectedItem = { + ...compact, + ...(includeRiskDetails ? details : {}), + ...(includeRiskJsonFields ? provenance : {}), + }; + const block = { + source: "releases", + entries: [], + detailSource: "RELEASES", + hasChangelog: true, + entry: { normalizedVersion: "5.2.1" }, + ...(includeBodies + ? { riskItems: [selectedItem], riskCoverage: c } + : {}), + }; + const fetchFn = mock((_url: string, init?: RequestInit) => { + request = JSON.parse(String(init?.body)); + return Promise.resolve( + jsonResponse({ + data: version + ? { + packageInfo: { + selectedVersion: { + resolvedVersion: version, + changelog: block, + }, + }, + } + : { packageChangelog: block }, + }), + ); + }); + const service = new PackageIntelligenceServiceImpl( + "https://pkgseer.dev", + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + const report = await service.packageChangelog({ + registry: "NPM", + packageName: "express", + version, + ...(!version ? { fromVersion: "5.0.0", toVersion: "5.2.1" } : {}), + riskTiers: ["MUST_ACT"], + includeBodies, + includeRiskDetails, + includeRiskJsonFields, + }); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(request?.variables).toMatchObject({ + includeBodies, + includeRiskDetails, + includeRiskJsonFields, + ...(includeBodies ? { riskTiers: ["MUST_ACT"] } : {}), + }); + expect(request?.query).toContain( + "riskItems @include(if: $includeBodies)", + ); + expect(request?.query).toContain( + "riskCoverage @include(if: $includeBodies)", + ); + expect(request?.query).toContain( + "fullText @include(if: $includeRiskJsonFields)", + ); + expect(request?.query).toContain( + "model @include(if: $includeRiskJsonFields)", + ); + expect(request?.query).toContain( + "tierConfidence @include(if: $includeRiskDetails)", + ); + expect(request?.query).toContain("riskTiers: $riskTiers"); + if (mode === "timeline") { + expect(request?.variables.riskTiers).toBeUndefined(); + expect(report.riskItems).toBeUndefined(); + expect(report.riskCoverage).toBeUndefined(); + } else { + expect(report.riskCoverage).toEqual(c); + expect(report.riskItems?.[0]?.ambiguous).toBe(true); + expect(report.riskItems?.[0]?.model).toBe( + includeRiskJsonFields ? provenance.model : undefined, + ); + expect(report.riskItems?.[0]?.tierConfidence).toBe( + includeRiskDetails ? 0.95 : undefined, + ); + expect(report.riskItems?.[0]?.heading).toBeUndefined(); + } + }); + } + } + it("rejects missing selected evidence instead of implying zero risk", async () => { + for (const block of [ + { entries: [] }, + { entries: [], riskItems: [compact] }, + { entries: [], riskCoverage: c }, + { entries: [], riskItems: [compact], riskCoverage: c }, + ]) { + const fetchFn = mock(() => + Promise.resolve(jsonResponse({ data: { packageChangelog: block } })), + ); + const service = new PackageIntelligenceServiceImpl( + "https://pkgseer.dev", + createMockTokenProvider(), + asFetchFn(fetchFn), + ); + await expect( + service.packageChangelog({ + registry: "NPM", + packageName: "express", + includeRiskJsonFields: true, + }), + ).rejects.toThrow(MalformedPackageIntelligenceResponseError); + } + }); +}); diff --git a/packages/core-internal/src/services/package-intelligence-service.ts b/packages/core-internal/src/services/package-intelligence-service.ts index 8c6b76b8..64b32d37 100644 --- a/packages/core-internal/src/services/package-intelligence-service.ts +++ b/packages/core-internal/src/services/package-intelligence-service.ts @@ -324,7 +324,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 +340,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 +365,9 @@ export interface PackageUpgradeChangelogRiskCoverage { itemsOmitted: number; } +export interface PackageUpgradeChangelogRiskCoverage + extends ChangelogRiskCoverage {} + export interface PackageUpgradeChangelog { riskItems: PackageUpgradeChangelogRiskItem[]; riskCoverage: PackageUpgradeChangelogRiskCoverage; @@ -702,6 +711,11 @@ export interface PackageChangelogParams { limit?: number; /** Include raw markdown bodies in entries. Defaults to true. */ includeBodies?: boolean; + /** Filter classified statements before the backend cap; omitted means all. */ + riskTiers?: ChangelogRiskItem["tier"][]; + /** Internal selection controls; full statement/provenance fields are JSON-only. */ + includeRiskJsonFields?: boolean; + includeRiskDetails?: boolean; } /** @@ -729,6 +743,9 @@ export interface ChangelogEntryDetail { } export interface ChangelogReport { + /** Absent in timeline-only (includeBodies:false) requests. */ + riskItems?: ChangelogRiskItem[]; + riskCoverage?: ChangelogRiskCoverage; /** Echo of addressing + filter as the backend saw it. */ package?: ChangelogPackageInfo; /** `"releases"` | `"changelog_file"` | `"hexdocs"` when resolved; absent for package versions with no changelog entry. */ @@ -2026,11 +2043,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,15 +2058,28 @@ 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(), +}); + +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 changelogRiskItemSchema = packageUpgradeChangelogRiskItemSchema.partial({ + tierConfidence: true, + kindConfidence: true, + heading: true, + model: true, + formulation: true, }); const packageUpgradeChangelogRiskCoverageSchema = z.object({ @@ -2422,6 +2451,9 @@ const changelogEntryDetailSchema = z.object({ }); const changelogReportResponseSchema = z.object({ + riskItems: z.array(changelogRiskItemSchema).optional(), + riskCoverage: packageUpgradeChangelogRiskCoverageSchema.optional(), + package: changelogPackageInfoSchema, source: z.string().nullable().optional(), entries: z.array(changelogEntryDetailSchema).nullable().optional(), @@ -2438,6 +2470,9 @@ const changelogGraphQLResponseSchema = z.object({ }); const exactChangelogDetailSchema = z.object({ + riskItems: z.array(changelogRiskItemSchema).optional(), + riskCoverage: packageUpgradeChangelogRiskCoverageSchema.optional(), + detailSource: z.string().nullable().optional(), hasChangelog: z.boolean(), entry: changelogEntryDetailSchema.nullable().optional(), @@ -2464,6 +2499,24 @@ const exactChangelogGraphQLResponseSchema = z.object({ errors: z.array(graphQLErrorSchema).optional(), }); +// The same wire selection serves latest/range and exact releases. Body omission +// retains the old timeline and avoids any backend classification work. +const CHANGELOG_RISK_SELECTION = ` + riskItems @include(if: $includeBodies) { + version tier ambiguous kind text textTruncated url source + tierConfidence @include(if: $includeRiskDetails) + kindConfidence @include(if: $includeRiskDetails) + heading @include(if: $includeRiskDetails) + fullText @include(if: $includeRiskJsonFields) + model @include(if: $includeRiskJsonFields) + formulation @include(if: $includeRiskJsonFields) + } + riskCoverage @include(if: $includeBodies) { + versionsClassified versionsNotAssessed versionsWithoutNotes versionsUnparseable + unitsNoImpact itemsMustActConfident itemsMustActAmbiguous + itemsShouldKnowConfident itemsShouldKnowAmbiguous itemsUnclassified itemsOmitted + }`; + const PACKAGE_CHANGELOG_QUERY = ` query PackageChangelog( $registry: Registry! @@ -2472,6 +2525,9 @@ query PackageChangelog( $toVersion: String $limit: Int $includeBodies: Boolean! = true + $riskTiers: [ChangelogRiskTier!] + $includeRiskJsonFields: Boolean! = false + $includeRiskDetails: Boolean! = false ) { packageChangelog( registry: $registry @@ -2479,6 +2535,7 @@ query PackageChangelog( fromVersion: $fromVersion toVersion: $toVersion limit: $limit + riskTiers: $riskTiers ) { package { name @@ -2488,6 +2545,7 @@ query PackageChangelog( limit } source + ${CHANGELOG_RISK_SELECTION} entries { version normalizedVersion @@ -2504,11 +2562,15 @@ query PackageChangelogExact( $name: String! $version: String $includeBodies: Boolean! = true + $riskTiers: [ChangelogRiskTier!] + $includeRiskJsonFields: Boolean! = false + $includeRiskDetails: Boolean! = false ) { packageInfo(registry: $registry, name: $name, version: $version) { selectedVersion { resolvedVersion - changelog { + changelog(riskTiers: $riskTiers) { + ${CHANGELOG_RISK_SELECTION} detailSource hasChangelog entry { @@ -2522,6 +2584,56 @@ query PackageChangelogExact( } }`; +/** Selected risk fields are mandatory; an old/partial schema is a protocol error. */ +function assertChangelogRiskSelection( + data: + | z.infer + | z.infer + | null + | undefined, + params: PackageChangelogParams, +): void { + if (params.includeBodies === false) return; + const items = data?.riskItems; + const detailsSelected = + params.includeRiskDetails === true || params.includeRiskJsonFields === true; + if ( + !items || + !data?.riskCoverage || + items.some( + (item) => + (params.includeRiskJsonFields === true && + (item.fullText === undefined || + item.model === undefined || + item.formulation === undefined)) || + (detailsSelected && + (item.tierConfidence === undefined || + item.kindConfidence === undefined || + item.heading === undefined)), + ) + ) { + throw new MalformedPackageIntelligenceResponseError( + "Missing selected changelog classification fields from the package-intelligence service.", + ); + } +} + +function normaliseChangelogRisk( + data: + | Pick< + z.infer, + "riskItems" | "riskCoverage" + > + | null + | undefined, +): Pick { + if (!data?.riskItems || !data.riskCoverage) return {}; + return stripNullProperties({ + riskItems: data.riskItems, + riskCoverage: data.riskCoverage, + }) as Pick; +} + function normaliseChangelogSource( raw: string | null | undefined, ): string | undefined { @@ -3897,6 +4009,15 @@ export class PackageIntelligenceServiceImpl toVersion: params.toVersion, limit: params.limit, includeBodies: params.includeBodies !== false, + riskTiers: + params.includeBodies !== false ? params.riskTiers : undefined, + includeRiskJsonFields: + params.includeBodies !== false && + params.includeRiskJsonFields === true, + includeRiskDetails: + params.includeBodies !== false && + (params.includeRiskDetails === true || + params.includeRiskJsonFields === true), }, fetchFn: this.fetchFn, clientHeaders: this.runtime.clientHeaders, @@ -3937,6 +4058,7 @@ export class PackageIntelligenceServiceImpl ); } + assertChangelogRiskSelection(data, params); return this.normaliseTimelineChangelogReport(data); } @@ -3955,6 +4077,15 @@ export class PackageIntelligenceServiceImpl name: params.packageName, version: params.version, includeBodies: params.includeBodies !== false, + riskTiers: + params.includeBodies !== false ? params.riskTiers : undefined, + includeRiskJsonFields: + params.includeBodies !== false && + params.includeRiskJsonFields === true, + includeRiskDetails: + params.includeBodies !== false && + (params.includeRiskDetails === true || + params.includeRiskJsonFields === true), }, fetchFn: this.fetchFn, clientHeaders: this.runtime.clientHeaders, @@ -3995,6 +4126,7 @@ export class PackageIntelligenceServiceImpl ); } + assertChangelogRiskSelection(selected.changelog, params); return this.normaliseExactChangelogReport(selected, params); } @@ -4025,17 +4157,14 @@ export class PackageIntelligenceServiceImpl package: packageInfo, source, entries, + ...normaliseChangelogRisk(data), }; } private normaliseExactChangelogReport( selected: { resolvedVersion: string; - changelog?: { - detailSource?: string | null; - hasChangelog: boolean; - entry?: z.infer | null; - } | null; + changelog?: z.infer | null; }, params: PackageChangelogParams, ): ChangelogReport { @@ -4048,6 +4177,7 @@ export class PackageIntelligenceServiceImpl registry: toPkgseerRegistryLowercase(params.registry), }, source: normaliseChangelogSource(changelog?.detailSource), + ...normaliseChangelogRisk(changelog), entries: [ { version: selected.resolvedVersion, diff --git a/packages/mcp/src/services/test-helpers.ts b/packages/mcp/src/services/test-helpers.ts index 8d487d19..aa8dca56 100644 --- a/packages/mcp/src/services/test-helpers.ts +++ b/packages/mcp/src/services/test-helpers.ts @@ -604,6 +604,20 @@ export const cratesFeatureDependencyReport: DependencyReport = { * markdown, ISO date, and a normalisedVersion that equals the raw. */ export const defaultChangelogReport: ChangelogReport = { + riskItems: [], + riskCoverage: { + versionsClassified: 2, + versionsNotAssessed: 0, + versionsWithoutNotes: 0, + versionsUnparseable: 0, + unitsNoImpact: 2, + itemsMustActConfident: 0, + itemsMustActAmbiguous: 0, + itemsShouldKnowConfident: 0, + itemsShouldKnowAmbiguous: 0, + itemsUnclassified: 0, + itemsOmitted: 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..c0fe3042 --- /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(/\[(\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..0deec3c9 100644 --- a/packages/mcp/src/shared/package-changelog-request.test.ts +++ b/packages/mcp/src/shared/package-changelog-request.test.ts @@ -272,3 +272,52 @@ 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.riskTiers).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.riskTiers).toBeUndefined(); + expect(result.explicitFilterFields.has("changes")).toBe(false); + } + }); + it("ignores the changes knob entirely for timeline-only requests", () => { + const result = buildPackageChangelogParams({ + target: "npm:express", + includeBodies: false, + changes: "unknown", + }); + expect(result.changes).toBeUndefined(); + expect(result.params.riskTiers).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"); + }); +}); diff --git a/packages/mcp/src/shared/package-changelog-request.ts b/packages/mcp/src/shared/package-changelog-request.ts index 8acd3f9a..d18497fc 100644 --- a/packages/mcp/src/shared/package-changelog-request.ts +++ b/packages/mcp/src/shared/package-changelog-request.ts @@ -31,6 +31,8 @@ export interface PackageChangelogRequestInput { limit?: number; /** Include raw markdown bodies in entries. Defaults to true. */ includeBodies?: boolean; + /** Cumulative statement view; ignored when bodies are omitted. */ + changes?: string; } /** Fields whose *explicit* presence the envelope echoes under `filter.*`. */ @@ -38,12 +40,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 +74,16 @@ export function buildPackageChangelogParams( const flagFrom = normaliseBound(input.fromVersion, registry, "--from"); const flagTo = normaliseBound(input.toVersion, registry, "--to"); const limit = normaliseLimit(input.limit); + const changes = + input.includeBodies === false ? undefined : normaliseChanges(input.changes); + const riskTiers: PackageChangelogParams["riskTiers"] = + 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 +95,17 @@ export function buildPackageChangelogParams( } return { mode: "exact", + changes, params: { registry, packageName: parsed.name, version, includeBodies: input.includeBodies, + riskTiers, }, - explicitFilterFields: new Set(["version"]), + explicitFilterFields: new Set( + explicitChanges ? ["version", "changes"] : ["version"], + ), }; } @@ -118,12 +138,14 @@ 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, @@ -131,6 +153,7 @@ export function buildPackageChangelogParams( toVersion, limit, includeBodies: input.includeBodies, + riskTiers, }, explicitFilterFields: explicit, }; @@ -152,7 +175,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 +200,14 @@ 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; +} diff --git a/packages/mcp/src/shared/package-changelog-response.test.ts b/packages/mcp/src/shared/package-changelog-response.test.ts index 6ab5a7e3..327f2f2b 100644 --- a/packages/mcp/src/shared/package-changelog-response.test.ts +++ b/packages/mcp/src/shared/package-changelog-response.test.ts @@ -584,3 +584,274 @@ describe("formatPackageChangelogTerminal", () => { expect(output).not.toContain("exact ^5.0.0"); }); }); + +describe("changelog classified statement evidence", () => { + const coverage = { + versionsClassified: 1, + versionsNotAssessed: 0, + versionsWithoutNotes: 0, + versionsUnparseable: 0, + unitsNoImpact: 133, + itemsMustActConfident: 13, + itemsMustActAmbiguous: 28, + itemsShouldKnowConfident: 12, + itemsShouldKnowAmbiguous: 10, + itemsUnclassified: 1, + itemsOmitted: 13, + }; + const item = { + version: "v5.2.1", + tier: "MUST_ACT" as const, + ambiguous: false, + tierConfidence: 0.1, + kind: "CHANGES_BEHAVIOR_OR_DEFAULT", + kindConfidence: 0.9, + text: "Removed a public default.", + textTruncated: false, + fullText: "Removed a public default.", + url: "https://example.com/history#v5.2.1", + source: "CHANGELOG_FILE", + heading: "Changes", + model: "jev-old", + formulation: "s-hier-v2", + }; + const report: ChangelogReport = { + ...baseReport, + entries: [ + { + ...baseReport.entries[0], + version: "v5.2.1", + normalizedVersion: "5.2.1", + body: "## Notes\n- original preview", + }, + ], + riskCoverage: coverage, + riskItems: [ + item, + { + ...item, + ambiguous: true, + tierConfidence: 0.95, + text: "Possible behavior change.", + model: "jev-new", + formulation: "s-hier-v5", + }, + { + ...item, + tier: "SHOULD_KNOW", + kind: undefined, + text: "A kindless notable statement.", + url: "https://example.com/release", + }, + { + ...item, + tier: "UNCLASSIFIED", + kind: undefined, + tierConfidence: undefined, + text: "Large statement.", + textTruncated: true, + }, + ], + }; + const payload = (): ReturnType => + buildPackageChangelogSuccessPayload(report, baseOptions); + it("preserves note previews and joins by raw version, showing server uncertainty separately", () => { + const text = formatPackageChangelogTerminal(payload(), { + useColors: false, + terminalWidth: 160, + }); + expect(text).toContain("original preview"); + expect(text).toContain( + 'Requires action\n * [behavior] "Removed a public default."', + ); + expect(text).toContain( + 'Possibly requires action\n * [behavior] (uncertain) "Possible behavior change."', + ); + expect(text).toContain( + 'Should know\n * "A kindless notable statement."', + ); + expect(text).toContain("Too long to classify - read it"); + expect(text).not.toContain("Requires action ("); + expect(text).not.toContain("jev-old"); + expect(text).not.toContain("Not a compatibility verdict"); + expect(text).toContain("13 require action (+28 uncertain)"); + expect(text).toContain("133 statements labeled no impact"); + expect(text).toContain("13 matching statements omitted by backend"); + expect(text).toContain("statement truncated by backend; fullText in JSON"); + expect(text.match(/https:\/\/example.com\/history#v5.2.1/g)).toHaveLength( + 1, + ); + }); + it("does not mislabel a release URL with the aggregate changelog-file source", () => { + const primary = "https://example.com/releases/5.2.1"; + const json = buildPackageChangelogSuccessPayload( + { + ...report, + source: "changelog_file", + entries: [ + { version: "v5.2.1", htmlUrl: primary, body: "release preview" }, + ], + riskItems: [{ ...item, url: primary, source: "RELEASES" }], + }, + baseOptions, + ); + const text = formatPackageChangelogTerminal(json, { useColors: false }); + expect(text).toContain(`Release notes: ${primary}`); + expect(text).not.toContain(`Changelog: ${primary}`); + }); + it("retains all per-item provenance and full text in JSON without mutating the report", () => { + const json = payload(); + expect(json.riskCoverage).toEqual(coverage); + expect(json.riskItems?.[0]).toMatchObject({ + ...item, + tier: "must_act", + kind: "changes_behavior_or_default", + source: "changelog_file", + }); + expect(json.riskItems?.[1]?.formulation).toBe("s-hier-v5"); + expect(report.riskItems?.[0]?.tier).toBe("MUST_ACT"); + }); + it("keeps all-tier totals while echoing the narrower active view", () => { + const json = buildPackageChangelogSuccessPayload( + { ...report, riskItems: [item] }, + { + ...baseOptions, + changes: "actionable", + explicitFilterFields: new Set(["changes"]), + }, + ); + const text = formatPackageChangelogTerminal(json, { + useColors: false, + terminalWidth: 180, + }); + expect(json.filter?.changes).toBe("actionable"); + expect(text).toContain( + "Showing actionable statements; note previews and totals include all tiers.", + ); + expect(text).toContain("12 should know (+10 uncertain)"); + }); + it("omits both bodies and classification from the timeline envelope", () => { + const json = buildPackageChangelogSuccessPayload(report, { + ...baseOptions, + includeBodies: false, + }); + expect(json.riskItems).toBeUndefined(); + expect(json.riskCoverage).toBeUndefined(); + expect(json.entries.items[0]?.body).toBeUndefined(); + const text = formatPackageChangelogTerminal(json, { useColors: false }); + expect(text).toContain("v5.2.1"); + expect(text).not.toContain("classified"); + expect(text).not.toContain("Removed a public default"); + }); + it("honestly reports entirely pending, missing and unparseable versions with zero quotes", () => { + const json = buildPackageChangelogSuccessPayload( + { + ...report, + riskItems: [], + riskCoverage: { + ...coverage, + versionsClassified: 0, + versionsNotAssessed: 4, + versionsWithoutNotes: 2, + versionsUnparseable: 1, + itemsOmitted: 0, + }, + }, + baseOptions, + ); + const text = formatPackageChangelogTerminal(json, { + useColors: false, + terminalWidth: 160, + }); + expect(text).toContain( + "0 classified | 4 not assessed | 2 without notes | 1 unparseable", + ); + expect(text).toContain("No matching classified statements returned"); + expect(text).toContain("Rerun in a few seconds to a minute"); + expect(text).toContain( + "Missing or unparseable notes are not evidence of no risk", + ); + expect(text.toLowerCase()).not.toContain("safe"); + }); + it("expands local excerpts in verbose but never uses JSON fullText as terminal evidence", () => { + const body = `${"Notable sentence ".repeat(30)}END`; + const json = buildPackageChangelogSuccessPayload( + { + ...report, + riskItems: [ + { + ...item, + tier: "SHOULD_KNOW", + text: body, + fullText: "JSON_ONLY_SECRET_SENTINEL", + }, + ], + }, + baseOptions, + ); + const compact = formatPackageChangelogTerminal(json, { useColors: false }); + expect(compact).not.toContain("END"); + expect(compact).toContain("Quotes ending in ..."); + const verbose = formatPackageChangelogTerminal(json, { + useColors: false, + verbose: true, + }); + expect(verbose).toContain("END"); + expect(verbose).not.toContain("JSON_ONLY_SECRET_SENTINEL"); + }); + it("sanitizes body, quote, metadata and source controls while keeping JSON raw", () => { + const controlSequence = "\u001b[31m"; + const dirty = { + ...report, + entries: [ + { + version: "v5.2.1", + body: `before${controlSequence}after\nsecond\u0007line`, + }, + ], + riskItems: [ + { + ...item, + text: `quoted${controlSequence}statement`, + url: `https://example.com/${controlSequence}notes`, + heading: `dirty${controlSequence}heading`, + }, + ], + }; + const json = buildPackageChangelogSuccessPayload(dirty, baseOptions); + const text = formatPackageChangelogTerminal(json, { + useColors: false, + verbose: true, + }); + expect(text).not.toContain("\u001b"); + expect(text).not.toContain("\u0007"); + expect(text).toContain("beforeafter"); + expect(text).toContain("secondline"); + expect(text).toContain("quotedstatement"); + expect(json.riskItems?.[0]?.text).toContain(controlSequence); + }); + it("wraps statement prose at the caller width and keeps labels visible without color", () => { + const json = buildPackageChangelogSuccessPayload( + { + ...report, + riskItems: [ + { + ...item, + text: "This public default changes the request behavior for existing application callers and requires examining each current call site.", + }, + ], + }, + baseOptions, + ); + const text = formatPackageChangelogTerminal(json, { + useColors: false, + terminalWidth: 45, + }); + const quoted = text + .split("\n") + .filter((line) => line.startsWith(" *") || line.startsWith(" ")); + expect(quoted.length).toBeGreaterThan(2); + expect(quoted.every((line) => line.length <= 45)).toBe(true); + expect(text).toContain("[behavior]"); + }); +}); diff --git a/packages/mcp/src/shared/package-changelog-response.ts b/packages/mcp/src/shared/package-changelog-response.ts index bdf5bb0c..b0bdf4e4 100644 --- a/packages/mcp/src/shared/package-changelog-response.ts +++ b/packages/mcp/src/shared/package-changelog-response.ts @@ -17,9 +17,27 @@ * entries omit that field. */ -import type { ChangelogReport } from "@githits/core-internal"; +import type { + ChangelogReport, + ChangelogRiskCoverage, +} from "@githits/core-internal"; +import { + type ClassifiedChangelogItem, + changelogRiskCoverageText, + changelogRiskSections, + changelogRiskSummary, + normaliseChangelogRiskItems, + PENDING_CLASSIFICATION_MESSAGE, + RISK_KIND_LABELS, + releaseNoteText, + safeRiskText, +} 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 +69,7 @@ export interface LeanEntriesBlock { * intent. */ export interface LeanChangelogFilter { + changes?: ChangelogChangesView; fromVersion?: string; toVersion?: string; limit?: number; @@ -58,6 +77,8 @@ export interface LeanChangelogFilter { } export interface LeanChangelogEnvelope { + riskItems?: ClassifiedChangelogItem[]; + riskCoverage?: ChangelogRiskCoverage; /** Present for spec addressing. */ registry?: string; /** Present for spec addressing. */ @@ -74,6 +95,7 @@ export interface LeanChangelogEnvelope { } export interface BuildChangelogPayloadOptions { + changes?: ChangelogChangesView; registry?: string; name?: string; mode: ChangelogMode; @@ -120,6 +142,10 @@ export function buildPackageChangelogSuccessPayload( }, }; + if (options.includeBodies && report.riskItems && report.riskCoverage) { + envelope.riskItems = normaliseChangelogRiskItems(report.riskItems); + envelope.riskCoverage = { ...report.riskCoverage }; + } if (report.source) envelope.source = report.source; if (options.registry) envelope.registry = options.registry; @@ -137,6 +163,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; } @@ -161,6 +189,7 @@ export interface FormatChangelogTerminalOptions { useColors: boolean; fullBodyHint?: string; bodyPreviewLines?: number; + terminalWidth?: number; } const DEFAULT_BODY_PREVIEW_LINES = 10; @@ -177,6 +206,48 @@ export function formatPackageChangelogTerminal( const lines: string[] = []; lines.push(buildSummaryLine(envelope, options)); lines.push(""); + const width = + options.terminalWidth === undefined || + !Number.isFinite(options.terminalWidth) + ? 80 + : Math.max(20, Math.floor(options.terminalWidth)); + const prose = (text: string, indent = " "): void => { + lines.push(...wrapTerminalProse(`${indent}${text}`, width, indent)); + }; + const coverage = envelope.riskCoverage; + if (coverage) { + lines.push(highlight("Changes (classified by agent)", options.useColors)); + prose(`All selected releases: ${changelogRiskSummary(coverage)}`); + prose(`Classification versions: ${changelogRiskCoverageText(coverage)}`); + if (envelope.filter?.changes && envelope.filter.changes !== "all") + prose( + `Showing ${envelope.filter.changes === "actionable" ? "actionable statements" : "actionable + should know statements"}; note previews and totals include all tiers.`, + ); + if (coverage.itemsOmitted) + prose( + `${coverage.itemsOmitted} matching statements omitted by backend (lowest priority first).`, + ); + if (coverage.versionsNotAssessed) prose(PENDING_CLASSIFICATION_MESSAGE); + if (coverage.versionsWithoutNotes || coverage.versionsUnparseable) + prose("Missing or unparseable notes are not evidence of no risk."); + if (!envelope.riskItems?.length) + prose("No matching classified statements returned."); + lines.push(""); + } + const sources = new Map(); + const reference = (url: string, source?: string): string => { + const clean = safeRiskText(url); + let sourceRecord = sources.get(clean); + if (!sourceRecord) { + sourceRecord = { + number: sources.size + 1, + label: `${source ? `${noteSourceLabel(source)}: ` : ""}${clean}`, + }; + sources.set(clean, sourceRecord); + } + return `[${sourceRecord.number}]`; + }; + let excerpted = false; if (envelope.entries.items.length === 0) { lines.push(dim("No entries in this range.", options.useColors)); @@ -188,13 +259,38 @@ export function formatPackageChangelogTerminal( const dateWidth = 10; // YYYY-MM-DD for (const entry of envelope.entries.items) { - const version = entry.version ?? "(unversioned)"; + const version = safeRiskText(entry.version ?? "(unversioned)"); + const items = (envelope.riskItems ?? []).filter( + (item) => item.version === entry.version, + ); const date = entry.publishedAt - ? formatDate(entry.publishedAt) + ? safeRiskText(formatDate(entry.publishedAt)) : dim("-", options.useColors); - const url = entry.htmlUrl - ? dim(entry.htmlUrl, options.useColors) - : dim("(no link)", options.useColors); + const noteLinks = new Map(); + // Timeline source is aggregate: individual releases can use alternate sources. + const entrySource = + items.find((item) => item.url === entry.htmlUrl)?.source ?? + (envelope.mode === "exact" ? envelope.source : undefined); + if (entry.htmlUrl) + noteLinks.set( + reference(entry.htmlUrl, entrySource), + noteSourceLabel(entrySource), + ); + for (const item of items) + if (item.url) + noteLinks.set( + reference(item.url, item.source), + noteSourceLabel(item.source), + ); + const url = coverage + ? dim( + [...noteLinks].map(([ref, label]) => `${label} ${ref}`).join("; ") || + "(no link)", + options.useColors, + ) + : entry.htmlUrl + ? dim(safeRiskText(entry.htmlUrl), options.useColors) + : dim("(no link)", options.useColors); const padded = padColumn(version, versionWidth); const datePadded = padColumn(date, dateWidth); lines.push( @@ -208,9 +304,81 @@ export function formatPackageChangelogTerminal( } else if (entry.body != null) { appendBodyLines(lines, entry.body, options); } + if (items.length && entry.body != null) lines.push(""); + for (const section of changelogRiskSections(items)) { + if (!section.items.length) continue; + lines.push(` ${section.label}`); + for (const item of section.items) { + const quote = releaseNoteText(item.text, (url) => reference(url)); + const cap = + item.tier === "must_act" || options.verbose + ? Infinity + : item.tier === "should_know" + ? 240 + : 120; + const chars = Array.from(quote); + const shortened = chars.length > cap; + excerpted ||= shortened; + const shown = shortened + ? `${chars.slice(0, cap).join("").trimEnd()}...` + : quote; + const kind = item.kind + ? `[${RISK_KIND_LABELS[item.kind] ?? safeRiskText(item.kind)}]` + : ""; + const source = item.url + ? reference(item.url, item.source) + : "[source URL unavailable]"; + const text = `${kind ? `${kind} ` : ""}${item.ambiguous ? "(uncertain) " : ""}"${shown}" ${source}${item.textTruncated ? " [statement truncated by backend; fullText in JSON]" : ""}`; + const rendered = wrapTerminalProse(` * ${text}`, width, " "); + lines.push( + ...rendered.map((line) => { + let styled = kind + ? line.replace(kind, colorize(kind, "yellow", options.useColors)) + : line; + if (item.ambiguous) + styled = styled.replace( + "(uncertain)", + dim("(uncertain)", options.useColors), + ); + return styled; + }), + ); + if (options.verbose) { + const details = [ + item.heading + ? `heading: ${releaseNoteText(item.heading, (url) => reference(url))}` + : undefined, + item.tierConfidence !== undefined + ? `tier confidence: ${item.tierConfidence}` + : undefined, + item.kindConfidence !== undefined + ? `kind confidence: ${item.kindConfidence}` + : undefined, + ] + .filter(Boolean) + .join(" | "); + if (details) prose(details, " "); + } + } + } lines.push(""); } - + if (coverage && sources.size) { + lines.push("Sources"); + const cited = new Set( + [...lines.join("\n").matchAll(/\[(\d+)\]/g)].map((match) => + Number(match[1]), + ), + ); + for (const source of sources.values()) + if (cited.has(source.number)) + lines.push(` [${source.number}] ${source.label}`); + } + if (excerpted) + prose( + "Quotes ending in ... are excerpts; use verbose for expanded quotes.", + ); + lines.push(""); return lines.join("\n"); } @@ -230,7 +398,7 @@ function appendBodyLines( : (options.bodyPreviewLines ?? DEFAULT_BODY_PREVIEW_LINES); const visible = bodyLines.slice(0, cap); for (const bodyLine of visible) { - lines.push(` ${bodyLine}`); + lines.push(` ${sanitizeTerminalText(bodyLine)}`); } const hidden = bodyLines.length - visible.length; if (hidden > 0) { @@ -254,7 +422,7 @@ function buildSummaryLine( 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); + return colorize(safeRiskText(parts.join(" | ")), "bold", options.useColors); } function modeSummary(envelope: LeanChangelogEnvelope): string { @@ -325,3 +493,11 @@ 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 ? safeRiskText(source) : "Notes"; +} diff --git a/packages/mcp/src/shared/package-upgrade-review-response.ts b/packages/mcp/src/shared/package-upgrade-review-response.ts index 80be9ad1..3d7be8b9 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; } @@ -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}`); @@ -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..5d8e6f22 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -1545,15 +1545,18 @@ 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", + riskItems: [], + riskCoverage: {}, entries: { items: [{ version: "5.2.1", hasChangelog: true }], }, }); } - return jsonResult({ entries: {} }); + return jsonResult({ entries: {}, riskItems: [], riskCoverage: {} }); 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..b9e6f989 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -1253,7 +1253,7 @@ 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( @@ -1274,7 +1274,7 @@ 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( @@ -1316,6 +1316,47 @@ async function runLiveSmoke(caller: McpSmokeCaller): Promise { ); assertRecord(changelogJson, "pkg_changelog json"); assertRecord(changelogJson.entries, "pkg_changelog json entries"); + assert( + Array.isArray(changelogJson.riskItems), + "pkg_changelog json missing classification items", + ); + assertRecord(changelogJson.riskCoverage, "pkg_changelog json coverage"); + 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"); + assert( + Array.isArray(filteredChangelog.riskItems) && + filteredChangelog.riskItems.every( + (item: unknown) => + typeof item === "object" && + item !== null && + "tier" in item && + item.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( + !("riskItems" in timelineChangelog) && + !("riskCoverage" 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..0bec1bfb 100644 --- a/packages/mcp/src/tools/package-changelog.test.ts +++ b/packages/mcp/src/tools/package-changelog.test.ts @@ -47,6 +47,7 @@ describe("createPackageChangelogTool — metadata", () => { ); expect(Object.keys(tool.schema).sort()).toEqual([ "body_lines", + "changes", "format", "limit", "omit_bodies", @@ -625,3 +626,54 @@ describe("createPackageChangelogTool — service errors", () => { expect(payload.code).toBe("UNKNOWN"); }); }); + +describe("changelog classification omission and verbose controls", () => { + it("text verbose overrides omission and activates changes", async () => { + const packageChangelog = mock(() => + Promise.resolve(defaultChangelogReport), + ); + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService({ packageChangelog }), + ); + await tool.handler( + { + target: "npm:express", + omit_bodies: true, + verbose: true, + changes: "important", + }, + {}, + ); + expect(packageChangelog).toHaveBeenCalledWith( + expect.objectContaining({ + includeBodies: true, + riskTiers: ["MUST_ACT", "SHOULD_KNOW"], + includeRiskDetails: true, + includeRiskJsonFields: false, + }), + ); + }); + it("JSON honors omission despite verbose and ignores invalid changes", async () => { + const packageChangelog = mock(() => + Promise.resolve(defaultChangelogReport), + ); + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService({ packageChangelog }), + ); + const result = await tool.handler( + { + target: "npm:express", + omit_bodies: true, + verbose: true, + changes: "invalid", + format: "json", + }, + {}, + ); + expect(result.isError).toBeUndefined(); + expect(JSON.parse(result.content[0]!.text)).not.toHaveProperty("riskItems"); + expect(packageChangelog).toHaveBeenCalledWith( + expect.objectContaining({ includeBodies: false, riskTiers: undefined }), + ); + }); +}); diff --git a/packages/mcp/src/tools/package-changelog.ts b/packages/mcp/src/tools/package-changelog.ts index e64da221..a164e306 100644 --- a/packages/mcp/src/tools/package-changelog.ts +++ b/packages/mcp/src/tools/package-changelog.ts @@ -27,6 +27,7 @@ export interface PackageChangelogArgs { verbose?: boolean; body_lines?: number; format?: "text" | "json"; + changes?: string; } /** @@ -45,11 +46,17 @@ 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( + "Classified statements: actionable (MUST_ACT, including uncertain), important (actionable + SHOULD_KNOW), or all (default, also too-long statements). Filters before the item cap; note previews and coverage totals stay unchanged. Ignored with omit_bodies:true; text verbose:true re-enables it.", + ), 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.", + "Omit each entry body and skip classification (default false). Use for version/date/URL timelines; large notes drop 10 KB+ per entry. Text verbose:true overrides omission.", ), verbose: z .boolean() @@ -78,7 +85,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."; + "Text previews 10 body lines alongside agent-classified statements; use `changes` to filter statements, or `body_lines` / `verbose:true` for more notes. `omit_bodies:true` skips notes and classification; `changes` is ignored unless text verbose overrides omission."; export const DESCRIPTION: string = `${DESCRIPTION_BASE}\n\n${PKG_CHANGELOG_GUARDRAIL}`; @@ -98,17 +105,23 @@ export function createPackageChangelogTool( const bodyPreviewLines = textFormat ? validateTextOptions(args) : undefined; - const { params, mode, explicitFilterFields } = + const { params, mode, explicitFilterFields, changes } = buildPackageChangelogParams({ target: args.target, limit: args.limit, includeBodies, + changes: args.changes, }); - const report = await service.packageChangelog(params); + const report = await service.packageChangelog({ + ...params, + includeRiskJsonFields: !textFormat, + includeRiskDetails: !textFormat || args.verbose === true, + }); const payload = buildPackageChangelogSuccessPayload(report, { registry: toPkgseerRegistryLowercase(params.registry), name: params.packageName, mode, + changes, explicitFilterFields, includeBodies, fromVersion: params.fromVersion, diff --git a/src/commands/pkg/changelog.test.ts b/src/commands/pkg/changelog.test.ts index f287b8eb..43c2fdc9 100644 --- a/src/commands/pkg/changelog.test.ts +++ b/src/commands/pkg/changelog.test.ts @@ -20,6 +20,9 @@ describe("pkg changelog help", () => { const help = command.helpInformation().replace(/\s+/g, " "); expect(help).toContain("up to ten latest-mode entries"); + expect(help).toContain("--changes "); + expect(help).toContain("important (actionable + should know)"); + expect(help).toContain("ignored with --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"); diff --git a/src/commands/pkg/changelog.ts b/src/commands/pkg/changelog.ts index f516d8dd..0b35d6c4 100644 --- a/src/commands/pkg/changelog.ts +++ b/src/commands/pkg/changelog.ts @@ -30,6 +30,7 @@ export interface PkgChangelogCommandOptions { // options; we map it to `includeBodies` in the builder. body?: boolean; json?: boolean; + changes?: string; } export interface PkgChangelogCommandDependencies { @@ -71,21 +72,27 @@ export async function pkgChangelogAction( ); } - const { params, mode, explicitFilterFields } = buildPackageChangelogParams({ - target: spec, - fromVersion: options.from, - toVersion: options.to, - limit, - includeBodies, - }); + const { params, mode, explicitFilterFields, changes } = + buildPackageChangelogParams({ + target: spec, + fromVersion: options.from, + toVersion: options.to, + limit, + includeBodies, + changes: options.changes, + }); - const report = - await deps.packageIntelligenceService.packageChangelog(params); + const report = await deps.packageIntelligenceService.packageChangelog({ + ...params, + includeRiskJsonFields: options.json === true, + includeRiskDetails: options.json === true || options.verbose === true, + }); const payload = buildPackageChangelogSuccessPayload(report, { registry: toPkgseerRegistryLowercase(params.registry), name: params.packageName, mode, + changes, explicitFilterFields, includeBodies, fromVersion: params.fromVersion, @@ -102,6 +109,7 @@ export async function pkgChangelogAction( const output = formatPackageChangelogTerminal(payload, { verbose: options.verbose ?? false, useColors: shouldUseColors(), + terminalWidth: process.stdout.columns, }); process.stdout.write(output); } catch (error) { @@ -178,7 +186,8 @@ 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. +bodies and classification entirely. Classified statements default to all; +--changes filters them without filtering note previews. Package spec: :[@ | @..]. Package-only; repository and site targets are rejected. Supported registries: @@ -208,7 +217,11 @@ export function registerPkgChangelogCommand(pkgCommand: Command): Command { ) .option( "--no-body", - "Drop body fields from entries (affects terminal + JSON)", + "Drop note bodies and skip classification (terminal + JSON)", + ) + .option( + "--changes ", + "Classified statements: actionable, important (actionable + should know), all (default); ignored with --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..d20ed177 100644 --- a/src/services/test-helpers.ts +++ b/src/services/test-helpers.ts @@ -822,6 +822,20 @@ export const cratesFeatureDependencyReport: DependencyReport = { * markdown, ISO date, and a normalisedVersion that equals the raw. */ export const defaultChangelogReport: ChangelogReport = { + riskItems: [], + riskCoverage: { + versionsClassified: 2, + versionsNotAssessed: 0, + versionsWithoutNotes: 0, + versionsUnparseable: 0, + unitsNoImpact: 2, + itemsMustActConfident: 0, + itemsMustActAmbiguous: 0, + itemsShouldKnowConfident: 0, + itemsShouldKnowAmbiguous: 0, + itemsUnclassified: 0, + itemsOmitted: 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..40dfa139 100644 --- a/src/tools/package-changelog-parity.test.ts +++ b/src/tools/package-changelog-parity.test.ts @@ -72,6 +72,7 @@ async function cliJson( } interface McpArgs { + changes?: string; target: string; limit?: number; omit_bodies?: boolean; @@ -398,3 +399,173 @@ describe("package_changelog parity", () => { }); }); }); + +// The backend supplies filtered items; neither host filters a capped result locally. +describe("classification filter 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} preserves all-tier coverage and per-item evidence`, async () => { + const report: ChangelogReport = { + ...defaultChangelogReport, + riskItems: [ + { + version: "5.2.1", + tier: "MUST_ACT", + ambiguous: true, + tierConfidence: 0.95, + text: "Possible default change.", + textTruncated: false, + fullText: "Possible default change.", + source: "RELEASES", + url: "https://example.com/v5.2.1", + model: "jev-1.13.0", + formulation: "s-hier-v5", + }, + ], + riskCoverage: { + ...defaultChangelogReport.riskCoverage!, + itemsMustActAmbiguous: 1, + itemsShouldKnowConfident: 13, + itemsOmitted: 2, + }, + }; + const fn = mock(() => Promise.resolve(report)); + 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).toMatchObject({ + filter: { changes }, + riskItems: [ + { tier: "must_act", ambiguous: true, formulation: "s-hier-v5" }, + ], + riskCoverage: { itemsShouldKnowConfident: 13, itemsOmitted: 2 }, + }); + const calls = fn.mock.calls as unknown as Array< + [Record] + >; + expect(calls[0]?.[0]).toEqual(calls[1]?.[0]); + expect(calls[0]?.[0]?.riskTiers).toEqual( + changes === "actionable" + ? ["MUST_ACT"] + : changes === "important" + ? ["MUST_ACT", "SHOULD_KNOW"] + : undefined, + ); + expect(calls[0]?.[0]?.includeRiskJsonFields).toBe(true); + }); + } + } + it("ignores changes and skips risk fields in both timeline JSON surfaces", async () => { + const cli = await cliJson("npm:express", { + changes: "ignored", + body: false, + }); + const mcp = await mcpJson({ + target: "npm:express", + changes: "ignored", + omit_bodies: true, + }); + expect(cli).toEqual(mcp.json); + expect(cli).not.toHaveProperty("riskItems"); + expect(cli).not.toHaveProperty("riskCoverage"); + expect(cli).not.toHaveProperty("filter.changes"); + }); + it("maps invalid active changes identically before service access", async () => { + const fn = mock(() => Promise.resolve(defaultChangelogReport)); + const cli = await cliJson( + "npm:express", + { changes: "safe" }, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: fn, + }), + }), + ); + const mcp = await mcpJson({ target: "npm:express", changes: "safe" }, fn); + expect(cli).toEqual(mcp.json); + expect(cli).toMatchObject({ code: "INVALID_ARGUMENT" }); + expect(fn).not.toHaveBeenCalled(); + }); +}); + +describe("classified changelog text parity", () => { + for (const changes of ["actionable", "important", "all"]) { + it(`shares the same version-grouped evidence for ${changes}`, async () => { + const report: ChangelogReport = { + ...defaultChangelogReport, + entries: [ + { + version: "5.2.1", + body: "Original short release note.", + htmlUrl: "https://example.com/v5.2.1", + }, + ], + riskItems: [ + { + version: "5.2.1", + tier: "MUST_ACT", + ambiguous: true, + text: "Possible behavior change.", + textTruncated: false, + source: "RELEASES", + url: "https://example.com/v5.2.1", + model: "jev", + formulation: "s-hier-v5", + }, + ], + riskCoverage: { + ...defaultChangelogReport.riskCoverage!, + itemsMustActAmbiguous: 1, + }, + }; + const fn = mock(() => Promise.resolve(report)); + 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@5.2.1", + { changes }, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: fn, + }), + }), + ); + } finally { + writeSpy.mockRestore(); + } + const tool = createParityMcpTool("pkg_changelog", { + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: fn, + }), + }); + const result = await tool.handler( + { target: "npm:express@5.2.1", changes }, + {}, + ); + expect(result.content[0]?.text).toBe( + Bun.stripANSI(writes.join("")).trimEnd(), + ); + expect(result.content[0]?.text).toContain("(uncertain)"); + }); + } +}); From b72b98acc089341cc0a7019507598b365efaf7b8 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Tue, 13 Oct 2026 13:12:59 +0300 Subject: [PATCH 02/14] fix: distinguish changelog source references from note links Preserve note Markdown while tracking only visible formatter citations. Exclude relative link labels from source admission in both changelog and upgrade review, and clarify the coverage heading. --- docs/implementation/pkg-changelog.md | 37 ++++++++-- docs/plans/changelog-classifications.md | 13 ++++ .../src/shared/changelog-risk-presentation.ts | 2 +- .../shared/package-changelog-response.test.ts | 69 +++++++++++++++++++ .../src/shared/package-changelog-response.ts | 25 ++++--- .../package-upgrade-review-response.test.ts | 17 ++++- .../shared/package-upgrade-review-response.ts | 4 +- 7 files changed, 149 insertions(+), 18 deletions(-) diff --git a/docs/implementation/pkg-changelog.md b/docs/implementation/pkg-changelog.md index 69175354..bfae8c25 100644 --- a/docs/implementation/pkg-changelog.md +++ b/docs/implementation/pkg-changelog.md @@ -67,7 +67,11 @@ Timeline-level `source` describes the aggregate and cannot identify every entry's source. Use an item's matching URL/source or exact-release source when available; otherwise label an entry link generically as Notes. Do not invent links or infer sources from URL patterns. The original markdown previews remain -available and may themselves contain links. +available and may themselves contain links. Changelog formatter references use +`[S1]` to stay distinct from note-authored numbered Markdown references; notes +retain their original links. Only rendered headers, quotes and verbose details +admit Sources entries; hidden quote-tail links cannot be admitted by matching +numbers in notes or relative Markdown links. JSON retains original Markdown. Terminal sanitation runs at presentation only, on notes, quotes, headings, versions and locators. JSON retains raw evidence with the established lowercase @@ -101,13 +105,36 @@ host-native note expansion hints. ## Verified complexity -Authenticated dev admission measurements on 2026-10-13: exact56, latest50=65, -range90 for the final maximal client JSON selections, below the500 limit. -A verified510-cost control operation (51fixed-cost packageVulnerabilities aliases) +Authenticated dev admission measurements on 2026-10-13: exact **56**, latest +with 50 entries **65**, and range **90** for the maximal client JSON selections, +below the 500 limit. +A verified 510-cost control operation (51 fixed-cost packageVulnerabilities aliases) was added only to admission probes, which were rejected before resolvers; -subtracting510 from566/575/600 gives the client costs. Product calls remain +subtracting 510 from 566/575/600 gives the client costs. Product calls remain a single query. Runtime directives can still count statically. +## Validation + +Unit coverage checks wire selections for each output mode, filter-before-cap +variables, CLI/MCP parity, pending and empty coverage, missing notes, omitted and +truncated statements, source attribution, raw-version joins, terminal sanitation +and preserved JSON evidence. Shared helpers retain upgrade-review regressions. +Source and built CLI/MCP smoke suites, build and typecheck passed. + +Authenticated dev CLI/local MCP checks on 2026-10-13 covered exact, latest and +range targets, each filter, empty ranges, releases without notes and no-body +timelines. Biome 2.4.2..2.4.15 returned 50 statements with 12 omitted for `all`, +and 40 with none omitted for `actionable`. All-tier counts stayed unchanged. +Express 5.0.0..5.2.1 had four classified versions and none pending; the warm +request kept the same coverage. Stored labels covered multiple formulations. +Cold pending coverage was tested with fixtures, not observed live. + +The exact-release Codex agent workload with explicit GitHits intent used +`quick_start` and `pkg_changelog` in default and verbose modes, with no isolation +violations. Its answer preserved uncertainty and cited the rollback evidence. +The neutral-intent run made no tool calls. These are inspected traces, not graded +quality results. + ## Release guidance staging Keep published `skills/githits-package` unchanged until this behavior is in its diff --git a/docs/plans/changelog-classifications.md b/docs/plans/changelog-classifications.md index bf553866..a6840e84 100644 --- a/docs/plans/changelog-classifications.md +++ b/docs/plans/changelog-classifications.md @@ -258,3 +258,16 @@ backlog; none is currently proposed. No quality grading ran. Initial missing dedicated CODEX_HOME preflight did not execute a model; retry used the existing dedicated eval home without reading auth material. Last touched request/CLI/parity files:89pass/266 assertions. + + +External code round1: direction sound. Accepted citation collision finding: +renderer-owned references now tracked separately from raw body previews; original note Markdown is retained; formatter references use a distinct `[S1]` +namespace. Relative Markdown links cannot count as owned citations. Regression covers +reference-style links, bare numbered refs, a URL after a cut quote, and verbose +heading/source admission; raw JSON unchanged. Relabeled mixed count line to +Classification coverage in both sibling changelog/upgrade formatter surfaces. +Internal follow-up correctly caught loss of note links in the initial remedy; +namespace separation replaced body rewriting. Authored markers are escaped in +quote normalization before formatter references are injected; raw notes stay +unchanged. Upgrade citation collection excludes relative Markdown links too. +No findings rejected; internal final closure is clean. The cited-only namespace preserves note links and quote evidence. Source numbers can have gaps after local excerpts; the registry keeps stable identifiers without adding renumbering machinery. Focused closure:123pass/743assertions, build/typecheck pass, compact/verbose live CLI/MCP text parity pass. External round2 pending. diff --git a/packages/mcp/src/shared/changelog-risk-presentation.ts b/packages/mcp/src/shared/changelog-risk-presentation.ts index c0fe3042..012832cf 100644 --- a/packages/mcp/src/shared/changelog-risk-presentation.ts +++ b/packages/mcp/src/shared/changelog-risk-presentation.ts @@ -75,7 +75,7 @@ export function releaseNoteText( .replace(/`([^`]+)`/g, "$1") .replace(/\[([^\]]+)\]\[[^\]]*\]/g, "$1") // Note-authored numbers must not impersonate our source citations. - .replace(/\[(\d+)\](?!\()/g, "($1)"); + .replace(/\[(S?\d+)\](?!\()/g, "($1)"); return plain .replace( /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<>]+)/g, diff --git a/packages/mcp/src/shared/package-changelog-response.test.ts b/packages/mcp/src/shared/package-changelog-response.test.ts index 327f2f2b..9bc6e9be 100644 --- a/packages/mcp/src/shared/package-changelog-response.test.ts +++ b/packages/mcp/src/shared/package-changelog-response.test.ts @@ -699,6 +699,75 @@ describe("changelog classified statement evidence", () => { expect(text).toContain(`Release notes: ${primary}`); expect(text).not.toContain(`Changelog: ${primary}`); }); + it("distinguishes note references from owned citations and hides unshown excerpt URLs", () => { + const primary = "https://example.com/history"; + const hidden = "https://example.com/hidden"; + const json = buildPackageChangelogSuccessPayload( + { + ...report, + entries: [ + { + version: "v5.2.1", + htmlUrl: primary, + body: "- Fixed parser [#12][2]\nSee [3] and [manual](https://example.com/manual)", + }, + ], + riskItems: [ + { + ...item, + tier: "SHOULD_KNOW", + url: primary, + text: `${"A noteworthy statement ".repeat(30)} ${hidden}`, + heading: "Extra [4] details https://example.com/heading", + }, + ], + }, + baseOptions, + ); + const compact = formatPackageChangelogTerminal(json, { useColors: false }); + expect(compact).toContain("[#12][2]"); + expect(compact).not.toContain("[#12](2)"); + expect(compact).toContain("See [3]"); + expect(compact).toContain("[manual](https://example.com/manual)"); + expect(compact).not.toContain(hidden); + expect(json.entries.items[0]?.body).toContain("[#12][2]"); + const verbose = formatPackageChangelogTerminal(json, { + useColors: false, + verbose: true, + }); + expect(verbose).toContain(hidden); + expect(verbose).toContain("https://example.com/heading"); + expect(verbose).toContain("Extra (4)"); + }); + it("does not admit hidden sources from relative links or authored citation markers", () => { + for (const authored of [ + "[S2](../MIGRATION.md)", + "[S2]", + "[2](../MIGRATION.md)", + ]) { + const hidden = "https://example.com/hidden"; + const json = buildPackageChangelogSuccessPayload( + { + ...report, + entries: [ + { version: "v5.2.1", htmlUrl: item.url, body: "Notes [2]" }, + ], + riskItems: [ + { + ...item, + tier: "SHOULD_KNOW", + text: `Migration ${authored}. ${"Longer change details ".repeat(30)} ${hidden}`, + }, + ], + }, + baseOptions, + ); + const text = formatPackageChangelogTerminal(json, { useColors: false }); + expect(text).not.toContain(hidden); + expect(text).toContain("[S1]"); + expect(text).toContain("Notes [2]"); + } + }); it("retains all per-item provenance and full text in JSON without mutating the report", () => { const json = payload(); expect(json.riskCoverage).toEqual(coverage); diff --git a/packages/mcp/src/shared/package-changelog-response.ts b/packages/mcp/src/shared/package-changelog-response.ts index b0bdf4e4..5a4b3d07 100644 --- a/packages/mcp/src/shared/package-changelog-response.ts +++ b/packages/mcp/src/shared/package-changelog-response.ts @@ -218,7 +218,7 @@ export function formatPackageChangelogTerminal( if (coverage) { lines.push(highlight("Changes (classified by agent)", options.useColors)); prose(`All selected releases: ${changelogRiskSummary(coverage)}`); - prose(`Classification versions: ${changelogRiskCoverageText(coverage)}`); + prose(`Classification coverage: ${changelogRiskCoverageText(coverage)}`); if (envelope.filter?.changes && envelope.filter.changes !== "all") prose( `Showing ${envelope.filter.changes === "actionable" ? "actionable statements" : "actionable + should know statements"}; note previews and totals include all tiers.`, @@ -234,6 +234,12 @@ export function formatPackageChangelogTerminal( prose("No matching classified statements returned."); lines.push(""); } + // Only renderer-owned headers and quotes can cite our source numbers. + const cited = new Set(); + const recordReferences = (text: string): void => { + for (const match of text.matchAll(/\[S(\d+)\](?!\()/g)) + cited.add(Number(match[1])); + }; const sources = new Map(); const reference = (url: string, source?: string): string => { const clean = safeRiskText(url); @@ -245,7 +251,7 @@ export function formatPackageChangelogTerminal( }; sources.set(clean, sourceRecord); } - return `[${sourceRecord.number}]`; + return `[S${sourceRecord.number}]`; }; let excerpted = false; @@ -291,6 +297,7 @@ export function formatPackageChangelogTerminal( : entry.htmlUrl ? dim(safeRiskText(entry.htmlUrl), options.useColors) : dim("(no link)", options.useColors); + if (coverage) recordReferences(url); const padded = padColumn(version, versionWidth); const datePadded = padColumn(date, dateWidth); lines.push( @@ -329,6 +336,7 @@ export function formatPackageChangelogTerminal( ? reference(item.url, item.source) : "[source URL unavailable]"; const text = `${kind ? `${kind} ` : ""}${item.ambiguous ? "(uncertain) " : ""}"${shown}" ${source}${item.textTruncated ? " [statement truncated by backend; fullText in JSON]" : ""}`; + recordReferences(text); const rendered = wrapTerminalProse(` * ${text}`, width, " "); lines.push( ...rendered.map((line) => { @@ -357,7 +365,10 @@ export function formatPackageChangelogTerminal( ] .filter(Boolean) .join(" | "); - if (details) prose(details, " "); + if (details) { + recordReferences(details); + prose(details, " "); + } } } } @@ -365,14 +376,10 @@ export function formatPackageChangelogTerminal( } if (coverage && sources.size) { lines.push("Sources"); - const cited = new Set( - [...lines.join("\n").matchAll(/\[(\d+)\]/g)].map((match) => - Number(match[1]), - ), - ); + for (const source of sources.values()) if (cited.has(source.number)) - lines.push(` [${source.number}] ${source.label}`); + lines.push(` [S${source.number}] ${source.label}`); } if (excerpted) prose( 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 3d7be8b9..78f480dd 100644 --- a/packages/mcp/src/shared/package-upgrade-review-response.ts +++ b/packages/mcp/src/shared/package-upgrade-review-response.ts @@ -907,7 +907,7 @@ function formatChangesSection( ); appendWrappedText( lines, - " Classification versions: ", + " Classification coverage: ", `${riskCoverageText(changelog)} | ${changelog.riskCoverage.unitsNoImpact} statements labeled no impact`, width, " ", @@ -1216,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]), ), ); From 84714ee8bdf90e765a686d07ae53c1707e54cd70 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Tue, 13 Oct 2026 13:17:37 +0300 Subject: [PATCH 03/14] fix: preserve changelog terminal newline behavior Avoid appending a second newline after the existing entry separator. Cover timeline and source-free classified output, and align command catalogs with the new statement view. --- docs/implementation/cli-commands.md | 2 +- docs/implementation/tools.md | 2 +- docs/plans/changelog-classifications.md | 10 ++++++++++ .../shared/package-changelog-response.test.ts | 16 ++++++++++++++++ .../mcp/src/shared/package-changelog-response.ts | 2 +- 5 files changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 79ea484f..e3d1b30c 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`, `--changes`, `--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`, `--verbose`, `--json` | Release-note previews and classified statements. Pin a version for one release; `--changes` filters statements, `--verbose` expands notes, `--no-body` skips notes and classification. | | `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. | diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index c221d7c1..a4f22cc3 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?`, `verbose?`, `body_lines?`, `format?` | Release-note previews and classified statements. `changes` filters statements; body omission skips classification. 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). | diff --git a/docs/plans/changelog-classifications.md b/docs/plans/changelog-classifications.md index a6840e84..bc068d26 100644 --- a/docs/plans/changelog-classifications.md +++ b/docs/plans/changelog-classifications.md @@ -271,3 +271,13 @@ namespace separation replaced body rewriting. Authored markers are escaped in quote normalization before formatter references are injected; raw notes stay unchanged. Upgrade citation collection excludes relative Markdown links too. No findings rejected; internal final closure is clean. The cited-only namespace preserves note links and quote evidence. Source numbers can have gaps after local excerpts; the registry keeps stable identifiers without adding renumbering machinery. Focused closure:123pass/743assertions, build/typecheck pass, compact/verbose live CLI/MCP text parity pass. External round2 pending. + +External code round2: round1 findings closed; direction sound. Fresh-context final +check found one low code issue: unconditional final newline sentinel duplicated +the entry-loop sentinel, including timeline-only output. Accepted; final push now +only occurs after nonempty content. Bounded sibling scan confirmed the empty +path already preserves its newline and Sources/excerpt footers still need theirs. +Tests assert timeline and classified-without-Sources endings; response/parity +68pass. Internal final closure clean. Updated two documentation catalog rows +with the implemented changes parameter. External round3 pending; the one final +fresh-context check was already used in round2. diff --git a/packages/mcp/src/shared/package-changelog-response.test.ts b/packages/mcp/src/shared/package-changelog-response.test.ts index 9bc6e9be..15042810 100644 --- a/packages/mcp/src/shared/package-changelog-response.test.ts +++ b/packages/mcp/src/shared/package-changelog-response.test.ts @@ -811,6 +811,22 @@ describe("changelog classified statement evidence", () => { expect(text).toContain("v5.2.1"); expect(text).not.toContain("classified"); expect(text).not.toContain("Removed a public default"); + expect(text.endsWith("\n")).toBe(true); + expect(text.endsWith("\n\n")).toBe(false); + }); + it("ends classified output without sources with one newline", () => { + const json = buildPackageChangelogSuccessPayload( + { + ...report, + entries: [{ version: item.version, body: "Notes" }], + riskItems: [{ ...item, url: undefined }], + }, + baseOptions, + ); + const text = formatPackageChangelogTerminal(json, { useColors: false }); + expect(text).not.toContain("Sources"); + expect(text.endsWith("\n")).toBe(true); + expect(text.endsWith("\n\n")).toBe(false); }); it("honestly reports entirely pending, missing and unparseable versions with zero quotes", () => { const json = buildPackageChangelogSuccessPayload( diff --git a/packages/mcp/src/shared/package-changelog-response.ts b/packages/mcp/src/shared/package-changelog-response.ts index 5a4b3d07..838ae1ed 100644 --- a/packages/mcp/src/shared/package-changelog-response.ts +++ b/packages/mcp/src/shared/package-changelog-response.ts @@ -385,7 +385,7 @@ export function formatPackageChangelogTerminal( prose( "Quotes ending in ... are excerpts; use verbose for expanded quotes.", ); - lines.push(""); + if (lines.at(-1) !== "") lines.push(""); return lines.join("\n"); } From 2cc4f3247efad9b75eef4447f3c08533d9c5d552 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Tue, 13 Oct 2026 13:21:16 +0300 Subject: [PATCH 04/14] fix: omit empty changelog source sections Gate the Sources heading and rows on the same visible citations. Cover the hidden-only URL edge case and record the completed review closure. --- docs/plans/changelog-classifications.md | 14 +++++++++++ .../shared/package-changelog-response.test.ts | 23 +++++++++++++++++++ .../src/shared/package-changelog-response.ts | 11 +++++---- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/docs/plans/changelog-classifications.md b/docs/plans/changelog-classifications.md index bc068d26..d0b22eca 100644 --- a/docs/plans/changelog-classifications.md +++ b/docs/plans/changelog-classifications.md @@ -281,3 +281,17 @@ Tests assert timeline and classified-without-Sources endings; response/parity 68pass. Internal final closure clean. Updated two documentation catalog rows with the implemented changes parameter. External round3 pending; the one final fresh-context check was already used in round2. + +## Completed + +External code round3 closed the newline finding and found one cosmetic empty +Sources heading when every registered URL was excerpted away. Accepted; heading +and rows now share the filtered visible-source list. The complete formatter and +upgrade sibling were checked for the same invariant; no other instances remain. +69 focused response/parity tests pass, and final internal closure is clean. +Three external rounds ran; no fourth was dispatched under the round limit. +All findings are resolved; there was no external round without code findings. +Final build, typecheck, built MCP smoke and dev compact/verbose text parity pass. +No backend contract issues or deferred development. Durable requirements, +complexity, validation and staged release guidance are in implementation docs. +The reviewed plan can now be deleted in the final cleanup commit. diff --git a/packages/mcp/src/shared/package-changelog-response.test.ts b/packages/mcp/src/shared/package-changelog-response.test.ts index 15042810..91fc245a 100644 --- a/packages/mcp/src/shared/package-changelog-response.test.ts +++ b/packages/mcp/src/shared/package-changelog-response.test.ts @@ -828,6 +828,29 @@ describe("changelog classified statement evidence", () => { expect(text.endsWith("\n")).toBe(true); expect(text.endsWith("\n\n")).toBe(false); }); + it("omits the Sources heading when its only URL is hidden by an excerpt", () => { + const hiddenUrl = "https://example.com/hidden-tail"; + const json = buildPackageChangelogSuccessPayload( + { + ...report, + entries: [{ version: item.version, body: "Notes" }], + riskItems: [ + { + ...item, + url: undefined, + tier: "SHOULD_KNOW", + text: `${"Longer change details ".repeat(30)} ${hiddenUrl}`, + }, + ], + }, + baseOptions, + ); + const text = formatPackageChangelogTerminal(json, { useColors: false }); + expect(text).toContain("Quotes ending in ... are excerpts"); + expect(text).not.toContain(hiddenUrl); + expect(text).not.toContain("Sources"); + expect(text.endsWith("\n\n")).toBe(false); + }); it("honestly reports entirely pending, missing and unparseable versions with zero quotes", () => { const json = buildPackageChangelogSuccessPayload( { diff --git a/packages/mcp/src/shared/package-changelog-response.ts b/packages/mcp/src/shared/package-changelog-response.ts index 838ae1ed..4844fd42 100644 --- a/packages/mcp/src/shared/package-changelog-response.ts +++ b/packages/mcp/src/shared/package-changelog-response.ts @@ -374,12 +374,13 @@ export function formatPackageChangelogTerminal( } lines.push(""); } - if (coverage && sources.size) { + const visibleSources = [...sources.values()].filter((source) => + cited.has(source.number), + ); + if (coverage && visibleSources.length) { lines.push("Sources"); - - for (const source of sources.values()) - if (cited.has(source.number)) - lines.push(` [S${source.number}] ${source.label}`); + for (const source of visibleSources) + lines.push(` [S${source.number}] ${source.label}`); } if (excerpted) prose( From 89a3454a35591b6f5458a589b7e96a242d6ecaf4 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Tue, 13 Oct 2026 13:21:27 +0300 Subject: [PATCH 05/14] docs: retire completed changelog classification plan Keep the shipped contract, selection rules, live validation, complexity measurements and release guidance in the permanent changelog implementation documentation. --- docs/plans/changelog-classifications.md | 297 ------------------------ 1 file changed, 297 deletions(-) delete mode 100644 docs/plans/changelog-classifications.md diff --git a/docs/plans/changelog-classifications.md b/docs/plans/changelog-classifications.md deleted file mode 100644 index d0b22eca..00000000 --- a/docs/plans/changelog-classifications.md +++ /dev/null @@ -1,297 +0,0 @@ -# Classified statements in package changelogs - -## Outcome and status - -Status: IMPLEMENTED, external code review pending. The owner confirmed production deployment on 2026-10-13. -Backend #3204 is merged; current SDL and changelog match the reviewed contract. -Plan review is clean after the round 3 wording fix. `pkg changelog` and MCP `pkg_changelog` retain their release-note -previews and additionally show the backend's classified statements per version. -Users can select actionable statements, actionable plus should-know statements, -or all classified statements. JSON retains evidence, provenance and honest -coverage. Classifications never become a package-level compatibility verdict. - -The user confirmed note previews remain the default on 2026-10-12 and requested -the three cumulative filters. After review, the user confirmed `all` as the -default and keeping `--no-body` as the existing timeline-only mode: it skips -classification, and `--changes` is a no-op there. No fourth `none` value. Backend changes are explicitly authorized. PR #463 -has merged; this work started from CLI main 6b35537, now refreshed to5531953 in a separate feature PR. - -## Verified evidence - -- Latest backend main 4fac1441178082398eb28419389448d7f35987be was read through - `gh api`; the local canonical checkout bf74b72f0 is stale. Latest SDL exposes - `riskItems` and `riskCoverage` only on `PackageUpgradeChangelog`, not on - `PackageChangelogResult` or `PackageVersionChangelog`. -- Latest/range requests already use `packageChangelog`. Exact pins use - `packageInfo.selectedVersion.changelog`, preserving the authoritative selected - version, including prereleases. No invented previous version is necessary. -- Backend `PackageChangelog` carries displayed and alternate note bodies, with - source URLs, internally. `PackageUpgradeReview.risk_entries/1` adapts these to - the reusable `Changelog.Risk` classifier. Stored fingerprints incorporate the - note bodies, so changing caller must not change classification inputs. -- `ChangelogRiskItem` already has `ambiguous`, bounded `text`, optional selected - `fullText`, `url`, and per-item model/formulation. Coverage has five pre-cap - confident/ambiguous tier counters, pending/missing/unparseable version counts, - no-impact count and `itemsOmitted`. -- Upgrade review has no classification filter; `--min-severity` is advisory-only. -- Current changelog text previews ten body lines, with explicit body omission and - verbose expansion. CLI and MCP share request/envelope/formatter modules. JSON - uses `entries: {count, items}` and echoes only explicit filters. - -## Ownership and boundaries - -The backend owns classification, stored labels, source aggregation, policy, -coverage, filtering before the cap and job budgets. The service client owns -typed GraphQL transport/validation. The shared MCP presentation layer owns -envelopes and terminal rendering used by both hosts. Classification presentation -primitives reused with upgrade review belong beside those shared formatters; -changelog must not manufacture an aggregate upgrade review to obtain labels. - -Backend work is handed off to its own Orca worktree per backend AGENTS: -`/Users/jpl/orca/workspaces/pkgseer-backend/changelog-classifications`. -The receiving agent owns backend planning, implementation, tests, docs and its -draft PR. This coordinator edits only githits-cli and reads the shared contract -artifact `/tmp/pkg-changelog-backend-contract.md`. - -No new classifier, queue, cache, storage, public dependency, package verdict, -repository-addressed CLI tool, extra GraphQL query, or upgrade-review filter. -Backend jobs and storage are existing infrastructure. Neither PR is merged or -deployed by this task. Deployment needs the owner's separate authorization. - -## Contract and behavior - -Backend contract confirmed by the receiving agent in -`/tmp/pkg-changelog-backend-contract.md`; final SDL is checked before client -queries are finalized: - -- Add `riskItems: [ChangelogRiskItem!]!` and - `riskCoverage: ChangelogRiskCoverage!` to both changelog result objects. -- Optional `riskTiers: [ChangelogRiskTier!]` on the timeline root and exact - changelog field filters statements before the 50-item cap. Omitted/all tiers - means all. Version selection and note previews remain unchanged. -- Coverage and the five tier counters describe all selected release versions, - before filtering/capping; `itemsOmitted` counts matching filtered statements - omitted by the cap. Document those different scopes explicitly. -- Risk selection is lazy and sibling fields share one computation. Body-only - legacy callers neither schedule classification nor incur classification waits. -- Selected versions' displayed plus alternate bodies are classified using the - same inputs/fingerprints and current job budget as upgrade review: six seconds - and at most ten batches, not the stale twenty-version ceiling. Latest main - `Changelog.Risk` lines55-58/129-158 confirm this. -- Empty selections and exact versions without notes remain successful. A missing - note is never evidence of no risk. Repo-only backend addressing must have an - explicit supported contract; CLI never invokes it. - -CLI `--changes ` and MCP `changes` have identical values: - -| View | Classified statements shown | -| --- | --- | -| `actionable` | MUST_ACT, including visibly uncertain MUST_ACT | -| `important` | MUST_ACT and SHOULD_KNOW, including uncertain items | -| `all` (default) | All three tiers, including statements too long to classify | - -Empty optional MCP strings normalize to the default; unknown non-empty views -fail locally with mapped INVALID_ARGUMENT when classification is enabled. The -explicit nonblank view is echoed in `filter.changes` only when effective. -Filtering affects classifications, not note previews or the release timeline. -Existing `--no-body` / `omit_bodies` skips note bodies AND risk selections, -preserving a fast timeline-only mode and its existing JSON envelope. `changes` -is ignored there (including validation/echo, like MCP ignored `body_lines`). -Existing verbose overrides for bodies remain unchanged: MCP text verbose enables -bodies/classifications even with omit_bodies, whereas CLI rejects no-body with -verbose as before. Risk field @include directives reuse includeBodies. - -Text retains version/date/source headings and note previews. Add adjacent -classification sections: Requires action, Possibly requires action, Should know, -Too long to classify - read it. Do not put subsection counts after titles. -Show kind labels when present; missing kind still has a tier. Uncertain derives -only from `ambiguous`, never tierConfidence. Confident MUST_ACT quotes are full -within the bounded wire text; lower-priority quotes are brief, verbose expands. -Model/formulation/confidences remain machine evidence, not default text clutter. -Keep source URLs available once through references when multiple sources apply. -Show whole-selection tier counters and classification coverage once, with the -pending rerun message, no-impact count and explicit matching-item omissions. - -JSON adds top-level `riskItems` and `riskCoverage` to the existing lean envelope -when classification is selected, with established lowercase enum/null-stripping -conventions. `fullText` is JSON-only, matching upgrade review; verbose expands -bounded quotes and existing full note bodies. Compact selection omits unconsumed -provenance/confidence/heading fields using conditional selections: model and -formulation for JSON, confidence/heading for JSON or verbose. Bodies and risk -fields share includeBodies selection, so omitted bodies produce the old timeline -envelope without classifier work. Do not silently accept missing risk fields -once selected; report schema/protocol errors. Join items to entries by `version`, -not `normalizedVersion`; keep source ownership separate from version spelling. - -## Ordered delivery - -### Backend slice — COMPLETE (#3204, owner-confirmed production deployment) - -Outcome: exact/latest/range APIs expose lazy filtered classification evidence. -Assumptions: existing Risk jobs and stored labels are reusable (verified). -Unknowns: none. Delivered handoff confirms exact/timeline riskTiers argument, -repo-only risk-selection validation and maximal backend complexity73/71/96 -(exact/latest50/range, including contentSafety). Measure final client documents. -Dependencies: none beyond existing backend services. -Acceptance: focused backend tests cover lazy selection, both sibling fields, -empty/no-notes, selected-version scopes, alternate-source locators, server -ambiguity/counters, filtered-before-cap ordering and existing six-second/ten-batch job budget. Draft -PR contains current SDL/docs and complexity evidence for exact/latest50/range. - -### Client slice — IMPLEMENTED, review pending - -Outcome: both hosts show useful per-version classifications and identical JSON, -with the three cumulative filters and existing note controls preserved. -Assumptions: backend contract above is delivered; no production fallback or -parallel aggregate query is introduced. Public deployment precedes release. -Unknowns/product decisions: none. Live dev validation passed. Stored labels meant -no live pending sample; pending-only and truncated/oversize edge cases are tested. -Dependencies: backend gate satisfied. SDL riskTiers/riskItems/riskCoverage and -filter-before-cap/all-tier-counter semantics verified on 2026-10-13. Source -branch fast-forwarded to current CLI main5531953 before implementation. - -Implementation order: - -1. Review this plan, then verify exact SDL and arguments from the backend handoff. -2. Extend core changelog params/report/query/Zod normalization. Factor a shared - risk item schema base, with an upgrade-review extension retaining REQUIRED - model/formulation/confidence/heading keys and a changelog extension allowing - the omitted compact fields. Add a regression proving upgrade review rejects - missing model/formulation. Reuse neutral risk types and factor presentation - primitives only where both formatters consume them. Preserve upgrade review. -3. Extend shared request validation, both host options and lean envelope. Add - per-version classification rendering and one coverage summary, with source - references, wrapping, color-independent labels and terminal sanitation. -4. Update tools/CLI/implementation docs and a dual-artifact minor fragment. - CLI --changes help and MCP changes description explicitly state it is ignored - when bodies are omitted; MCP text verbose re-enables it. - Stage exact edits for canonical skills/githits-package/SKILL.md and its - package reference in durable implementation docs, with timeline-only no-body - semantics and filter guidance. Leave the published package skill unchanged in - this feature PR to avoid advertising unreleased flags, following the explicit - CLI code/package release-branch guidance rule in - .agents/skills/githits-release/SKILL.md (read by the reviewer). The onboarding - section alone is not the authority for this package guidance decision. Its current no-body timeline advice remains true. Test - descriptor prefix contracts; generate/check plugin assets only if a canonical - plugin input actually changes. -5. Verify focused tests, build and both smoke surfaces, then internal pre-flight - and one external reviewer per round. Open a separate draft client PR. - -Acceptance tests: - -- CLI/MCP JSON and text parity for exact/latest/range and all three filters. -- Wire selections/variables prove backend tier filtering, no-body skipping risk/body - fields with ignored changes, MCP verbose override and CLI conflict rejection, - JSON-only fullText and provenance versus compact/verbose selections, and one query per call. -- Pending-only, zero risk items, missing/unparseable notes, omitted matching - items, oversize/truncated quotes, mixed provenance, and kindless items. -- Backend ambiguity overrides any numeric confidence (test opposite numeric - values); no client-side confidence policy. -- Version matching uses entry.version even when normalizedVersion differs. -- Existing notes, latest limits, excluded lower bound, exact prereleases, empty - results and mapped errors keep working. All terminal strings sanitize control - sequences; JSON stays unchanged. Narrow-width text wraps prose. -- Upgrade-review regression tests pass after any shared extraction. -- Dev source CLI/local stdio MCP verify exact express@5.2.1, latest express and - express@5.0.0..5.2.1, plus a many-item Biome range with each filter; report - cold/warm coverage and operation complexity below 500. Auth uses normal CLI. -- Targeted changelog descriptor/agent workloads run only after the backend is - available, with trace/final/metrics/isolation artifacts inspected. Do not - describe harness completion as graded quality. - -Baseline verification: five existing changelog test files pass (129 tests, -423 assertions, 446 ms). Authenticated dev exact `npm:express@5.2.1` returned its -release body and locator through normal CLI auth. Internal technical plan review -found no findings; static query complexity remains a measurement requirement. -External round 1: direction sound. Accepted the default/timeline clarification -(user confirmed all + timeline-only no-body, no fourth view), guidance/descriptor -coverage, JSON-only fullText, and explicit version join. No rejected findings. -Round 2 accepted schema-base/required-upgrade/optional-changelog extensions -with a strict upgrade-provenance regression; corrected remaining no-body -wording and pinned package-guidance release authority. Round 3 confirmed technical closure; its only finding was this explicit help/ -description requirement, now added. The round is clean under the minor-wording -rule. No fresh-context external subagent or validation was run during planning. - -No performance optimization is proposed. Measure GraphQL complexity on the exact -documents before/after; preserve bounded backend classification wait. Rendering -adds a bounded 50 statements and does not require a performance benchmark claim. - -## Reorientation, completion and cleanup - -After receiving the backend contract, compare it against every assumption above; -update the plan and review any material change before implementation. After -owner-authorized deployment, verify live coverage and schema using normal auth. -Do not merge/release/deploy. If deployment is pending, deliver draft PRs and state -the remaining verification explicitly; keep this plan current until complete. - -When implementation and reviews are complete and live acceptance passes, transfer -durable contracts/semantics to implementation docs, then delete this plan in the -final commit of the client PR. Major deferred work belongs in the repository -backlog; none is currently proposed. - - -## Client implementation evidence (2026-10-13) - -- Implemented note-preserving classifications and views, no-body selection gates, - raw-version joins, mode-specific field selection/validation and shared quote - presentation. Source labels use matching item evidence, not aggregate source. -- Focused8files:440pass/0fail/1800 assertions. Source/built CLI and MCP smoke - suites passed; build and typecheck passed after two typed test assertions fixed. -- Authenticated dev CLI/localstdioMCP exact/latest/range and all views, Biome, - latest50, empty, no-notes and timeline-only JSON parity passed. Range text is - equal except intended host-specific body expansion hint. -- Expressrange4classified/0pending/125no-impact,0confident+8uncertain action, - 6confident+2uncertain know; warm unchanged. Biome13classified/0pending, - 13confident+27uncertain action,12confident+10uncertain know,813no-impact: - all50returned/12omitted versus actionable40returned/0omitted. -- Latest50 selects50classified/0pending,41action+42know precap,597no-impact, - all50returned/33omitted versus actionable41returned/0omitted. Counter scopes - stable acrossfilters; stored formulationsv2/v3/v5 preserved peritem. -- Complexity maximalactualclientJSON: exact56/latest50=65/range90. Verified - rejected-control510 plus documents566/575/600, before resolver execution. -- Internal codepreflight: direction sound, no findings after typed assertions. - External code review pending. Descriptor-only Codex exact-release eval: neutral - run completed with zero tool calls (not tool-use evidence); explicit GitHits - intent run completed with3logicalcalls (quick_start, pkg_changelog default and - verbose), no isolation violations, high self-reported confidence,20.8seconds. - Final answer quoted the rollback/security context and preserved uncertainty. - No quality grading ran. Initial missing dedicated CODEX_HOME preflight did not - execute a model; retry used the existing dedicated eval home without reading - auth material. Last touched request/CLI/parity files:89pass/266 assertions. - - -External code round1: direction sound. Accepted citation collision finding: -renderer-owned references now tracked separately from raw body previews; original note Markdown is retained; formatter references use a distinct `[S1]` -namespace. Relative Markdown links cannot count as owned citations. Regression covers -reference-style links, bare numbered refs, a URL after a cut quote, and verbose -heading/source admission; raw JSON unchanged. Relabeled mixed count line to -Classification coverage in both sibling changelog/upgrade formatter surfaces. -Internal follow-up correctly caught loss of note links in the initial remedy; -namespace separation replaced body rewriting. Authored markers are escaped in -quote normalization before formatter references are injected; raw notes stay -unchanged. Upgrade citation collection excludes relative Markdown links too. -No findings rejected; internal final closure is clean. The cited-only namespace preserves note links and quote evidence. Source numbers can have gaps after local excerpts; the registry keeps stable identifiers without adding renumbering machinery. Focused closure:123pass/743assertions, build/typecheck pass, compact/verbose live CLI/MCP text parity pass. External round2 pending. - -External code round2: round1 findings closed; direction sound. Fresh-context final -check found one low code issue: unconditional final newline sentinel duplicated -the entry-loop sentinel, including timeline-only output. Accepted; final push now -only occurs after nonempty content. Bounded sibling scan confirmed the empty -path already preserves its newline and Sources/excerpt footers still need theirs. -Tests assert timeline and classified-without-Sources endings; response/parity -68pass. Internal final closure clean. Updated two documentation catalog rows -with the implemented changes parameter. External round3 pending; the one final -fresh-context check was already used in round2. - -## Completed - -External code round3 closed the newline finding and found one cosmetic empty -Sources heading when every registered URL was excerpted away. Accepted; heading -and rows now share the filtered visible-source list. The complete formatter and -upgrade sibling were checked for the same invariant; no other instances remain. -69 focused response/parity tests pass, and final internal closure is clean. -Three external rounds ran; no fourth was dispatched under the round limit. -All findings are resolved; there was no external round without code findings. -Final build, typecheck, built MCP smoke and dev compact/verbose text parity pass. -No backend contract issues or deferred development. Durable requirements, -complexity, validation and staged release guidance are in implementation docs. -The reviewed plan can now be deleted in the final cleanup commit. From 03fd5539ebbb8029da1efb4814ed90ebcf010ac6 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Tue, 13 Oct 2026 13:25:30 +0300 Subject: [PATCH 06/14] test: align changelog catalog schema with changes filter Include the new changes argument in the server-level compact schema contract. This closes the sole Linux and Windows CI failure without changing runtime behavior. --- packages/mcp/src/mcp/server.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/mcp/src/mcp/server.test.ts b/packages/mcp/src/mcp/server.test.ts index d8f50727..45751a05 100644 --- a/packages/mcp/src/mcp/server.test.ts +++ b/packages/mcp/src/mcp/server.test.ts @@ -578,7 +578,15 @@ describe("MCP compact target schemas", () => { ], [ "pkg_changelog", - ["body_lines", "format", "limit", "omit_bodies", "target", "verbose"], + [ + "body_lines", + "changes", + "format", + "limit", + "omit_bodies", + "target", + "verbose", + ], ], ] as const)("%s exposes the compact target schema", (name, properties) => { const descriptor = getMcpToolDescriptors().find( From d8a225dd7a455a7b35593f1975f79b6fca26021a Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Wed, 14 Oct 2026 10:41:54 +0300 Subject: [PATCH 07/14] feat: show complete changelog statements with efficient view projections Replace duplicate note previews with paged parsed statements and nearby sources. Keep original notes behind verbatim, preserve verbose as its deprecated alias, and select only consumed fields for text, JSON and timeline views. Add coverage, projection, paging and parity tests plus compatibility documentation. --- .../changelog-classified-statements.added.md | 6 - ...changelog-classified-statements.changed.md | 6 + docs/implementation/cli-commands.md | 57 +- docs/implementation/pkg-changelog.md | 322 ++++--- docs/implementation/tools.md | 24 +- docs/plans/changelog-statement-view.md | 216 +++++ .../package-changelog-statements.test.ts | 408 ++++++++ .../package-intelligence-service.test.ts | 208 +---- .../services/package-intelligence-service.ts | 773 ++++++++------- packages/mcp/src/mcp/server.test.ts | 1 + packages/mcp/src/services/test-helpers.ts | 44 +- .../shared/package-changelog-request.test.ts | 23 +- .../src/shared/package-changelog-request.ts | 57 +- .../shared/package-changelog-response.test.ts | 880 ++++-------------- .../src/shared/package-changelog-response.ts | 539 +++++------ packages/mcp/src/smoke-test.test.ts | 12 +- packages/mcp/src/smoke-test.ts | 51 +- .../mcp/src/tools/package-changelog.test.ts | 154 ++- packages/mcp/src/tools/package-changelog.ts | 64 +- scripts/cli-smoke.ts | 18 +- src/commands/pkg/changelog.test.ts | 75 +- src/commands/pkg/changelog.ts | 47 +- src/services/test-helpers.ts | 44 +- src/tools/package-changelog-parity.test.ts | 193 ++-- 24 files changed, 2124 insertions(+), 2098 deletions(-) delete mode 100644 changes/changelog-classified-statements.added.md create mode 100644 changes/changelog-classified-statements.changed.md create mode 100644 docs/plans/changelog-statement-view.md create mode 100644 packages/core-internal/src/services/package-changelog-statements.test.ts diff --git a/changes/changelog-classified-statements.added.md b/changes/changelog-classified-statements.added.md deleted file mode 100644 index b0fade50..00000000 --- a/changes/changelog-classified-statements.added.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"githits": minor -"@githits/mcp": minor ---- - -- **Classified changelog statements** - Package changelogs retain release-note previews and add source-linked agent classifications, uncertainty and coverage, with `changes` views for actionable, important or all statements. Timeline-only body omission skips classification; JSON preserves complete statement evidence and provenance. diff --git a/changes/changelog-classified-statements.changed.md b/changes/changelog-classified-statements.changed.md new file mode 100644 index 00000000..2de2cc71 --- /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, uncertainty, coverage and nearby sources. `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 e3d1b30c..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`, `--changes`, `--no-body`, `--verbose`, `--json` | Release-note previews and classified statements. Pin a version for one release; `--changes` filters statements, `--verbose` expands notes, `--no-body` skips notes and classification. | +| `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,42 +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 --changes important -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`), one classification summary/coverage block, then each entry's `version date source references` 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. Classified statements follow each preview, with kind labels and uncertain items separated from confident action statements. Source references resolve once below the entries. 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. - -**`--changes `.** Selects classified statements: `all` (default), `actionable` (MUST_ACT including uncertain items), or `important` (MUST_ACT plus SHOULD_KNOW). Backend filtering precedes the 50-item cap; note previews and all-tier coverage totals remain unchanged. See [changelog evidence](pkg-changelog.md) for uncertainty, coverage and source semantics. - -**`--no-body`.** Drops body fields from entries and skips classification. `--changes` is ignored in this mode, including validation and filter echo. 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}, riskItems?, riskCoverage?, filter?}`. Classification fields are present unless bodies are omitted; JSON includes complete statement text and per-item provenance. `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`, an effective `--changes`, 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 index bfae8c25..28342cd6 100644 --- a/docs/implementation/pkg-changelog.md +++ b/docs/implementation/pkg-changelog.md @@ -1,154 +1,186 @@ # Package changelog evidence -`pkg changelog` and MCP `pkg_changelog` show release-note previews alongside -agent-classified statements. They answer what shipped in selected releases; -upgrade review additionally compares security and dependencies between versions. -Statements and their source links are the evidence. Classification does not -establish an application's compatibility or assign a package-level risk. +`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 -Existing package targets remain unchanged: latest `registry:name`, exact -`registry:name@version`, or range `registry:name@from..to`. The lower bound is -excluded and the upper bound included. Latest uses a 1–50 entry limit; ranges -return all selected entries. Exact uses the backend-selected release, including -prereleases, without inventing a prior version. - -Default output retains the first ten logical lines of each note body and adds -classified statements grouped under that release's raw `version`. The normalized -version is available in JSON but is not the statement join key. Per-version -sections separate confident action from possible action, should-know statements -and statements too long to classify. A missing kind still retains its tier. -Uncertainty comes only from the backend `ambiguous` boolean; confidence numbers -are evidence, never a client policy. Labels remain understandable without color. - -| CLI `--changes` / MCP `changes` | Statements returned | +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, 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 + +Each bullet identifies its tier, any kind, and uncertainty. NO_IMPACT reads +`[no impact identified]`, a model statement assessment rather than a guarantee. +A null classification reads `[not assessed]`; UNCLASSIFIED means only too large +to classify. A missing kind retains its tier. Uncertainty comes only from the +backend `ambiguous` boolean, never a client confidence threshold. Model and +formulation can differ per item and remain machine-readable provenance. + +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 | | --- | --- | -| `all` (default) | Actionable, should know and too-long statements | -| `actionable` | MUST_ACT, including visibly uncertain MUST_ACT | -| `important` | MUST_ACT and SHOULD_KNOW, including uncertain items | - -The backend filters before its 50-item cap. Filtering does not filter release -entries or their note previews. A filter therefore retains context and may still -show other changes in the original notes. Empty optional agent strings mean -`all`; unknown active values fail before service access. - -`--no-body` / `omit_bodies:true` preserves the version/date/link timeline and -skips both bodies and classification. `changes` is ignored, including validation -and echo, in that mode. MCP text `verbose:true` keeps its existing override: -notes and classifications are enabled even when `omit_bodies:true`. CLI still -rejects `--no-body` combined with `--verbose`. JSON honors omission despite -verbose because verbose is text-only. - -Verbose expands note bodies and locally abbreviated quotes, plus headings and -confidence evidence. Text uses the backend-bounded `text`; a backend cut is -explicitly marked and the complete `fullText` is available in JSON. No-impact -statements appear only as a count. Model and formulation stay per-item JSON -provenance, allowing stored labels from different classifier revisions. - -## Coverage and locators - -The summary uses backend counts before filtering and capping, never counts of -returned quotes. Confident and uncertain counts remain separate. Version -coverage and no-impact counts likewise cover all selected releases. Only -`itemsOmitted` describes the selected filter: matching statements cut by the cap. -The text labels those scopes explicitly. - -Pending versions may still be running or their job may have failed; rerun in a -few seconds to a minute to read completed stored labels. Current backend jobs -share a six-second wait budget and allow at most ten classification batches; -there is no twenty-version ceiling. Missing notes and unparseable statements -are never evidence of no risk. Empty selections and exact releases without -notes remain successful. - -Each item uses its own backend `url`, including alternate note sources. Version -headings collect available note locators and the Sources table lists URLs once. -Timeline-level `source` describes the aggregate and cannot identify every -entry's source. Use an item's matching URL/source or exact-release source when -available; otherwise label an entry link generically as Notes. Do not invent -links or infer sources from URL patterns. The original markdown previews remain -available and may themselves contain links. Changelog formatter references use -`[S1]` to stay distinct from note-authored numbered Markdown references; notes -retain their original links. Only rendered headers, quotes and verbose details -admit Sources entries; hidden quote-tail links cannot be admitted by matching -numbers in notes or relative Markdown links. JSON retains original Markdown. - -Terminal sanitation runs at presentation only, on notes, quotes, headings, -versions and locators. JSON retains raw evidence with the established lowercase -enum and absent-null conventions. - -## Transport and envelope - -Backend #3204 added `riskItems` and `riskCoverage` to `PackageChangelogResult` -and `PackageVersionChangelog`, with optional `riskTiers` on `packageChangelog` -and `selectedVersion.changelog`. Its production deployment was confirmed by the -owner on 2026-10-13. Both surfaces reuse existing stored labels and alternate -bodies. Risk computation is lazy when selected, and sibling fields share work. - -The client makes one query. Body and risk fields use `includeBodies` directives -so timeline-only calls do not trigger classification. Compact text selects only -consumed statement fields; verbose adds confidence/heading fields. JSON adds -`fullText`, model and formulation. The shared risk schema keeps upgrade-review -provenance required while permitting omitted compact changelog fields. Missing -selected fields are protocol errors; there is no old-schema fallback. - -The existing envelope retains `registry`, `name`, optional `source`, `mode`, -`entries: {count, items}` and explicit `filter`. When classification is selected, -add top-level `riskItems` and `riskCoverage`. `filter.changes` echoes only an -explicit, effective view. Timeline-only envelopes retain their earlier shape. - -Core transport lives in `package-intelligence-service.ts`; shared request, -envelope and per-tool formatter live under `packages/mcp/src/shared`. -`changelog-risk-presentation.ts` owns the small statement presentation primitives -also consumed by upgrade review. CLI and MCP handlers select modes and supply -host-native note expansion hints. - -## Verified complexity - -Authenticated dev admission measurements on 2026-10-13: exact **56**, latest -with 50 entries **65**, and range **90** for the maximal client JSON selections, -below the 500 limit. -A verified 510-cost control operation (51 fixed-cost packageVulnerabilities aliases) -was added only to admission probes, which were rejected before resolvers; -subtracting 510 from 566/575/600 gives the client costs. Product calls remain -a single query. Runtime directives can still count statically. - -## Validation - -Unit coverage checks wire selections for each output mode, filter-before-cap -variables, CLI/MCP parity, pending and empty coverage, missing notes, omitted and -truncated statements, source attribution, raw-version joins, terminal sanitation -and preserved JSON evidence. Shared helpers retain upgrade-review regressions. -Source and built CLI/MCP smoke suites, build and typecheck passed. - -Authenticated dev CLI/local MCP checks on 2026-10-13 covered exact, latest and -range targets, each filter, empty ranges, releases without notes and no-body -timelines. Biome 2.4.2..2.4.15 returned 50 statements with 12 omitted for `all`, -and 40 with none omitted for `actionable`. All-tier counts stayed unchanged. -Express 5.0.0..5.2.1 had four classified versions and none pending; the warm -request kept the same coverage. Stored labels covered multiple formulations. -Cold pending coverage was tested with fixtures, not observed live. - -The exact-release Codex agent workload with explicit GitHits intent used -`quick_start` and `pkg_changelog` in default and verbose modes, with no isolation -violations. Its answer preserved uncertainty and cited the rollback evidence. -The neutral-intent run made no tool calls. These are inspected traces, not graded -quality results. +| 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 | 56,782 | 3.061s | +| Express4.20.0 | 2,501 | 6,439 | 1.481s | +| Express4.18.2..5.1.0 | 16,861 | 40,562 | 3.062s | +| Biome2.4.2..2.4.15 | 39,824 | 259,575 | 10.672s | + +Biome repeat took10.218s with identical output bytes; its JSON was772,829 bytes +and took11.570s. Times include CLI startup/auth and all required page requests. +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. Build and typecheck passed. Source unauthenticated +CLI/MCP and built Node smoke modes validate registration/auth handling. 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. ## Release guidance staging -Keep published `skills/githits-package` unchanged until this behavior is in its -release branch, following `.agents/skills/githits-release/SKILL.md`. At release: - -- In SKILL.md, add an example `githits pkg changelog npm:express --changes important`. -- Expand the changelog routing sentence: "Release-note previews include - agent-classified statements by default. Use `--changes actionable` for action - statements or `--changes important` for action plus should-know statements; - these filter classifications, not note previews. `--no-body` remains a compact - timeline and skips classification, so `--changes` is ignored there." -- In references/package.md, add `--changes actionable|important|all` to changelog - flags and the same filtering/body-omission distinction. Retain range boundaries, - exact selection and upgrade-review routing. Do not add a package verdict. - -Run plugin generation/check and skill packaging tests when applying those -canonical release edits. No generated plugin input changes in this feature PR. +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 a4f22cc3..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?`, `changes?`, `omit_bodies?`, `verbose?`, `body_lines?`, `format?` | Release-note previews and classified statements. `changes` filters statements; body omission skips classification. Latest mode caps entries; pins select one release; `@from..to` excludes `from` and includes `to`. Empty selections succeed. | +| `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,29 @@ JSON retains backend order and multiplicity. ### `pkg_changelog` response shape -Package changelogs now add agent-classified statements to existing note previews. See [changelog evidence](pkg-changelog.md) for the complete contract and release guidance staging. `changes: "actionable" | "important" | "all"` (default all) filters statements before the backend cap, without filtering notes or all-tier coverage totals. `ambiguous` alone controls uncertainty. Body omission skips both notes and risk fields; `changes` is ignored unless text verbose overrides omission. JSON adds per-item evidence/provenance and all selected-release coverage under top-level `riskItems` and `riskCoverage`. +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. Classification selections are also omitted, so no classifier work runs and `changes` is ignored. 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`, `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. @@ -549,7 +561,7 @@ Package changelogs now add agent-classified statements to existing note previews **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 @@ -801,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/docs/plans/changelog-statement-view.md b/docs/plans/changelog-statement-view.md new file mode 100644 index 00000000..a720e153 --- /dev/null +++ b/docs/plans/changelog-statement-view.md @@ -0,0 +1,216 @@ +# Complete changelog statement view + +Status: CODE REVIEW READY — backend complete, owner approved UI route 2026-10-14. + +## Outcome, scope and decisions + +Update existing draft CLI/MCP PR #476 so default changelogs show every selected +parsed statement once, with classification and nearby sources. Remove raw-note +previews from the default. All parsed units include model NO_IMPACT and absent +answers. Preserve source order, nested wording, headings and predecessor context. +Classification remains per statement; no package risk verdict or client threshold. + +Owner approved: all default; --changes actionable|important|all; --verbatim shows +original notes instead of bullets; --verbose is only a deprecated alias for that +view; JSON carries structured evidence/provenance. Preserve --no-body / MCP +omit_bodies as timeline-only. Raw/timeline modes ignore changes, including echo +and validation. Conflicting timeline and explicit original-note switches fail +before service access. MCP verbatim mirrors CLI; explicit false alone preserves default; explicit body_lines still requests its legacy preview view. + +MCP body_lines is existing released behavior. Retain as a deprecated original-note +preview request: it both selects and caps that view, avoiding a flag that silently +does nothing or requires a second optional switch. Explicit verbatim/verbose show +full notes, ignoring body_lines; omit_bodies wins over body_lines alone. JSON ignores +this text-only legacy preview knob. No other verbose behavior remains. This is an +implementation compatibility choice grounded in existing calls/tests, not a new +product mode. No open product decisions. + +Non-goals: upgrade-review redesign, backend/remote MCP edits, deployment/release, +client parsing/deduplication, new storage/cache/jobs or model policy. No merge. + +## Verified evidence and assumptions + +- Backend #3228 merged and deployed dev/production; exact/latest/range expose + statements(first:1..100 default100, offset>=0 default0, tiers nullable). +- Complete schema/contract: ~/proj/githits/pkgseer-backend/priv/graphql and + /tmp/pkg-changelog-statements-backend-contract.md. Deprecated changelog riskItems + remain functional; upgrade-review risk API unchanged. +- Page items: raw version, position, sourceIndex/sourcePosition, heading, source/url, + text/textTruncated, fullText, context and nullable classification. Tier includes + MUST_ACT, SHOULD_KNOW, NO_IMPACT, oversize-only UNCLASSIFIED. ambiguous is authoritative; + nullable classification is actual missing evidence, not a no-impact answer. +- Page counts and nextOffset support complete retrieval; coverage per version has + status and total/assessed/not-assessed/no-impact/oversize counts. Nonempty tier + filters exclude absent labels but coverage remains complete across selected releases. +- Meaningful source representative/deduplication and predecessor context belong to + backend; retain its returned evidence, never infer equivalence locally. +- Authenticated backend dev verification:271 units in100/100/71 pages, stable coverage. + Corpus maxima141/release and875/range justify pagination. Final backend maximal + complexity exact103/latest50=101/range126, not yet final client measurements. +- Offset pages are current evidence, not immutable snapshots. In all mode, compare only + parse identity (versions, unit totals and zero-unit note status), page counts and + contiguous raw version/position keys; classification arrivals do not move offsets. + Derive per-release assessment counts/status from the items actually collected, + preserving WITHOUT_NOTES/UNPARSEABLE for zero units. Do not reject new labels in + this mode. Filtered mode compares full assessment coverage/matching counts/order + and returns an explicit classification-in-progress rerun message if they change. + No retry/polling infrastructure. Same-count source edits are not reliably detectable + from this API; do not claim snapshot consistency. This honors the delivered contract. +- Existing service executes queries and validates DTOs; shared request/envelope/text + live in packages/mcp/src/shared; CLI and MCP are thin callers. Existing request, + response/parity/server tests pass, and exact Express4.20.0 dev baseline captured + with normal CLI authentication under /tmp/changelog-statements-baseline-*.log. + +## Ownership and next increment + +Backend owns extraction/source identity/labels. Core service owns page collection +and protocol validation so CLI/MCP cannot diverge. Shared request builder owns +view/flag resolution; per-tool shared formatter owns source/heading grouping and +sanitation. No new module unless it reduces existing complexity; source-page types +and query projection belong with package-intelligence-service. + +1. Backend contract (COMPLETE): all parsed statements and optional stored answers + are retrievable. Evidence above and390 focused backend cases; dependencies and + product unknowns:none. No backend edits in this increment. +2. Client statement view (CODE REVIEW READY): default statements, explicit original notes + and timeline, complete paged evidence with local sources. Depends on deployed + contract (met); assumptions above; unknown product decisions:none. Expected + outcome: CLI/MCP offer the same views/data, query only consumed fields, and show + pending/missing/oversize coverage honestly. Update #476; do not merge. + +Implementation order: +- Add neutral typed statement/classification/context/coverage/page DTOs and Zod + validation. Preserve classification:null. Replace deprecated changelog risk + selections; leave upgrade-review schemas/helper behavior intact. +- Core request controls express the selected view plus JSON detail selection and + tier filter. Fetch100 per page only in statement mode; first request includes + timeline presentation data, subsequent requests omit bodies/package/date fields. + Use raw version/position to validate ordered, nonduplicate statement keys. Collect + until exhausted, checking forward nextOffset/counts/parse identity (full assessment coverage for + filtered mode) and selected field + presence. No invented default classifications or old-schema fallback. +- Shared request builder resolves flags and filters before access. Update CLI + Commander options and MCP descriptors without changing discovery first sentence. +- Replace duplicate preview formatter with one source-ordered bullet formatter. + Labels identify action/should-know/no impact identified/too-large/not-assessed plus optional kind + and uncertainty. Ordinary statements are not locally excerpted; backend1000-char + cuts are explicit. Preserve nested lines/code and context. Source URL appears at + its version/source block; embedded note links stay inline, no global Sources table. + Original-note/timeline views use existing entry metadata and sanitation. + Filtered views explicitly report excluded not-assessed and oversize counts, + including when no statements match; JSON retains these in coverage. +- JSON keeps existing entries envelope and adds a complete statements block with + items/count/totalCount/coverage. Enum casing follows existing lower-snake conventions; + provenance/fullText retained only in statement JSON. No raw bodies there by default. +- Update mock fixtures/tests, smoke structural assertions, server catalog keys, + command/tool/implementation docs and release fragment. Public skill changes stay + staged for release per repo release policy; no plugin generation unless input changes. + +## Wire projections and efficiency acceptance + +| View | Fields required | +| --- | --- | +| Statement text | first-page timeline identity/date/URL; every page bounded statement text, version/position/sourceIndex, heading/source/URL, context text/heading/cut, classification tier/ambiguous/kind, page controls + coverage version/total/status; assessment counts derived from all collected items (filtered mode selects backend counts) | +| Statement JSON | above plus normalized entry version, sourcePosition, fullText/context fullText, confidences and model/formulation; no entry bodies | +| Verbatim text/JSON, verbose alias | timeline + raw entry.body; no statements, risk fields or classification jobs; no statement provenance/fullText | +| Timeline/no-body | version/date/URL (+ JSON normalized version); no raw body or statements/classification | +| Subsequent statement page | no package echo, source summary, dates, normalized timeline versions or bodies; page items match text/JSON projection, mode-specific coverage and page controls; no timeline inventory | + +Conditional fields must have tests for actual wire variables/selections in every +mode and flag combination. Queries remain one call for timeline/verbatim or <=100 +statements; additional calls only for required pages. Final maximal actual documents +must stay below500; no performance speedup is claimed without measured evidence. + +## Verification, completion and cleanup + +Test parsed no-impact, absent classifications, oversize, missing/unparseable/empty +notes, source order/raw joins/context/dedup preserved, mixed formulations, >100 +retrieval for both timeline and an exact release above100, labels arriving between +pages in all and filtered modes, filtered pages, coverage/count changes, malformed/nonprogressing offsets and duplicate/order keys, +filtered text exclusions for pending and oversize statements even with zero matches, +query projections and option conflicts/legacy aliases (including verbatim:false +with body_lines), multiline/code sanitation, +width and lossless JSON. Use bun test focused core/shared/CLI/MCP/parity/server files +and upgrade formatter regressions. Build/typecheck/Biome/diff check; source and +built CLI/MCP smoke suites. Authenticated dev CLI/local MCP exactExpress4.20.0, +Express4.18.2..5.1.0 (>100), Biome2.4.2..2.4.15, all3filters, verbatim/verbose alias, +timeline and JSON/text parity. Inspect targeted exact-release agent trace/final/ +metrics/isolation artifacts; no graded quality claim. Measure actual text bytes +before/after for default latest Express, exact Express4.20.0 and its271-statement +range and Biome2.4.2..2.4.15; measure built-client page count/wall time for Biome. +Public API does not expose whether labels were already stored; report observed +coverage and timing without inventing a cold claim. Read credential guidance, +use normal auth, never handle or expose tokens. No local broad backend tests. + +Review this plan internally then one external reviewer; implementation gets its +own review rounds (one external per round). Scope is this redesigned increment; +prior #476 implementation review evidence applies only to unchanged areas. Fix +accepted findings with bounded sibling scans. Keep plan through review, transfer +permanent semantics/measurements to docs, then delete in final clean-review commit. +Push/update draft #476 and complete CI. No merge, deployment or release. + +Internal plan review: direction sound. Accepted projection mismatch: page items +are disjoint and alternate source inventory is not available, so checks now use +comparable coverage/counts/progress and ordering keys, with no snapshot guarantee. +Accepted explicit NO_IMPACT presentation: [no impact identified], a per-statement +model answer rather than a package verdict. Baseline127pass/802assertions and +authenticated current dev exact output pass. External plan round2 clean after +three minor wording fixes: explicit filtered-exclusion acceptance, Biome built-client +bytes/timing acceptance, and this completed review status. + +## Compatibility and external round1 closure + +Approved original-note-only explicit view applies equally to text and JSON flags: +default JSON now omits entries.body and carries parsed statement evidence; callers +needing bodies use --verbatim --json / verbatim:true,format:json. Keeping unused +raw bodies by default would contradict the owner's separate-copy flag and minimal +fetching direction. No repeat approval is needed for this already approved route. +Other intentional released behavior changes: default text drops previews; MCP +verbose selects original-note view in JSON too; verbose+omit_bodies now errors like +its verbatim alias; body_lines is a legacy preview request; explicit false by itself +does not select a view, but a present body_lines does. Rewrite the unmerged fragment +as changed with minor impact for both0.x artifacts, explicitly noting JSON migration. + +External plan round1: direction sound. Accepted cold all-mode pagination correction +(derive assessment from collected items; check parse identity, not arriving labels), +explicit filtered exclusions, false/legacy precedence, exact paging case and measured +latency/size evidence. Compatibility findings are documented, including JSON removal +as part of the approved route; repeat owner approval request rejected on that evidence. +Measurement and internal closure precede external round2. + +Live latency planning evidence (2026-10-14): npm:eslint@8.0.0..8.10.0 has162 +units across12 releases. First complete two-page retrieval took6.426s, repeat +3.902s, including a separate normal-auth helper invocation per page; this is not +pure server latency or a client speedup claim. Both responses were fully assessed +at return, so the public API cannot prove they were initially cold. Quote text +alone totals27,900 UTF-8 bytes. Evidence /tmp/changelog-statement-probe-measurements.json. +The cold/arriving-label case will be exercised deterministically in core paging +tests; backend's6s per-parent budget still makes9-page worst-case54s plus transport +an estimate, not a measured timeout. Final built-client timings/bytes remain acceptance. + +## Implementation evidence + +Neutral statement DTOs and projection builder implemented in core; host view +resolution/formatter shared. Exact text statement queries omit duplicate entry +version, aggregate source and hasChangelog; JSON adds them where consumed. +Follow-ups omit all first-page metadata. Coverage must match the selected raw +version timeline, preserving joins and preventing hidden evidence. + +567 focused tests/2,855 assertions pass; build/typecheck and changed-file Biome +pass. Authenticated built Node CLI/local MCP complete matrix passes: exact40, +latest335, range271, Biome875 statements; actionable/important6/8,30/43,40/62. +All selected units assessed; no pending/oversize/unparseable live, fixtures cover +those plus arriving-label behavior. Missing-note is-even has one WITHOUT_NOTES +version. Complexity85exact/88latest50/113range, dev admission probes with510control. +Built all text Biome9pages10.672s, repeat10.218s;259,575bytes vs39,824 old preview +bytes. This is complete evidence rather than a speedup claim; no cold claim. +Other counts/timings/bytes moved to permanent pkg-changelog.md. + +Targeted Codex descriptor-intent workload succeeds4calls25.154s, uses default +statements then verbatim:true; no isolation violations, no quality grade. +Full source live smoke harnesses fail their auth probe because isolated HOME +cannot use this system keychain. No credentials copied/exported. Run required +source unauthenticated/registration modes and built Node modes; authenticated +affected-feature matrix above replaces full live harness proof for this task. +Internal complete code preflight direction sound, no findings; final gates and +external code review pending. Plan stays until clean external review. 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 c689655a..a7813e2f 100644 --- a/packages/core-internal/src/services/package-intelligence-service.test.ts +++ b/packages/core-internal/src/services/package-intelligence-service.test.ts @@ -2422,22 +2422,6 @@ describe("PackageIntelligenceServiceImpl.packageVulnerabilities", () => { describe("PackageIntelligenceServiceImpl — packageChangelog", () => { const ENDPOINT = "https://pkgseer.dev"; - const emptyRisk = { - riskItems: [], - riskCoverage: { - versionsClassified: 2, - versionsNotAssessed: 0, - versionsWithoutNotes: 0, - versionsUnparseable: 0, - unitsNoImpact: 2, - itemsMustActConfident: 0, - itemsMustActAmbiguous: 0, - itemsShouldKnowConfident: 0, - itemsShouldKnowAmbiguous: 0, - itemsUnclassified: 0, - itemsOmitted: 0, - }, - }; it("treats an empty source with no entries as a successful empty selection", async () => { const fetchFn = mock(() => @@ -2445,7 +2429,6 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { jsonResponse({ data: { packageChangelog: { - ...emptyRisk, package: { name: "express", registry: "NPM" }, source: "", entries: [], @@ -2462,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; @@ -2476,7 +2461,6 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { jsonResponse({ data: { packageChangelog: { - ...emptyRisk, package: { name: "express", registry: "NPM" }, source: "releases", entries: [ @@ -2501,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"); @@ -2514,9 +2498,6 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { expect(parsed.variables).toEqual({ registry: "NPM", name: "express", - includeBodies: false, - includeRiskJsonFields: false, - includeRiskDetails: false, }); }); @@ -2526,7 +2507,6 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { jsonResponse({ data: { packageChangelog: { - ...emptyRisk, package: { name: "react", registry: "NPM" }, source: null, entries: [ @@ -2551,6 +2531,8 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { const result = await service.packageChangelog({ registry: "NPM", + view: "verbatim", + includeJsonFields: true, packageName: "react", }); @@ -2564,7 +2546,6 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { jsonResponse({ data: { packageChangelog: { - ...emptyRisk, package: { name: "react", registry: "NPM" }, source: "", entries: [ @@ -2589,6 +2570,8 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { const result = await service.packageChangelog({ registry: "NPM", + view: "verbatim", + includeJsonFields: true, packageName: "react", }); @@ -2607,7 +2590,6 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { selectedVersion: { resolvedVersion: "5.2.1", changelog: { - ...emptyRisk, detailSource: "RELEASES", hasChangelog: true, entry: { @@ -2632,6 +2614,8 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { const result = await service.packageChangelog({ registry: "NPM", + view: "verbatim", + includeJsonFields: true, packageName: "express", version: "^5.0.0", }); @@ -2642,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"); @@ -2651,9 +2635,6 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { registry: "NPM", name: "express", version: "^5.0.0", - includeBodies: true, - includeRiskJsonFields: false, - includeRiskDetails: false, }); expect(result.source).toBe("releases"); expect(result.entries).toEqual([ @@ -2679,7 +2660,6 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { selectedVersion: { resolvedVersion: "5.2.1", changelog: { - ...emptyRisk, detailSource: "PACKAGE_VERSION", hasChangelog: false, entry: { @@ -2705,16 +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, - includeRiskJsonFields: false, - includeRiskDetails: false, }); expect(result.source).toBe("package_version"); expect(result.entries[0]).toMatchObject({ @@ -2733,7 +2710,6 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { selectedVersion: { resolvedVersion: "0.0.1", changelog: { - ...emptyRisk, detailSource: null, hasChangelog: false, entry: null, @@ -2752,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", @@ -2779,6 +2756,8 @@ describe("PackageIntelligenceServiceImpl — packageChangelog", () => { try { await service.packageChangelog({ registry: "NPM", + view: "verbatim", + includeJsonFields: true, packageName: "express", version: "99.99.99", }); @@ -4345,156 +4324,3 @@ describe("PackageIntelligenceServiceImpl — packageDependencies", () => { ).rejects.toBeInstanceOf(MalformedPackageIntelligenceResponseError); }); }); - -describe("changelog classification wire selection", () => { - const c = { - versionsClassified: 1, - versionsNotAssessed: 0, - versionsWithoutNotes: 0, - versionsUnparseable: 0, - unitsNoImpact: 20, - itemsMustActConfident: 0, - itemsMustActAmbiguous: 1, - itemsShouldKnowConfident: 3, - itemsShouldKnowAmbiguous: 0, - itemsUnclassified: 0, - itemsOmitted: 0, - }; - const compact = { - version: "5.2.1", - tier: "MUST_ACT", - ambiguous: true, - kind: "CHANGES_BEHAVIOR_OR_DEFAULT", - text: "Changed a default.", - textTruncated: false, - url: "https://example.com/v5.2.1", - source: "RELEASES", - }; - const details = { tierConfidence: 0.95, kindConfidence: 0.9, heading: null }; - const provenance = { - model: "jev-1.13.0", - formulation: "s-hier-v5", - fullText: "Changed a default.", - }; - for (const version of [undefined, "5.2.1"]) { - for (const mode of ["compact", "verbose", "json", "timeline"] as const) { - it(`requests only consumed fields for ${version ? "exact" : "timeline/range"} ${mode}`, async () => { - let request: - | { query: string; variables: Record } - | undefined; - const includeBodies = mode !== "timeline"; - const includeRiskDetails = mode === "verbose" || mode === "json"; - const includeRiskJsonFields = mode === "json"; - const selectedItem = { - ...compact, - ...(includeRiskDetails ? details : {}), - ...(includeRiskJsonFields ? provenance : {}), - }; - const block = { - source: "releases", - entries: [], - detailSource: "RELEASES", - hasChangelog: true, - entry: { normalizedVersion: "5.2.1" }, - ...(includeBodies - ? { riskItems: [selectedItem], riskCoverage: c } - : {}), - }; - const fetchFn = mock((_url: string, init?: RequestInit) => { - request = JSON.parse(String(init?.body)); - return Promise.resolve( - jsonResponse({ - data: version - ? { - packageInfo: { - selectedVersion: { - resolvedVersion: version, - changelog: block, - }, - }, - } - : { packageChangelog: block }, - }), - ); - }); - const service = new PackageIntelligenceServiceImpl( - "https://pkgseer.dev", - createMockTokenProvider(), - asFetchFn(fetchFn), - ); - const report = await service.packageChangelog({ - registry: "NPM", - packageName: "express", - version, - ...(!version ? { fromVersion: "5.0.0", toVersion: "5.2.1" } : {}), - riskTiers: ["MUST_ACT"], - includeBodies, - includeRiskDetails, - includeRiskJsonFields, - }); - expect(fetchFn).toHaveBeenCalledTimes(1); - expect(request?.variables).toMatchObject({ - includeBodies, - includeRiskDetails, - includeRiskJsonFields, - ...(includeBodies ? { riskTiers: ["MUST_ACT"] } : {}), - }); - expect(request?.query).toContain( - "riskItems @include(if: $includeBodies)", - ); - expect(request?.query).toContain( - "riskCoverage @include(if: $includeBodies)", - ); - expect(request?.query).toContain( - "fullText @include(if: $includeRiskJsonFields)", - ); - expect(request?.query).toContain( - "model @include(if: $includeRiskJsonFields)", - ); - expect(request?.query).toContain( - "tierConfidence @include(if: $includeRiskDetails)", - ); - expect(request?.query).toContain("riskTiers: $riskTiers"); - if (mode === "timeline") { - expect(request?.variables.riskTiers).toBeUndefined(); - expect(report.riskItems).toBeUndefined(); - expect(report.riskCoverage).toBeUndefined(); - } else { - expect(report.riskCoverage).toEqual(c); - expect(report.riskItems?.[0]?.ambiguous).toBe(true); - expect(report.riskItems?.[0]?.model).toBe( - includeRiskJsonFields ? provenance.model : undefined, - ); - expect(report.riskItems?.[0]?.tierConfidence).toBe( - includeRiskDetails ? 0.95 : undefined, - ); - expect(report.riskItems?.[0]?.heading).toBeUndefined(); - } - }); - } - } - it("rejects missing selected evidence instead of implying zero risk", async () => { - for (const block of [ - { entries: [] }, - { entries: [], riskItems: [compact] }, - { entries: [], riskCoverage: c }, - { entries: [], riskItems: [compact], riskCoverage: c }, - ]) { - const fetchFn = mock(() => - Promise.resolve(jsonResponse({ data: { packageChangelog: block } })), - ); - const service = new PackageIntelligenceServiceImpl( - "https://pkgseer.dev", - createMockTokenProvider(), - asFetchFn(fetchFn), - ); - await expect( - service.packageChangelog({ - registry: "NPM", - packageName: "express", - includeRiskJsonFields: true, - }), - ).rejects.toThrow(MalformedPackageIntelligenceResponseError); - } - }); -}); diff --git a/packages/core-internal/src/services/package-intelligence-service.ts b/packages/core-internal/src/services/package-intelligence-service.ts index 64b32d37..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, @@ -709,13 +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; - /** Filter classified statements before the backend cap; omitted means all. */ - riskTiers?: ChangelogRiskItem["tier"][]; - /** Internal selection controls; full statement/provenance fields are JSON-only. */ - includeRiskJsonFields?: boolean; - includeRiskDetails?: 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; } /** @@ -742,15 +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 { - /** Absent in timeline-only (includeBodies:false) requests. */ - riskItems?: ChangelogRiskItem[]; - riskCoverage?: ChangelogRiskCoverage; - /** 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[]; } @@ -2074,14 +2128,6 @@ const changelogRiskDetailFields = { }; const packageUpgradeChangelogRiskItemSchema = changelogRiskItemBaseSchema.extend(changelogRiskDetailFields); -const changelogRiskItemSchema = packageUpgradeChangelogRiskItemSchema.partial({ - tierConfidence: true, - kindConfidence: true, - heading: true, - model: true, - formulation: true, -}); - const packageUpgradeChangelogRiskCoverageSchema = z.object({ versionsClassified: z.number().int(), versionsNotAssessed: z.number().int(), @@ -2431,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(), @@ -2450,15 +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({ - riskItems: z.array(changelogRiskItemSchema).optional(), - riskCoverage: packageUpgradeChangelogRiskCoverageSchema.optional(), - - 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({ @@ -2468,16 +2558,6 @@ const changelogGraphQLResponseSchema = z.object({ .optional(), errors: z.array(graphQLErrorSchema).optional(), }); - -const exactChangelogDetailSchema = z.object({ - riskItems: z.array(changelogRiskItemSchema).optional(), - riskCoverage: packageUpgradeChangelogRiskCoverageSchema.optional(), - - detailSource: z.string().nullable().optional(), - hasChangelog: z.boolean(), - entry: changelogEntryDetailSchema.nullable().optional(), -}); - const exactChangelogGraphQLResponseSchema = z.object({ data: z .object({ @@ -2485,7 +2565,7 @@ const exactChangelogGraphQLResponseSchema = z.object({ .object({ selectedVersion: z .object({ - resolvedVersion: z.string(), + resolvedVersion: z.string().optional(), changelog: exactChangelogDetailSchema.nullable().optional(), }) .nullable() @@ -2499,139 +2579,156 @@ const exactChangelogGraphQLResponseSchema = z.object({ errors: z.array(graphQLErrorSchema).optional(), }); -// The same wire selection serves latest/range and exact releases. Body omission -// retains the old timeline and avoids any backend classification work. -const CHANGELOG_RISK_SELECTION = ` - riskItems @include(if: $includeBodies) { - version tier ambiguous kind text textTruncated url source - tierConfidence @include(if: $includeRiskDetails) - kindConfidence @include(if: $includeRiskDetails) - heading @include(if: $includeRiskDetails) - fullText @include(if: $includeRiskJsonFields) - model @include(if: $includeRiskJsonFields) - formulation @include(if: $includeRiskJsonFields) +/** 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" : ""} } - riskCoverage @include(if: $includeBodies) { - versionsClassified versionsNotAssessed versionsWithoutNotes versionsUnparseable - unitsNoImpact itemsMustActConfident itemsMustActAmbiguous - itemsShouldKnowConfident itemsShouldKnowAmbiguous itemsUnclassified itemsOmitted - }`; - -const PACKAGE_CHANGELOG_QUERY = ` -query PackageChangelog( - $registry: Registry! - $name: String! - $fromVersion: String - $toVersion: String - $limit: Int - $includeBodies: Boolean! = true - $riskTiers: [ChangelogRiskTier!] - $includeRiskJsonFields: Boolean! = false - $includeRiskDetails: Boolean! = false -) { - packageChangelog( - registry: $registry - name: $name - fromVersion: $fromVersion - toVersion: $toVersion - limit: $limit - riskTiers: $riskTiers - ) { - package { - name - registry - fromVersion - toVersion - limit + 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" : ""} } } - source - ${CHANGELOG_RISK_SELECTION} - entries { - version - normalizedVersion - body @include(if: $includeBodies) - htmlUrl - publishedAt + }` + : ""; + 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 - $riskTiers: [ChangelogRiskTier!] - $includeRiskJsonFields: Boolean! = false - $includeRiskDetails: Boolean! = false -) { - packageInfo(registry: $registry, name: $name, version: $version) { - selectedVersion { - resolvedVersion - changelog(riskTiers: $riskTiers) { - ${CHANGELOG_RISK_SELECTION} - 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)); } } -}`; - -/** Selected risk fields are mandatory; an old/partial schema is a protocol error. */ -function assertChangelogRiskSelection( - data: - | z.infer - | z.infer - | null - | undefined, - params: PackageChangelogParams, -): void { - if (params.includeBodies === false) return; - const items = data?.riskItems; - const detailsSelected = - params.includeRiskDetails === true || params.includeRiskJsonFields === true; if ( - !items || - !data?.riskCoverage || - items.some( - (item) => - (params.includeRiskJsonFields === true && - (item.fullText === undefined || - item.model === undefined || - item.formulation === undefined)) || - (detailsSelected && - (item.tierConfidence === undefined || - item.kindConfidence === undefined || - item.heading === undefined)), - ) - ) { + items.length !== first.matchingCount || + coverage.reduce((sum, c) => sum + c.statementsTotal, 0) !== + first.totalCount || + (!filtered && first.matchingCount !== first.totalCount) + ) throw new MalformedPackageIntelligenceResponseError( - "Missing selected changelog classification fields from the package-intelligence service.", + "Inconsistent changelog statement counts.", ); - } -} - -function normaliseChangelogRisk( - data: - | Pick< - z.infer, - "riskItems" | "riskCoverage" - > - | null - | undefined, -): Pick { - if (!data?.riskItems || !data.riskCoverage) return {}; - return stripNullProperties({ - riskItems: data.riskItems, - riskCoverage: data.riskCoverage, - }) as Pick; + 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( @@ -3986,106 +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, - riskTiers: - params.includeBodies !== false ? params.riskTiers : undefined, - includeRiskJsonFields: - params.includeBodies !== false && - params.includeRiskJsonFields === true, - includeRiskDetails: - params.includeBodies !== false && - (params.includeRiskDetails === true || - params.includeRiskJsonFields === true), - }, - 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 (!statements.hasMore) break; + nextOffset = statements.nextOffset; } - - if (response.status < 200 || response.status >= 300) { - throw this.createHttpError(response); - } - - const parsed = changelogGraphQLResponseSchema.safeParse( - response.parsedBody, - ); - if (!parsed.success) { + if (!report) throw new MalformedPackageIntelligenceResponseError( - "Malformed response from the package-intelligence service.", + "Empty changelog response.", ); - } - - if (parsed.data.errors && parsed.data.errors.length > 0) { - throw promoteGenericVersionNotFound( - this.createGraphQLError(parsed.data.errors), - params, - ); - } - - 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.", + ); } - - assertChangelogRiskSelection(data, params); - 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, - riskTiers: - params.includeBodies !== false ? params.riskTiers : undefined, - includeRiskJsonFields: - params.includeBodies !== false && - params.includeRiskJsonFields === true, - includeRiskDetails: - params.includeBodies !== false && - (params.includeRiskDetails === true || - params.includeRiskJsonFields === true), + ...(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, @@ -4093,101 +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.", ); - } - - assertChangelogRiskSelection(selected.changelog, params); - 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, - ...normaliseChangelogRisk(data), - }; - } - - private normaliseExactChangelogReport( - selected: { - resolvedVersion: string; - changelog?: z.infer | 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), - ...normaliseChangelogRisk(changelog), - 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 45751a05..eab8abc0 100644 --- a/packages/mcp/src/mcp/server.test.ts +++ b/packages/mcp/src/mcp/server.test.ts @@ -585,6 +585,7 @@ describe("MCP compact target schemas", () => { "limit", "omit_bodies", "target", + "verbatim", "verbose", ], ], diff --git a/packages/mcp/src/services/test-helpers.ts b/packages/mcp/src/services/test-helpers.ts index aa8dca56..4be4f425 100644 --- a/packages/mcp/src/services/test-helpers.ts +++ b/packages/mcp/src/services/test-helpers.ts @@ -604,19 +604,37 @@ export const cratesFeatureDependencyReport: DependencyReport = { * markdown, ISO date, and a normalisedVersion that equals the raw. */ export const defaultChangelogReport: ChangelogReport = { - riskItems: [], - riskCoverage: { - versionsClassified: 2, - versionsNotAssessed: 0, - versionsWithoutNotes: 0, - versionsUnparseable: 0, - unitsNoImpact: 2, - itemsMustActConfident: 0, - itemsMustActAmbiguous: 0, - itemsShouldKnowConfident: 0, - itemsShouldKnowAmbiguous: 0, - itemsUnclassified: 0, - itemsOmitted: 0, + 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", diff --git a/packages/mcp/src/shared/package-changelog-request.test.ts b/packages/mcp/src/shared/package-changelog-request.test.ts index 0deec3c9..35e111d4 100644 --- a/packages/mcp/src/shared/package-changelog-request.test.ts +++ b/packages/mcp/src/shared/package-changelog-request.test.ts @@ -286,7 +286,7 @@ describe("classified statement views", () => { ] as const) { it(`maps ${changes} to backend tiers for ${target}`, () => { const result = buildPackageChangelogParams({ target, changes }); - expect(result.params.riskTiers).toEqual( + expect(result.params.statementTiers).toEqual( expected ? [...expected] : undefined, ); expect(result.changes).toBe(changes); @@ -301,18 +301,18 @@ describe("classified statement views", () => { changes, }); expect(result.changes).toBe("all"); - expect(result.params.riskTiers).toBeUndefined(); + 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", - includeBodies: false, + view: "timeline", changes: "unknown", }); expect(result.changes).toBeUndefined(); - expect(result.params.riskTiers).toBeUndefined(); + expect(result.params.statementTiers).toBeUndefined(); expect(result.explicitFilterFields.has("changes")).toBe(false); }); it("rejects unknown active views locally", () => { @@ -321,3 +321,18 @@ describe("classified statement views", () => { ).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 d18497fc..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,9 +32,8 @@ 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; - /** Cumulative statement view; ignored when bodies are omitted. */ + view?: PackageChangelogView; + /** Cumulative statement filter; ignored outside statement mode. */ changes?: string; } @@ -74,9 +76,10 @@ 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 = - input.includeBodies === false ? undefined : normaliseChanges(input.changes); - const riskTiers: PackageChangelogParams["riskTiers"] = + view === "statements" ? normaliseChanges(input.changes) : undefined; + const statementTiers: PackageChangelogParams["statementTiers"] = changes === "actionable" ? ["MUST_ACT"] : changes === "important" @@ -100,8 +103,8 @@ export function buildPackageChangelogParams( registry, packageName: parsed.name, version, - includeBodies: input.includeBodies, - riskTiers, + view, + statementTiers, }, explicitFilterFields: new Set( explicitChanges ? ["version", "changes"] : ["version"], @@ -152,8 +155,8 @@ export function buildPackageChangelogParams( fromVersion, toVersion, limit, - includeBodies: input.includeBodies, - riskTiers, + view, + statementTiers, }, explicitFilterFields: explicit, }; @@ -211,3 +214,37 @@ function normaliseChanges(raw: string | undefined): ChangelogChangesView { } 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 91fc245a..6edf04c5 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,706 +260,230 @@ 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, - }, - ], - }; - const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); - const output = formatPackageChangelogTerminal(envelope, { - verbose: false, - useColors: false, - }); - 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)", - ); - }); - - 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, - 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", - ); - 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(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, - useColors: false, - }); - expect(output).toContain("+1 more line "); - expect(output).not.toContain("+1 more lines"); - }); - - 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("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, - }); - expect(output).toContain("-"); - }); - - 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, - useColors: false, - }); - expect(output).toContain("No entries in this range."); - }); - - 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"]), - }); - const output = formatPackageChangelogTerminal(envelope, { - verbose: false, - useColors: false, - }); - expect(output).toContain( - "range (4.0.0, 5.0.0] (start excluded; end included)", - ); - }); - - 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, - useColors: false, - }); - expect(output).not.toMatch(/[·…—–→]/); - }); - - 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, - useColors: false, - }); - expect(output).toContain("latest up to 5.2.1"); - }); - - 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, - useColors: false, - }); - expect(output).toContain("(empty release notes)"); - }); - - it("says release notes are unavailable for exact no-notes results", () => { - const report: ChangelogReport = { - ...baseReport, - source: "package_version", - entries: [ - { +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", - hasChangelog: false, - }, - ], - }; - const envelope = buildPackageChangelogSuccessPayload(report, { - ...baseOptions, - mode: "exact", - version: "5.2.1", - explicitFilterFields: new Set(["version"]), - }); - const output = formatPackageChangelogTerminal(envelope, { - verbose: false, - useColors: false, - }); - expect(output).toContain("exact 5.2.1"); - expect(output).toContain("Release notes are unavailable."); - }); - - it("labels exact text with the resolved release, not the requested selector", () => { - const report: ChangelogReport = { - ...baseReport, - source: "releases", - entries: [ - { - version: "5.2.1", - normalizedVersion: "5.2.1", - publishedAt: "2026-01-15T12:00:00Z", - htmlUrl: "https://github.com/expressjs/express/releases/tag/5.2.1", - body: "## Patch", - hasChangelog: true, - }, - ], + 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, { + } + function payload(report = evidence(), changes?: "actionable" | "important") { + return buildPackageChangelogSuccessPayload(report, { ...baseOptions, - mode: "exact", - version: "^5.0.0", - explicitFilterFields: new Set(["version"]), + view: "statements", + changes, + explicitFilterFields: new Set(changes ? ["changes"] : []), }); - const output = formatPackageChangelogTerminal(envelope, { - verbose: false, - useColors: false, - }); - expect(envelope.filter?.version).toBe("^5.0.0"); - expect(output).toContain("exact 5.2.1"); - expect(output).not.toContain("exact ^5.0.0"); - }); -}); - -describe("changelog classified statement evidence", () => { - const coverage = { - versionsClassified: 1, - versionsNotAssessed: 0, - versionsWithoutNotes: 0, - versionsUnparseable: 0, - unitsNoImpact: 133, - itemsMustActConfident: 13, - itemsMustActAmbiguous: 28, - itemsShouldKnowConfident: 12, - itemsShouldKnowAmbiguous: 10, - itemsUnclassified: 1, - itemsOmitted: 13, - }; - const item = { - version: "v5.2.1", - tier: "MUST_ACT" as const, - ambiguous: false, - tierConfidence: 0.1, - kind: "CHANGES_BEHAVIOR_OR_DEFAULT", - kindConfidence: 0.9, - text: "Removed a public default.", - textTruncated: false, - fullText: "Removed a public default.", - url: "https://example.com/history#v5.2.1", - source: "CHANGELOG_FILE", - heading: "Changes", - model: "jev-old", - formulation: "s-hier-v2", - }; - const report: ChangelogReport = { - ...baseReport, - entries: [ - { - ...baseReport.entries[0], - version: "v5.2.1", - normalizedVersion: "5.2.1", - body: "## Notes\n- original preview", - }, - ], - riskCoverage: coverage, - riskItems: [ - item, - { - ...item, - ambiguous: true, - tierConfidence: 0.95, - text: "Possible behavior change.", - model: "jev-new", - formulation: "s-hier-v5", - }, - { - ...item, - tier: "SHOULD_KNOW", - kind: undefined, - text: "A kindless notable statement.", - url: "https://example.com/release", - }, - { - ...item, - tier: "UNCLASSIFIED", - kind: undefined, - tierConfidence: undefined, - text: "Large statement.", - textTruncated: true, - }, - ], - }; - const payload = (): ReturnType => - buildPackageChangelogSuccessPayload(report, baseOptions); - it("preserves note previews and joins by raw version, showing server uncertainty separately", () => { + } + it("renders all parsed units once, source URLs nearby, without raw-note duplication", () => { const text = formatPackageChangelogTerminal(payload(), { useColors: false, - terminalWidth: 160, + terminalWidth: 200, }); - expect(text).toContain("original preview"); - expect(text).toContain( - 'Requires action\n * [behavior] "Removed a public default."', - ); - expect(text).toContain( - 'Possibly requires action\n * [behavior] (uncertain) "Possible behavior change."', - ); - expect(text).toContain( - 'Should know\n * "A kindless notable statement."', - ); - expect(text).toContain("Too long to classify - read it"); - expect(text).not.toContain("Requires action ("); - expect(text).not.toContain("jev-old"); - expect(text).not.toContain("Not a compatibility verdict"); - expect(text).toContain("13 require action (+28 uncertain)"); - expect(text).toContain("133 statements labeled no impact"); - expect(text).toContain("13 matching statements omitted by backend"); - expect(text).toContain("statement truncated by backend; fullText in JSON"); - expect(text.match(/https:\/\/example.com\/history#v5.2.1/g)).toHaveLength( - 1, - ); - }); - it("does not mislabel a release URL with the aggregate changelog-file source", () => { - const primary = "https://example.com/releases/5.2.1"; - const json = buildPackageChangelogSuccessPayload( - { - ...report, - source: "changelog_file", - entries: [ - { version: "v5.2.1", htmlUrl: primary, body: "release preview" }, - ], - riskItems: [{ ...item, url: primary, source: "RELEASES" }], - }, - baseOptions, - ); - const text = formatPackageChangelogTerminal(json, { useColors: false }); - expect(text).toContain(`Release notes: ${primary}`); - expect(text).not.toContain(`Changelog: ${primary}`); - }); - it("distinguishes note references from owned citations and hides unshown excerpt URLs", () => { - const primary = "https://example.com/history"; - const hidden = "https://example.com/hidden"; - const json = buildPackageChangelogSuccessPayload( - { - ...report, - entries: [ - { - version: "v5.2.1", - htmlUrl: primary, - body: "- Fixed parser [#12][2]\nSee [3] and [manual](https://example.com/manual)", - }, - ], - riskItems: [ - { - ...item, - tier: "SHOULD_KNOW", - url: primary, - text: `${"A noteworthy statement ".repeat(30)} ${hidden}`, - heading: "Extra [4] details https://example.com/heading", - }, - ], - }, - baseOptions, + 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"), ); - const compact = formatPackageChangelogTerminal(json, { useColors: false }); - expect(compact).toContain("[#12][2]"); - expect(compact).not.toContain("[#12](2)"); - expect(compact).toContain("See [3]"); - expect(compact).toContain("[manual](https://example.com/manual)"); - expect(compact).not.toContain(hidden); - expect(json.entries.items[0]?.body).toContain("[#12][2]"); - const verbose = formatPackageChangelogTerminal(json, { - useColors: false, - verbose: true, - }); - expect(verbose).toContain(hidden); - expect(verbose).toContain("https://example.com/heading"); - expect(verbose).toContain("Extra (4)"); - }); - it("does not admit hidden sources from relative links or authored citation markers", () => { - for (const authored of [ - "[S2](../MIGRATION.md)", - "[S2]", - "[2](../MIGRATION.md)", - ]) { - const hidden = "https://example.com/hidden"; - const json = buildPackageChangelogSuccessPayload( - { - ...report, - entries: [ - { version: "v5.2.1", htmlUrl: item.url, body: "Notes [2]" }, - ], - riskItems: [ - { - ...item, - tier: "SHOULD_KNOW", - text: `Migration ${authored}. ${"Longer change details ".repeat(30)} ${hidden}`, - }, - ], - }, - baseOptions, - ); - const text = formatPackageChangelogTerminal(json, { useColors: false }); - expect(text).not.toContain(hidden); - expect(text).toContain("[S1]"); - expect(text).toContain("Notes [2]"); - } - }); - it("retains all per-item provenance and full text in JSON without mutating the report", () => { - const json = payload(); - expect(json.riskCoverage).toEqual(coverage); - expect(json.riskItems?.[0]).toMatchObject({ - ...item, + for (const label of [ + "requires action: behavior", + "should know", + "no impact identified", + "too long to classify - read it", + "not assessed", + ]) + expect(text).toContain(`[${label}]`); + 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("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", - source: "changelog_file", + ambiguous: true, + tierConfidence: 0.95, }); - expect(json.riskItems?.[1]?.formulation).toBe("s-hier-v5"); - expect(report.riskItems?.[0]?.tier).toBe("MUST_ACT"); - }); - it("keeps all-tier totals while echoing the narrower active view", () => { - const json = buildPackageChangelogSuccessPayload( - { ...report, riskItems: [item] }, - { - ...baseOptions, - changes: "actionable", - explicitFilterFields: new Set(["changes"]), - }, + expect(p.statements?.items[4]?.classification).toBeNull(); + expect(p.statements?.items[3]?.context?.fullText).toBe( + "Earlier uncut quote", ); - const text = formatPackageChangelogTerminal(json, { + expect(report.statements?.items[0]?.classification?.tier).toBe("MUST_ACT"); + expect(p.statements?.coverage[0]?.status).toBe("not_assessed"); + }); + 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, - terminalWidth: 180, }); - expect(json.filter?.changes).toBe("actionable"); - expect(text).toContain( - "Showing actionable statements; note previews and totals include all tiers.", - ); - expect(text).toContain("12 should know (+10 uncertain)"); - }); - it("omits both bodies and classification from the timeline envelope", () => { - const json = buildPackageChangelogSuccessPayload(report, { - ...baseOptions, - includeBodies: false, + 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("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 p = payload(report); + const text = formatPackageChangelogTerminal(p, { + useColors: false, + terminalWidth: 72, }); - expect(json.riskItems).toBeUndefined(); - expect(json.riskCoverage).toBeUndefined(); - expect(json.entries.items[0]?.body).toBeUndefined(); - const text = formatPackageChangelogTerminal(json, { useColors: false }); - expect(text).toContain("v5.2.1"); - expect(text).not.toContain("classified"); - expect(text).not.toContain("Removed a public default"); - expect(text.endsWith("\n")).toBe(true); - expect(text.endsWith("\n\n")).toBe(false); - }); - it("ends classified output without sources with one newline", () => { - const json = buildPackageChangelogSuccessPayload( - { - ...report, - entries: [{ version: item.version, body: "Notes" }], - riskItems: [{ ...item, url: undefined }], - }, - baseOptions, - ); - const text = formatPackageChangelogTerminal(json, { useColors: false }); - expect(text).not.toContain("Sources"); - expect(text.endsWith("\n")).toBe(true); - expect(text.endsWith("\n\n")).toBe(false); - }); - it("omits the Sources heading when its only URL is hidden by an excerpt", () => { - const hiddenUrl = "https://example.com/hidden-tail"; - const json = buildPackageChangelogSuccessPayload( - { - ...report, - entries: [{ version: item.version, body: "Notes" }], - riskItems: [ - { - ...item, - url: undefined, - tier: "SHOULD_KNOW", - text: `${"Longer change details ".repeat(30)} ${hiddenUrl}`, - }, - ], - }, - baseOptions, - ); - const text = formatPackageChangelogTerminal(json, { useColors: false }); - expect(text).toContain("Quotes ending in ... are excerpts"); - expect(text).not.toContain(hiddenUrl); - expect(text).not.toContain("Sources"); - expect(text.endsWith("\n\n")).toBe(false); + expect(text).not.toContain("\x1b"); + expect(text).toContain("Injected"); + expect(p.statements?.items[0]?.text).toContain("\x1b"); }); - it("honestly reports entirely pending, missing and unparseable versions with zero quotes", () => { - const json = buildPackageChangelogSuccessPayload( - { - ...report, - riskItems: [], - riskCoverage: { - ...coverage, - versionsClassified: 0, - versionsNotAssessed: 4, - versionsWithoutNotes: 2, - versionsUnparseable: 1, - itemsOmitted: 0, - }, - }, - baseOptions, - ); - const text = formatPackageChangelogTerminal(json, { + it("preserves backend Unicode while formatter punctuation stays ASCII", () => { + const report = evidence(); + report.statements!.items[0]!.text = "* α — 保留 …"; + const text = formatPackageChangelogTerminal(payload(report), { useColors: false, - terminalWidth: 160, }); - expect(text).toContain( - "0 classified | 4 not assessed | 2 without notes | 1 unparseable", - ); - expect(text).toContain("No matching classified statements returned"); - expect(text).toContain("Rerun in a few seconds to a minute"); - expect(text).toContain( - "Missing or unparseable notes are not evidence of no risk", - ); - expect(text.toLowerCase()).not.toContain("safe"); + expect(text).toContain("α — 保留 …"); + expect(text).toContain("[requires action: behavior]"); }); - it("expands local excerpts in verbose but never uses JSON fullText as terminal evidence", () => { - const body = `${"Notable sentence ".repeat(30)}END`; - const json = buildPackageChangelogSuccessPayload( - { - ...report, - riskItems: [ - { - ...item, - tier: "SHOULD_KNOW", - text: body, - fullText: "JSON_ONLY_SECRET_SENTINEL", - }, - ], - }, - baseOptions, - ); - const compact = formatPackageChangelogTerminal(json, { useColors: false }); - expect(compact).not.toContain("END"); - expect(compact).toContain("Quotes ending in ..."); - const verbose = formatPackageChangelogTerminal(json, { + 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, - verbose: true, + terminalWidth: 72, }); - expect(verbose).toContain("END"); - expect(verbose).not.toContain("JSON_ONLY_SECRET_SENTINEL"); + expect(text).toContain("last word"); + for (const line of text.split("\n")) + expect(line.length).toBeLessThanOrEqual(72); }); - it("sanitizes body, quote, metadata and source controls while keeping JSON raw", () => { - const controlSequence = "\u001b[31m"; - const dirty = { - ...report, - entries: [ - { - version: "v5.2.1", - body: `before${controlSequence}after\nsecond\u0007line`, - }, - ], - riskItems: [ - { - ...item, - text: `quoted${controlSequence}statement`, - url: `https://example.com/${controlSequence}notes`, - heading: `dirty${controlSequence}heading`, - }, - ], - }; - const json = buildPackageChangelogSuccessPayload(dirty, baseOptions); - const text = formatPackageChangelogTerminal(json, { + it("uses color only as an aid, and mutes uncertain labels", () => { + const plain = formatPackageChangelogTerminal(payload(), { useColors: false, - verbose: true, }); - expect(text).not.toContain("\u001b"); - expect(text).not.toContain("\u0007"); - expect(text).toContain("beforeafter"); - expect(text).toContain("secondline"); - expect(text).toContain("quotedstatement"); - expect(json.riskItems?.[0]?.text).toContain(controlSequence); + const colored = formatPackageChangelogTerminal(payload(), { + useColors: true, + }); + expect(Bun.stripANSI(colored)).toBe(plain); + expect(colored).toContain("\x1b[2m[requires action: behavior]"); }); - it("wraps statement prose at the caller width and keeps labels visible without color", () => { - const json = buildPackageChangelogSuccessPayload( - { - ...report, - riskItems: [ - { - ...item, - text: "This public default changes the request behavior for existing application callers and requires examining each current call site.", - }, - ], - }, - baseOptions, - ); - const text = formatPackageChangelogTerminal(json, { - useColors: false, - terminalWidth: 45, + it("original-note view shows all lines unless the deprecated explicit cap is present", () => { + const report = { + ...baseReport, + entries: [{ version: "1", body: "first\nsecond\nlast" }], + }; + const p = buildPackageChangelogSuccessPayload(report, { + ...baseOptions, + view: "verbatim", }); - const quoted = text - .split("\n") - .filter((line) => line.startsWith(" *") || line.startsWith(" ")); - expect(quoted.length).toBeGreaterThan(2); - expect(quoted.every((line) => line.length <= 45)).toBe(true); - expect(text).toContain("[behavior]"); + 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 4844fd42..08bb987a 100644 --- a/packages/mcp/src/shared/package-changelog-response.ts +++ b/packages/mcp/src/shared/package-changelog-response.ts @@ -11,27 +11,19 @@ * - **`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, - ChangelogRiskCoverage, + ChangelogStatement, + ChangelogStatements, + PackageChangelogView, } from "@githits/core-internal"; -import { - type ClassifiedChangelogItem, - changelogRiskCoverageText, - changelogRiskSections, - changelogRiskSummary, - normaliseChangelogRiskItems, - PENDING_CLASSIFICATION_MESSAGE, - RISK_KIND_LABELS, - releaseNoteText, - safeRiskText, -} from "./changelog-risk-presentation.js"; +import { RISK_KIND_LABELS } from "./changelog-risk-presentation.js"; import { colorize, dim, highlight } from "./colors.js"; import type { ChangelogChangesView, @@ -77,8 +69,7 @@ export interface LeanChangelogFilter { } export interface LeanChangelogEnvelope { - riskItems?: ClassifiedChangelogItem[]; - riskCoverage?: ChangelogRiskCoverage; + statements?: LeanChangelogStatements; /** Present for spec addressing. */ registry?: string; /** Present for spec addressing. */ @@ -100,8 +91,7 @@ export interface BuildChangelogPayloadOptions { 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; @@ -125,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) { @@ -142,9 +132,29 @@ export function buildPackageChangelogSuccessPayload( }, }; - if (options.includeBodies && report.riskItems && report.riskCoverage) { - envelope.riskItems = normaliseChangelogRiskItems(report.riskItems); - envelope.riskCoverage = { ...report.riskCoverage }; + 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; @@ -180,332 +190,267 @@ 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(""); - const width = - options.terminalWidth === undefined || - !Number.isFinite(options.terminalWidth) - ? 80 - : Math.max(20, Math.floor(options.terminalWidth)); - const prose = (text: string, indent = " "): void => { + const prose = (text: string, indent = ""): void => { lines.push(...wrapTerminalProse(`${indent}${text}`, width, indent)); }; - const coverage = envelope.riskCoverage; - if (coverage) { - lines.push(highlight("Changes (classified by agent)", options.useColors)); - prose(`All selected releases: ${changelogRiskSummary(coverage)}`); - prose(`Classification coverage: ${changelogRiskCoverageText(coverage)}`); + 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`, + " ", + ); + 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( - `Showing ${envelope.filter.changes === "actionable" ? "actionable statements" : "actionable + should know statements"}; note previews and totals include all tiers.`, + `Filter excludes ${pending} not-assessed and ${oversize} too-long-to-classify statements.`, + " ", ); - if (coverage.itemsOmitted) + if (pending) prose( - `${coverage.itemsOmitted} matching statements omitted by backend (lowest priority first).`, + "Classification is still in progress or failed. Rerun in a few seconds to a minute to fill stored answers.", + " ", ); - if (coverage.versionsNotAssessed) prose(PENDING_CLASSIFICATION_MESSAGE); - if (coverage.versionsWithoutNotes || coverage.versionsUnparseable) - prose("Missing or unparseable notes are not evidence of no risk."); - if (!envelope.riskItems?.length) - prose("No matching classified statements returned."); - lines.push(""); - } - // Only renderer-owned headers and quotes can cite our source numbers. - const cited = new Set(); - const recordReferences = (text: string): void => { - for (const match of text.matchAll(/\[S(\d+)\](?!\()/g)) - cited.add(Number(match[1])); - }; - const sources = new Map(); - const reference = (url: string, source?: string): string => { - const clean = safeRiskText(url); - let sourceRecord = sources.get(clean); - if (!sourceRecord) { - sourceRecord = { - number: sources.size + 1, - label: `${source ? `${noteSourceLabel(source)}: ` : ""}${clean}`, - }; - sources.set(clean, sourceRecord); - } - return `[S${sourceRecord.number}]`; - }; - let excerpted = false; - - if (envelope.entries.items.length === 0) { - lines.push(dim("No entries in this range.", options.useColors)); - lines.push(""); - return lines.join("\n"); + 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 = safeRiskText(entry.version ?? "(unversioned)"); - const items = (envelope.riskItems ?? []).filter( + lines.push(""); + lines.push( + ...wrapTerminalProse( + sanitizeTerminalText( + `${entry.version ?? "(unversioned)"}${entry.publishedAt ? ` (${formatDate(entry.publishedAt)})` : ""}`, + ), + width, + ).map((line) => highlight(line, 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, ); - const date = entry.publishedAt - ? safeRiskText(formatDate(entry.publishedAt)) - : dim("-", options.useColors); - const noteLinks = new Map(); - // Timeline source is aggregate: individual releases can use alternate sources. - const entrySource = - items.find((item) => item.url === entry.htmlUrl)?.source ?? - (envelope.mode === "exact" ? envelope.source : undefined); - if (entry.htmlUrl) - noteLinks.set( - reference(entry.htmlUrl, entrySource), - noteSourceLabel(entrySource), + if (!items.length) { + if (entry.htmlUrl) prose(sanitizeTerminalText(entry.htmlUrl), " "); + const coverage = statements.coverage.find( + (v) => v.version === entry.version, ); - for (const item of items) - if (item.url) - noteLinks.set( - reference(item.url, item.source), - noteSourceLabel(item.source), - ); - const url = coverage - ? dim( - [...noteLinks].map(([ref, label]) => `${label} ${ref}`).join("; ") || - "(no link)", - options.useColors, - ) - : entry.htmlUrl - ? dim(safeRiskText(entry.htmlUrl), options.useColors) - : dim("(no link)", options.useColors); - if (coverage) recordReferences(url); - const padded = padColumn(version, versionWidth); - const datePadded = padColumn(date, dateWidth); - lines.push( - `${highlight(`${padded} ${datePadded}`, options.useColors)} ${url}`, - ); - if (entry.hasChangelog === false) { - lines.push(""); - lines.push( - ` ${dim("Release notes are unavailable.", options.useColors)}`, + prose( + coverage?.status === "without_notes" + ? "No release notes available." + : coverage?.status === "unparseable" + ? "Notes contain no parsed statements." + : "No statements match this filter.", + " ", ); - } else if (entry.body != null) { - appendBodyLines(lines, entry.body, options); + continue; } - if (items.length && entry.body != null) lines.push(""); - for (const section of changelogRiskSections(items)) { - if (!section.items.length) continue; - lines.push(` ${section.label}`); - for (const item of section.items) { - const quote = releaseNoteText(item.text, (url) => reference(url)); - const cap = - item.tier === "must_act" || options.verbose - ? Infinity - : item.tier === "should_know" - ? 240 - : 120; - const chars = Array.from(quote); - const shortened = chars.length > cap; - excerpted ||= shortened; - const shown = shortened - ? `${chars.slice(0, cap).join("").trimEnd()}...` - : quote; - const kind = item.kind - ? `[${RISK_KIND_LABELS[item.kind] ?? safeRiskText(item.kind)}]` - : ""; - const source = item.url - ? reference(item.url, item.source) - : "[source URL unavailable]"; - const text = `${kind ? `${kind} ` : ""}${item.ambiguous ? "(uncertain) " : ""}"${shown}" ${source}${item.textTruncated ? " [statement truncated by backend; fullText in JSON]" : ""}`; - recordReferences(text); - const rendered = wrapTerminalProse(` * ${text}`, width, " "); - lines.push( - ...rendered.map((line) => { - let styled = kind - ? line.replace(kind, colorize(kind, "yellow", options.useColors)) - : line; - if (item.ambiguous) - styled = styled.replace( - "(uncertain)", - dim("(uncertain)", options.useColors), - ); - return styled; - }), + 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 (options.verbose) { - const details = [ - item.heading - ? `heading: ${releaseNoteText(item.heading, (url) => reference(url))}` - : undefined, - item.tierConfidence !== undefined - ? `tier confidence: ${item.tierConfidence}` - : undefined, - item.kindConfidence !== undefined - ? `kind confidence: ${item.kindConfidence}` - : undefined, - ] - .filter(Boolean) - .join(" | "); - if (details) { - recordReferences(details); - prose(details, " "); - } - } } + 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 - ${sanitizeTerminalText(item.context.heading)}`, + " ", + ); + else prose("Context", " "); + 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 tierLabel = + tier === "must_act" + ? "requires action" + : tier === "should_know" + ? "should know" + : tier === "no_impact" + ? "no impact identified" + : tier === "unclassified" + ? "too long to classify - read it" + : "not assessed"; + const kind = classification?.kind + ? RISK_KIND_LABELS[classification.kind as keyof typeof RISK_KIND_LABELS] + : undefined; + const label = `[${tierLabel}${kind ? `: ${kind}` : ""}]`; + const muted = + classification?.ambiguous || tier === "no_impact" || !classification; + const coloredLabel = muted + ? dim(label, options.useColors) + : colorize( + label, + tier === "must_act" + ? "red" + : tier === "should_know" + ? "yellow" + : "cyan", + options.useColors, + ); + const start = lines.length; + renderStatementText( + lines, + item.text, + ` * ${label}${classification?.ambiguous ? " (uncertain)" : ""} `, + width, + ); + // Apply trusted styling after sanitation and wrapping; it never changes meaning. + if (lines[start]) + lines[start] = lines[start] + .replace(label, coloredLabel) + .replace("(uncertain)", dim("(uncertain)", options.useColors)); + if (item.textTruncated) + prose( + `Statement truncated; ${options.fullStatementHint ?? "use --json for full text"}.`, + " ", + ); } - lines.push(""); } - const visibleSources = [...sources.values()].filter((source) => - cited.has(source.number), - ); - if (coverage && visibleSources.length) { - lines.push("Sources"); - for (const source of visibleSources) - lines.push(` [S${source.number}] ${source.label}`); - } - if (excerpted) - prose( - "Quotes ending in ... are excerpts; use verbose for expanded quotes.", - ); - if (lines.at(-1) !== "") lines.push(""); - return lines.join("\n"); + return `${lines.join("\n")}\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"); + 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 indent = + index === 0 ? prefix : ` ${safe.match(/^ */)?.[0] ?? ""}`; + const value = index === 0 ? content : content.trimStart(); + const isFence = value.startsWith("```") || value.startsWith("~~~"); + if (fenced || isFence || (index > 0 && /^ {4}/.test(safe))) + lines.push(`${indent}${value}`); + else lines.push(...wrapTerminalProse(`${indent}${value}`, width, " ")); + 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(` ${sanitizeTerminalText(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(safeRiskText(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"; + 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 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; -} - 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 ? safeRiskText(source) : "Notes"; + return source ? sanitizeTerminalText(source) : "Notes"; } diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index 5d8e6f22..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": @@ -1549,14 +1549,16 @@ function smokeJsonResponse( if (args.target === "npm:express@5.2.1") { return jsonResult({ mode: "exact", - riskItems: [], - riskCoverage: {}, + statements: { items: [], coverage: [] }, entries: { items: [{ version: "5.2.1", hasChangelog: true }], }, }); } - return jsonResult({ entries: {}, riskItems: [], riskCoverage: {} }); + 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 b9e6f989..4cfcdb90 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -1257,9 +1257,7 @@ async function runLiveSmoke(caller: McpSmokeCaller): Promise { 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", ); } @@ -1278,9 +1276,7 @@ async function runLiveSmoke(caller: McpSmokeCaller): Promise { 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,11 +1309,23 @@ 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.riskItems), - "pkg_changelog json missing classification items", + 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", ); - assertRecord(changelogJson.riskCoverage, "pkg_changelog json coverage"); const filteredChangelog = assertJsonResult( await callTool(caller, "pkg_changelog", { target: "npm:express@5.2.1", @@ -1330,14 +1335,21 @@ async function runLiveSmoke(caller: McpSmokeCaller): Promise { "pkg_changelog actionable", ); assertRecord(filteredChangelog, "pkg_changelog actionable"); + assertRecord( + filteredChangelog.statements, + "pkg_changelog filtered statements", + ); assert( - Array.isArray(filteredChangelog.riskItems) && - filteredChangelog.riskItems.every( + Array.isArray(filteredChangelog.statements.items) && + filteredChangelog.statements.items.every( (item: unknown) => typeof item === "object" && item !== null && - "tier" in item && - item.tier === "must_act", + "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", ); @@ -1353,8 +1365,7 @@ async function runLiveSmoke(caller: McpSmokeCaller): Promise { ); assertRecord(timelineChangelog, "pkg_changelog timeline-only"); assert( - !("riskItems" in timelineChangelog) && - !("riskCoverage" in timelineChangelog), + !("statements" in timelineChangelog), "pkg_changelog timeline-only classified notes", ); diff --git a/packages/mcp/src/tools/package-changelog.test.ts b/packages/mcp/src/tools/package-changelog.test.ts index 0bec1bfb..ec70a8d6 100644 --- a/packages/mcp/src/tools/package-changelog.test.ts +++ b/packages/mcp/src/tools/package-changelog.test.ts @@ -43,7 +43,7 @@ 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", @@ -52,6 +52,7 @@ describe("createPackageChangelogTool — metadata", () => { "limit", "omit_bodies", "target", + "verbatim", "verbose", ]); expect(tool.annotations?.readOnlyHint).toBe(true); @@ -121,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({ @@ -202,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 }, {}, @@ -213,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"); } }, ); @@ -254,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!, @@ -362,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(() => @@ -385,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(); @@ -406,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(), ); @@ -424,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(); }); }); @@ -627,53 +599,73 @@ describe("createPackageChangelogTool — service errors", () => { }); }); -describe("changelog classification omission and verbose controls", () => { - it("text verbose overrides omission and activates changes", async () => { - const packageChangelog = mock(() => - Promise.resolve(defaultChangelogReport), - ); +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 }), + createMockPackageIntelligenceService({ packageChangelog: fn }), ); - await tool.handler( - { - target: "npm:express", - omit_bodies: true, - verbose: true, - changes: "important", - }, + const result = await tool.handler( + { target: "npm:express", verbatim: false, body_lines: 1 }, {}, ); - expect(packageChangelog).toHaveBeenCalledWith( - expect.objectContaining({ - includeBodies: true, - riskTiers: ["MUST_ACT", "SHOULD_KNOW"], - includeRiskDetails: true, - includeRiskJsonFields: false, - }), + 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("JSON honors omission despite verbose and ignores invalid changes", async () => { - const packageChangelog = mock(() => - Promise.resolve(defaultChangelogReport), - ); - const tool = createPackageChangelogTool( - createMockPackageIntelligenceService({ packageChangelog }), - ); - const result = await tool.handler( - { - target: "npm:express", - omit_bodies: true, - verbose: true, - changes: "invalid", - format: "json", - }, - {}, - ); - expect(result.isError).toBeUndefined(); - expect(JSON.parse(result.content[0]!.text)).not.toHaveProperty("riskItems"); - expect(packageChangelog).toHaveBeenCalledWith( - expect.objectContaining({ includeBodies: false, riskTiers: undefined }), + 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 a164e306..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,6 +27,7 @@ export interface PackageChangelogArgs { limit?: number; omit_bodies?: boolean; verbose?: boolean; + verbatim?: boolean; body_lines?: number; format?: "text" | "json"; changes?: string; @@ -50,31 +53,37 @@ const schema: ZodRawShape = { .string() .optional() .describe( - "Classified statements: actionable (MUST_ACT, including uncertain), important (actionable + SHOULD_KNOW), or all (default, also too-long statements). Filters before the item cap; note previews and coverage totals stay unchanged. Ignored with omit_bodies:true; text verbose:true re-enables it.", + "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 and skip classification (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.", ), }; @@ -85,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 alongside agent-classified statements; use `changes` to filter statements, or `body_lines` / `verbose:true` for more notes. `omit_bodies:true` skips notes and classification; `changes` is ignored unless text verbose overrides omission."; + "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}`; @@ -100,22 +109,23 @@ 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 { 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, - includeRiskJsonFields: !textFormat, - includeRiskDetails: !textFormat || args.verbose === true, + includeJsonFields: !textFormat, }); const payload = buildPackageChangelogSuccessPayload(report, { registry: toPkgseerRegistryLowercase(params.registry), @@ -123,7 +133,7 @@ export function createPackageChangelogTool( mode, changes, explicitFilterFields, - includeBodies, + view, fromVersion: params.fromVersion, toVersion: params.toVersion, limit: params.limit, @@ -133,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(), ); } @@ -150,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 43c2fdc9..6d661a37 100644 --- a/src/commands/pkg/changelog.test.ts +++ b/src/commands/pkg/changelog.test.ts @@ -19,10 +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 --no-body"); + 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"); @@ -65,69 +65,17 @@ 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("[no impact identified] fixed a thing"); 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((( @@ -187,7 +135,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(); }); @@ -386,7 +335,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(); }); @@ -437,7 +386,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 0b35d6c4..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,9 +26,10 @@ 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; @@ -65,12 +67,12 @@ 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 { view } = resolvePackageChangelogView({ + omitBodies: options.body === false || options.noBody === true, + verbatim: options.verbatim, + verbose: options.verbose, + json: options.json, + }); const { params, mode, explicitFilterFields, changes } = buildPackageChangelogParams({ @@ -78,14 +80,13 @@ export async function pkgChangelogAction( fromVersion: options.from, toVersion: options.to, limit, - includeBodies, + view, changes: options.changes, }); const report = await deps.packageIntelligenceService.packageChangelog({ ...params, - includeRiskJsonFields: options.json === true, - includeRiskDetails: options.json === true || options.verbose === true, + includeJsonFields: options.json === true, }); const payload = buildPackageChangelogSuccessPayload(report, { @@ -94,7 +95,7 @@ export async function pkgChangelogAction( mode, changes, explicitFilterFields, - includeBodies, + view, fromVersion: params.fromVersion, toVersion: params.toVersion, limit: params.limit, @@ -107,7 +108,6 @@ export async function pkgChangelogAction( } const output = formatPackageChangelogTerminal(payload, { - verbose: options.verbose ?? false, useColors: shouldUseColors(), terminalWidth: process.stdout.columns, }); @@ -180,14 +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 and classification entirely. Classified statements default to all; ---changes filters them without filtering note previews. +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: @@ -211,17 +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 note bodies and skip classification (terminal + JSON)", + "Show only the version/date/URL timeline; skip parsing and classification", ) .option( "--changes ", - "Classified statements: actionable, important (actionable + should know), all (default); ignored with --no-body", + "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 d20ed177..d37d1db2 100644 --- a/src/services/test-helpers.ts +++ b/src/services/test-helpers.ts @@ -822,19 +822,37 @@ export const cratesFeatureDependencyReport: DependencyReport = { * markdown, ISO date, and a normalisedVersion that equals the raw. */ export const defaultChangelogReport: ChangelogReport = { - riskItems: [], - riskCoverage: { - versionsClassified: 2, - versionsNotAssessed: 0, - versionsWithoutNotes: 0, - versionsUnparseable: 0, - unitsNoImpact: 2, - itemsMustActConfident: 0, - itemsMustActAmbiguous: 0, - itemsShouldKnowConfident: 0, - itemsShouldKnowAmbiguous: 0, - itemsUnclassified: 0, - itemsOmitted: 0, + 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", diff --git a/src/tools/package-changelog-parity.test.ts b/src/tools/package-changelog-parity.test.ts index 40dfa139..7d153a66 100644 --- a/src/tools/package-changelog-parity.test.ts +++ b/src/tools/package-changelog-parity.test.ts @@ -76,6 +76,8 @@ interface McpArgs { target: string; limit?: number; omit_bodies?: boolean; + verbatim?: boolean; + verbose?: boolean; } async function mcpJson( @@ -210,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", @@ -226,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 () => { @@ -400,40 +402,15 @@ describe("package_changelog parity", () => { }); }); -// The backend supplies filtered items; neither host filters a capped result locally. -describe("classification filter 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} preserves all-tier coverage and per-item evidence`, async () => { - const report: ChangelogReport = { - ...defaultChangelogReport, - riskItems: [ - { - version: "5.2.1", - tier: "MUST_ACT", - ambiguous: true, - tierConfidence: 0.95, - text: "Possible default change.", - textTruncated: false, - fullText: "Possible default change.", - source: "RELEASES", - url: "https://example.com/v5.2.1", - model: "jev-1.13.0", - formulation: "s-hier-v5", - }, - ], - riskCoverage: { - ...defaultChangelogReport.riskCoverage!, - itemsMustActAmbiguous: 1, - itemsShouldKnowConfident: 13, - itemsOmitted: 2, - }, - }; - const fn = mock(() => Promise.resolve(report)); + it(`${target} ${changes}: JSON evidence and wire request match`, async () => { + const fn = mock(() => Promise.resolve(defaultChangelogReport)); const cli = await cliJson( target, { changes }, @@ -445,127 +422,71 @@ describe("classification filter parity", () => { ); const mcp = await mcpJson({ target, changes }, fn); expect(cli).toEqual(mcp.json); - expect(cli).toMatchObject({ - filter: { changes }, - riskItems: [ - { tier: "must_act", ambiguous: true, formulation: "s-hier-v5" }, - ], - riskCoverage: { itemsShouldKnowConfident: 13, itemsOmitted: 2 }, - }); + 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]?.riskTiers).toEqual( + expect(calls[0]?.[0]?.statementTiers).toEqual( changes === "actionable" ? ["MUST_ACT"] : changes === "important" ? ["MUST_ACT", "SHOULD_KNOW"] : undefined, ); - expect(calls[0]?.[0]?.includeRiskJsonFields).toBe(true); + expect(calls[0]?.[0]?.includeJsonFields).toBe(true); }); } } - it("ignores changes and skips risk fields in both timeline JSON surfaces", async () => { - const cli = await cliJson("npm:express", { - changes: "ignored", - body: false, - }); - const mcp = await mcpJson({ - target: "npm:express", - changes: "ignored", - omit_bodies: 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"); }); - expect(cli).toEqual(mcp.json); - expect(cli).not.toHaveProperty("riskItems"); - expect(cli).not.toHaveProperty("riskCoverage"); - expect(cli).not.toHaveProperty("filter.changes"); - }); - it("maps invalid active changes identically before service access", async () => { - const fn = mock(() => Promise.resolve(defaultChangelogReport)); - const cli = await cliJson( - "npm:express", - { changes: "safe" }, - cliDeps({ - packageIntelligenceService: createMockPackageIntelligenceService({ - packageChangelog: fn, - }), - }), - ); - const mcp = await mcpJson({ target: "npm:express", changes: "safe" }, fn); - expect(cli).toEqual(mcp.json); - expect(cli).toMatchObject({ code: "INVALID_ARGUMENT" }); - expect(fn).not.toHaveBeenCalled(); - }); -}); - -describe("classified changelog text parity", () => { - for (const changes of ["actionable", "important", "all"]) { - it(`shares the same version-grouped evidence for ${changes}`, async () => { - const report: ChangelogReport = { - ...defaultChangelogReport, - entries: [ - { - version: "5.2.1", - body: "Original short release note.", - htmlUrl: "https://example.com/v5.2.1", - }, - ], - riskItems: [ - { - version: "5.2.1", - tier: "MUST_ACT", - ambiguous: true, - text: "Possible behavior change.", - textTruncated: false, - source: "RELEASES", - url: "https://example.com/v5.2.1", - model: "jev", - formulation: "s-hier-v5", - }, - ], - riskCoverage: { - ...defaultChangelogReport.riskCoverage!, - itemsMustActAmbiguous: 1, - }, - }; - const fn = mock(() => Promise.resolve(report)); - 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@5.2.1", - { changes }, - cliDeps({ - packageIntelligenceService: createMockPackageIntelligenceService({ - packageChangelog: fn, - }), - }), - ); - } finally { - writeSpy.mockRestore(); - } - const tool = createParityMcpTool("pkg_changelog", { - packageIntelligenceService: createMockPackageIntelligenceService({ - packageChangelog: fn, - }), + 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, }); - const result = await tool.handler( - { target: "npm:express@5.2.1", changes }, - {}, - ); - expect(result.content[0]?.text).toBe( - Bun.stripANSI(writes.join("")).trimEnd(), - ); - expect(result.content[0]?.text).toContain("(uncertain)"); + 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(), + ); + }); }); From 2098e586e78c4e555c4301d2ed0a3d651d6971b2 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Wed, 14 Oct 2026 10:56:35 +0300 Subject: [PATCH 08/14] fix: wrap nested changelog prose and clarify predecessor context Measure indented code relative to the statement list marker, preserving real code while wrapping continuation paragraphs and nested bullets. Label context as belonging to the following statement and add an Express-shaped regression case with rebuilt live text parity evidence. --- docs/implementation/pkg-changelog.md | 21 +++++++++------ docs/plans/changelog-statement-view.md | 27 +++++++++++++++++-- .../shared/package-changelog-response.test.ts | 20 ++++++++++++++ .../src/shared/package-changelog-response.ts | 24 ++++++++++++----- 4 files changed, 75 insertions(+), 17 deletions(-) diff --git a/docs/implementation/pkg-changelog.md b/docs/implementation/pkg-changelog.md index 28342cd6..35c2155a 100644 --- a/docs/implementation/pkg-changelog.md +++ b/docs/implementation/pkg-changelog.md @@ -147,13 +147,16 @@ Old built preview+risk text versus new all-statements bytes: | Selection | Before bytes | After bytes | New built text time | | --- | --- | --- | --- | -| Express latest10 | 20,039 | 56,782 | 3.061s | -| Express4.20.0 | 2,501 | 6,439 | 1.481s | -| Express4.18.2..5.1.0 | 16,861 | 40,562 | 3.062s | -| Biome2.4.2..2.4.15 | 39,824 | 259,575 | 10.672s | - -Biome repeat took10.218s with identical output bytes; its JSON was772,829 bytes -and took11.570s. Times include CLI startup/auth and all required page requests. +| 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 @@ -161,7 +164,9 @@ 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. Build and typecheck passed. Source unauthenticated +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. Full live smoke harnesses could not use macOS keychain authentication under their isolated HOME (system-keychain-unavailable), without exporting credentials; the affected diff --git a/docs/plans/changelog-statement-view.md b/docs/plans/changelog-statement-view.md index a720e153..d1846368 100644 --- a/docs/plans/changelog-statement-view.md +++ b/docs/plans/changelog-statement-view.md @@ -212,5 +212,28 @@ Full source live smoke harnesses fail their auth probe because isolated HOME cannot use this system keychain. No credentials copied/exported. Run required source unauthenticated/registration modes and built Node modes; authenticated affected-feature matrix above replaces full live harness proof for this task. -Internal complete code preflight direction sound, no findings; final gates and -external code review pending. Plan stays until clean external review. +Internal complete code preflight direction sound, no findings; final gates pass; external code review in progress. Plan stays until clean external review. + +## External implementation round1 closure + +Direction sound. F1 accepted: blanket four-space detection confused nested prose +with code. Renderer now measures code indentation relative to the first bullet's +content indent and excludes nested list markers; prose retains its hanging indent. +Bounded sibling scan: statement/context renderer (one shared helper), original +notes (explicit verbatim remains unchanged), width/code/Unicode tests and live +exact/latest/range evidence. New Express-style case covers long four-space prose +and nested bullets plus genuinely indented code. F2 accepted: context label now +explicitly says Context for next statement, including heading variant. Checked +context truncation hints, heading state and source groups; provenance unchanged. +No policy, wire or field changes; no complexity/JSON rerun required. New text bytes +and representative live text output remeasured, with permanent docs updated. +Round2 reviews the full revised increment. + +Round1 closure evidence:162affected tests/992assertions pass; rebuilt/typechecked +formatter and changed-file Biome pass; all4 source/built unauth smoke modes pass. +Authenticated rebuilt CLI/local MCP text parity passes all4 representative views; +final bytes latest57,029/exact6,482/range40,760/Biome260,050, text times +5.907s/2.174s/3.030s/14.853s under concurrent validation. No speedup claim. +Wire/JSON/classification policy unchanged, prior85/88/113complexity proof applies. +Standalone public package validation also passed. Internal full revised closure +clean, no findings. Prior d8a225d CI all tests/build/compat/package checks green. diff --git a/packages/mcp/src/shared/package-changelog-response.test.ts b/packages/mcp/src/shared/package-changelog-response.test.ts index 6edf04c5..f74fdaf2 100644 --- a/packages/mcp/src/shared/package-changelog-response.test.ts +++ b/packages/mcp/src/shared/package-changelog-response.test.ts @@ -436,6 +436,26 @@ describe("complete statement evidence view", () => { expect(text).toContain("Injected"); expect(p.statements?.items[0]?.text).toContain("\x1b"); }); + 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(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("preserves backend Unicode while formatter punctuation stays ASCII", () => { const report = evidence(); report.statements!.items[0]!.text = "* α — 保留 …"; diff --git a/packages/mcp/src/shared/package-changelog-response.ts b/packages/mcp/src/shared/package-changelog-response.ts index 08bb987a..94b9fc5e 100644 --- a/packages/mcp/src/shared/package-changelog-response.ts +++ b/packages/mcp/src/shared/package-changelog-response.ts @@ -326,10 +326,10 @@ export function formatPackageChangelogTerminal( if (item.context) { if (item.context.heading && item.context.heading !== heading) prose( - `Context - ${sanitizeTerminalText(item.context.heading)}`, + `Context for next statement - ${sanitizeTerminalText(item.context.heading)}`, " ", ); - else prose("Context", " "); + else prose("Context for next statement", " "); renderStatementText(lines, item.context.text, " ", width); if (item.context.textTruncated) prose( @@ -396,6 +396,8 @@ function renderStatementText( 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, " ")); @@ -405,13 +407,21 @@ function renderStatementText( lines.push(""); return; } - const indent = - index === 0 ? prefix : ` ${safe.match(/^ */)?.[0] ?? ""}`; + const leading = safe.match(/^ */)?.[0] ?? ""; + const indent = index === 0 ? prefix : ` ${leading}`; const value = index === 0 ? content : content.trimStart(); const isFence = value.startsWith("```") || value.startsWith("~~~"); - if (fenced || isFence || (index > 0 && /^ {4}/.test(safe))) - lines.push(`${indent}${value}`); - else lines.push(...wrapTerminalProse(`${indent}${value}`, width, " ")); + 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; }); } From f76afd32598bb501b0fe59d9c389a1c04ed9d76d Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Wed, 14 Oct 2026 11:02:34 +0300 Subject: [PATCH 09/14] docs: retain changelog validation and remove completed plan Record the clean external review and CI evidence in permanent implementation documentation. Remove the completed working plan now that its contract, compatibility decisions and measurements are documented. --- docs/implementation/pkg-changelog.md | 7 +- docs/plans/changelog-statement-view.md | 239 ------------------------- 2 files changed, 5 insertions(+), 241 deletions(-) delete mode 100644 docs/plans/changelog-statement-view.md diff --git a/docs/implementation/pkg-changelog.md b/docs/implementation/pkg-changelog.md index 35c2155a..6edc2d39 100644 --- a/docs/implementation/pkg-changelog.md +++ b/docs/implementation/pkg-changelog.md @@ -22,7 +22,7 @@ previous version. Statement joins use raw `entry.version`, not normalizedVersion | `--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, parsing or classification | +| `--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, @@ -167,7 +167,10 @@ Focused core/shared/CLI/MCP/parity/server/smoke, smoke-script and upgrade regres 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. Full live +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. diff --git a/docs/plans/changelog-statement-view.md b/docs/plans/changelog-statement-view.md deleted file mode 100644 index d1846368..00000000 --- a/docs/plans/changelog-statement-view.md +++ /dev/null @@ -1,239 +0,0 @@ -# Complete changelog statement view - -Status: CODE REVIEW READY — backend complete, owner approved UI route 2026-10-14. - -## Outcome, scope and decisions - -Update existing draft CLI/MCP PR #476 so default changelogs show every selected -parsed statement once, with classification and nearby sources. Remove raw-note -previews from the default. All parsed units include model NO_IMPACT and absent -answers. Preserve source order, nested wording, headings and predecessor context. -Classification remains per statement; no package risk verdict or client threshold. - -Owner approved: all default; --changes actionable|important|all; --verbatim shows -original notes instead of bullets; --verbose is only a deprecated alias for that -view; JSON carries structured evidence/provenance. Preserve --no-body / MCP -omit_bodies as timeline-only. Raw/timeline modes ignore changes, including echo -and validation. Conflicting timeline and explicit original-note switches fail -before service access. MCP verbatim mirrors CLI; explicit false alone preserves default; explicit body_lines still requests its legacy preview view. - -MCP body_lines is existing released behavior. Retain as a deprecated original-note -preview request: it both selects and caps that view, avoiding a flag that silently -does nothing or requires a second optional switch. Explicit verbatim/verbose show -full notes, ignoring body_lines; omit_bodies wins over body_lines alone. JSON ignores -this text-only legacy preview knob. No other verbose behavior remains. This is an -implementation compatibility choice grounded in existing calls/tests, not a new -product mode. No open product decisions. - -Non-goals: upgrade-review redesign, backend/remote MCP edits, deployment/release, -client parsing/deduplication, new storage/cache/jobs or model policy. No merge. - -## Verified evidence and assumptions - -- Backend #3228 merged and deployed dev/production; exact/latest/range expose - statements(first:1..100 default100, offset>=0 default0, tiers nullable). -- Complete schema/contract: ~/proj/githits/pkgseer-backend/priv/graphql and - /tmp/pkg-changelog-statements-backend-contract.md. Deprecated changelog riskItems - remain functional; upgrade-review risk API unchanged. -- Page items: raw version, position, sourceIndex/sourcePosition, heading, source/url, - text/textTruncated, fullText, context and nullable classification. Tier includes - MUST_ACT, SHOULD_KNOW, NO_IMPACT, oversize-only UNCLASSIFIED. ambiguous is authoritative; - nullable classification is actual missing evidence, not a no-impact answer. -- Page counts and nextOffset support complete retrieval; coverage per version has - status and total/assessed/not-assessed/no-impact/oversize counts. Nonempty tier - filters exclude absent labels but coverage remains complete across selected releases. -- Meaningful source representative/deduplication and predecessor context belong to - backend; retain its returned evidence, never infer equivalence locally. -- Authenticated backend dev verification:271 units in100/100/71 pages, stable coverage. - Corpus maxima141/release and875/range justify pagination. Final backend maximal - complexity exact103/latest50=101/range126, not yet final client measurements. -- Offset pages are current evidence, not immutable snapshots. In all mode, compare only - parse identity (versions, unit totals and zero-unit note status), page counts and - contiguous raw version/position keys; classification arrivals do not move offsets. - Derive per-release assessment counts/status from the items actually collected, - preserving WITHOUT_NOTES/UNPARSEABLE for zero units. Do not reject new labels in - this mode. Filtered mode compares full assessment coverage/matching counts/order - and returns an explicit classification-in-progress rerun message if they change. - No retry/polling infrastructure. Same-count source edits are not reliably detectable - from this API; do not claim snapshot consistency. This honors the delivered contract. -- Existing service executes queries and validates DTOs; shared request/envelope/text - live in packages/mcp/src/shared; CLI and MCP are thin callers. Existing request, - response/parity/server tests pass, and exact Express4.20.0 dev baseline captured - with normal CLI authentication under /tmp/changelog-statements-baseline-*.log. - -## Ownership and next increment - -Backend owns extraction/source identity/labels. Core service owns page collection -and protocol validation so CLI/MCP cannot diverge. Shared request builder owns -view/flag resolution; per-tool shared formatter owns source/heading grouping and -sanitation. No new module unless it reduces existing complexity; source-page types -and query projection belong with package-intelligence-service. - -1. Backend contract (COMPLETE): all parsed statements and optional stored answers - are retrievable. Evidence above and390 focused backend cases; dependencies and - product unknowns:none. No backend edits in this increment. -2. Client statement view (CODE REVIEW READY): default statements, explicit original notes - and timeline, complete paged evidence with local sources. Depends on deployed - contract (met); assumptions above; unknown product decisions:none. Expected - outcome: CLI/MCP offer the same views/data, query only consumed fields, and show - pending/missing/oversize coverage honestly. Update #476; do not merge. - -Implementation order: -- Add neutral typed statement/classification/context/coverage/page DTOs and Zod - validation. Preserve classification:null. Replace deprecated changelog risk - selections; leave upgrade-review schemas/helper behavior intact. -- Core request controls express the selected view plus JSON detail selection and - tier filter. Fetch100 per page only in statement mode; first request includes - timeline presentation data, subsequent requests omit bodies/package/date fields. - Use raw version/position to validate ordered, nonduplicate statement keys. Collect - until exhausted, checking forward nextOffset/counts/parse identity (full assessment coverage for - filtered mode) and selected field - presence. No invented default classifications or old-schema fallback. -- Shared request builder resolves flags and filters before access. Update CLI - Commander options and MCP descriptors without changing discovery first sentence. -- Replace duplicate preview formatter with one source-ordered bullet formatter. - Labels identify action/should-know/no impact identified/too-large/not-assessed plus optional kind - and uncertainty. Ordinary statements are not locally excerpted; backend1000-char - cuts are explicit. Preserve nested lines/code and context. Source URL appears at - its version/source block; embedded note links stay inline, no global Sources table. - Original-note/timeline views use existing entry metadata and sanitation. - Filtered views explicitly report excluded not-assessed and oversize counts, - including when no statements match; JSON retains these in coverage. -- JSON keeps existing entries envelope and adds a complete statements block with - items/count/totalCount/coverage. Enum casing follows existing lower-snake conventions; - provenance/fullText retained only in statement JSON. No raw bodies there by default. -- Update mock fixtures/tests, smoke structural assertions, server catalog keys, - command/tool/implementation docs and release fragment. Public skill changes stay - staged for release per repo release policy; no plugin generation unless input changes. - -## Wire projections and efficiency acceptance - -| View | Fields required | -| --- | --- | -| Statement text | first-page timeline identity/date/URL; every page bounded statement text, version/position/sourceIndex, heading/source/URL, context text/heading/cut, classification tier/ambiguous/kind, page controls + coverage version/total/status; assessment counts derived from all collected items (filtered mode selects backend counts) | -| Statement JSON | above plus normalized entry version, sourcePosition, fullText/context fullText, confidences and model/formulation; no entry bodies | -| Verbatim text/JSON, verbose alias | timeline + raw entry.body; no statements, risk fields or classification jobs; no statement provenance/fullText | -| Timeline/no-body | version/date/URL (+ JSON normalized version); no raw body or statements/classification | -| Subsequent statement page | no package echo, source summary, dates, normalized timeline versions or bodies; page items match text/JSON projection, mode-specific coverage and page controls; no timeline inventory | - -Conditional fields must have tests for actual wire variables/selections in every -mode and flag combination. Queries remain one call for timeline/verbatim or <=100 -statements; additional calls only for required pages. Final maximal actual documents -must stay below500; no performance speedup is claimed without measured evidence. - -## Verification, completion and cleanup - -Test parsed no-impact, absent classifications, oversize, missing/unparseable/empty -notes, source order/raw joins/context/dedup preserved, mixed formulations, >100 -retrieval for both timeline and an exact release above100, labels arriving between -pages in all and filtered modes, filtered pages, coverage/count changes, malformed/nonprogressing offsets and duplicate/order keys, -filtered text exclusions for pending and oversize statements even with zero matches, -query projections and option conflicts/legacy aliases (including verbatim:false -with body_lines), multiline/code sanitation, -width and lossless JSON. Use bun test focused core/shared/CLI/MCP/parity/server files -and upgrade formatter regressions. Build/typecheck/Biome/diff check; source and -built CLI/MCP smoke suites. Authenticated dev CLI/local MCP exactExpress4.20.0, -Express4.18.2..5.1.0 (>100), Biome2.4.2..2.4.15, all3filters, verbatim/verbose alias, -timeline and JSON/text parity. Inspect targeted exact-release agent trace/final/ -metrics/isolation artifacts; no graded quality claim. Measure actual text bytes -before/after for default latest Express, exact Express4.20.0 and its271-statement -range and Biome2.4.2..2.4.15; measure built-client page count/wall time for Biome. -Public API does not expose whether labels were already stored; report observed -coverage and timing without inventing a cold claim. Read credential guidance, -use normal auth, never handle or expose tokens. No local broad backend tests. - -Review this plan internally then one external reviewer; implementation gets its -own review rounds (one external per round). Scope is this redesigned increment; -prior #476 implementation review evidence applies only to unchanged areas. Fix -accepted findings with bounded sibling scans. Keep plan through review, transfer -permanent semantics/measurements to docs, then delete in final clean-review commit. -Push/update draft #476 and complete CI. No merge, deployment or release. - -Internal plan review: direction sound. Accepted projection mismatch: page items -are disjoint and alternate source inventory is not available, so checks now use -comparable coverage/counts/progress and ordering keys, with no snapshot guarantee. -Accepted explicit NO_IMPACT presentation: [no impact identified], a per-statement -model answer rather than a package verdict. Baseline127pass/802assertions and -authenticated current dev exact output pass. External plan round2 clean after -three minor wording fixes: explicit filtered-exclusion acceptance, Biome built-client -bytes/timing acceptance, and this completed review status. - -## Compatibility and external round1 closure - -Approved original-note-only explicit view applies equally to text and JSON flags: -default JSON now omits entries.body and carries parsed statement evidence; callers -needing bodies use --verbatim --json / verbatim:true,format:json. Keeping unused -raw bodies by default would contradict the owner's separate-copy flag and minimal -fetching direction. No repeat approval is needed for this already approved route. -Other intentional released behavior changes: default text drops previews; MCP -verbose selects original-note view in JSON too; verbose+omit_bodies now errors like -its verbatim alias; body_lines is a legacy preview request; explicit false by itself -does not select a view, but a present body_lines does. Rewrite the unmerged fragment -as changed with minor impact for both0.x artifacts, explicitly noting JSON migration. - -External plan round1: direction sound. Accepted cold all-mode pagination correction -(derive assessment from collected items; check parse identity, not arriving labels), -explicit filtered exclusions, false/legacy precedence, exact paging case and measured -latency/size evidence. Compatibility findings are documented, including JSON removal -as part of the approved route; repeat owner approval request rejected on that evidence. -Measurement and internal closure precede external round2. - -Live latency planning evidence (2026-10-14): npm:eslint@8.0.0..8.10.0 has162 -units across12 releases. First complete two-page retrieval took6.426s, repeat -3.902s, including a separate normal-auth helper invocation per page; this is not -pure server latency or a client speedup claim. Both responses were fully assessed -at return, so the public API cannot prove they were initially cold. Quote text -alone totals27,900 UTF-8 bytes. Evidence /tmp/changelog-statement-probe-measurements.json. -The cold/arriving-label case will be exercised deterministically in core paging -tests; backend's6s per-parent budget still makes9-page worst-case54s plus transport -an estimate, not a measured timeout. Final built-client timings/bytes remain acceptance. - -## Implementation evidence - -Neutral statement DTOs and projection builder implemented in core; host view -resolution/formatter shared. Exact text statement queries omit duplicate entry -version, aggregate source and hasChangelog; JSON adds them where consumed. -Follow-ups omit all first-page metadata. Coverage must match the selected raw -version timeline, preserving joins and preventing hidden evidence. - -567 focused tests/2,855 assertions pass; build/typecheck and changed-file Biome -pass. Authenticated built Node CLI/local MCP complete matrix passes: exact40, -latest335, range271, Biome875 statements; actionable/important6/8,30/43,40/62. -All selected units assessed; no pending/oversize/unparseable live, fixtures cover -those plus arriving-label behavior. Missing-note is-even has one WITHOUT_NOTES -version. Complexity85exact/88latest50/113range, dev admission probes with510control. -Built all text Biome9pages10.672s, repeat10.218s;259,575bytes vs39,824 old preview -bytes. This is complete evidence rather than a speedup claim; no cold claim. -Other counts/timings/bytes moved to permanent pkg-changelog.md. - -Targeted Codex descriptor-intent workload succeeds4calls25.154s, uses default -statements then verbatim:true; no isolation violations, no quality grade. -Full source live smoke harnesses fail their auth probe because isolated HOME -cannot use this system keychain. No credentials copied/exported. Run required -source unauthenticated/registration modes and built Node modes; authenticated -affected-feature matrix above replaces full live harness proof for this task. -Internal complete code preflight direction sound, no findings; final gates pass; external code review in progress. Plan stays until clean external review. - -## External implementation round1 closure - -Direction sound. F1 accepted: blanket four-space detection confused nested prose -with code. Renderer now measures code indentation relative to the first bullet's -content indent and excludes nested list markers; prose retains its hanging indent. -Bounded sibling scan: statement/context renderer (one shared helper), original -notes (explicit verbatim remains unchanged), width/code/Unicode tests and live -exact/latest/range evidence. New Express-style case covers long four-space prose -and nested bullets plus genuinely indented code. F2 accepted: context label now -explicitly says Context for next statement, including heading variant. Checked -context truncation hints, heading state and source groups; provenance unchanged. -No policy, wire or field changes; no complexity/JSON rerun required. New text bytes -and representative live text output remeasured, with permanent docs updated. -Round2 reviews the full revised increment. - -Round1 closure evidence:162affected tests/992assertions pass; rebuilt/typechecked -formatter and changed-file Biome pass; all4 source/built unauth smoke modes pass. -Authenticated rebuilt CLI/local MCP text parity passes all4 representative views; -final bytes latest57,029/exact6,482/range40,760/Biome260,050, text times -5.907s/2.174s/3.030s/14.853s under concurrent validation. No speedup claim. -Wire/JSON/classification policy unchanged, prior85/88/113complexity proof applies. -Standalone public package validation also passed. Internal full revised closure -clean, no findings. Prior d8a225d CI all tests/build/compat/package checks green. From 28f42f718b24c117f00865e115e0d04bbdb39ee9 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Wed, 14 Oct 2026 12:02:42 +0300 Subject: [PATCH 10/14] fix: compact changelog classification tags Use kind-only labels with action and information markers, and leave no-impact statements as plain bullets. Preserve uncertainty, assessment gaps and JSON evidence in the shared CLI and MCP formatter. --- ...changelog-classified-statements.changed.md | 2 +- docs/implementation/pkg-changelog.md | 16 ++-- docs/plans/changelog-compact-tags.md | 37 +++++++++ .../shared/package-changelog-response.test.ts | 66 +++++++++++++--- .../src/shared/package-changelog-response.ts | 75 +++++++++++-------- src/commands/pkg/changelog.test.ts | 3 +- 6 files changed, 151 insertions(+), 48 deletions(-) create mode 100644 docs/plans/changelog-compact-tags.md diff --git a/changes/changelog-classified-statements.changed.md b/changes/changelog-classified-statements.changed.md index 2de2cc71..1a7d2fa0 100644 --- a/changes/changelog-classified-statements.changed.md +++ b/changes/changelog-classified-statements.changed.md @@ -3,4 +3,4 @@ "@githits/mcp": minor --- -- **Complete changelog statements** - Package changelogs now default to all parsed bullets with available agent classifications, uncertainty, coverage and nearby sources. `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. +- **Complete changelog statements** - Package changelogs now default to all parsed bullets with available agent classifications, compact kind tags, action/should-know 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/pkg-changelog.md b/docs/implementation/pkg-changelog.md index 6edc2d39..ec3d24f2 100644 --- a/docs/implementation/pkg-changelog.md +++ b/docs/implementation/pkg-changelog.md @@ -37,12 +37,16 @@ verbose classification-detail mode or implicit default note preview. ## Evidence, coverage and sources -Each bullet identifies its tier, any kind, and uncertainty. NO_IMPACT reads -`[no impact identified]`, a model statement assessment rather than a guarantee. -A null classification reads `[not assessed]`; UNCLASSIFIED means only too large -to classify. A missing kind retains its tier. Uncertainty comes only from the -backend `ambiguous` boolean, never a client confidence threshold. Model and -formulation can differ per item and remain machine-readable provenance. +Known impact kinds use compact tags such as `[behavior]` or `[deprecation]`. +`!` marks MUST_ACT and `i` marks SHOULD_KNOW, with a short legend shown once +when either occurs. Markers keep meaning visible without color and survive a +missing kind; red/yellow highlighting is optional. NO_IMPACT uses plain bullets. +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 diff --git a/docs/plans/changelog-compact-tags.md b/docs/plans/changelog-compact-tags.md new file mode 100644 index 00000000..2dee380a --- /dev/null +++ b/docs/plans/changelog-compact-tags.md @@ -0,0 +1,37 @@ +# Compact changelog tags + +Status: READY FOR EXTERNAL REVIEW. Owner follow-up to open draft PR476, base f76afd3. + +Remove the repetitive no-impact tag and tier words inside impact tags. The existing +shared changelog formatter owns presentation; no new module or backend change. +Keep JSON, GraphQL projections, filters, paging and upgrade review unchanged. + +Use plain bullets for NO_IMPACT. Known impact kinds use [behavior]/[deprecation] +labels, with ! for MUST_ACT and i for SHOULD_KNOW. One short legend when either +tier appears keeps meaning visible without color, especially for kindless items. +Red/yellow remain optional highlighting; uncertain prefixes dim and retain the +explicit (uncertain) marker from backend ambiguous. Unknown and oversized +statements keep explicit assessment status; no client confidence policy. + +Verified: existing formatter prepends [tier: kind] and [no impact identified]; CLI +and MCP share it. Those words are text-only; no query/JSON change is needed. +Tests cover untagged no-impact (including uncertainty and preserved JSON), kindless +severity, known kind/color equivalence, assessment gaps and existing nested code. +Focused tests started red before implementation. Baseline built output artifacts +from the reviewed PR are available for exactExpress4.20.0 and Biome2.4.2..2.4.15. + +Implement the compact prefix in place, update durable docs and existing unreleased +fragment, run focused tests/build/typecheck and source/built credential-free smokes. +Use normal dev CLI/local MCP auth for representative text parity and byte counts; +query complexity remains85/88/113 since no selections change. Run the targeted +exact-release descriptor-intent workload because text severity cues changed. +Review internally then on retained Opus reviewer (owner changes before merge), +commit/push/update PR476 and complete CI; no merge/release. After clean review, +transfer validation to implementation docs and delete this completed plan. + +Validation: 165 focused tests / 1,011 assertions pass; build, typecheck, Biome +and four source/built credential-free smoke modes pass. Authenticated dev built +CLI/local MCP text parity passes for Express4.20.0 and5.2.1; exact output is +5,658 bytes versus6,482 before tag shortening. Targeted descriptor-intent eval +passes, three logical calls in27.5s, inspected rollback/CVE evidence, no isolation +violations; no quality grade. Internal code reviewer: direction sound, no findings. diff --git a/packages/mcp/src/shared/package-changelog-response.test.ts b/packages/mcp/src/shared/package-changelog-response.test.ts index f74fdaf2..ed1db364 100644 --- a/packages/mcp/src/shared/package-changelog-response.test.ts +++ b/packages/mcp/src/shared/package-changelog-response.test.ts @@ -343,14 +343,15 @@ describe("complete statement evidence view", () => { expect(text.indexOf("https://example.com/history#L1")).toBeLessThan( text.indexOf("Quoted statement 0"), ); - for (const label of [ - "requires action: behavior", - "should know", - "no impact identified", - "too long to classify - read it", - "not assessed", - ]) - expect(text).toContain(`[${label}]`); + expect(text).toContain("! requires action | i should know"); + expect(text).toContain("* ! [behavior] (uncertain) Quoted statement 0"); + expect(text).toContain("* i (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("[should know"); expect(text.match(/\(uncertain\)/g)).toHaveLength(2); expect(text).toContain("nested wording"); expect(text).toContain("foo( 1)"); @@ -360,6 +361,51 @@ describe("complete statement evidence view", () => { expect(text).not.toContain("jev"); expect(text).not.toContain("Full uncut statement"); }); + 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, + }); + expect(text).toContain("* (uncertain) Quoted statement 2"); + expect(text).not.toContain("[no impact identified]"); + expect(text).not.toContain("[notable change]"); + expect(text).not.toContain("! requires action | i should know"); + expect(payload(report).statements?.items[0]?.classification).toMatchObject({ + tier: "no_impact", + kind: "notable_change", + ambiguous: true, + }); + }); + 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(text).toContain("* ! Quoted statement 0"); + expect(text).toContain("* i (uncertain) Quoted statement 1"); + expect(text).not.toContain("[requires action]"); + expect(text).not.toContain("[should know]"); + }); + 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[33mi [deprecation]"); + expect(plain).toContain("* i [deprecation] Quoted statement 1"); + expect(Bun.stripANSI(colored)).toBe(plain); + }); it("preserves structured evidence and null classification without mutating the service result", () => { const report = evidence(); const p = payload(report); @@ -463,7 +509,7 @@ describe("complete statement evidence view", () => { useColors: false, }); expect(text).toContain("α — 保留 …"); - expect(text).toContain("[requires action: behavior]"); + expect(text).toContain("! [behavior]"); }); it("wraps prose to caller width without truncating ordinary statements", () => { const report = evidence(); @@ -484,7 +530,7 @@ describe("complete statement evidence view", () => { useColors: true, }); expect(Bun.stripANSI(colored)).toBe(plain); - expect(colored).toContain("\x1b[2m[requires action: behavior]"); + expect(colored).toContain("\x1b[2m! [behavior]"); }); it("original-note view shows all lines unless the deprecated explicit cap is present", () => { const report = { diff --git a/packages/mcp/src/shared/package-changelog-response.ts b/packages/mcp/src/shared/package-changelog-response.ts index 94b9fc5e..dd7050b5 100644 --- a/packages/mcp/src/shared/package-changelog-response.ts +++ b/packages/mcp/src/shared/package-changelog-response.ts @@ -252,6 +252,14 @@ export function formatPackageChangelogTerminal( `${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", + ) + ) + prose("! requires action | i should know", " "); 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") @@ -339,45 +347,52 @@ export function formatPackageChangelogTerminal( } const classification = item.classification; const tier = classification?.tier; - const tierLabel = - tier === "must_act" - ? "requires action" - : tier === "should_know" - ? "should know" - : tier === "no_impact" - ? "no impact identified" - : tier === "unclassified" - ? "too long to classify - read it" - : "not assessed"; + const marker = + tier === "must_act" ? "!" : tier === "should_know" ? "i" : undefined; const kind = classification?.kind ? RISK_KIND_LABELS[classification.kind as keyof typeof RISK_KIND_LABELS] : undefined; - const label = `[${tierLabel}${kind ? `: ${kind}` : ""}]`; - const muted = - classification?.ambiguous || tier === "no_impact" || !classification; - const coloredLabel = muted - ? dim(label, options.useColors) - : colorize( - label, - tier === "must_act" - ? "red" - : tier === "should_know" - ? "yellow" - : "cyan", - options.useColors, - ); + const label = + tier === "no_impact" + ? undefined + : tier === "unclassified" + ? "[too long to classify - read it]" + : !classification + ? "[not assessed]" + : kind + ? `[${kind}]` + : undefined; + const annotation = [marker, label].filter(Boolean).join(" "); const start = lines.length; renderStatementText( lines, item.text, - ` * ${label}${classification?.ambiguous ? " (uncertain)" : ""} `, + ` * ${annotation ? `${annotation} ` : ""}${classification?.ambiguous ? "(uncertain) " : ""}`, width, ); - // Apply trusted styling after sanitation and wrapping; it never changes meaning. - if (lines[start]) - lines[start] = lines[start] - .replace(label, coloredLabel) - .replace("(uncertain)", dim("(uncertain)", options.useColors)); + // Style only the trusted prefix after sanitation and wrapping. Missing + // kinds leave the marker intact; an empty annotation 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"}.`, diff --git a/src/commands/pkg/changelog.test.ts b/src/commands/pkg/changelog.test.ts index 6d661a37..9faac44c 100644 --- a/src/commands/pkg/changelog.test.ts +++ b/src/commands/pkg/changelog.test.ts @@ -71,7 +71,8 @@ describe("pkgChangelogAction", () => { expect(combined).toContain("5.2.1"); // The default retains statement wording without raw note duplication. expect(combined).not.toContain("## Patch"); - expect(combined).toContain("[no impact identified] fixed a thing"); + expect(combined).toContain("* fixed a thing"); + expect(combined).not.toContain("[no impact identified]"); expect(combined).not.toContain("use --verbose"); writeSpy.mockRestore(); }); From 8b16f2a119d2ecebcc6fcb66e6d89875b55c92a7 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Wed, 14 Oct 2026 12:07:01 +0300 Subject: [PATCH 11/14] fix: explain plain changelog bullets once Clarify the compact legend for no-impact assessments, including uncertain and no-impact-only selections. Record verification in permanent docs and remove the completed follow-up plan after closing the wording finding. --- docs/implementation/pkg-changelog.md | 16 +++++++- docs/plans/changelog-compact-tags.md | 37 ------------------- .../shared/package-changelog-response.test.ts | 4 +- .../src/shared/package-changelog-response.ts | 8 +++- 4 files changed, 24 insertions(+), 41 deletions(-) delete mode 100644 docs/plans/changelog-compact-tags.md diff --git a/docs/implementation/pkg-changelog.md b/docs/implementation/pkg-changelog.md index ec3d24f2..eaa85a17 100644 --- a/docs/implementation/pkg-changelog.md +++ b/docs/implementation/pkg-changelog.md @@ -39,7 +39,8 @@ verbose classification-detail mode or implicit default note preview. Known impact kinds use compact tags such as `[behavior]` or `[deprecation]`. `!` marks MUST_ACT and `i` marks SHOULD_KNOW, with a short legend shown once -when either occurs. Markers keep meaning visible without color and survive a +when assessed impact or no-impact statements occur. The legend also explains +plain bullets as no impact identified. Markers keep meaning visible without color and survive a missing kind; red/yellow highlighting is optional. NO_IMPACT uses plain bullets. Uncertain statements retain `(uncertain)` with muted highlighting, based only on the backend `ambiguous` boolean; confidence values remain JSON evidence. @@ -185,6 +186,19 @@ 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 compact-label follow-up uses kind-only tags, !/i tier markers and one legend +(including plain-bullet meaning); no-impact statements stay untagged. Its focused +formatter/CLI/MCP/parity/upgrade tests pass165 cases/1,011 assertions, with build, +typecheck and source/built credential-free smokes passing. Authenticated dev +built CLI/local MCP text parity covers Express4.20.0 and5.2.1. The exact-release +text is5,689bytes versus6,482before shortening labels; this is output size, not +rendering speed. JSON and query selections, and their measured complexity, are +unchanged. The repeated descriptor-intent workload uses three logical calls in +27.5s and cites rollback/rejected-CVE evidence with no isolation violations; it +is an inspected trace, not a quality grade. External Opus round3 found only the +missing explanation for plain bullets; the legend and no-impact-only regression +now close that wording finding. No unresolved findings or deferred work remain. + ## Release guidance staging Published skills/githits-package stays unchanged until this behavior reaches its diff --git a/docs/plans/changelog-compact-tags.md b/docs/plans/changelog-compact-tags.md deleted file mode 100644 index 2dee380a..00000000 --- a/docs/plans/changelog-compact-tags.md +++ /dev/null @@ -1,37 +0,0 @@ -# Compact changelog tags - -Status: READY FOR EXTERNAL REVIEW. Owner follow-up to open draft PR476, base f76afd3. - -Remove the repetitive no-impact tag and tier words inside impact tags. The existing -shared changelog formatter owns presentation; no new module or backend change. -Keep JSON, GraphQL projections, filters, paging and upgrade review unchanged. - -Use plain bullets for NO_IMPACT. Known impact kinds use [behavior]/[deprecation] -labels, with ! for MUST_ACT and i for SHOULD_KNOW. One short legend when either -tier appears keeps meaning visible without color, especially for kindless items. -Red/yellow remain optional highlighting; uncertain prefixes dim and retain the -explicit (uncertain) marker from backend ambiguous. Unknown and oversized -statements keep explicit assessment status; no client confidence policy. - -Verified: existing formatter prepends [tier: kind] and [no impact identified]; CLI -and MCP share it. Those words are text-only; no query/JSON change is needed. -Tests cover untagged no-impact (including uncertainty and preserved JSON), kindless -severity, known kind/color equivalence, assessment gaps and existing nested code. -Focused tests started red before implementation. Baseline built output artifacts -from the reviewed PR are available for exactExpress4.20.0 and Biome2.4.2..2.4.15. - -Implement the compact prefix in place, update durable docs and existing unreleased -fragment, run focused tests/build/typecheck and source/built credential-free smokes. -Use normal dev CLI/local MCP auth for representative text parity and byte counts; -query complexity remains85/88/113 since no selections change. Run the targeted -exact-release descriptor-intent workload because text severity cues changed. -Review internally then on retained Opus reviewer (owner changes before merge), -commit/push/update PR476 and complete CI; no merge/release. After clean review, -transfer validation to implementation docs and delete this completed plan. - -Validation: 165 focused tests / 1,011 assertions pass; build, typecheck, Biome -and four source/built credential-free smoke modes pass. Authenticated dev built -CLI/local MCP text parity passes for Express4.20.0 and5.2.1; exact output is -5,658 bytes versus6,482 before tag shortening. Targeted descriptor-intent eval -passes, three logical calls in27.5s, inspected rollback/CVE evidence, no isolation -violations; no quality grade. Internal code reviewer: direction sound, no findings. diff --git a/packages/mcp/src/shared/package-changelog-response.test.ts b/packages/mcp/src/shared/package-changelog-response.test.ts index ed1db364..31367b39 100644 --- a/packages/mcp/src/shared/package-changelog-response.test.ts +++ b/packages/mcp/src/shared/package-changelog-response.test.ts @@ -373,7 +373,9 @@ describe("complete statement evidence view", () => { expect(text).toContain("* (uncertain) Quoted statement 2"); expect(text).not.toContain("[no impact identified]"); expect(text).not.toContain("[notable change]"); - expect(text).not.toContain("! requires action | i should know"); + expect(text).toContain( + "! requires action | i should know | plain: no impact identified", + ); expect(payload(report).statements?.items[0]?.classification).toMatchObject({ tier: "no_impact", kind: "notable_change", diff --git a/packages/mcp/src/shared/package-changelog-response.ts b/packages/mcp/src/shared/package-changelog-response.ts index dd7050b5..082b57a9 100644 --- a/packages/mcp/src/shared/package-changelog-response.ts +++ b/packages/mcp/src/shared/package-changelog-response.ts @@ -256,10 +256,14 @@ export function formatPackageChangelogTerminal( statements.items.some( (item) => item.classification?.tier === "must_act" || - item.classification?.tier === "should_know", + item.classification?.tier === "should_know" || + item.classification?.tier === "no_impact", ) ) - prose("! requires action | i should know", " "); + prose( + "! requires action | i 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") From 40c2adef996090884dad1a08800aafc836b42a56 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Wed, 14 Oct 2026 12:07:37 +0300 Subject: [PATCH 12/14] docs: correct compact changelog output size Record the final built CLI measurement of 5,688 bytes after the legend wording fix. --- docs/implementation/pkg-changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/implementation/pkg-changelog.md b/docs/implementation/pkg-changelog.md index eaa85a17..f1b2e401 100644 --- a/docs/implementation/pkg-changelog.md +++ b/docs/implementation/pkg-changelog.md @@ -191,7 +191,7 @@ The compact-label follow-up uses kind-only tags, !/i tier markers and one legend formatter/CLI/MCP/parity/upgrade tests pass165 cases/1,011 assertions, with build, typecheck and source/built credential-free smokes passing. Authenticated dev built CLI/local MCP text parity covers Express4.20.0 and5.2.1. The exact-release -text is5,689bytes versus6,482before shortening labels; this is output size, not +text is5,688bytes versus6,482before shortening labels; this is output size, not rendering speed. JSON and query selections, and their measured complexity, are unchanged. The repeated descriptor-intent workload uses three logical calls in 27.5s and cites rollback/rejected-CVE evidence with no isolation violations; it From ee2786f2567f3982a85e006cfbe89198639c6f69 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Wed, 14 Oct 2026 12:18:31 +0300 Subject: [PATCH 13/14] fix: put changelog action markers inside kind tags Replace the confusing information prefix with kind-only should-know labels and bracket action kinds as [!behavior]. Preserve explicit tiers for missing kinds, backend uncertainty and plain no-impact bullets. --- ...changelog-classified-statements.changed.md | 2 +- docs/implementation/pkg-changelog.md | 12 ++++---- docs/plans/changelog-bracket-tags.md | 28 +++++++++++++++++++ .../shared/package-changelog-response.test.ts | 26 ++++++++--------- .../src/shared/package-changelog-response.ts | 15 +++++----- 5 files changed, 55 insertions(+), 28 deletions(-) create mode 100644 docs/plans/changelog-bracket-tags.md diff --git a/changes/changelog-classified-statements.changed.md b/changes/changelog-classified-statements.changed.md index 1a7d2fa0..15717445 100644 --- a/changes/changelog-classified-statements.changed.md +++ b/changes/changelog-classified-statements.changed.md @@ -3,4 +3,4 @@ "@githits/mcp": minor --- -- **Complete changelog statements** - Package changelogs now default to all parsed bullets with available agent classifications, compact kind tags, action/should-know 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. +- **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/pkg-changelog.md b/docs/implementation/pkg-changelog.md index f1b2e401..4bda24ed 100644 --- a/docs/implementation/pkg-changelog.md +++ b/docs/implementation/pkg-changelog.md @@ -37,11 +37,11 @@ verbose classification-detail mode or implicit default note preview. ## Evidence, coverage and sources -Known impact kinds use compact tags such as `[behavior]` or `[deprecation]`. -`!` marks MUST_ACT and `i` marks SHOULD_KNOW, with a short legend shown once -when assessed impact or no-impact statements occur. The legend also explains -plain bullets as no impact identified. Markers keep meaning visible without color and survive a -missing kind; red/yellow highlighting is optional. NO_IMPACT uses plain bullets. +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 @@ -186,7 +186,7 @@ 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 compact-label follow-up uses kind-only tags, !/i tier markers and one legend +The compact-label follow-up uses kind tags with bracketed action markers and one legend (including plain-bullet meaning); no-impact statements stay untagged. Its focused formatter/CLI/MCP/parity/upgrade tests pass165 cases/1,011 assertions, with build, typecheck and source/built credential-free smokes passing. Authenticated dev diff --git a/docs/plans/changelog-bracket-tags.md b/docs/plans/changelog-bracket-tags.md new file mode 100644 index 00000000..0e0eaa11 --- /dev/null +++ b/docs/plans/changelog-bracket-tags.md @@ -0,0 +1,28 @@ +# Changelog bracket tags + +Status: READY FOR REVIEW. Owner follow-up after compact-tag delivery in PR476. +Base40c2ade; shared changelog formatter owns this presentation-only change. + +Owner-observed i prefix is confusing. Move action emphasis inside the kind tag: +[!behavior], with red optional; should-know is [deprecation], yellow optional. +Action without kind uses [!]; should-know without kind uses [should know] so it +cannot look like no impact. Uncertainty remains explicit and muted. No-impact +stays plain; null/oversize assessment status tags remain. One short legend explains +markers and plain bullets. Backend kind names/spelling and ambiguity stay unchanged. +No JSON, GraphQL, filters, collection, upgrade-review or backend changes. + +Verify focused formatter/CLI/MCP parity and upgrade regression cases, build and + typecheck, source/built smoke gates, normal authenticated dev Express4.22.0 and +5.2.1 text parity. Baseline built Express4.20.0 is5688bytes; compare final bytes, +not speed. Complexity remains85/88/113 (unchanged selections). Targeted exact +release agent trace only. Internal code preflight then retained Opus review for +this new owner increment, fix findings within three rounds. Commit/push to same +PR476; do not merge. Move evidence to permanent docs/delete plan after clean review. + +Evidence: focused165/0 tests (1013 assertions); build/typecheck/Biome pass. +Dev built CLI/local MCP text parity passes4.22.0,4.20.0,5.2.1; bytes2341, +5687,1157. Baseline4.20.0=5688bytes; no speed claim. Source smokes pass, built +MCP passes and built CLI in progress. Descriptor-intent exact workload succeeds +19.1s/3 calls; trace/final/metrics inspected, rollback/rejectedCVE cited, zero +isolation violations, no quality grade. Internal direction/code preflight found +only two stale assertions, corrected and focused suite rerun; no code findings. diff --git a/packages/mcp/src/shared/package-changelog-response.test.ts b/packages/mcp/src/shared/package-changelog-response.test.ts index 31367b39..b84c03b1 100644 --- a/packages/mcp/src/shared/package-changelog-response.test.ts +++ b/packages/mcp/src/shared/package-changelog-response.test.ts @@ -343,15 +343,15 @@ describe("complete statement evidence view", () => { expect(text.indexOf("https://example.com/history#L1")).toBeLessThan( text.indexOf("Quoted statement 0"), ); - expect(text).toContain("! requires action | i should know"); - expect(text).toContain("* ! [behavior] (uncertain) Quoted statement 0"); - expect(text).toContain("* i (uncertain) Quoted statement 1"); + 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("[should know"); + expect(text).not.toContain("* i "); expect(text.match(/\(uncertain\)/g)).toHaveLength(2); expect(text).toContain("nested wording"); expect(text).toContain("foo( 1)"); @@ -374,7 +374,7 @@ describe("complete statement evidence view", () => { expect(text).not.toContain("[no impact identified]"); expect(text).not.toContain("[notable change]"); expect(text).toContain( - "! requires action | i should know | plain: no impact identified", + "! requires action | kind tags: should know | plain: no impact identified", ); expect(payload(report).statements?.items[0]?.classification).toMatchObject({ tier: "no_impact", @@ -389,10 +389,10 @@ describe("complete statement evidence view", () => { const text = formatPackageChangelogTerminal(payload(report), { useColors: false, }); - expect(text).toContain("* ! Quoted statement 0"); - expect(text).toContain("* i (uncertain) Quoted statement 1"); + expect(text).toContain("* [!] Quoted statement 0"); + expect(text).toContain("* [should know] (uncertain) Quoted statement 1"); expect(text).not.toContain("[requires action]"); - expect(text).not.toContain("[should know]"); + expect(text).toContain("[should know]"); }); it("colors confident action and should-know prefixes while keeping the same no-color meaning", () => { const report = evidence(); @@ -403,9 +403,9 @@ describe("complete statement evidence view", () => { 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[33mi [deprecation]"); - expect(plain).toContain("* i [deprecation] Quoted statement 1"); + 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("preserves structured evidence and null classification without mutating the service result", () => { @@ -511,7 +511,7 @@ describe("complete statement evidence view", () => { useColors: false, }); expect(text).toContain("α — 保留 …"); - expect(text).toContain("! [behavior]"); + expect(text).toContain("[!behavior]"); }); it("wraps prose to caller width without truncating ordinary statements", () => { const report = evidence(); @@ -532,7 +532,7 @@ describe("complete statement evidence view", () => { useColors: true, }); expect(Bun.stripANSI(colored)).toBe(plain); - expect(colored).toContain("\x1b[2m! [behavior]"); + expect(colored).toContain("\x1b[2m[!behavior]"); }); it("original-note view shows all lines unless the deprecated explicit cap is present", () => { const report = { diff --git a/packages/mcp/src/shared/package-changelog-response.ts b/packages/mcp/src/shared/package-changelog-response.ts index 082b57a9..8b57ebc2 100644 --- a/packages/mcp/src/shared/package-changelog-response.ts +++ b/packages/mcp/src/shared/package-changelog-response.ts @@ -261,7 +261,7 @@ export function formatPackageChangelogTerminal( ) ) prose( - "! requires action | i should know | plain: no impact identified", + "! requires action | kind tags: should know | plain: no impact identified", " ", ); const pending = c.reduce((sum, v) => sum + v.statementsNotAssessed, 0); @@ -351,12 +351,10 @@ export function formatPackageChangelogTerminal( } const classification = item.classification; const tier = classification?.tier; - const marker = - tier === "must_act" ? "!" : tier === "should_know" ? "i" : undefined; const kind = classification?.kind ? RISK_KIND_LABELS[classification.kind as keyof typeof RISK_KIND_LABELS] : undefined; - const label = + const annotation = tier === "no_impact" ? undefined : tier === "unclassified" @@ -364,9 +362,10 @@ export function formatPackageChangelogTerminal( : !classification ? "[not assessed]" : kind - ? `[${kind}]` - : undefined; - const annotation = [marker, label].filter(Boolean).join(" "); + ? `[${tier === "must_act" ? "!" : ""}${kind}]` + : tier === "must_act" + ? "[!]" + : "[should know]"; const start = lines.length; renderStatementText( lines, @@ -375,7 +374,7 @@ export function formatPackageChangelogTerminal( width, ); // Style only the trusted prefix after sanitation and wrapping. Missing - // kinds leave the marker intact; an empty annotation cannot style a quote. + // kinds retain explicit tier meaning; plain bullets cannot style a quote. if (annotation && lines[start]?.startsWith(` * ${annotation}`)) { const styled = classification?.ambiguous || !classification From 732e71881190c444f643e138a4d5ed8c8653b1b6 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Wed, 14 Oct 2026 12:21:15 +0300 Subject: [PATCH 14/14] docs: record final bracket-tag verification Transfer the completed owner follow-up evidence into changelog documentation, correct the assertion count raised in the clean final review, and remove the temporary plan. --- docs/implementation/pkg-changelog.md | 26 ++++++++++++++------------ docs/plans/changelog-bracket-tags.md | 28 ---------------------------- 2 files changed, 14 insertions(+), 40 deletions(-) delete mode 100644 docs/plans/changelog-bracket-tags.md diff --git a/docs/implementation/pkg-changelog.md b/docs/implementation/pkg-changelog.md index 4bda24ed..47e88df9 100644 --- a/docs/implementation/pkg-changelog.md +++ b/docs/implementation/pkg-changelog.md @@ -186,18 +186,20 @@ 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 compact-label follow-up uses kind tags with bracketed action markers and one legend -(including plain-bullet meaning); no-impact statements stay untagged. Its focused -formatter/CLI/MCP/parity/upgrade tests pass165 cases/1,011 assertions, with build, -typecheck and source/built credential-free smokes passing. Authenticated dev -built CLI/local MCP text parity covers Express4.20.0 and5.2.1. The exact-release -text is5,688bytes versus6,482before shortening labels; this is output size, not -rendering speed. JSON and query selections, and their measured complexity, are -unchanged. The repeated descriptor-intent workload uses three logical calls in -27.5s and cites rollback/rejected-CVE evidence with no isolation violations; it -is an inspected trace, not a quality grade. External Opus round3 found only the -missing explanation for plain bullets; the legend and no-impact-only regression -now close that wording finding. No unresolved findings or deferred work remain. +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 diff --git a/docs/plans/changelog-bracket-tags.md b/docs/plans/changelog-bracket-tags.md deleted file mode 100644 index 0e0eaa11..00000000 --- a/docs/plans/changelog-bracket-tags.md +++ /dev/null @@ -1,28 +0,0 @@ -# Changelog bracket tags - -Status: READY FOR REVIEW. Owner follow-up after compact-tag delivery in PR476. -Base40c2ade; shared changelog formatter owns this presentation-only change. - -Owner-observed i prefix is confusing. Move action emphasis inside the kind tag: -[!behavior], with red optional; should-know is [deprecation], yellow optional. -Action without kind uses [!]; should-know without kind uses [should know] so it -cannot look like no impact. Uncertainty remains explicit and muted. No-impact -stays plain; null/oversize assessment status tags remain. One short legend explains -markers and plain bullets. Backend kind names/spelling and ambiguity stay unchanged. -No JSON, GraphQL, filters, collection, upgrade-review or backend changes. - -Verify focused formatter/CLI/MCP parity and upgrade regression cases, build and - typecheck, source/built smoke gates, normal authenticated dev Express4.22.0 and -5.2.1 text parity. Baseline built Express4.20.0 is5688bytes; compare final bytes, -not speed. Complexity remains85/88/113 (unchanged selections). Targeted exact -release agent trace only. Internal code preflight then retained Opus review for -this new owner increment, fix findings within three rounds. Commit/push to same -PR476; do not merge. Move evidence to permanent docs/delete plan after clean review. - -Evidence: focused165/0 tests (1013 assertions); build/typecheck/Biome pass. -Dev built CLI/local MCP text parity passes4.22.0,4.20.0,5.2.1; bytes2341, -5687,1157. Baseline4.20.0=5688bytes; no speed claim. Source smokes pass, built -MCP passes and built CLI in progress. Descriptor-intent exact workload succeeds -19.1s/3 calls; trace/final/metrics inspected, rollback/rejectedCVE cited, zero -isolation violations, no quality grade. Internal direction/code preflight found -only two stale assertions, corrected and focused suite rerun; no code findings.