diff --git a/changes/upgrade-review-complete-statements.changed.md b/changes/upgrade-review-complete-statements.changed.md new file mode 100644 index 00000000..d12e1cad --- /dev/null +++ b/changes/upgrade-review-complete-statements.changed.md @@ -0,0 +1,6 @@ +--- +"githits": minor +"@githits/mcp": minor +--- + +- **Complete upgrade changelog evidence** - Upgrade reviews collect every matching parsed statement with source links, context and backend-owned uncertainty; add actionable, important and all filters while keeping whole-range batch triage. JSON replaces changelog.riskItems with changelog.statements and removes riskCoverage.itemsOmitted; the public MCP smoke helper adopts that contract. Release and hosted adoption require the aggregate statement schema in production. diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 7682f74a..11fa706d 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -56,7 +56,7 @@ envelope when `--json` is requested; terminal output remains human-readable. | `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`, `--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. | +| `pkg upgrade-review [spec]` | single package spec with current version plus `--to`, positional package range, OR repeatable `--package` ranges | `--to`, repeatable `--package`, `--changes `, `--no-transitive-security`, `--dependency-issues`, `--min-severity`, `--verbose`, `--json` | Compare current and target versions for vulnerabilities, release-note statements, compatibility and dependency changes, and security evidence. `--changes` filters detailed quotes; whole-range counters and batch ranking remain unchanged. 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. | | `grep ` | ordered package, repository and `site:` operands | `-F/--fixed-strings`, `-i/--ignore-case`, `-s/--case-sensitive`, `-A`, `-B`, `-C`, repeatable `--path`, `--path-prefix`, `--glob`, `--corpus`, `--limit`, `--cursor`, `--wait`, `--json` | Regex, case-sensitive and zero-context defaults; all repository files plus independently selected hosted package docs. Text groups matching rows beneath numbered copyable file/page locators, with native highlighting, one Sources summary and reusable read templates; coverage gaps and continuation stay visible. JSON preserves all selected fields and backend occurrence order. See [unified grep](unified-grep.md). Legacy `code grep` remains unchanged. | @@ -756,65 +756,74 @@ githits pkg upgrade-review npm:express@5.0.0..5.2.1 githits pkg upgrade-review npm:express@5.0.0 --to 5.2.1 githits pkg upgrade-review --package npm:zod@4.3.6..4.4.3 --package npm:lint-staged@16.2.7..16.4.0 githits pkg upgrade-review npm:express@5.0.0 --to 5.2.1 --verbose +githits pkg upgrade-review npm:express@5.0.0 --to 5.2.1 --changes important githits pkg upgrade-review npm:express@5.0.0 --to 5.2.1 --json ``` Use `..` for positional and repeatable `--package` ranges. The legacy `->` delimiter is rejected with guidance to use `..`. +`--changes ` accepts `actionable`, `important`, or `all`. Omitted or blank +input selects `focused` (`MUST_ACT`, `SHOULD_KNOW`, and `UNCLASSIFIED`). +`actionable` selects `MUST_ACT`, including uncertain labels; `important` selects +`MUST_ACT` and `SHOULD_KNOW`; `all` includes every parsed statement, including +no-impact, null-classification, and oversized units. This upgrade-review default +is focused; standalone `pkg changelog` still defaults to all parsed statements. + The human-readable CLI and MCP `pkg_upgrade_review` output use one shared formatter. It starts with `Upgrade review - N package(s)`, adds one -`Across packages:` line and a triage table only for batches. Default batch rows -are sorted by backend confident action-statement totals before the cap and include classification coverage, -peer-change and compatibility-note counts. -The public 30-package limit and one aggregate request remain. The owner is -addressing the dev-confirmed large-batch complexity violation in the backend -(30 packages cost 524 against a limit of 500); the combined #3072/#3077 risk -contract passes on dev. See [Upgrade review](pkg-upgrade-review.md) for evidence. -`--verbose` adds full per-package reports. Single-package reports group identity, -security, deprecation, changes, compatibility, dependencies, dependency -issues, and unknown evidence. Empty optional groups are omitted, but a returned -zero-valued dependency comparison remains visible. Missing target security -evidence renders `Target: deprecation unknown` so absence is not confused with -verified non-deprecation. The formatter reports evidence and missing evidence; -it does not make a package approval, safety, or risk claim. Agent-classified statements are grouped by version, combining releases and changelog -files in one block and listing each source URL once via numbered references. -Each displayed version heading includes all available release-note/changelog -references. Statements use their own backend URL, including outside sampled -entries; matching entry links are used only when a statement URL is absent. -Requires action and Possibly requires action quotes are full; Should know and Too long to classify quotes use compact -240-character prefixes expanded by `--verbose`. `Changes (classified by agent)` shows attribution; -model/formulation provenance stays in JSON. Every report shows classified, -not-assessed and without-notes versions, positive unparseable/omitted counts, -no-impact counts, and rerun guidance when background classification is still running or failed. A rerun a few seconds to a minute later retrieves completed stored labels without rerunning the model. -Each statement begins with `*`; optional kind labels use brackets such as -`[security fix]`, colored in color-enabled output. Quotes without a kind retain -their tier and bullet without inventing a category. Backend `ambiguous: true` -adds a muted `(uncertain)` marker; uncertain MUST_ACT items appear -under Possibly requires action. Batch act/know counts separate uncertain -statements using pre-cap coverage totals, such as `4 act (+2 uncertain)`, and ties never use uncertain counts. Per-version section headings omit counts; omitted quotes do not reduce summary totals. Tier confidence stays evidence, never a client threshold. -UNCLASSIFIED means only oversize statements and renders as Too long to classify -- read it. Coverage and no-impact -counts share one summary. Lexical hints have a separate `Keyword matches` -subsection within each version for evidence not already quoted. Literal matches -in classified quotes are not repeated as separate keyword rows and never imply -a breaking-change verdict. -Sampled-entry sections are removed; `--verbose` includes returned note previews -for versions without statement evidence. Quotes are not a compatibility verdict. See [Upgrade review](pkg-upgrade-review.md). +`Across packages:` line and a triage table only for batches. Compact batch text +does not fetch statement pages. Its coverage counters and row ranking use +whole-range evidence, regardless of `--changes`; default rows are sorted by +confident action-statement totals and include classification coverage, +peer-change, and compatibility-note counts. + +The public 30-package limit and one initial aggregate request remain; detailed +reports continue only matching statement pages. Dev-only +measurements put 30-package JSON at 475 units and triage at 391, both below the +500-unit limit. The new aggregate statement field passes on dev; production availability must +be confirmed before releasing this migration. +See [Upgrade review](pkg-upgrade-review.md) for evidence. + +Single-package and verbose batch reports show identity, security, deprecation, +Changes, compatibility, dependencies, dependency issues, and unknown evidence. +Empty optional groups are omitted, but a returned zero-valued dependency +comparison remains visible. Missing target security evidence renders +`Target: deprecation unknown` so absence is not confused with verified +non-deprecation. The formatter reports evidence and missing evidence; it does +not make a package approval, safety, or risk claim. + +Detailed Changes output renders all matching statements from their paginated +pages as source-ordered bullets with nearby source links and context. Known and +kindless tiers use bracketed tags; ambiguous labels are marked uncertain. It +has no tier headings, global numbered reference table, or duplicate sampled-note +previews. Keyword matches remain separate lexical evidence, are not repeated +when already shown in a quote, and never imply a breaking-change verdict. +Whole-range action/know counts separate uncertain labels and batch ranking uses +confident totals, so filters do not change counters or package order. Coverage +retains classified, not-assessed, without-notes, no-impact, oversize, and +unparseable totals, plus rerun guidance for pending classification. Quotes are +evidence, not a compatibility verdict. + +`--verbose` expands quotes to their backend-bounded text and adds full batch, +security, and dependency detail. JSON alone includes uncut `fullText`, source +positions, and classification provenance. Upgrade JSON replaces `changelog.riskItems` with +`changelog.statements`, including the effective `selection` in detailed +results; `riskCoverage.itemsOmitted` is removed. `--json` and MCP +`format: "json"` share this lossless machine surface. `text-v1` remains an +in-place evolving presentation, not a byte-stable prose contract. See +[Upgrade review](pkg-upgrade-review.md) for evidence. The shared formatter wraps free prose to the caller width (minimum 20 columns). The CLI passes `process.stdout.columns` and enables ANSI only when supported; MCP disables ANSI and uses the 80-column default. Outcome and section headings are bold, package identity is bold cyan, and yellow is limited to compact -attention summaries, labels, and matched signal terms. Kind labels and matched keywords are yellow. Detail +attention summaries, labels, and matched signal terms. Action tags are red, should-know tags yellow, and uncertain tags muted. Detail prose and locators remain plain. Color never carries information that is absent from the words. Formatter-authored punctuation stays ASCII while backend -Unicode is preserved. `--verbose` expands the bounded evidence rows in place. -Statement `url` is selected independently of sampled entries, and `--json` -selects the complete `fullText` separately from bounded `text`/`textTruncated`. -Text and verbose output keep their existing design and do not fetch fullText; -locator/full-text adoption in the text view belongs to the owner's design pass. -`--json` remains the structured, lossless machine surface and is shared with MCP +Unicode is preserved. Statement URLs accompany their own quotes, independently +of the release-entry details. `--json` remains the structured, lossless machine +surface and is shared with MCP `format: "json"`; `text-v1` is an in-place evolving presentation, not a byte-stable prose contract. diff --git a/docs/implementation/pkg-changelog.md b/docs/implementation/pkg-changelog.md index 47e88df9..a5a176bc 100644 --- a/docs/implementation/pkg-changelog.md +++ b/docs/implementation/pkg-changelog.md @@ -77,7 +77,9 @@ minute to read completed stored labels. No client polling or automatic reruns. 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. +before pagination. Upgrade review also collects this statement shape, with a +focused default instead of changelog's all-statements default; see +[pkg-upgrade-review.md](pkg-upgrade-review.md) for its projections and coverage. 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. diff --git a/docs/implementation/pkg-upgrade-review.md b/docs/implementation/pkg-upgrade-review.md index 920471ee..5f82c0b9 100644 --- a/docs/implementation/pkg-upgrade-review.md +++ b/docs/implementation/pkg-upgrade-review.md @@ -1,983 +1,353 @@ # Package Upgrade Review -## Purpose +## Purpose and ownership -Dependency-upgrade reviews are a distinct agent workflow. Agents should not infer acceptability from semver, especially for patch updates. The tool surface should make evidence collection cheaper than manually composing `pkg_changelog`, `pkg_vulns`, and `pkg_deps` calls for every package. +CLI `pkg upgrade-review` and package-owned MCP `pkg_upgrade_review` compare +current and target package versions. They report vulnerabilities, release-note +statements, target deprecation metadata, peers, dependency changes and missing +evidence. Semver alone does not establish compatibility. -`pkg_upgrade_review` is the MCP/CLI-facing tool for this workflow. It answers: "What changed between the currently used version and the target version, and what evidence is available or missing?" +The backend owns parsing, source order, restatement suppression, classification, +ambiguity policy and whole-range counters. Core owns GraphQL projections, +validation and complete collection. MCP shared modules own request normalization, +JSON normalization and presentation. CLI commands and MCP handlers adapt host +inputs; both use the same formatter with color and width as inputs. -Original risk fields were verified against authenticated dev on 2026-10-07. -On 2026-10-08 the owner confirmed backend #3072 and #3077 merged and deployed to -dev; current CLI/local MCP requests verified the combined ambiguity, pre-cap -counter, locator and complete-text contract. The merged SDL at -707adc4571d86f506eed004e975870fbd44e14ff confirms names and nullability; the local -bf74b72f0 checkout was stale. Dev disables introspection. Production availability -and the formerly rejected 30-package operation passed on 2026-10-12; see the production release verification -below. +- `src/commands/pkg/upgrade-review.ts`: CLI addressing and flags. +- `packages/mcp/src/tools/package-upgrade-review.ts`: MCP arguments and handler. +- `packages/mcp/src/shared/package-upgrade-review-request.ts`: shared validation + and tier/view selection; `changelog-changes.ts` also serves standalone changelog. +- `packages/core-internal/src/services/package-intelligence-service.ts`: aggregate + query, statement projection/protocol validation and collection. +- `packages/mcp/src/shared/package-upgrade-review-response.ts`: upgrade envelope + and formatter. `package-changelog-response.ts` supplies the shared statement + normalization and version/source bullet renderer. -## Current Schema Fit +The hosted MCP transport lives in `remote-mcp`, outside this repository. Its +behavior changes only after adopting a released `@githits/mcp` and deploying it. -The backend now exposes the aggregate query the CLI/MCP tool should call directly: +## Inputs and defaults -```graphql -packageUpgradeReview( - packages: [PackageUpgradeReviewPackageInput!]! - includeTransitiveSecurity: Boolean - minSeverity: Float - changelogLimit: Int -): PackageUpgradeReviewResponse! -``` - -The CLI/MCP implementation must not fall back to composing `packageSummary`, `packageVulnerabilities`, `packageChangelog`, or `packageDependencies` calls. If the aggregate query is unavailable, surface the backend protocol error. The aggregate and combined risk fields were verified on dev after #3072/#3077 deployment and on production during 0.29.0 release preparation, including the public 30-package maximum. - -Optional evidence is controlled by GraphQL field selection and local query variables: - -- `includeTransitiveSecurity` is sent to the backend root field and also controls the selected `security.transitive` subtree. -- `include_dependency_issues` / `--dependency-issues` controls the selected `dependencyIssues @include(...)` subtree. It is not a backend root argument. -- `changelogLimit` caps ordinary changelog entries per package. -- `min_severity` maps to CVSS thresholds and is omitted for `low`, matching the existing upgrade-review request builder behavior. - -Out-of-scope evidence remains unchanged: `versionDiff` and runtime/engine compatibility are future enhancements, and the tool must not infer accept/reject recommendations. - -## Proposed MCP Tool - -Use one tool name for single and batch reviews. The schema accepts either one package (`registry` + `package_name`) or `packages[]`, but not both. This keeps the agent decision simple: use `pkg_upgrade_review` for dependency bump evidence. - -```ts -pkg_upgrade_review({ - registry: "npm", - package_name: "@modelcontextprotocol/sdk", - current_version: "1.26.0", - target_version: "1.29.0", - skip_transitive_security: false, - include_dependency_issues: true, - min_severity: "low" | "medium" | "high" | "critical", - format: "text" | "json" -}) -``` - -Batch form: - -```ts -pkg_upgrade_review({ - packages: [ - { - registry: "npm", - package_name: "zod", - current_version: "4.3.6", - target_version: "4.4.3" - }, - { - registry: "npm", - package_name: "lint-staged", - current_version: "16.2.7", - target_version: "16.4.0" - } - ], - skip_transitive_security: false, - include_dependency_issues: true, - format: "text" -}) -``` - -Validation rules: - -- Require either `packages` or the single-package fields. -- Reject `packages` combined with `registry`, `package_name`, `current_version`, or `target_version`. -- Accept at most 30 nonblank `packages[]` rows. Blank rows are removed before applying the limit, and a larger batch is rejected by the shared CLI/MCP request builder before any package-intelligence service call. -- Normalize exact Go current/target versions to canonical lowercase-`v` form while accepting either input form. Reject tag-style `v` versions for other registries except Swift, matching `pkg_vulns`, `pkg_deps`, and `pkg_changelog`. -- Keep transitive security evidence enabled by default because direct-only security hides important dependency-tree evidence. Allow callers to pass `skip_transitive_security: true` when latency is more important than transitive vulnerability context. -- Keep `include_dependency_issues` default `false` initially for the same reason. Turn it on automatically only when the caller explicitly asks for lockfile/dependency-tree evidence, or document that agents should pass it for lockfile reviews. -- Changelog keyword detection scans the full backend range response and keyword-hit evidence is retained within each version group so relevant signals are not hidden by the ordinary sample limit. The sampled-entry cap is internal; agents should not need to tune it. -- `min_severity` maps to the same CVSS thresholds as `pkg_vulns` (`low=0.1`, `medium=4`, `high=7`, `critical=9`). It filters direct current/target vulnerability queries and transitive `vulnerabilitySummary(minSeverity:)` aggregates. - -CLI shape: +Single-package CLI forms: ```bash -githits pkg upgrade-review npm:@modelcontextprotocol/sdk@1.26.0..1.29.0 -githits pkg upgrade-review npm:@modelcontextprotocol/sdk@1.26.0 --to 1.29.0 -githits pkg upgrade-review --package npm:zod@4.3.6..4.4.3 --package npm:lint-staged@16.2.7..16.4.0 -``` - -The positional range is the concise single-package form. The positional -`:@ --to ` form remains available when the -target is supplied separately, and repeatable `--package` entries remain the -batch form and the CLI parity path for MCP `packages[]`. - -The CLI command owns parsing of positional compact ranges and normalises either -single-package spelling into the existing structured request. A positional -range already contains its target, so combining it with `--to` is rejected -locally with both valid alternatives. Any positional argument combined with a -repeatable `--package` entry is also rejected locally; choose one positional -single-package form or the batch form. Empty range sides, multiple or adjacent -delimiters, and explicit empty-current ranges receive positional range grammar -instead of `--package` guidance. - -Range delimiters are interpreted only in the version suffix after the final -version-separating `@`; delimiter-like text in a package name remains opaque. - -Use `..` as the range delimiter for positional and repeatable `--package` -forms. The legacy `->` delimiter is rejected locally with guidance to use -`..`. A JSON input file can be added later if repeatable flags are too awkward -in real use. - -## JSON Shape - -The JSON envelope should be data-first and structured for both single and batch output. - -```ts -interface UpgradeReviewResponse { - summary: { - total: number; - withUnknowns: number; - withAddedAdvisories: number; - withBreakingSignals: number; - withDirectDependencyChanges: number; - withTransitiveVulnerabilityAdditions: number; - }; - reviews: UpgradeReview[]; -} - -interface UpgradeReview { - registry: string; - name: string; - currentVersion: string; - targetVersion: string; - latestVersion?: string; - versionDelta: "patch" | "minor" | "major" | "prerelease" | "downgrade" | "same" | "unknown"; - security: UpgradeSecurity; - changelog: UpgradeChangelog; - compatibility?: UpgradeCompatibility; - dependencyChanges?: UpgradeDependencyChanges; - dependencyIssues?: UpgradeDependencyIssues; - unknowns: string[]; -} -``` - -The summary contains factual counters only. `summary.total` is `reviews.length`; the other counters report evidence categories present in at least one review. The tool does not assign package-level risk levels or make accept/reject recommendations. Statement-level model labels remain quoted evidence with provenance. - -Security block: - -```ts -interface UpgradeSecurity { - current: VersionVulnerabilitySummary; - target: VersionVulnerabilitySummary; - added: AdvisorySummary[]; - removed: AdvisorySummary[]; - notAddressed: AdvisorySummary[]; - fixed: AdvisorySummary[]; - introduced: AdvisorySummary[]; - unchanged: AdvisorySummary[]; - transitive?: TransitiveSecuritySummary; -} +githits pkg upgrade-review npm:express@5.0.0..5.2.1 +githits pkg upgrade-review npm:express@5.0.0 --to 5.2.1 +githits pkg upgrade-review npm:express@5.0.0..5.2.1 --changes actionable +githits pkg upgrade-review npm:express@5.0.0..5.2.1 --changes all --json ``` -Advisory diffs (`added`, `removed`, `notAddressed`) are now backend-provided. The backend must diff logical advisories over `id ∪ aliases[]`, not raw IDs, or GHSA/RUSTSEC duplicates will be misclassified. The legacy aliases (`introduced`, `fixed`, `unchanged`) remain in JSON for compatibility while the text output uses vulnerability-focused labels: `added`, `fixed`, and `still-present`. +Batch CLI uses repeatable `--package :@..`. +MCP accepts either `registry`, `package_name`, `current_version`, `target_version` +or `packages[]` containing those fields. Single and batch addressing are mutually +exclusive. The shared builder removes blank batch rows and rejects more than +30 upgrades before any service call. Go versions accept either spelling and +normalize to canonical lowercase `v`; other registries reject tag-style `v` +except Swift. -Transitive vulnerability diffs are backend-provided and package/advisory-aware. A dependency version change with the same affected advisory remains `stillAffectedPackageDetails`, not a fixed plus introduced pair. A package can appear in both added and still-affected groups if the target keeps one advisory and adds another. Detail pages preserve backend `totalCount` / `truncated` metadata so JSON and text output do not present capped samples as complete evidence. +Ranges exclude the current version and include the target. A positional range +already supplies its target, so `--to` with that form is invalid. `->`, empty +range sides and repeated/adjacent delimiters get local grammar errors. Only the +version suffix after the final version-separating `@` is parsed as a range. -```ts -interface TransitiveSecuritySummary { - currentAffected: number; - targetAffected: number; - introducedPackages: string[]; - fixedPackages: string[]; - introducedPackageDetails: TransitiveVulnerablePackage[]; - introducedPackageDetailsTotalCount: number; - introducedPackageDetailsTruncated: boolean; - fixedPackageDetails: TransitiveVulnerablePackage[]; - fixedPackageDetailsTotalCount: number; - fixedPackageDetailsTruncated: boolean; - stillAffectedPackageDetails: TransitiveVulnerablePackage[]; - stillAffectedPackageDetailsTotalCount: number; - stillAffectedPackageDetailsTruncated: boolean; -} -``` +Transitive security defaults on; `--no-transitive-security` or +`skip_transitive_security: true` disables it. Dependency issue analysis defaults +off; `--dependency-issues` / `include_dependency_issues: true` selects it. +`--min-severity` / `min_severity` accepts low, medium, high or critical; low is +unfiltered, while the other values map to CVSS 4, 7 and 9. -Version vulnerability summaries should include package-version metadata from `PackageVersionIdentity`: +### Detailed statement filters -```ts -interface VersionVulnerabilitySummary { - version: string; - publishedAt?: string; - deprecated?: boolean; - deprecationReason?: string; - affectedCount: number; - nonAffectingCount: number; - allCount: number; - advisories: AdvisorySummary[]; -} -``` - -Preserve `deprecated: undefined` when GraphQL returns `null`; only `false` means verified not deprecated. +CLI `--changes ` and MCP `changes` share these meanings: -Changelog block: +| Selection | Backend tiers | Evidence shown | +| --- | --- | --- | +| Omitted or blank: `focused` | MUST_ACT, SHOULD_KNOW, UNCLASSIFIED | Action, knowledge and oversize statements | +| `actionable` | MUST_ACT | Includes uncertain action labels | +| `important` | MUST_ACT, SHOULD_KNOW | Action and knowledge labels, including uncertain ones | +| `all` | Unfiltered | Every parsed statement, including no impact and null classification | + +Input is trimmed and lowercased; invalid values fail locally. `focused` is +output metadata, not an accepted explicit filter. Standalone `pkg changelog` +continues to default to `all`; the explicit filter meanings are identical. + +Filters limit detailed quotes only. Whole-range security, classification counters +and batch ranking do not change. Detailed text always names the effective +selection and its exclusions. JSON always includes `statements.selection`, even +for an implicit default, and echoes only an explicitly supplied filter in the +top-level `filter.changes`. Compact batch text explains an explicit filter applies +to detailed reports. + +## Backend operation and projections + +Use `packageUpgradeReview` directly. Never compose package summary, +vulnerability, changelog or dependency calls as a fallback. Missing fields, +schema/deployment skew and resolver errors use normal package-intelligence +protocol/error handling. + +The initial request sends the complete batch once, retaining the public maximum +of 30. `changelogLimit: 20` bounds ordinary entry metadata, not statements. +`changelog.statements(first: 100, offset: 0, tiers: ...)` covers the full range and +filters before pagination. All matching pages are retrieved for detailed views. + +| Host view | Selected changelog evidence | +| --- | --- | +| Compact multi-package text | Whole-range coverage/tier counters, source/fallback, entry and keyword totals/signals; no statement pages or entry arrays | +| Single text / verbose batch | Bounded statements, context, classification tier/kind/ambiguity, full assessment coverage and entry locator metadata; keyword bodies for actual lexical excerpts | +| JSON | Complete matching pages with uncut statement/context text, source positions, confidence and per-item model/formulation; ordinary entry/keyword bodies and metadata retained | +| Continuation | One review identity and statement page/progress/coverage only | + +Text does not fetch `fullText`, confidence, model or formulation. It omits +ordinary note bodies and duplicate sampled previews. `keywordEntries.body` is +required in detailed text: a keyword match can occur beyond its preview. +Optional transitive security and dependency issues retain their existing request +controls. Complexity counts directives conservatively; runtime omission does not +prove a lower admission cost. + +Continuations are sequential, in original review order, with one package per +aggregate request, `includeTransitiveSecurity: false` and `changelogLimit: 1`. +They do not repeat summary, security, dependencies, riskCoverage or entry arrays. +Zero is clamped to one by the backend, so it offers no smaller continuation. +Duplicate reviews are retained; the client adds no deduplicating cache, fanout, +retry loop, background job or snapshot mechanism. + +## Completeness and consistency + +Both changelog paths share statement selection, schemas, normalization, +page-progress validation and collection. Validate selected JSON evidence fields, +page size at most 100, monotonic offsets, remaining/matching counts, +`hasMore`/`nextOffset`, coverage membership, positions and backend source order. +Filtered positions may have gaps; unfiltered retained positions are contiguous +per version. Original `sourcePosition` may have gaps after suppression. + +Upgrade coverage spans the full range and must not be compared with the sampled +entry version list. Continuations must return the initial review's canonical +identity. Complete collected selected tier/ambiguity counts must agree with +whole-range counters; all mode additionally reconciles no-impact and assessment +coverage. + +Upgrade review compares complete assessment coverage and total/matching counts +between pages, including all mode, because its global counters came from the +initial aggregate. Observed changes raise a retryable changed-evidence error +with rerun guidance. Stable pages that disagree with those counters are malformed +backend evidence, not a guessed retry condition. Selected fields missing from a +successful response also fail validation. Omitted triage arrays remain absent; +they are not fabricated empty evidence. + +Standalone changelog all mode still tolerates labels arriving between pages and +derives assessment coverage from collected statements. This migration does not +change that behavior. + +The API uses current offsets, not an immutable snapshot. Coverage checks cannot +detect every same-count source edit. A cold large all view may need a rerun after +background classification settles; there is no automatic restart or guaranteed +fixed latency. Background jobs can take seconds to a minute. Stored labels +remain useful across classifier changes, so model/formulation can differ between +items. Missing or unparseable notes are never evidence of no risk. + +## Text evidence + +Security precedes Changes; compatibility, dependencies, dependency issues and +unknowns retain their existing sections. Changes leads with whole-range confident +and uncertain counts and classified/not-assessed/without-notes coverage. Versions +and sources follow backend order, with authoritative URLs beside their bullets. +There is no duplicate sampled-note dump or global numbered source table. + +Statement tags preserve meaning without color: + +- MUST_ACT: `[!behavior]`, or `[!]` when kind is absent. +- SHOULD_KNOW: `[security fix]`, or `[should know]` when kind is absent. +- Backend `ambiguous: true`: muted `(uncertain)`; never a client confidence cutoff. +- NO_IMPACT: plain bullet. +- Null classification: `[not assessed]`. +- UNCLASSIFIED: `[too long to classify - read it]`. + +Headings, predecessor context and nested evidence remain intact. Confident and +uncertain action statements use full backend-bounded text. Compact non-action +quotes use at most 240 Unicode characters and an explicit verbose expansion +hint. `--verbose` / `verbose: true` expands all bounded quotes, batch reports and +security/dependency examples. It does not fetch uncut text or original notes. +Backend text/context truncation separately points to JSON full text. + +Existing `breakingSignals`, `migrationSignals` and entry `signals` remain +functional. Additional keyword excerpts appear as lexical evidence within the +version; a complete chunk already contained in a displayed same-source statement +is not repeated. A filtered-out statement does not suppress separate keyword +facts. Keyword quotes normalize Markdown list markers and sanitize text/signals; +URLs already shown for the version or statement sources are not repeated. Source +groups nest beneath the version header. The client does not infer classifications +from keywords or invent URLs. + +All untrusted quotes, headings, context, dates, versions and URLs follow terminal +sanitization and prose wrapping. Formatter punctuation is ASCII; backend Unicode +is retained. Long locators remain intact. Color only styles trusted annotations. + +Compact batches show one triage row per review, sorted by whole-range confident +MUST_ACT counts. Uncertain counts are separate and never a secondary ranking key; +ties retain backend order. JSON order remains backend order. Rows retain target +deprecation, security, keyword, compatibility, dependency and unknown facts. + +## JSON migration + +The top-level summary/reviews envelope and security, compatibility, dependency, +unknown and ordinary entry/keyword facts remain. Enum spelling is normalized to +lowercase. Existing null omission conventions apply to optional evidence, while +`classification: null` remains explicit and distinct from NO_IMPACT. False +ambiguity and zero numeric values remain evidence. + +The complete detailed collection replaces `changelog.riskItems`: ```ts -interface UpgradeChangelog { - source?: "releases" | "changelog_file" | "hexdocs"; - entries: Array<{ - version: string | null; - publishedAt?: string; - htmlUrl?: string; - body?: string; - bodyPreview?: string; - headline?: string; - signals?: string[]; +interface UpgradeStatements { + selection: "focused" | "actionable" | "important" | "all"; + count: number; // items.length, after complete collection + totalCount: number; // all parsed retained statements, before filtering + matchingCount: number; // matches before pagination + items: Array<{ + version: string; + position: number; + sourceIndex: number; + sourcePosition: number; + heading?: string; + source?: string; + url?: string; + text: string; + textTruncated: boolean; + fullText: string; + context?: { heading?: string; text: string; textTruncated: boolean; fullText: string }; + classification: null | { + tier: "must_act" | "should_know" | "no_impact" | "unclassified"; + ambiguous: boolean; + kind?: string; + tierConfidence?: number; + kindConfidence?: number; + model: string; + formulation: string; + }; + }>; + coverage: Array<{ + version: string; + status: "classified" | "not_assessed" | "without_notes" | "unparseable"; + statementsTotal: number; + statementsAssessed: number; + statementsNotAssessed: number; + statementsNoImpact: number; + statementsUnclassified: number; }>; - sampledEntries: UpgradeChangelogEntry[]; - keywordEntries: UpgradeChangelogEntry[]; - totalKeywordEntries: number; - totalEntries: number; - truncated: boolean; - breakingSignals: string[]; - migrationSignals: string[]; - riskItems: UpgradeChangelogRiskItem[]; - riskCoverage: { - versionsClassified: number; - versionsNotAssessed: number; - versionsWithoutNotes: number; - versionsUnparseable: number; - unitsNoImpact: number; - itemsMustActConfident: number; - itemsMustActAmbiguous: number; - itemsShouldKnowConfident: number; - itemsShouldKnowAmbiguous: number; - itemsUnclassified: number; - itemsOmitted: number; - }; -} - -interface UpgradeChangelogRiskItem { - version: string; - tier: "must_act" | "should_know" | "unclassified"; - ambiguous: boolean; - tierConfidence?: number; - kind?: string; - kindConfidence?: number; - text: string; - textTruncated: boolean; - fullText?: string; // selected for JSON only - url?: string; // backend locator; null becomes omitted - heading?: string; - source?: string; - model: string; - formulation: string; -} -``` - -When backend `PackageChangelogResult.source` is `null`, render this as package-version fallback in text but keep JSON source omitted and set `fallback: "package_versions"` or equivalent explicit metadata if needed. Body-less entries are not release-note evidence and must be reported as missing evidence. - -Compatibility block: - -```ts -interface UpgradeCompatibility { - peerDependencyChanges: string[]; - notes: string[]; -} -``` - -Direct package deprecations can be populated from `PackageVersionIdentity.deprecated` / `deprecationReason`. Runtime engines are not exposed by the current schema; do not add an `engines` field in v1. Peer dependency changes can be partially populated from dependency groups today. - -Dependency changes block: - -```ts -interface UpgradeDependencyChanges { - direct: UpgradeDependencyChangeGroup; - transitive: UpgradeDependencyChangeGroup; -} - -interface UpgradeDependencyChangeGroup { - added: UpgradeDependencyChangeItem[]; - removed: UpgradeDependencyChangeItem[]; - changed: UpgradeDependencyChangeItem[]; } ``` -Dependency changes are backend-provided by the aggregate upgrade-review response. Direct dependency set or constraint changes are reported as facts because they alter install behavior even when direct vulnerabilities and changelog checks are clean. Transitive dependency graph changes are rendered for context. The backend excludes the target/current root package node and synthetic nodes from transitive change rows. - -## Text Shape - -`text-v1` is an in-place, unstable text contract. It is a compact product -surface for humans and agents, not a serialization of the JSON fields. The -default output leads with the outcome and groups each package in this order: - -1. package identity and version relationship; -2. `Security`, with direct and optional transitive summary rows first, followed - by non-empty advisory groups; -3. `Deprecation` when target deprecation is known or target evidence is missing; -4. `Changes`; -5. `Compatibility` when it has evidence; -6. `Dependencies` when the comparison object was returned; -7. `Dependency issues` when that object was returned; and -8. `Unknown evidence` last. - -A batch of more than one package adds one `Across packages:` summary after the -headline and a triage table sorted by the backend coverage count of confident `must_act` statements before the item cap. The aggregate line labels the backend `withUnknowns` counter as reported -unknowns and independently counts reviews with not-assessed, missing-note or -unparseable classification coverage. This avoids claiming zero evidence gaps -while the classifier is still pending. Equal confident-action counts keep backend order; uncertain counts are never a secondary sort key. Totals are computed by the backend before its statement cap; returned quote counts may be smaller. Default batch output has one unwrapped row per package, including peer dependency -change and compatibility-note counts (or not checked when absent); `--verbose` adds the detailed reports in -that order. JSON preserves backend review order. Zero and one package omit it. The summary and package sections report -facts only; they never call an upgrade safe, risky, approved, or rejected. - -The Changes section uses one version block in backend release-entry order, -then any statement-only versions outside the entry sample. Within each version, -Requires action precedes Possibly requires action, Should know and Too long to classify. All returned statements -remain visible. Sources from release notes and changelog files are combined -without claiming differently worded statements are equivalent. - -```text -Changes (classified by agent) - 0 require action (+8 uncertain) | 6 should know (+2 uncertain) | 0 too long to classify - Classification versions: 4 classified | 0 not assessed | 0 without notes | 125 statements labeled no impact - ... other versions, each once - 5.1.0 (Release notes [5]; Changelog [12]) - Possibly requires action - * [runtime/platform] (uncertain) "build: Node.js 23.0 by @bjohansebas in [11]" [5] - * (uncertain) "deps: remove safe-buffer" [12] - ... remaining returned quotes - Should know - * [security fix] "fix(securite): fix vulnerabilities by @Abdel-Monaam-Aouini in [13]" [5] - Sources - ... each source URL once -``` - -The abbreviated example illustrates grouping; live output quotes every returned -item. Default omits unrelated sampled headlines and versions with no statement -or keyword evidence. No-impact stays a count. `--verbose` includes returned note -previews and locators for those otherwise-hidden versions, within the same -version grouping. Coverage remains visible in both modes; aggregate counts -cannot identify which specific versions are pending. - -The formatter preserves backend Unicode and uses ASCII for its own punctuation. -Free prose wraps at the caller width (80 by default, minimum 20), while locators -remain intact. CLI supplies terminal width and optional ANSI; MCP uses 80 and -no ANSI. Headings and identity are styled, attention labels and matched keywords -may be yellow; removing ANSI preserves words and hierarchy. - -Other compact evidence limits remain: five advisories, transitive package -details and dependency-issue locators per group, ten peer changes, and bounded -dependency examples. Verbose expands these. Defined zero-valued dependency -comparisons remain visible; omitted comparisons stay omitted. Missing target -security retains `Target: deprecation unknown`. - -## Fact Reporting Rules - -The tool reports facts and missing evidence. It does not assign package-level `low` / `medium` / `high` risk, an overall score, or an accept/reject verdict. Per-statement agent classifications are evidence: the quoted release-note statement, tier, optional kind, version and referenced source. JSON additionally preserves model and formulation provenance. Labels describe the classifier's reading of that statement, never compatibility of the package with the caller's code. The calling agent or human reviewer owns that assessment. - -The factual evidence includes: - -- Version relationship: major, prerelease, downgrade, same-version, or unknown version shape. -- Target deprecation metadata: verified deprecated, verified not deprecated, or unavailable. -- Direct advisory diff: added, fixed, and still-present vulnerabilities after alias-cluster deduplication. -- Changelog evidence: source, body availability, version-grouped quotes, lexical keyword hints, agent labels and explicit coverage; raw model provenance stays in JSON. -- Peer dependency metadata changes. -- Direct and transitive dependency graph changes. -- Transitive vulnerability and dependency issue diffs when requested. -- Missing or filtered evidence in `unknowns[]`. - -Keyword matching should be lexical and transparent, not hidden model inference. Treat matches only as sampling hints. Start with conservative terms in changelog bodies: `breaking`, `breaks`, `removed`, `drop support`, `migration`, `migrate`, `deprecated`, `renamed`. Avoid broad ecosystem terms such as `node`, `python`, `peer`, `requires`, `config`, or `engine`; real runs showed those produce false positives from CI/config mentions that are not compatibility changes. Handle obvious negations such as `no breaking changes`. - -## Current Implementation - -The CLI/MCP implementation has one active backend path: - -1. `buildPackageUpgradeReviewRequest` validates single-package vs batch mode, normalises registry values to backend enum casing, maps `min_severity` to CVSS thresholds, and keeps `low` unfiltered. -2. `buildPackageUpgradeReview` calls `PackageIntelligenceService.packageUpgradeReview` exactly once with the full package batch. -3. `PackageIntelligenceServiceImpl.packageUpgradeReview` posts the aggregate GraphQL query, validates the typed response with Zod, strips `null` fields to the existing JSON omission convention, and reuses standard package-intelligence transport/auth/error handling. -4. The shared response module normalises backend enum strings to the existing CLI/MCP JSON/text casing (`NPM` -> `npm`, `MAJOR` -> `major`, `HIGH` -> `high`) and owns the grouped terminal/text-v1 formatter described above. - -There is deliberately no compatibility fallback to the old client-side fanout. Backend schema mismatch, missing resolver, or deployment skew should surface as the normal package-intelligence backend/protocol error. This prevents the CLI from silently making many backend calls after the aggregate tool exists. - -## Implementation Notes For CLI/MCP - -- `packages/mcp/src/tools/package-upgrade-review.ts` is the MCP entrypoint. -- `src/commands/pkg/upgrade-review.ts` is the CLI entrypoint. -- The CLI command owns positional range parsing and normalisation; the shared - request builder continues to receive structured single-package fields or - `packages[]` and does not parse CLI shorthand. -- `packages/mcp/src/shared/package-upgrade-review-request.ts` owns validation and backend param construction. -- `packages/mcp/src/shared/package-upgrade-review-response.ts` owns backend response normalisation and text/JSON formatting. -- `packages/core-internal/src/services/package-intelligence-service.ts` owns the aggregate GraphQL query and typed service method. -- Parity tests assert CLI `--json` and MCP `format:"json"` equality for single, batch, backend unknown-evidence, and validation-error paths. - -## Acceptance Criteria - -- MCP `pkg_upgrade_review` and CLI `githits pkg upgrade-review` expose equivalent JSON envelopes for single-package and repeatable-package batch input. -- The tool calls the aggregate backend `packageUpgradeReview` operation once per request. -- The tool has no fallback to `packageSummary`, `packageVulnerabilities`, `packageChangelog`, `packageDependencies`, or the old upgrade dependency probe. -- The tool never returns package-level risk levels or compatibility verdicts. Changelog statement labels include the quote and agent attribution, with model/formulation provenance in JSON; all other evidence remains factual. -- Backend enum casing is normalised to the existing public JSON/text contract. -- Transitive security defaults on and can be disabled with `skip_transitive_security` / `--no-transitive-security`; `include_dependency_issues` selects the backend `dependencyIssues` subtree only when requested. -- Backend schema mismatch surfaces a protocol error; the owner confirms the aggregate and risk fields are already deployed. - -## Resolved Decisions - -- `packages[]` is part of `pkg_upgrade_review` v1 because batch upgrade review is the core agent UX problem. -- Transitive security evidence defaults on so vulnerability output includes dependency-tree evidence by default. -- `include_dependency_issues` is a separate flag and defaults to `false`. -- Direct/transitive dependency-change diffs are backend-provided and rendered as facts. -- `versionDiff` remains deferred until a dedicated typed service/error surface exists. -- Text mode shows compact summaries by default. `--verbose` / `verbose: true` adds dependency-change examples, including transitive version changes. -- Runtime/engine compatibility is not asserted because no stable backend field exists. Lexical changelog signals are reported only as sampled evidence hints unless later verified by schema data. - - -## Model-classified release-note statements - -`riskItems` and `riskCoverage` are selected in the same aggregate operation, -independently of `changelogLimit`. The backend returns up to 50 statements per -review, ordered `MUST_ACT`, `SHOULD_KNOW`, `UNCLASSIFIED`, cutting lower-priority -items first. No-impact statements appear only in `unitsNoImpact`. Keep existing -`breakingSignals`, `migrationSignals` and entry `signals`: these remain lexical -hints, separate from model labels. - -Single-package text combines evidence from different sources under one heading -per version. Each statement and additional keyword quote starts with `*`. Confident Requires action and uncertain Possibly requires action quotes are full; Should know and Too long to classify -prefixes are up to 240 codepoints after Markdown link destinations become -numbered source references. Bullet, blockquote and inline-code syntax renders as -visible words; GitHub alert markers become e.g. `IMPORTANT:`. Words are not -paraphrased or semantically deduplicated. Local prefixes end in `...`, with one -expansion hint; backend truncation separately says `[statement truncated by -backend]`, including verbose. Verbose expands quotes and shows headings and -confidence. Kinds map to removal, behavior, runtime/platform, packaging/modules, -deprecation, security fix and notable change. Kind labels use brackets, such as -`[security fix]`, colored yellow only in color-enabled CLI output. A missing -kind means no confident category was returned; the tier still applies. Those -quotes retain a bullet and no invented kind label. Coverage and no-impact units -share one summary, wrapping naturally at the caller width. - -Only backend `ambiguous` controls uncertainty. MUST_ACT with `ambiguous: true` -appears as Possibly requires action with a muted `(uncertain)` marker; false -appears as Requires action. SHOULD_KNOW retains its tier and marks true items -uncertain, after false items. Numeric tier confidence remains evidence in JSON -and verbose text and never drives a client policy. The backend can change its -ambiguity policy without a client release; ambiguous tiers may be raised or -unchanged. A supplied kind is rendered without a client threshold; missing -kinds still retain their tier. - -UNCLASSIFIED means only a statement too large to classify; its section says -Too long to classify - read it. Null confidence normalizes to omission for that -case. Summary and batch counts use the backend coverage totals before the -50-item cap, not counts reconstructed from returned quotes. Confident and -ambiguous act/know totals stay separate; batch ranking uses only the confident -action total, preserving backend order on ties. Per-version section headings omit counts; the summary carries the pre-cap totals. Positive omitted counts explain the -difference without guessing classifications of absent statements. - - -Every visible entry/link URL appears once in the Sources list, referenced by -version headings or quotes. Inline links clipped from compact quotes are omitted. -Each displayed version heading includes all available release-note/changelog -references from its items and entries, such as `5.2.1 (Release notes [1]; Changelog [2])`. -Statement `url` is authoritative even outside the entry sample. When it is absent, -entry links require matching version and `detailSource`; a missing locator is -identified by its source type and never borrowed from another source. Quoted -HTTP(S) links are also available through references. The Changes heading identifies agent -classification without model/formulation identifiers; batch text also identifies attribution; JSON retains them and lower-case entry `detailSource`. - -Keyword matching consumes `breakingSignals`, `migrationSignals` and entry -`signals`. A matched full chunk already contained in a statement with the same -version and defined source is not repeated as a keyword row. Distinct keyword -text or source is quoted in a separate `Keyword matches` subsection within that -version. Keyword matches report literal words, not a breaking-change verdict; -for example, an erroneous change described as reverted can still contain "breaking". These are lexical hints, separate -from the agent-assigned tiers and kinds. Commit-list -noise and generic headings stay excluded. Keywords already displayed with quotes -are not repeated in an aggregate footer; signals with no returned matching -excerpt remain explicit as `Keyword matches without excerpts`. Sampled/other/heuristic entry sections -are removed. All newly rendered strings use terminal sanitization; JSON stays -raw. - -Coverage always shows classified, not-assessed and without-notes counts, plus -no-impact units. Positive unparseable and omitted counts are explicit. Pending -versions are still being classified by background jobs or the job may have failed. Rerun a few seconds to a minute later to retrieve completed stored labels without rerunning the model; this does not promise that a failed job completed. Missing/unparseable -notes or an empty item list never imply no risk. Package-version fallback and -entry/locator sampling limits remain visible when present; ordinary entry -sampling never bounds statement classification. Classification runs asynchronously in backend jobs; there are no client retries or polling. - -Public JSON adds `changelog.riskItems` and `changelog.riskCoverage` on both CLI -and MCP, retaining the existing envelope, lower-case enums and null-to-omission -convention. Thus null tier confidence (too-large unclassified statements), kind, -kind confidence, heading or source are omitted. Confidence zero remains zero. -All quote text, truncation flags and per-item model/formulation values are preserved. Stored labels survive classifier changes, so a review may contain different models or formulations (for example s-hier-v2 and s-hier-v3); no review-level provenance assumption is made. - -The original query measured 262 complexity units before the change, 281 with all risk -fields, and 284 including entry `detailSource` on the three existing entry -selections, with both optional evidence subtrees enabled. Measurement used 501 -unique aliases of `summary.total`; dev rejected the probes with total operation -complexities 763, 782 and 785 respectively, without executing resolvers. Each -alias adds one unit. The original single-package 284 was below production’s 500 limit; no fields were trimmed or second query added in that original change. Current selection/batch measurements and the pending backend budget correction are below. The historical near-494 number does -not describe the current operation. - - -## Dev verification (2026-10-07) - -The following original measurements predate backend #3060/#3063/#3064. Updated confidence-contract measurements are recorded below. - -All authenticated calls used the normal CLI auth configuration with -`GITHITS_ENV=dev`, `GITHITS_API_URL=https://api-dev.githits.com`, -`GITHITS_MCP_URL=https://mcp-dev.githits.com`, and -`GITHITS_CODE_NAV_URL=https://pkgseer-backend-dev.fly.dev`. No credential material was exposed or extracted by the agent. - -Initial label requests (cold attempts) and subsequent CLI/MCP warm requests -returned the same counts below. Initial storage state cannot be proven from the -response, so these three ranges are not claimed to have been cold. - -| Range | Act / know / unclassified | Classified / not assessed / without notes / unparseable | No impact | Omitted | -| --- | --- | --- | --- | --- | -| npm:axios@0.27.2..1.0.0 | 6 / 1 / 3 | 1 / 0 / 0 / 0 | 83 | 0 | -| npm:express@4.21.2..5.0.0 | 14 / 2 / 6 | 1 / 0 / 0 / 0 | 63 | 0 | -| pypi:fastapi@0.109.2..0.110.0 | 3 / 0 / 0 | 1 / 0 / 0 / 0 | 26 | 0 | - -`bun run src/cli.ts pkg upgrade-review ` and `--json` passed for these -ranges. A three-package repeatable `--package` batch rendered Express, Axios, -FastAPI in action-count order. A local stdio `bun run src/cli.ts mcp start` -session called `pkg_upgrade_review` for each range in JSON, for the three-package -batch in text, and for Axios with `verbose: true`; JSON matched the CLI exactly. - -Validation: `bun test` passed 5,635 tests / 22,556 assertions across 235 files; -`bun run typecheck`, changed-TypeScript Biome, `bun run build`, -`bun run validate:packages`, `bun run plugins:generate` and -`bun run plugins:check` passed. Both built secret-free CLI/MCP smokes passed. -Source CLI/MCP smokes passed their unauthenticated paths with -`GITHITS_AUTH_STORAGE=file`; isolated live cohorts reported `AUTH_REQUIRED` and -skipped. With default auth, their empty config roots selected an unavailable -system keychain and failed before upgrade-review. Authenticated validation of -the changed surfaces used the real config in the direct calls above. - -Targeted `bun run agent:e2e --agent codex --server local --guidance-profile -descriptors --workload eval/agentic/workloads/package-upgrade-safety.md` used -the existing dedicated eval home. Neutral intent finished with high confidence -and no GitHits calls, so it does not validate tool use. With -`--intent-profile githits`, the same workload completed five logical MCP calls, -including a seven-package upgrade-review batch with `verbose: true` and a -single SDK rerun, with medium final confidence and no isolation violations. -The SDK batch returned 0 classified / 4 not assessed; the later single request -returned 4 classified / 0 not assessed, 9 statements and 17 no-impact units, -exercising the rerun guidance. No quality grading stage ran; no answer-quality -claim is made. - -Backend observations: dev rejects introspection, so deployed field validation -was paired with the owner-supplied schema. Axios includes near-duplicate removal -statements from releases and changelog_file; both are preserved. The eval also -showed transient SDK transitive-security counts changing between batch and -single requests. An immediate raw aggregate replay with identical options -returned equal zero-valued transitive blocks for both shapes; the cause is -unconfirmed and outside the new risk fields. No backend changes were made. - -After the batch-compatibility review fix, shared formatter/CLI-MCP parity -tests passed 35 tests / 241 assertions; typecheck/build, package validation, -plugin checks and both built smokes were rerun. - -Integration retained main PRs #460 and #461 plus release metadata #462; changelog/source-diff follow-up -guidance and all existing catalog assertions remain in place. - -Implementation review: internal full-delta review passed. External Claude round -1 identified default-batch compatibility evidence being suppressed; peer-change -and compatibility-note counts now retain it, including explicit missing evidence. -Round 2 and its fresh full-delta check returned no findings. Wire validation -retains the verified three-tier, seven-kind and 0-1 confidence contract. No -deferred implementation or refactoring work was identified. - -## Version-grouped UX revision (2026-10-07) - -The owner reviewed real Express `5.0.0..5.2.1` output and found repeated sources, -versions and entry previews. The shared formatter now combines returned evidence -under one version heading, replaces Markdown destinations with unique source -references, removes the sampled/heuristic entry sections and keeps classifier -identifiers exclusively in JSON. The same saved dev response's default Changes -section went from 4,212 bytes / 69 lines to 2,717 bytes / 52 lines at width 80; -this is an output-size observation, not a runtime performance claim. - -The full Bun suite passed 5,638 tests / 22,598 assertions. Focused formatter, -CLI/MCP parity, handler and descriptor-catalog checks initially passed 55 tests / -769 assertions. Typecheck, build, public-package validation, both built smokes and -source auth-handling smokes passed. Source isolated live cohorts still skipped -with AUTH_REQUIRED as described above. Normal-auth dev CLI verified the Express -example; local stdio MCP verified that example, all three original requested -ranges and their batch, with one heading per version, no model identifiers and -no repeated entry sections. Query selections and JSON projection are unchanged, -so complexity remains 284 / 500. The model owner remains the backend; no backend -or remote-MCP changes were needed. - -Targeted descriptor-only Codex eval with GitHits intent completed successfully -with medium confidence and nine logical MCP calls, including three -`pkg_upgrade_review` calls. Trace/final/metrics were inspected; no isolation -violation artifact was emitted. No quality grading ran. Internal review found -that excerpts could leave unreferenced link URLs in Sources; filtering the -source list to rendered references closes that gap for statements and keyword -quotes. The final affected checks passed 56 tests / 774 assertions, with -post-fix typecheck/build/package validation. The full-suite count above predates -that final focused source-list regression. - - -External UX review (round 3): direction sound. One low finding showed -note-authored numeric Markdown references could impersonate Sources citations -and keep an otherwise clipped link in the source list. Reference-style link -labels now render as words, and note-authored `[n]` markers render as `(n)` -before formatter citations are inserted. The same rendering covers statement -quotes, keyword excerpts, verbose note previews and headings; JSON remains raw. -The round limit prevents another external review of this fix. It is verified by -a regression covering these paths and a clean internal closure review; no unresolved -finding remains. Final focused checks passed 57 tests / 782 assertions, with -post-fix typecheck/build/package validation. - - -Owner follow-up: statement, keyword and verbose note items now use `*` bullets. -The redundant `Heuristic keywords` footer was removed; lexical evidence is -shown in a per-version `Keyword matches` subsection, and aggregate-only signals retain an -explicit missing-excerpt line. The old sampled-entry section also carried the Express 5.1.0 changelog URL, -which was lost when that section was removed because its classified quotes -came from releases. At that point, a version-level `Notes` reference retained returned -entry URLs whose source is not represented by a quote. Missing-locator -references still describe absent exact version/source pairs; the notes link -is never attributed to a quote from another source. The real -Express fixture asserts all returned entry URLs remain visible and unique. - -Follow-up validation: 58 focused formatter/parity/MCP tests passed with 800 -assertions; typecheck, Biome, build and public-package validation passed. Source -auth-handling and built CLI/MCP smokes passed. Normal-auth dev CLI Express and -local stdio MCP four ranges plus batch passed. Express has 11 statement bullets, -a version-level 5.1.0 Notes reference, all three returned changelog URLs and no -repeated keyword footer. Internal follow-up review returned no findings; the -existing three-round external-review limit remains in effect. - - -Presentation refinement: coverage and no-impact counts share one summary; -optional kind labels use brackets and color, while lexical hints appear in a -separate section within each version. Before the backend lowered its kind threshold, the observed 5.2.0 `res.redirect` warning -had `should_know` tier confidence 0.95 but no kind; its quote receives no invented -category. Bullets remain because labels are optional. - -Validation: 69 focused formatter/parity/MCP/release-boundary tests passed with -884 assertions; typecheck, Biome, build, package validation and all four -source/built smokes passed. Dev CLI and sequential local MCP four ranges plus -batch passed. One concurrent MCP probe hit the SDK 60-second timeout; the same -probe passed sequentially, and the cause is unconfirmed. Internal full-follow-up -review was clean; no additional external round under the existing limit. - - -## Historical background classification and confidence contract follow-up - -Backend #3060/#3063/#3064 are deployed on dev; production deployment was pending -at verification. No production probes or backend edits were performed. Field -names and query selections are unchanged; the prior measured complexity remains -284/500 for that selection. The local backend checkout at #3041 predates the -changes, so its old descriptions are not used as current semantic evidence. -Normal-auth dev responses verified both stored s-hier-v2 and new s-hier-v3 labels. - -| Range | Confident / uncertain act | Confident / uncertain know | Too long | Classified / not assessed / without notes / unparseable | No impact | Omitted | Formulation | -| --- | --- | --- | --- | --- | --- | --- | --- | -| npm:express@5.0.0..5.2.1 | 0 / 8 | 6 / 2 | 0 | 4 / 0 / 0 / 0 | 125 | 0 | s-hier-v2 | -| npm:express@4.19.2..4.21.2 | 3 / 1 | 8 / 2 | 0 | 4 / 0 / 0 / 0 | 43 | 0 | s-hier-v3 | -| npm:@biomejs/biome@2.4.2..2.4.15 | 13 / 28 | 9 / 0 | 0 | 13 / 0 / 0 / 0 | 812 | 13 | s-hier-v2 | - -These ranges were already fully classified; no cold-job completion claim is -made. The batch accepts both Express ranges independently. Biome's returned -statement counts exclude its 13 omitted items; their classifications are not -inferred. Source quotes, keyword evidence and all per-item JSON provenance stay -intact. No deployed data-contract contradiction was observed. - - -Follow-up verification: full `bun test` passed 5,645 tests / 22,680 assertions; -typecheck, Biome, build, public-package validation and all four source/built -CLI/MCP smokes passed. Source isolated live cohorts still skipped AUTH_REQUIRED -as recorded earlier. Direct normal-auth dev CLI and local stdio MCP checked all -three ranges in text/JSON and their batch. JSON risk fields matched between -surfaces, comparing fields without imposing order within backend tier ties. -The batch orders Biome (13 act +28 uncertain), Express 4.19.2..4.21.2 (3 +1), -then Express 5.0.0..5.2.1 (0 +8). A same-response comparison shows the 5.1.0 -internal-dependency quotes move from Requires action (6) to Possibly requires -action (6) with `(uncertain)` markers, preserving the full quote. - -Targeted Claude descriptor-only agent eval was attempted but the CLI reported -Not logged in, made zero tool calls and produced no final answer/grade. This is -an unavailable qualitative check, not a passing eval. No credentials were read -or exposed and no app login was started. Internal final review returned no findings; the existing external code-review round limit remains in effect. - -Final confidence-contract review: direction sound, no findings. No deferred implementation or refactoring work. The working plan is removed after review closure; no additional external code round under the existing limit. - - -## Backend-owned ambiguity and pre-cap totals (#3072) - -Implementation targets the backend PR schema. `ambiguous` is required per item; -false is preserved in JSON. Required coverage fields are -`itemsMustActConfident`, `itemsMustActAmbiguous`, `itemsShouldKnowConfident`, -`itemsShouldKnowAmbiguous` and `itemsUnclassified`. The confident counters -exclude ambiguous items; each classified tier total is confident plus ambiguous. -All five totals precede the 50-item cap, and zero values are preserved. Summaries and batch ordering consume these -totals directly. Tier confidence is evidence only, with no client threshold. -The Express fixture adds explicit mocked ambiguity/totals to an earlier captured -response; these additions are not claimed as live dev observations. - -Offline measurement used the exact query and SDL from backend -`bb3807cccee5b78d07aa865edccab2fc4f8e1c4b`, Absinthe 1.11.0 and the root field’s -actual complexity callback, without running resolvers or loading backend -configuration. All selected child fields use default complexity. The old -single-package baseline reproduced 284; adding the six fields gives 290 for one, -306 for three and 498 for 27 packages, with both optional sections enabled. - -The root callback adds 20 plus 8 per package to child complexity 262. A single -30-package operation costs 522 (the prior query already cost 516), exceeding the -production limit of 500. No selected field can be removed while retaining every -consumer. The owner chose to correct the backend complexity budget. The client -keeps one aggregate request and the public 30-package limit; it does not split -requests or encode a package count derived from a private backend formula. -These measurements precede that backend correction. The full supported batch -range cannot be claimed to fit the budget until the backend fix is verified. - -The owner supplied the #3072/#3077 dev deployment notice on 2026-10-08. -Combined-contract dev verification and server complexity corroboration completed -as recorded below. The production and 30-package gates were subsequently closed -during 0.29.0 release preparation, recorded at the end of this document. PR #463 -is merged. - -Before removal of the proposed client split, full `bun test` passed 5,652 tests / -22,738 assertions; typecheck, Biome, build, public-package validation and all four -secret-free source/built CLI/MCP smokes passed. Built smoke launches initially -overlapped package validation rebuilding dist; both passed after that rebuild -finished. After owner-directed split removal, the full suite passed 5,651 tests / 22,732 -assertions; typecheck, Biome, build, public-package validation and all four -secret-free smokes passed again. -Stable ambiguity/counter implementation internal review is clean. The existing -three-round external code limit remains; no fourth external code round. Targeted -agent evaluation was retried after deployment and remains unavailable because -Claude CLI is not logged in; no qualitative pass is claimed. - -## Known output limitations - -At 80 columns, an offline Express fixture with explicitly mocked #3072 flags and -counters rendered 97 lines: the first quote on line 22 and source list on line 63. -Two batch rows were 482 and 480 characters wide. These are layout measurements, -not new dev results or a graded usability evaluation. Batch rows are currently -unwrapped, and detailed security evidence precedes change quotes. - -Backend risk items now expose `url` independently of sampled entries and -`fullText` for complete statements. Both are available in JSON; the existing -text formatter uses statement URLs and available note references while preserving -bounded `text`. The client does not guess source URLs. Compact prefixes may -hide qualifications; verbose currently expands every section rather than a focused -item and does not recover backend-cut `text`. - -Statement tiers do not establish consumer applicability or reconcile later -reverts. Express evidence includes internal dependency removals/build changes -and a fix later reverted with its CVE rejected. Backend facts can identify -changed APIs and supersession; only the calling agent or user can inspect local -usage and decide what migration/testing is needed. Current output does not guide -that local investigation explicitly. Further evidence requirements are captured -in the durable data-limitations table and backlog; consumer applicability remains -with the calling agent. - -## Data available to a separately designed view - -CLI text design belongs to the owner. The data audit does not change rendering, -query selections or client classification policy. All 14 intended risk-item fields (including mode-specific full text) -and all 11 #3072 coverage fields are selected, validated and preserved in the public -CLI/MCP JSON, subject to the documented enum/null normalization. Numeric -confidence, false ambiguity, zero totals, raw quotes and mixed per-item provenance -remain available independently of text/verbose mode. Existing entry URLs/bodies, -keyword signals, security deltas, compatibility and dependency evidence remain. - -| View requirement | Data available now | Limitation | -| --- | --- | --- | -| Statement grouping and uncertainty | version, tier, ambiguous, kind, heading, confidence | Missing kind is not a missing tier; consumer relevance is not established. | -| Accurate triage totals | five pre-cap tier/ambiguity counters | Returned quotes are capped at 50; totals alone cannot supply omitted evidence. | -| Quoted evidence and provenance | text, textTruncated, fullText in JSON, model, formulation | Bounded text remains unchanged; text/verbose currently do not fetch uncut fullText. | -| Source navigation | item url independent of entry sampling; source enum and entry htmlUrl | URL can be absent; registry links may be package-wide. The text formatter uses these locators in version headings and source references. | -| Coverage overview | classified, not-assessed, without-notes, unparseable version counts | Counts cannot enumerate the affected versions or distinguish a failed job from ongoing classification. | -| Investigation beyond returned evidence | source text where a URL/body is returned; separate changelog/source tools | No risk-item continuation or item lookup is exposed by this aggregate contract. Reruns can fill classification coverage, not bypass the item/text caps. | -| Range interpretation | version and original quoted statements; endpoint security/dependency comparisons | No explicit relationship identifies a statement later reverted or superseded. | -| Consumer applicability | statements, kinds/headings, optional compatibility/dependency facts | The calling agent/user must inspect local usage; the backend cannot determine private application compatibility. | - -The latest locator/full-text additions close those two data gaps without -changing text design. A source URL can still be absent where the backend has no -URL, and registry URLs can be package-wide. Aggregate counts cannot enumerate -coverage membership, and omitted statements still lack continuation. Prospective -contract requirements are recorded in the durable limitations table above and -`docs/plans/open-backlog.md`; they require separate backend design. - -## Latest locator and full-text contract (2026-10-08) - -Backend main bf74b72f0 and SDL hash sha256:cbebf81f69dd add `url: String` and -`fullText: String!` on ChangelogRiskItem. The schema changelog says each URL points -to the source body quoted, including restatements from another source, independent -of `changelogLimit`/sampled entries. It may be a release page, anchored changelog -section, HexDocs page or package-wide registry notes link; null means no URL. -`fullText` is complete uncut text, not a replacement for bounded `text`. - -Core/public response types preserve URL (null-to-omission) and optional fullText. -The single query always selects URL and selects fullText with the internal -`includeChangelogFullText` Boolean. CLI --json and MCP format=json set it true; -compact and verbose text set it false because the current formatter does not -consume fullText. No public flag, second query or output design changes are added. -Raw untrusted text remains lossless in JSON, and ambiguity/provenance stay intact. - -**Combined-schema mismatch resolved on dev:** the earlier bf74b72f0 local -snapshot omitted #3072 fields. Backend #3072 merged at 707adc4571d86f506eed004e975870fbd44e14ff -and #3077 at d3d49cd417eebe688a149cb24e219a33b840da1e. The owner confirmed dev -deployment; authenticated CLI and local MCP verify all fields together. The -client retains backend ambiguity and pre-cap totals, with no confidence threshold -or returned-item totals fallback. The backend batch budget issue is independently -confirmed below; no backend edits were made. - -Locator adaptation verification: focused suite passed 291 tests / 1,233 assertions; -full Bun suite passed 5,656 tests / 22,773 assertions. Typecheck, Biome, build, -public-package validation and all four secret-free source/built CLI/MCP smokes -passed. Wire tests cover fullText include true/false/unset, URL independent of -empty sampled entries, null URL normalization, exact uncut text and rejection of -null fullText. CLI/MCP parity tests cover JSON mode selection and untouched compact/ -verbose output. JSON smoke fixtures now contain a statement to exercise these -assertions rather than passing on an empty array. Internal preflight is clean; -no fourth external review under this PR's existing cap. - -Offline Absinthe measurement used an **explicitly synthesized intended combined -SDL**: main sha256:cbebf81f69dd plus the exact six field declarations from #3072 -at 3b2b2e484. This is not a claim that a combined schema is deployed. With the -current root callback and both optional evidence sections enabled, costs are -292 for one, 308 for three, 500 for 27 and 524 for 30 packages. Absinthe includes -conditional selections in complexity even when the include variable is false; -fullText payload gating does not reduce the measured complexity. The -500 limit still fails for 28–30 packages. The owner chose backend budget handling; -there is no client split, counter reconstruction or second query. This offline -measurement is now corroborated by dev, as recorded below. No credentials were -read, extracted or displayed. - -## Combined-contract dev verification (2026-10-08) - -Normal CLI auth was used with `GITHITS_ENV=dev` and explicit API/MCP/OSS overrides -`https://api-dev.githits.com`, `https://mcp-dev.githits.com`, and -`https://pkgseer-backend-dev.fly.dev`. An inherited accounts override was removed; -no credential file, token value or authorization header was inspected or printed. - -CLI JSON was requested twice per range, plus CLI text and a three-range batch in -both modes. Local stdio MCP repeated those requests and Express verbose text. -First/repeat CLI JSON matched exactly. All single and batch CLI/MCP JSON and text -matched exactly, including risk items, coverage, URLs and fullText. All sampled -ranges were fully classified on the first request; this is not a cold classifier -or background-job completion claim. The first CLI requests were slow; later -repeats were prompt, and the cause was not isolated to classification. - -| Range | Confident/uncertain act | Confident/uncertain know | Versions classified/not assessed/without notes/unparseable | No impact | Returned/omitted | Formulation | -| --- | --- | --- | --- | --- | --- | --- | -| npm:express@5.0.0..5.2.1 | 0 / 8 | 6 / 2 | 4 / 0 / 0 / 0 | 125 | 16 / 0 | s-hier-v2 | -| npm:express@4.19.2..4.21.2 | 3 / 1 | 8 / 2 | 4 / 0 / 0 / 0 | 43 | 14 / 0 | s-hier-v3 | -| npm:@biomejs/biome@2.4.2..2.4.15 | 13 / 28 | 12 / 10 | 13 / 0 / 0 / 0 | 812 | 50 / 13 | s-hier-v2 | - -All 80 returned items had a URL and string fullText. Complete text starts with the -bounded quote. None of these ranges returned a backend-truncated quote; that edge -case remains covered by unit/parity tests rather than a live observation. Pre-cap -counter sums equal returned plus omitted items (Biome 63 = 50 + 13), and uncertain -counts follow `ambiguous` only. No numeric confidence policy was added. - -Express excerpt: - -```text -0 require action (+8 uncertain) | 6 should know (+2 uncertain) -Possibly requires action (6) - * (uncertain) "deps: remove safe-buffer" - * (uncertain) "deps: remove utils-merge" -``` - -Batch triage preserves confident-action ordering and the untruncated totals: - -```text -npm:@biomejs/biome 2.4.2 -> 2.4.15 | 13 act (+28 uncertain) | 12 know (+10 uncertain) -npm:express 4.19.2 -> 4.21.2 | 3 act (+1 uncertain) | 8 know (+2 uncertain) -npm:express 5.0.0 -> 5.2.1 | 0 act (+8 uncertain) | 6 know (+2 uncertain) -``` - -Dev complexity probes added 501 scalar aliases to the exact query and received -operation complexities 793 for one and 809 for three, without resolver execution: -subtracting 501 confirms **292 / 308**. A real unsplit 30-package request with -both optional evidence sections was rejected at **524**, maximum **500**, before -resolvers ran. The backend budget correction remains open; no client split or -selection removal was added. Conditional fullText still contributes complexity -even when payload is omitted. - -The implementation remains at 5,656 passing unit tests / 22,773 assertions and -passing build/typecheck/package/four secret-free smoke checks. Code CI passed on -817909a. The targeted Claude agent eval was retried with the dev environment: -failed because Claude CLI was not logged in, zero tool calls, no final artifact or -qualitative grade. This does not affect the successful direct CLI/MCP validation. -No output-design or backend implementation changes were needed for this verification. - -## Owner-requested note navigation verification (2026-10-12) - -The shared Changes heading now identifies agent classification. The owner also -removed the redundant compatibility disclaimer from single and batch text; the -fact-reporting rule remains unchanged. Displayed version -headings carry available note references, including statement URLs outside sampled -entries, replacing the earlier standalone Notes row. Subsection counts are omitted; -pre-cap summary totals remain. Already quoted lexical evidence is not repeated -as a keyword row; distinct keyword excerpts and raw JSON remain available. - -Normal-auth dev Express 5.0.0..5.2.1 CLI and local stdio MCP text matched exactly. -All displayed versions retained their note locators without missing-URL placeholders: - -```text -Changes (classified by agent) - ... summary and coverage - 5.2.1 (Changelog [1]; Release notes [2]) - Possibly requires action - ... quoted evidence - Should know - ... quoted evidence - 5.2.0 (Release notes [5]; Changelog [6]) - 5.1.0 (Release notes [11]; Changelog [12]) - 5.0.1 (Changelog [15]) -``` - -Dev returned its stored labels: 0 confident / 8 uncertain action statements, -6 confident / 2 uncertain knowledge statements, 4 classified versions and 125 -no-impact statements. These are dev observations, not inferred production counts. -The first request waited on Keychain approval; after owner approval the rerun -passed. Credentials were not inspected or printed. Focused formatter/MCP/parity -checks passed 55 tests / 498 assertions; typecheck, Biome, build and source/built -CLI/MCP smokes passed. Internal preflight was clean after one doc wording fix. -The external PR review cap remains in effect. Targeted agent eval could not run -tool calls because Claude CLI was not logged in. GraphQL selection is unchanged; -no new complexity measurement was needed for this presentation-only refinement. - -## Production release verification (2026-10-12) - -The owner confirmed the combined schema and backend batch-budget correction are -both deployed to production. Release preparation independently ran the local -CLI and local stdio MCP against the production preset with endpoint overrides removed and normal -auth handling; credentials were not inspected or printed. - -- `pkg upgrade-review npm:express@4.19.2 --to 4.21.2 --json` succeeded with one - review and 14 statements, each including complete `fullText`. -- One unsplit request with 30 repeated `npm:express@4.19.2..4.21.2` package - entries succeeded with 30 reviews. This checks the formerly rejected public - maximum against the exact current operation, rather than inferring readiness - from a backend deployment. Each review preserved all 14 complete statements. -- Production local stdio MCP single-package JSON matched the CLI response - exactly. MCP accepted 30 packages with dependency issues and transitive - security enabled, preserving 14 complete statements in every review. MCP text - included the version-grouped agent-classified evidence. -- Required backend ambiguity, pre-cap coverage counters and source locators - passed the client response validator. The earlier dev complexity rejection - remains historical evidence; no client limit, split, fallback or backend edit - was introduced. - -The production support and maximum-batch gates are closed. Hosted MCP clients -receive the new package behavior after `@githits/mcp` adoption and deployment. -The completed backend verification plan is removed after clean release review. - -Release validation: full `bun test` passed 5,694 tests / 22,867 assertions; -typecheck, formatting, lint, both package builds, generated-asset checks, -public-package publish dry-run validation and source/built CLI/MCP smoke checks -passed. Source live cohorts were skipped with `AUTH_REQUIRED` under explicitly -credential-free file storage; the production CLI/MCP probes above used normal auth. -The initial source live probes failed in isolated homes with default keyring -storage; switching the smoke runs to credential-free file storage resolved the -auth-handling checks. An initial built CLI launch overlapped package validation's -rebuild and found no `dist/cli.js`; sequential reruns passed. - -Targeted neutral descriptor-only Codex `package-upgrade-safety` evaluation -completed in 115.229 seconds with a high-confidence final answer but zero GitHits -tool calls. The answer used external web sources, so it provides no qualitative -coverage of the changed upgrade-review tool. No isolation-violation artifact was -emitted by the validator. Tool traces, final and metrics were inspected; no -quality grade ran. Local artifacts are retained under -`.agent-eval/release-029-upgrade-safety/`. +JSON selects fullText and sourcePosition, including complete context text, and +requires confidence/model/formulation fields on non-null classifications before +normalization. Per-item provenance is not collapsed to one formulation per review. + +`riskCoverage` retains versionsClassified, versionsNotAssessed, +versionsWithoutNotes, versionsUnparseable, unitsNoImpact, itemsMustActConfident, +itemsMustActAmbiguous, itemsShouldKnowConfident, itemsShouldKnowAmbiguous and +itemsUnclassified. Each tier total is confident plus ambiguous. Remove +`itemsOmitted`: it describes the deprecated 50-item backend risk API, not complete +statement pages. Neither that old API nor a synthesized compatibility riskItems +array is selected or returned. + +This is a pending minor migration for both public 0.x artifacts. Consumers must +read classification fields under each statement, handle nullable classification +and use collection/coverage counts rather than the old cap. Core and shared +legacy risk-item types/options/helpers are removed after consumer migration. +The public `@githits/mcp/smoke-test` helper now validates the complete statement +contract and rejects legacy cap fields. Hosted adoption requires the new aggregate +statement schema in production before release/deployment; this increment verifies +dev and does not deploy either server. + +## Other evidence and fact-reporting rules + +Direct advisory deltas are backend-provided over logical advisory identity +(`id` and aliases); introduced/fixed/unchanged aliases remain in JSON while text +uses added/fixed/still-present wording. Transitive diffs are package/advisory-aware: +a changed dependency version with the same affected advisory remains still +present. A package can have both introduced and retained advisories. Security +pages retain totalCount/truncated metadata; complete changelog pagination does not +make those bounded security details complete. + +Undefined target deprecation means unknown, not false. Direct dependency set or +constraint changes remain install facts. Transitive graph changes provide context; +root/synthetic graph nodes are excluded by the backend. Peer changes and +compatibility notes are factual. Runtime engines/versionDiff are not invented. + +Per-statement agent classifications are quoted evidence with provenance, never a +package-level score, risk level or compatibility verdict. They express the model's +reading of a statement, not applicability to the caller's code. The calling +agent/user owns assessment and local testing. Never label an upgrade safe or +approved from absent statements, missing notes or zero counters. Supersession, +reverts and private-code applicability remain limitations recorded in +[open-backlog.md](../plans/open-backlog.md). + +## Verification (2026-10-14) + +The local canonical backend SDL and dev queries verify the new aggregate field; +dev disables introspection. The initial batch remains unsplit at the public +30-package maximum. The calibrated admission control independently costs 5100; +subtracting it from each rejected combined probe measures the actual operation: + +| Final operation, security and dependency issues enabled | Complexity | +| --- | --- | +| JSON initial, 1 / 3 / 30 packages | 330 / 340 / 475 | +| JSON all initial, 30 packages | 475 | +| Compact triage, 30 packages | 391 | +| JSON continuation, 1 package | 88 | + +The maximum is 25 below dev's 500 limit. No second initial query, reduced public +maximum or backend change is used. The previous capped operation and maximum +were verified on production during the 0.29.0 release on 2026-10-12; that earlier +verification does not certify deployment of this new field. + +Authenticated built CLI and local stdio MCP on dev produced equal focused JSON +for these ranges. All were warm stored labels; no cold-latency claim is made. + +| Range | Parsed / focused returned | Act confident + uncertain | Know confident + uncertain | No impact | Classified / pending / without notes / unparseable versions | +| --- | --- | --- | --- | --- | --- | +| npm:express 5.0.0..5.2.1 | 141 / 15 | 0 + 8 | 6 + 1 | 126 | 4 / 0 / 0 / 0 | +| npm:express 4.19.2..4.21.2 | 57 / 14 | 3 + 4 | 7 + 0 | 43 | 4 / 0 / 0 / 0 | +| npm:@biomejs/biome 2.4.2..2.4.15 | 875 / 62 | 13 + 27 | 12 + 10 | 813 | 13 / 0 / 0 / 0 | + +Express text/JSON parity passed for focused, actionable, important and all; +three-package compact/verbose text parity also passed. Biome all returned all +875 statements. Source and built CLI/MCP smoke modes passed. Unit coverage +includes pagination past 100, filtered gaps, nullable/oversize/truncated evidence, +zero/pending coverage, counter agreement and changes, canonical/duplicate review +identity, sanitization, source/keyword preservation and mode-specific over-fetch +controls. Standalone changelog regression behavior remains covered. + +Built Node Biome all, with the explicitly requested dev URLs, completed in +13.683 seconds across nine observed backend requests (one initial page and eight +continuations), returning 875 items. This single-run walltime includes startup, +normal authentication, network, serialization and a request-count observer; it +is not isolated backend latency or a speedup claim. + +For the same three warm ranges with transitive security and dependency issues +selected, compact serialized raw GraphQL responses were 443,505 bytes for the +previous capped projection, 467,275 for detailed statements and 16,202 for triage. +The detailed response includes the 12 Biome matches beyond the old cap. Helper +walltimes include authentication/startup and are not comparable product latency. + +The descriptor/intent Codex `package-upgrade-safety` workload completed on the +explicit Fly dev endpoint in 177.3 seconds with nine logical calls, including two +completed upgrade reviews. The final answer used returned quotes, sources and +coverage to suggest local checks; that is observed behavior, not a quality grade. +One nonexistent TypeScript release follow-up failed; a research call remained +unfinished in the trace. No isolation violations were recorded. The first run +using the repo's `oss-dev.githits.dev` preset timed out after starting a review; +the cause was not established. Direct reproduction of its exact seven-package +arguments succeeded on the explicit endpoint using both built Node and source +Bun MCP (3.1 and 9.2 seconds). No auth storage or tokens were inspected/exported. + +After the review's MCP argument-wording fix, the same workload ran again. Its +seven-package upgrade review completed in 19.7 seconds and nine calls completed; +the 182-second harness timeout occurred after it entered broader research. That +run produced no final answer or quality grade. The completed tool trace does not +make the whole workload successful; no isolation violations were recorded. + +Local verification artifacts are under +`/tmp/upgrade-review-statements-implementation/`; raw traces/metrics are local +working evidence, while the measurements and limitations above remain durable. +Public skill guidance needs the corresponding filter/JSON update during release +preparation, not an unpublished behavior change in this feature PR. diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index c165ce26..1341c9ec 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -176,7 +176,7 @@ callable. | `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?`, `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. | +| `pkg_upgrade_review` | `registry?`, `package_name?`, `current_version?`, `target_version?`, `packages?`, `changes?: string`, `skip_transitive_security?`, `include_dependency_issues?`, `min_severity?`, `verbose?`, `format?` | Review a package upgrade: vulnerabilities, release statements, compatibility, and dependency changes. `changes` accepts `actionable`, `important`, or `all`; omitted or blank means focused. Filters affect detailed quotes, not whole-range counters or batch ranking. Reports facts without an upgrade verdict; supports up to 30 packages. | | `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). | @@ -537,7 +537,7 @@ 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 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. +**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 also uses a complete statement collection, with a focused default and whole-range riskCoverage counters; see [pkg-upgrade-review.md](pkg-upgrade-review.md). 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`. @@ -567,15 +567,29 @@ Source links stay next to release/source groups, without a global Sources table. Tool and CLI guidance encourage using heuristic signals as a starting point to evaluate codebase impact, with `pkg_changelog` / `pkg changelog` and `code_diff` / -`code diff` available for full release-note and source-change details. This does -not require exhaustive changelog retrieval or prescribe an assessment format. - -The JSON envelope is the stable structured boundary: it preserves the existing -`summary` and `reviews` fields, normalized evidence categories, bounded backend -metadata, and unknown evidence without adding assessment fields. CLI -`--json` and MCP `format: "json"` use this same envelope and are compared by -the package-upgrade-review parity test. `text-v1` evolves in place and is not a -byte-stable contract. +`code diff` available for original release-note bodies and source-change detail. +Upgrade review returns filtered statement evidence without prescribing an +assessment format. + +`changes` is a string filter: omitted or blank means `focused` (`MUST_ACT`, +`SHOULD_KNOW`, and `UNCLASSIFIED`); `actionable` selects `MUST_ACT`, including +uncertain labels; `important` selects `MUST_ACT` and `SHOULD_KNOW`; `all` includes +all parsed statements, including no-impact, null-classification, and oversized +units. Standalone `pkg_changelog` still defaults to all parsed statements. +Filters select detailed quotes only. Compact batch triage fetches no statement +pages, and its whole-range coverage counters and package ranking do not change +with the selected filter. + +The JSON envelope preserves `summary`, `reviews`, security/dependency evidence, +and unknowns. Upgrade changelog JSON replaces `changelog.riskItems` with the +paginated `changelog.statements` collection, including `count`, `totalCount`, +`matchingCount`, `selection`, `items`, and `coverage`; detailed results always +include the effective `selection`. Statement items retain source positions, +context, uncut `fullText`, and nullable classification with provenance. The +whole-range `riskCoverage` counters remain, except `itemsOmitted`, which is +removed. CLI `--json` and MCP `format: "json"` use this same envelope and are +compared by the package-upgrade-review parity test. `text-v1` evolves in place +and is not a byte-stable contract. Default text is one shared formatter for CLI and MCP. It leads with `Upgrade review - N package(s)`, then groups each package as identity, @@ -590,19 +604,24 @@ locators, truncation facts, and follow-up guidance. The formatter accepts ANSI and width as inputs. CLI supplies its current terminal width and enables ANSI when supported; MCP uses no ANSI and the 80-column default. Outcome and section headings are bold-only, package identity -is bold cyan, and yellow is limited to compact attention summaries, labels, and -matched signal terms. Heuristic section labels remain plain; only the matched -keyword and excerpt marker are yellow. Evidence detail and locators remain +is bold cyan, compact attention summaries use yellow. Action statement tags use red, +should-know tags yellow, and uncertain annotations muted. Keyword sections +remain separate lexical evidence. Evidence detail and locators remain plain, and prose remains meaningful without color. Free prose uses hanging indentation and a minimum width of 20, while coordinates, versions, advisory -IDs, and URLs remain unsplit. Changelog labels are exact (`Repository releases`, -`Package versions (no release notes)`, or the normalized non-empty source -verbatim). A defined -zero-valued dependency comparison renders both direct and transitive zero -counts; undefined evidence is omitted. Existing sample caps and `verbose` -expansion remain unchanged except -that dependency-issue locators are capped at five rows per category in default -text with an explicit remainder; `verbose` expands them fully. +IDs, and URLs remain unsplit. Changelog groups use backend source labels such as Release notes or +Changelog and their authoritative URLs. A defined zero-valued dependency comparison renders both direct and transitive zero +counts; undefined evidence is omitted. Existing security/advisory sample caps +remain unchanged. Dependency-issue locators are capped at five rows per +category in default text with an explicit remainder; `verbose` expands them +fully. Detailed `Changes` renders all matching statements from their paginated +pages as source-ordered bullets with nearby source links and context, without +tier headings, global numbered references, or duplicate sampled-note previews. +`verbose` expands quotes only to backend-bounded text and adds full batch, +security, and dependency detail; JSON alone contains uncut `fullText` and +classification provenance. Filters never change whole-range counters or +package ranking. See [Upgrade review](pkg-upgrade-review.md) for the complete +shared CLI/MCP behavior and JSON contract. ### `list` / `read` / `grep` response shapes diff --git a/docs/plans/open-backlog.md b/docs/plans/open-backlog.md index 8ab74896..0a3635f7 100644 --- a/docs/plans/open-backlog.md +++ b/docs/plans/open-backlog.md @@ -32,9 +32,26 @@ Single-run token changes and self-reported confidence are not quality scores. ## Upgrade review evidence completeness -The aggregate backend contract cannot enumerate coverage membership, retrieve -statements omitted by the cap, or identify later reverted/superseded statements. -These require backend API design rather than client heuristics; they are outside -the completed classification/locator increment. Preserve the concrete data -requirements in the [upgrade review limitations](../implementation/pkg-upgrade-review.md#known-output-limitations) -when planning that work. Consumer applicability remains the calling agent's job. +The new aggregate `changelog.statements` contract exposes per-version coverage +membership and complete pages beyond the legacy risk-item cap. Client adoption +is documented in [upgrade review](../implementation/pkg-upgrade-review.md). + +Structured supersession/revert relationships remain unavailable. That requires +backend API design, not client heuristics, and stays outside this migration. +Preserve the concrete requirements in the +[upgrade review limitations](../implementation/pkg-upgrade-review.md#other-evidence-and-fact-reporting-rules). +Consumer applicability remains the calling agent's job. + +Concrete remaining case: Express 5.2.0 describes a security fix for +CVE-2024-51999; 5.2.1 says the prior change was erroneous, reverted, and the CVE +rejected. Separate versioned quotes are available, but no field links the earlier +claim to its correction. A reader must reconcile them manually; tier counts do +not describe the effective state at the target version. + +Outcome: backend-provided supersession/revert relationships that retain the +original statements and cite the correcting statement's version/source evidence. +Acceptance: fixtures for that Express range expose the relationship with locators +and provenance; clients can display it without keyword-based guessing or a +package verdict. This needs backend/domain design and remains outside the +completed statement migration. Private-code applicability still requires local +usage inspection and before/after checks by the caller. 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 a7813e2f..c16f46d7 100644 --- a/packages/core-internal/src/services/package-intelligence-service.test.ts +++ b/packages/core-internal/src/services/package-intelligence-service.test.ts @@ -4,7 +4,6 @@ import { AuthenticationError, TermsAcceptanceRequiredError, } from "./githits-service.js"; -import type { PackageUpgradeReviewParams } from "./package-intelligence-service.js"; import { MalformedPackageIntelligenceResponseError, PackageIntelligenceAccessError, @@ -3150,482 +3149,6 @@ describe("PackageIntelligenceServiceImpl — package docs targets", () => { }); }); -describe("PackageIntelligenceServiceImpl — packageUpgradeReview", () => { - const ENDPOINT = "https://pkgseer.dev"; - - const upgradeReviewBody = { - data: { - packageUpgradeReview: { - summary: { - total: 1, - withUnknowns: 0, - withAddedAdvisories: 1, - withBreakingSignals: 0, - withDirectDependencyChanges: 0, - withTransitiveVulnerabilityAdditions: 0, - }, - reviews: [ - { - registry: "NPM", - name: "express", - currentVersion: "4.18.0", - targetVersion: "5.0.0", - latestVersion: "5.0.0", - versionDelta: "MAJOR", - security: { - current: null, - target: null, - added: [ - { - id: "GHSA-test", - aliases: [], - summary: "Example advisory", - severity: 7.5, - severityLabel: "HIGH", - fixedIn: ["5.0.1"], - isMalicious: false, - }, - ], - removed: [], - notAddressed: [], - fixed: [], - introduced: [], - unchanged: [], - }, - changelog: { - source: null, - fallback: "PACKAGE_VERSIONS", - entries: [], - sampledEntries: [], - keywordEntries: [], - totalKeywordEntries: 0, - totalEntries: 0, - totalEntriesWithBodies: 0, - truncated: false, - hasReleaseNoteBodies: false, - breakingSignals: [], - migrationSignals: [], - riskItems: [ - { - version: "5.0.0", - tier: "UNCLASSIFIED", - ambiguous: false, - tierConfidence: null, - kind: null, - kindConfidence: null, - text: "raw quote\u001b[31m", - textTruncated: true, - url: null, - heading: null, - source: null, - model: "jev-1.13.0", - formulation: "d-hier-v1", - }, - ], - riskCoverage: { - versionsClassified: 0, - versionsNotAssessed: 20, - versionsWithoutNotes: 2, - versionsUnparseable: 1, - unitsNoImpact: 0, - itemsMustActConfident: 0, - itemsMustActAmbiguous: 0, - itemsShouldKnowConfident: 0, - itemsShouldKnowAmbiguous: 0, - itemsUnclassified: 0, - itemsOmitted: 8, - }, - }, - compatibility: null, - dependencyChanges: null, - dependencyIssues: null, - unknowns: [], - }, - ], - }, - }, - }; - - for (const count of [1, 3, 30]) { - it(`sends all ${count} upgrades in one aggregate query`, async () => { - const packages: PackageUpgradeReviewParams["packages"] = Array.from( - { length: count }, - (_, index) => ({ - registry: "NPM", - name: `package-${index}`, - currentVersion: "1.0.0", - targetVersion: "2.0.0", - }), - ); - const requests: Array<{ - query: string; - variables: PackageUpgradeReviewParams; - }> = []; - const fetchFn = mock((_url: string, init?: RequestInit) => { - const request = JSON.parse(String(init?.body)); - requests.push(request); - const batch = request.variables - .packages as PackageUpgradeReviewParams["packages"]; - const base = upgradeReviewBody.data.packageUpgradeReview.reviews[0]!; - return Promise.resolve( - jsonResponse({ - data: { - packageUpgradeReview: { - summary: { - total: batch.length, - withUnknowns: batch.length, - withAddedAdvisories: batch.length, - withBreakingSignals: batch.length, - withDirectDependencyChanges: batch.length, - withTransitiveVulnerabilityAdditions: batch.length, - }, - reviews: batch.map((pkg) => ({ - ...base, - ...pkg, - changelog: { - ...base.changelog, - riskItems: [ - { - ...base.changelog.riskItems[0], - tier: "MUST_ACT", - ambiguous: true, - tierConfidence: 0.95, - }, - ], - riskCoverage: { - ...base.changelog.riskCoverage, - versionsClassified: 1, - versionsNotAssessed: 0, - itemsMustActAmbiguous: 1, - }, - }, - })), - }, - }, - }), - ); - }); - const service = new PackageIntelligenceServiceImpl( - ENDPOINT, - createMockTokenProvider(), - asFetchFn(fetchFn), - ); - const result = await service.packageUpgradeReview({ - packages, - includeTransitiveSecurity: true, - includeDependencyIssues: true, - changelogLimit: 20, - minSeverity: 7, - }); - expect(requests.map((r) => r.variables.packages.length)).toEqual([count]); - expect(requests.flatMap((r) => r.variables.packages)).toEqual(packages); - for (const request of requests) { - expect(request.variables).toMatchObject({ - includeTransitiveSecurity: true, - includeDependencyIssues: true, - changelogLimit: 20, - minSeverity: 7, - }); - expect(request.query).toBe(requests[0]!.query); - } - expect(result.summary).toEqual({ - total: count, - withUnknowns: count, - withAddedAdvisories: count, - withBreakingSignals: count, - withDirectDependencyChanges: count, - withTransitiveVulnerabilityAdditions: count, - }); - expect(result.reviews.map((r) => r.name)).toEqual( - packages.map((p) => p.name), - ); - expect( - result.reviews.every( - (r) => - r.changelog.riskItems[0]!.ambiguous && - r.changelog.riskItems[0]!.tierConfidence === 0.95 && - r.changelog.riskCoverage.itemsMustActAmbiguous === 1 && - r.changelog.riskCoverage.itemsMustActConfident === 0, - ), - ).toBe(true); - }); - } - - it("preserves backend validation for more than 30 packages", async () => { - const fetchFn = mock((_url: string, init?: RequestInit) => { - expect(JSON.parse(String(init?.body)).variables.packages).toHaveLength( - 31, - ); - return Promise.resolve( - jsonResponse({ - errors: [ - { - message: "packages must contain at most 30 upgrades", - extensions: { code: "VALIDATION_ERROR" }, - }, - ], - }), - ); - }); - const service = new PackageIntelligenceServiceImpl( - ENDPOINT, - createMockTokenProvider(), - asFetchFn(fetchFn), - ); - await expect( - service.packageUpgradeReview({ - packages: Array.from({ length: 31 }, () => ({ - registry: "NPM", - name: "express", - currentVersion: "4.0.0", - targetVersion: "5.0.0", - })), - includeTransitiveSecurity: true, - includeDependencyIssues: true, - changelogLimit: 20, - }), - ).rejects.toThrow("packages must contain at most 30 upgrades"); - expect(fetchFn).toHaveBeenCalledTimes(1); - }); - - for (const includeChangelogFullText of [undefined, false, true]) { - it(`selects source URLs and controls full text with ${includeChangelogFullText}`, async () => { - const body = structuredClone(upgradeReviewBody); - const item = body.data.packageUpgradeReview.reviews[0]!.changelog - .riskItems[0]! as Record; - const fullText = `Quoted evidence ${"context ".repeat(180)}END\u001b[31m`; - item.text = fullText.slice(0, 1000); - item.url = "https://example.com/History.md#L20-L40"; - if (includeChangelogFullText === true) item.fullText = fullText; - let request: - | { query: string; variables: Record } - | undefined; - const fetchFn = mock((_url: string, init?: RequestInit) => { - request = JSON.parse(String(init?.body)); - return Promise.resolve(jsonResponse(body)); - }); - const service = new PackageIntelligenceServiceImpl( - ENDPOINT, - createMockTokenProvider(), - asFetchFn(fetchFn), - ); - const result = await service.packageUpgradeReview({ - packages: [ - { - registry: "NPM", - name: "express", - currentVersion: "4.0.0", - targetVersion: "5.0.0", - }, - ], - includeTransitiveSecurity: false, - includeDependencyIssues: false, - includeChangelogFullText, - changelogLimit: 1, - }); - expect(fetchFn).toHaveBeenCalledTimes(1); - expect(request?.variables.includeChangelogFullText).toBe( - includeChangelogFullText === true, - ); - expect(request?.query).toContain( - "fullText @include(if: $includeChangelogFullText)", - ); - expect(request?.query.match(/riskItems \{([^}]+)\}/)?.[1]).toContain( - "url", - ); - expect(result.reviews[0]!.changelog.entries).toEqual([]); - expect(result.reviews[0]!.changelog.riskItems[0]).toMatchObject({ - url: item.url, - text: fullText.slice(0, 1000), - textTruncated: true, - }); - expect(result.reviews[0]!.changelog.riskItems[0]!.fullText).toBe( - includeChangelogFullText === true ? fullText : undefined, - ); - }); - } - - 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 - .riskItems[0]! as Record; - item.fullText = null; - const service = new PackageIntelligenceServiceImpl( - ENDPOINT, - createMockTokenProvider(), - asFetchFn(mock(() => Promise.resolve(jsonResponse(body)))), - ); - await expect( - service.packageUpgradeReview({ - packages: [ - { - registry: "NPM", - name: "express", - currentVersion: "4.0.0", - targetVersion: "5.0.0", - }, - ], - includeTransitiveSecurity: false, - includeDependencyIssues: false, - includeChangelogFullText: true, - changelogLimit: 1, - }), - ).rejects.toThrow("Malformed response"); - }); - - it("sends aggregate upgrade-review variables and maps the typed response", async () => { - let capturedBody: string | undefined; - const fetchFn = mock((_url: string, init?: RequestInit) => { - capturedBody = init?.body as string; - return Promise.resolve(jsonResponse(upgradeReviewBody)); - }); - const service = new PackageIntelligenceServiceImpl( - ENDPOINT, - createMockTokenProvider(), - asFetchFn(fetchFn), - ); - - const result = await service.packageUpgradeReview({ - packages: [ - { - registry: "NPM", - name: "express", - currentVersion: "4.18.0", - targetVersion: "5.0.0", - }, - ], - includeTransitiveSecurity: false, - includeDependencyIssues: true, - changelogLimit: 20, - minSeverity: 7, - }); - - const parsed = JSON.parse(capturedBody ?? "{}"); - expect(parsed.query).toContain("packageUpgradeReview("); - expect(parsed.query).toContain("dependencyIssues @include"); - expect(parsed.query).toContain( - "transitive @include(if: $includeTransitiveSecurity)", - ); - expect(parsed.query).toContain("riskItems {"); - expect(parsed.query).toContain("riskCoverage {"); - expect(parsed.query).toContain("detailSource"); - for (const field of [ - "version", - "tier", - "ambiguous", - "tierConfidence", - "kind", - "kindConfidence", - "text", - "textTruncated", - "url", - "heading", - "source", - "model", - "formulation", - ]) { - expect(parsed.query.match(/riskItems \{([^}]+)\}/)?.[1]).toContain(field); - } - for (const field of [ - "versionsClassified", - "versionsNotAssessed", - "versionsWithoutNotes", - "versionsUnparseable", - "unitsNoImpact", - "itemsMustActConfident", - "itemsMustActAmbiguous", - "itemsShouldKnowConfident", - "itemsShouldKnowAmbiguous", - "itemsUnclassified", - "itemsOmitted", - ]) { - expect(parsed.query.match(/riskCoverage \{([^}]+)\}/)?.[1]).toContain( - field, - ); - } - expect(parsed.query).toContain("breakingSignals"); - expect(parsed.query).toContain("migrationSignals"); - expect(parsed.query).toContain("signals"); - expect(parsed.query).not.toContain("duplicateIds"); - expect(parsed.query).not.toContain("matchedAffectedVersionRanges"); - expect(parsed.query).not.toContain("affectedVersionRangesCount"); - expect(parsed.query).not.toContain("affectedVersionRangesTruncated"); - expect(parsed.query).not.toContain("affectsInspectedVersion"); - expect(parsed.variables).toMatchObject({ - packages: [ - { - registry: "NPM", - name: "express", - currentVersion: "4.18.0", - targetVersion: "5.0.0", - }, - ], - includeTransitiveSecurity: false, - includeDependencyIssues: true, - changelogLimit: 20, - minSeverity: 7, - }); - expect(result.summary.withAddedAdvisories).toBe(1); - expect(result.reviews[0]?.security.added[0]?.severityLabel).toBe("HIGH"); - expect(result.reviews[0]?.changelog.source).toBeUndefined(); - expect(result.reviews[0]?.compatibility).toBeUndefined(); - expect(result.reviews[0]?.changelog.riskItems).toEqual([ - { - version: "5.0.0", - tier: "UNCLASSIFIED", - ambiguous: false, - text: "raw quote\u001b[31m", - textTruncated: true, - model: "jev-1.13.0", - formulation: "d-hier-v1", - }, - ]); - expect(result.reviews[0]?.changelog.riskCoverage).toEqual({ - versionsClassified: 0, - versionsNotAssessed: 20, - versionsWithoutNotes: 2, - versionsUnparseable: 1, - unitsNoImpact: 0, - itemsMustActConfident: 0, - itemsMustActAmbiguous: 0, - itemsShouldKnowConfident: 0, - itemsShouldKnowAmbiguous: 0, - itemsUnclassified: 0, - itemsOmitted: 8, - }); - }); -}); - describe("PackageIntelligenceServiceImpl — packageDependencies", () => { const ENDPOINT = "https://pkgseer.dev"; diff --git a/packages/core-internal/src/services/package-intelligence-service.ts b/packages/core-internal/src/services/package-intelligence-service.ts index c0f1734f..ae9b064f 100644 --- a/packages/core-internal/src/services/package-intelligence-service.ts +++ b/packages/core-internal/src/services/package-intelligence-service.ts @@ -239,8 +239,9 @@ export interface PackageUpgradeReviewPackageParams { } export interface PackageUpgradeReviewParams { - /** Select complete statement text only when the caller consumes it. */ - includeChangelogFullText?: boolean; + /** Host projection; compact batch text skips statement retrieval entirely. */ + changelogView?: "text" | "json" | "triage"; + statementTiers?: ChangelogStatementTier[]; packages: PackageUpgradeReviewPackageParams[]; includeTransitiveSecurity: boolean; includeDependencyIssues: boolean; @@ -320,59 +321,31 @@ export interface PackageUpgradeChangelogEntry { signals: string[]; } -/** Backend model labels for one release-note statement, never a package verdict. */ -export interface ChangelogRiskItem { - version: string; - tier: "MUST_ACT" | "SHOULD_KNOW" | "UNCLASSIFIED"; - /** Backend ambiguity policy; confidence is evidence, not a client threshold. */ - ambiguous: boolean; - tierConfidence?: number; - kind?: string; - kindConfidence?: number; - text: string; - textTruncated: boolean; - /** Complete statement, present only when selected. */ - fullText?: string; - /** Exact backend source locator, independent of sampled entries. */ - 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 ChangelogRiskCoverage { versionsClassified: number; versionsNotAssessed: number; versionsWithoutNotes: number; versionsUnparseable: number; unitsNoImpact: number; - /** Statement totals before the per-review cap, separated by backend ambiguity. */ + /** Whole-range statement totals, separated by backend ambiguity. */ itemsMustActConfident: number; itemsMustActAmbiguous: number; itemsShouldKnowConfident: number; itemsShouldKnowAmbiguous: number; itemsUnclassified: number; - itemsOmitted: number; } export interface PackageUpgradeChangelogRiskCoverage extends ChangelogRiskCoverage {} export interface PackageUpgradeChangelog { - riskItems: PackageUpgradeChangelogRiskItem[]; + statements?: ChangelogStatements; riskCoverage: PackageUpgradeChangelogRiskCoverage; source?: string; fallback?: string; - entries: PackageUpgradeChangelogEntry[]; - sampledEntries: PackageUpgradeChangelogEntry[]; - keywordEntries: PackageUpgradeChangelogEntry[]; + entries?: PackageUpgradeChangelogEntry[]; + sampledEntries?: PackageUpgradeChangelogEntry[]; + keywordEntries?: PackageUpgradeChangelogEntry[]; totalKeywordEntries: number; totalEntries: number; totalEntriesWithBodies: number; @@ -752,7 +725,7 @@ export type ChangelogStatementAssessmentStatus = export interface ChangelogStatementClassification { tier: ChangelogStatementTier; ambiguous: boolean; - kind?: ChangelogRiskItem["kind"]; + kind?: string; tierConfidence?: number; kindConfidence?: number; model?: string; @@ -2097,37 +2070,18 @@ const packageUpgradeChangelogEntrySchema = z.object({ signals: z.array(z.string()), }); -const changelogRiskItemBaseSchema = z.object({ - version: z.string(), - tier: z.enum(["MUST_ACT", "SHOULD_KNOW", "UNCLASSIFIED"]), - ambiguous: z.boolean(), - kind: z - .enum([ - "REMOVES_OR_RENAMES_API", - "CHANGES_BEHAVIOR_OR_DEFAULT", - "RAISES_RUNTIME_OR_PLATFORM_REQUIREMENT", - "CHANGES_PACKAGING_OR_MODULE_FORMAT", - "DEPRECATES_WITHOUT_REMOVAL", - "SECURITY_FIX", - "NOTABLE_CHANGE", - ]) - .nullable(), - text: z.string(), - textTruncated: z.boolean(), - fullText: z.string().optional(), - url: z.string().nullable(), - source: z.string().nullable(), -}); +const changelogKindSchema = z + .enum([ + "REMOVES_OR_RENAMES_API", + "CHANGES_BEHAVIOR_OR_DEFAULT", + "RAISES_RUNTIME_OR_PLATFORM_REQUIREMENT", + "CHANGES_PACKAGING_OR_MODULE_FORMAT", + "DEPRECATES_WITHOUT_REMOVAL", + "SECURITY_FIX", + "NOTABLE_CHANGE", + ]) + .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 packageUpgradeChangelogRiskCoverageSchema = z.object({ versionsClassified: z.number().int(), versionsNotAssessed: z.number().int(), @@ -2139,17 +2093,70 @@ const packageUpgradeChangelogRiskCoverageSchema = z.object({ itemsShouldKnowConfident: z.number().int(), itemsShouldKnowAmbiguous: z.number().int(), itemsUnclassified: z.number().int(), - itemsOmitted: z.number().int(), }); +const changelogStatementClassificationSchema = z.object({ + tier: z.enum(["MUST_ACT", "SHOULD_KNOW", "NO_IMPACT", "UNCLASSIFIED"]), + ambiguous: z.boolean(), + kind: changelogKindSchema, + 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 packageUpgradeChangelogSchema = z.object({ - riskItems: z.array(packageUpgradeChangelogRiskItemSchema), + statements: changelogStatementPageSchema.optional(), riskCoverage: packageUpgradeChangelogRiskCoverageSchema, source: z.string().nullable().optional(), fallback: z.string().nullable().optional(), - entries: z.array(packageUpgradeChangelogEntrySchema), - sampledEntries: z.array(packageUpgradeChangelogEntrySchema), - keywordEntries: z.array(packageUpgradeChangelogEntrySchema), + entries: z.array(packageUpgradeChangelogEntrySchema).optional(), + sampledEntries: z.array(packageUpgradeChangelogEntrySchema).optional(), + keywordEntries: z.array(packageUpgradeChangelogEntrySchema).optional(), totalKeywordEntries: z.number().int(), totalEntries: z.number().int(), totalEntriesWithBodies: z.number().int(), @@ -2242,9 +2249,25 @@ const packageUpgradeReviewGraphQLResponseSchema = z.object({ errors: z.array(graphQLErrorSchema).optional(), }); -const PACKAGE_UPGRADE_REVIEW_QUERY = ` +/** Upgrade projections share statement fields with standalone changelog. */ +export function packageUpgradeReviewQuery( + params: PackageUpgradeReviewParams, + continuation = false, +): string { + const view = params.changelogView ?? "text"; + const statements = + view === "triage" ? "" : changelogStatementSelection(view === "json", true); + if (continuation) + return `query PackageUpgradeReviewStatements( + $packages: [PackageUpgradeReviewPackageInput!]! $offset: Int! $tiers: [ChangelogStatementTier!] + ) { + packageUpgradeReview(packages: $packages, includeTransitiveSecurity: false, changelogLimit: 1) { + reviews { registry name currentVersion targetVersion changelog { ${statements} } } + } + }`; + return ` query PackageUpgradeReview( - $includeChangelogFullText: Boolean! + ${view !== "triage" ? "$offset: Int! $tiers: [ChangelogStatementTier!]" : ""} $packages: [PackageUpgradeReviewPackageInput!]! $includeTransitiveSecurity: Boolean! $includeDependencyIssues: Boolean! @@ -2334,22 +2357,7 @@ query PackageUpgradeReview( } } changelog { - riskItems { - version - tier - ambiguous - tierConfidence - kind - kindConfidence - text - textTruncated - fullText @include(if: $includeChangelogFullText) - url - heading - source - model - formulation - } + ${statements} riskCoverage { versionsClassified versionsNotAssessed @@ -2361,18 +2369,15 @@ query PackageUpgradeReview( itemsShouldKnowConfident itemsShouldKnowAmbiguous itemsUnclassified - itemsOmitted } source fallback - entries { - ...PackageUpgradeChangelogEntryFields - } - sampledEntries { - ...PackageUpgradeChangelogEntryFields - } - keywordEntries { - ...PackageUpgradeChangelogEntryFields + ${ + view === "triage" + ? "" + : `entries { detailSource version publishedAt htmlUrl signals ${view === "json" ? "body bodyPreview headline" : ""} } + sampledEntries { detailSource version publishedAt htmlUrl signals ${view === "json" ? "body bodyPreview headline" : ""} } + keywordEntries { detailSource version publishedAt htmlUrl signals body ${view === "json" ? "bodyPreview headline" : ""} }` } totalKeywordEntries totalEntries @@ -2432,17 +2437,6 @@ fragment PackageUpgradeTransitivePackagePageFields on PackageUpgradeTransitivePa truncated } -fragment PackageUpgradeChangelogEntryFields on PackageUpgradeChangelogEntry { - detailSource - version - publishedAt - htmlUrl - body - bodyPreview - headline - signals -} - fragment PackageUpgradeDependencyChangeGroupFields on PackageUpgradeDependencyChangeGroup { added { name @@ -2472,6 +2466,7 @@ fragment PackageUpgradeDependencyChangeGroupFields on PackageUpgradeDependencyCh type } }`; +} // -------------------------------------------------------------------- // Zod schema + query for packageChangelog @@ -2485,59 +2480,6 @@ 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({ source: z.string().nullable().optional(), entries: z.array(changelogEntryDetailSchema).nullable().optional(), @@ -2579,6 +2521,148 @@ const exactChangelogGraphQLResponseSchema = z.object({ errors: z.array(graphQLErrorSchema).optional(), }); +function changelogStatementSelection( + json: boolean, + assessment: boolean, +): string { + return `statements(first: 100, offset: $offset, tiers: $tiers) { + totalCount matchingCount remainingCount nextOffset hasMore + coverage { version status statementsTotal ${assessment ? "statementsAssessed statementsNotAssessed statementsNoImpact statementsUnclassified" : ""} } + items { + version position sourceIndex heading source url text textTruncated + ${json ? "sourcePosition fullText" : ""} + context { heading text textTruncated ${json ? "fullText" : ""} } + classification { tier ambiguous kind ${json ? "tierConfidence kindConfidence model formulation" : ""} } + } + }`; +} + +const upgradeStatementContinuationSchema = z.object({ + data: z + .object({ + packageUpgradeReview: z + .object({ + reviews: z.array( + z.object({ + registry: z.string(), + name: z.string(), + currentVersion: z.string(), + targetVersion: z.string(), + changelog: z.object({ statements: changelogStatementPageSchema }), + }), + ), + }) + .nullable() + .optional(), + }) + .nullable() + .optional(), + errors: z.array(graphQLErrorSchema).optional(), +}); + +function validateChangelogStatementPage( + page: ChangelogStatementPage, + offset: number, + json: boolean, +): void { + if ( + json && + page.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 + page.items.length; + if ( + page.items.length > 100 || + page.remainingCount !== page.matchingCount - next || + page.hasMore !== page.remainingCount > 0 || + (page.hasMore + ? page.nextOffset !== next || next <= offset + : page.nextOffset !== null) + ) + throw new MalformedPackageIntelligenceResponseError( + "Invalid changelog statement page progress.", + ); +} + +function changelogCoverageSignature( + coverage: ChangelogStatementPage["coverage"], +): string { + return JSON.stringify( + coverage.map((c) => [ + c.version, + c.status, + c.statementsTotal, + c.statementsAssessed, + c.statementsNotAssessed, + c.statementsNoImpact, + c.statementsUnclassified, + ]), + ); +} + +function validateUpgradeStatementCounts( + statements: ChangelogStatements, + counters: ChangelogRiskCoverage, + tiers?: ChangelogStatementTier[], +): void { + const coverage = statements.coverage; + const versionCounts = [ + "CLASSIFIED", + "NOT_ASSESSED", + "WITHOUT_NOTES", + "UNPARSEABLE", + ].map((status) => coverage.filter((c) => c.status === status).length); + const expectedVersions = [ + counters.versionsClassified, + counters.versionsNotAssessed, + counters.versionsWithoutNotes, + counters.versionsUnparseable, + ]; + const selected = tiers?.length + ? tiers + : ["MUST_ACT", "SHOULD_KNOW", "UNCLASSIFIED", "NO_IMPACT"]; + const count = (tier: ChangelogStatementTier, ambiguous: boolean): number => + statements.items.filter( + (item) => + item.classification?.tier === tier && + item.classification.ambiguous === ambiguous, + ).length; + if ( + JSON.stringify(versionCounts) !== JSON.stringify(expectedVersions) || + coverage.reduce((sum, c) => sum + c.statementsNoImpact, 0) !== + counters.unitsNoImpact || + (selected.includes("MUST_ACT") && + (count("MUST_ACT", false) !== counters.itemsMustActConfident || + count("MUST_ACT", true) !== counters.itemsMustActAmbiguous)) || + (selected.includes("SHOULD_KNOW") && + (count("SHOULD_KNOW", false) !== counters.itemsShouldKnowConfident || + count("SHOULD_KNOW", true) !== counters.itemsShouldKnowAmbiguous)) || + (selected.includes("UNCLASSIFIED") && + statements.items.filter( + (item) => item.classification?.tier === "UNCLASSIFIED", + ).length !== counters.itemsUnclassified) || + (!tiers?.length && + statements.items.filter( + (item) => item.classification?.tier === "NO_IMPACT", + ).length !== counters.unitsNoImpact) + ) + throw new MalformedPackageIntelligenceResponseError( + "Upgrade statement evidence disagrees with whole-range counters.", + ); +} + /** Build only the projection consumed by the selected host view and page. */ function packageChangelogQuery( params: PackageChangelogParams, @@ -2589,19 +2673,7 @@ function packageChangelogQuery( 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" : ""} - } - 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" : ""} } - } - }` + ? changelogStatementSelection(json, Boolean(params.statementTiers?.length)) : ""; const pageVars = statementView ? "$offset: Int! $tiers: [ChangelogStatementTier!]" @@ -2652,6 +2724,11 @@ function collectChangelogStatements( "Missing selected changelog statements.", ); const coverage = first.coverage; + if (new Set(coverage.map((c) => c.version)).size !== coverage.length) + throw new MalformedPackageIntelligenceResponseError( + "Duplicate changelog coverage versions.", + ); + const sourceIndices = new Map(); const items: ChangelogStatement[] = []; const positions = new Map(); let lastVersionIndex = -1; @@ -2663,6 +2740,8 @@ function collectChangelogStatements( index < lastVersionIndex || index < 0 || item.position <= previous || + item.position >= (coverage[index]?.statementsTotal ?? 0) || + item.sourceIndex < (sourceIndices.get(item.version) ?? -1) || (!filtered && item.position !== previous + 1) ) throw new MalformedPackageIntelligenceResponseError( @@ -2670,6 +2749,7 @@ function collectChangelogStatements( ); lastVersionIndex = index; positions.set(item.version, item.position); + sourceIndices.set(item.version, item.sourceIndex); items.push(normaliseChangelogStatement(item)); } } @@ -2689,7 +2769,8 @@ function collectChangelogStatements( c.statementsNotAssessed === undefined || c.statementsNoImpact === undefined || c.statementsUnclassified === undefined || - c.statementsAssessed + c.statementsNotAssessed !== c.statementsTotal + c.statementsAssessed + c.statementsNotAssessed !== c.statementsTotal || + c.statementsNoImpact + c.statementsUnclassified > c.statementsAssessed ) throw new MalformedPackageIntelligenceResponseError( "Missing or inconsistent selected changelog coverage counts.", @@ -3585,9 +3666,11 @@ export class PackageIntelligenceServiceImpl response = await postPkgseerGraphql({ endpointUrl: this.endpointUrl, token, - query: PACKAGE_UPGRADE_REVIEW_QUERY, + query: packageUpgradeReviewQuery(params), variables: { - includeChangelogFullText: params.includeChangelogFullText === true, + ...((params.changelogView ?? "text") === "triage" + ? {} + : { offset: 0, tiers: params.statementTiers }), packages: params.packages, includeTransitiveSecurity: params.includeTransitiveSecurity, includeDependencyIssues: params.includeDependencyIssues, @@ -3630,7 +3713,156 @@ export class PackageIntelligenceServiceImpl ); } - return stripNullProperties(data) as PackageUpgradeReviewResponse; + const detailed = (params.changelogView ?? "text") !== "triage"; + if (!detailed) + return stripNullProperties(data) as PackageUpgradeReviewResponse; + const result: PackageUpgradeReviewResponse = { + summary: data.summary, + reviews: [], + }; + for (const review of data.reviews) { + const page = review.changelog.statements; + if ( + !page || + !review.changelog.entries || + !review.changelog.sampledEntries || + !review.changelog.keywordEntries + ) + throw new MalformedPackageIntelligenceResponseError( + "Missing selected upgrade changelog evidence.", + ); + const pages: ChangelogStatementPage[] = []; + let current = page; + let offset = 0; + while (true) { + if ( + current.items.some( + (item) => + params.statementTiers?.length && + (!item.classification || + !params.statementTiers.includes(item.classification.tier)), + ) + ) + throw new MalformedPackageIntelligenceResponseError( + "Statement does not match selected tiers.", + ); + if ( + current.totalCount !== page.totalCount || + current.matchingCount !== page.matchingCount || + changelogCoverageSignature(current.coverage) !== + changelogCoverageSignature(page.coverage) + ) + throw new PackageIntelligenceBackendError( + "Changelog evidence changed during retrieval; classification may still be in progress. Rerun to retrieve the current view.", + undefined, + undefined, + true, + ); + validateChangelogStatementPage( + current, + offset, + params.changelogView === "json", + ); + pages.push(current); + if (current.nextOffset === null) break; + offset = current.nextOffset; + current = await this.fetchUpgradeStatementPage( + token, + params, + review, + offset, + ); + } + // Upgrade counters were read on page one, so even unfiltered assessment + // coverage must stay stable; standalone all deliberately tolerates arrival. + const collected = collectChangelogStatements( + pages, + Boolean(params.statementTiers?.length), + ); + if ( + changelogCoverageSignature(collected.coverage) !== + changelogCoverageSignature(page.coverage) + ) + throw new MalformedPackageIntelligenceResponseError( + "Upgrade statement assessment coverage disagrees with collected evidence.", + ); + validateUpgradeStatementCounts( + collected, + review.changelog.riskCoverage, + params.statementTiers, + ); + const normalizedReview = stripNullProperties( + review, + ) as PackageUpgradeReview; + normalizedReview.changelog.statements = collected; + result.reviews.push(normalizedReview); + } + return result; + } + + private async fetchUpgradeStatementPage( + token: string, + params: PackageUpgradeReviewParams, + identity: Pick< + PackageUpgradeReview, + "registry" | "name" | "currentVersion" | "targetVersion" + >, + offset: number, + ): Promise { + let response: PkgseerGraphqlResponse; + try { + response = await postPkgseerGraphql({ + endpointUrl: this.endpointUrl, + token, + query: packageUpgradeReviewQuery(params, true), + variables: { + packages: [ + { + registry: identity.registry, + name: identity.name, + currentVersion: identity.currentVersion, + targetVersion: identity.targetVersion, + }, + ], + offset, + tiers: params.statementTiers, + }, + fetchFn: this.fetchFn, + clientHeaders: this.runtime.clientHeaders, + userAgent: this.runtime.userAgent, + diagnostics: this.runtime.diagnostics, + }); + } catch (cause) { + if (cause instanceof PkgseerTransportError) + throw this.createTransportError(cause); + throw cause; + } + if (response.status < 200 || response.status >= 300) + throw this.createHttpError(response); + const parsed = upgradeStatementContinuationSchema.safeParse( + response.parsedBody, + ); + if (!parsed.success) + throw new MalformedPackageIntelligenceResponseError( + "Malformed upgrade statement continuation.", + ); + if (parsed.data.errors?.length) + throw this.createGraphQLError(parsed.data.errors); + const reviews = parsed.data.data?.packageUpgradeReview?.reviews; + const review = reviews?.[0]; + if ( + reviews?.length !== 1 || + !review || + ["registry", "name", "currentVersion", "targetVersion"].some( + (key) => + review[key as keyof typeof identity] !== + identity[key as keyof typeof identity], + ) + ) + throw new MalformedPackageIntelligenceResponseError( + "Upgrade statement continuation identity mismatch.", + ); + return review.changelog.statements; } private async executePackageUpgradeDependencyProbe( @@ -4096,35 +4328,11 @@ export class PackageIntelligenceServiceImpl 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.", - ); + validateChangelogStatementPage( + statements, + offset, + params.includeJsonFields === true, + ); pages.push(statements); // Validate comparable evidence before making another request. Final counts // and ordering are checked by collection once all pages have arrived. diff --git a/packages/core-internal/src/services/package-upgrade-review-statements.test.ts b/packages/core-internal/src/services/package-upgrade-review-statements.test.ts new file mode 100644 index 00000000..e9bf8cd1 --- /dev/null +++ b/packages/core-internal/src/services/package-upgrade-review-statements.test.ts @@ -0,0 +1,608 @@ +import { describe, expect, it, mock } from "bun:test"; +import { + MalformedPackageIntelligenceResponseError, + PackageIntelligenceBackendError, + PackageIntelligenceServiceImpl, + type PackageUpgradeReviewParams, + packageUpgradeReviewQuery, +} from "./package-intelligence-service.js"; +import { createMockTokenProvider } from "./test-helpers.js"; + +const identity = { + registry: "NPM", + name: "express", + currentVersion: "0.0.0", + targetVersion: "1.0.0", +} as const; +function statement(position: number, tier = "MUST_ACT", ambiguous = false) { + return { + version: "1.0.0", + position, + sourceIndex: 0, + sourcePosition: position + 1, + heading: null, + source: "RELEASES", + url: "https://example.com/notes", + text: `Statement ${position}`, + textTruncated: false, + fullText: `Full statement ${position}`, + context: null, + classification: { + tier, + ambiguous, + kind: null, + tierConfidence: 0.1, + kindConfidence: null, + model: "jev", + formulation: "v3", + }, + }; +} +function coverage(total: number) { + return [ + { + version: "1.0.0", + status: "CLASSIFIED", + statementsTotal: total, + statementsAssessed: total, + statementsNotAssessed: 0, + statementsNoImpact: 0, + statementsUnclassified: 0, + }, + ]; +} +function page(total = 101, offset = 0) { + const count = Math.min(100, total - offset); + const remainingCount = total - offset - count; + return { + items: Array.from({ length: count }, (_, i) => statement(offset + i)), + coverage: coverage(total), + totalCount: total, + matchingCount: total, + remainingCount, + hasMore: remainingCount > 0, + nextOffset: remainingCount > 0 ? offset + count : null, + }; +} +function review(statements = page()) { + return { + ...identity, + latestVersion: null, + versionDelta: "MAJOR", + unknowns: [], + security: { + current: null, + target: null, + added: [], + removed: [], + notAddressed: [], + fixed: [], + introduced: [], + unchanged: [], + }, + changelog: { + statements, + riskCoverage: { + versionsClassified: 1, + versionsNotAssessed: 0, + versionsWithoutNotes: 0, + versionsUnparseable: 0, + unitsNoImpact: 0, + itemsMustActConfident: statements.totalCount, + itemsMustActAmbiguous: 0, + itemsShouldKnowConfident: 0, + itemsShouldKnowAmbiguous: 0, + itemsUnclassified: 0, + }, + entries: [], + sampledEntries: [], + keywordEntries: [], + totalKeywordEntries: 0, + totalEntries: 1, + totalEntriesWithBodies: 1, + truncated: true, + hasReleaseNoteBodies: true, + breakingSignals: [], + migrationSignals: [], + }, + }; +} +const summary = { + total: 1, + withUnknowns: 0, + withAddedAdvisories: 0, + withBreakingSignals: 0, + withDirectDependencyChanges: 0, + withTransitiveVulnerabilityAdditions: 0, +}; +function initial( + reviews: (Omit, keyof typeof identity> & { + registry: string; + name: string; + currentVersion: string; + targetVersion: string; + })[] = [review()], +) { + return { + data: { + packageUpgradeReview: { + summary: { ...summary, total: reviews.length }, + reviews, + }, + }, + }; +} +function continuation(statements = page(101, 100)) { + return { + data: { + packageUpgradeReview: { + reviews: [{ ...identity, changelog: { statements } }], + }, + }, + }; +} +function setup( + responses: unknown[], + changes: Partial = {}, +) { + const requests: { + query: string; + variables: Record & { + packages: PackageUpgradeReviewParams["packages"]; + offset?: number; + tiers?: PackageUpgradeReviewParams["statementTiers"]; + }; + }[] = []; + const fetchFn = mock( + async (_: unknown, init?: RequestInit): Promise => { + requests.push(JSON.parse(String(init?.body))); + return new Response(JSON.stringify(responses[requests.length - 1]), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + ); + const service = new PackageIntelligenceServiceImpl( + "https://example.test", + createMockTokenProvider(), + fetchFn as unknown as typeof fetch, + ); + const params: PackageUpgradeReviewParams = { + packages: [identity], + includeTransitiveSecurity: true, + includeDependencyIssues: true, + changelogLimit: 20, + statementTiers: ["MUST_ACT", "SHOULD_KNOW", "UNCLASSIFIED"], + changelogView: "json", + ...changes, + }; + return { requests, run: () => service.packageUpgradeReview(params) }; +} + +describe("complete upgrade statement retrieval", () => { + for (const enabled of [true, false]) + it(`preserves initial security variables and minimal projection when enabled=${enabled}`, async () => { + const { run, requests } = setup([initial([review(page(1))])], { + minSeverity: 7, + changelogLimit: 12, + includeTransitiveSecurity: enabled, + includeDependencyIssues: enabled, + }); + await run(); + expect(requests[0]?.variables).toMatchObject({ + packages: [identity], + minSeverity: 7, + changelogLimit: 12, + includeTransitiveSecurity: enabled, + includeDependencyIssues: enabled, + }); + const query = requests[0]!.query; + expect(query).toContain( + "dependencyIssues @include(if: $includeDependencyIssues)", + ); + expect(query).toContain( + "transitive @include(if: $includeTransitiveSecurity)", + ); + for (const field of [ + "duplicateIds", + "matchedAffectedVersionRanges", + "affectsInspectedVersion", + "affectedVersionRangesCount", + "affectedVersionRangesTruncated", + ]) + expect(query).not.toContain(field); + }); + it("preserves added security advisory mapping", async () => { + const body = initial([review(page(1))]); + const advisory = { + id: "GHSA-added", + aliases: ["CVE-added"], + summary: "Observed advisory", + severity: 7.5, + severityLabel: "HIGH", + fixedIn: ["2.0.0"], + isMalicious: false, + }; + Reflect.set(body.data.packageUpgradeReview.reviews[0]!.security, "added", [ + advisory, + ]); + const result = await setup([body]).run(); + expect(result.reviews[0]!.security.added).toEqual([advisory]); + }); + for (const field of ["security", "versionDelta"]) + it(`keeps strict aggregate review validation for ${field}`, async () => { + const body = initial([review(page(1))]); + Reflect.deleteProperty(body.data.packageUpgradeReview.reviews[0]!, field); + await expect(setup([body]).run()).rejects.toThrow( + MalformedPackageIntelligenceResponseError, + ); + }); + it("rejects null selected fullText", async () => { + const body = initial([review(page(1))]); + Reflect.set( + body.data.packageUpgradeReview.reviews[0]!.changelog.statements.items[0]!, + "fullText", + null, + ); + await expect(setup([body]).run()).rejects.toThrow( + MalformedPackageIntelligenceResponseError, + ); + }); + it("passes through backend validation above the public batch maximum", async () => { + const { run, requests } = setup( + [ + { + errors: [ + { + message: "packages must contain at most 30 upgrades", + extensions: { code: "VALIDATION_ERROR" }, + }, + ], + }, + ], + { + packages: Array.from({ length: 31 }, () => ({ ...identity })), + }, + ); + await expect(run()).rejects.toThrow( + "packages must contain at most 30 upgrades", + ); + expect(requests).toHaveLength(1); + expect(requests[0]!.variables.packages).toHaveLength(31); + }); + it("collects past both the legacy cap and first page without repeating aggregate fields", async () => { + const { run, requests } = setup([initial(), continuation()]); + const result = await run(); + expect(result.reviews[0]!.changelog.statements?.items).toHaveLength(101); + expect( + result.reviews[0]!.changelog.statements?.items[100]?.classification, + ).toMatchObject({ ambiguous: false, tierConfidence: 0.1 }); + expect(requests[0]!.variables.offset).toBe(0); + expect(requests[1]!.variables).toEqual({ + packages: [identity], + offset: 100, + tiers: ["MUST_ACT", "SHOULD_KNOW", "UNCLASSIFIED"], + }); + expect(requests[1]!.query).toContain( + "includeTransitiveSecurity: false, changelogLimit: 1", + ); + for (const field of [ + "security {", + "summary {", + "riskCoverage", + "entries {", + "dependencyChanges", + "riskItems", + "itemsOmitted", + ]) + expect(requests[1]!.query).not.toContain(field); + }); + for (const count of [1, 3, 30]) + it(`initial aggregate keeps ${count} packages in one request`, async () => { + const base = review(page(1)); + const reviews = Array.from({ length: count }, (_, i) => ({ + ...base, + name: `p${i}`, + })); + const { run, requests } = setup([initial(reviews)], { + packages: reviews.map( + ({ registry, name, currentVersion, targetVersion }) => ({ + registry: registry as "NPM", + name, + currentVersion, + targetVersion, + }), + ), + }); + expect((await run()).reviews).toHaveLength(count); + expect(requests).toHaveLength(1); + expect(requests[0]!.variables.packages).toHaveLength(count); + }); + it("continues duplicate canonical reviews in original review order", async () => { + const go = { + ...review(), + registry: "GO", + name: "example.com/pkg", + currentVersion: "v0.0.0", + targetVersion: "v1.0.0", + }; + const next = { + data: { + packageUpgradeReview: { + reviews: [ + { + registry: go.registry, + name: go.name, + currentVersion: go.currentVersion, + targetVersion: go.targetVersion, + changelog: { statements: page(101, 100) }, + }, + ], + }, + }, + }; + const { run, requests } = setup([initial([go, go]), next, next], { + packages: Array.from({ length: 2 }, () => ({ + registry: "GO", + name: go.name, + currentVersion: "0.0.0", + targetVersion: "1.0.0", + })), + }); + const result = await run(); + expect(result.reviews).toHaveLength(2); + expect(requests).toHaveLength(3); + expect( + requests.slice(1).map((r) => r.variables.packages[0]?.currentVersion), + ).toEqual(["v0.0.0", "v0.0.0"]); + expect(requests.slice(1).map((r) => r.variables.offset)).toEqual([ + 100, 100, + ]); + }); + it("accepts filtered position gaps and coverage beyond the ordinary entry sample", async () => { + const body = initial([review(page(3))]); + const r = body.data.packageUpgradeReview.reviews[0]!; + r.changelog.statements.items.splice(1, 1); + r.changelog.statements.matchingCount = 2; + r.changelog.statements.coverage[0]!.statementsNoImpact = 1; + r.changelog.riskCoverage.unitsNoImpact = 1; + r.changelog.riskCoverage.itemsMustActConfident = 2; + const result = await setup([body]).run(); + expect( + result.reviews[0]!.changelog.statements!.items.map( + (item) => item.position, + ), + ).toEqual([0, 2]); + expect(result.reviews[0]!.changelog.entries).toEqual([]); + }); + it("returns entirely unassessed all evidence while focused returns zero matches", async () => { + for (const all of [false, true]) { + const body = initial([review(page(2))]); + const r = body.data.packageUpgradeReview.reviews[0]!; + for (const item of r.changelog.statements.items) + Reflect.set(item, "classification", null); + Object.assign(r.changelog.statements.coverage[0]!, { + status: "NOT_ASSESSED", + statementsAssessed: 0, + statementsNotAssessed: 2, + }); + Object.assign(r.changelog.riskCoverage, { + versionsClassified: 0, + versionsNotAssessed: 1, + itemsMustActConfident: 0, + }); + if (!all) { + r.changelog.statements.items = []; + r.changelog.statements.matchingCount = 0; + } + const result = await setup([body], { + statementTiers: all + ? undefined + : ["MUST_ACT", "SHOULD_KNOW", "UNCLASSIFIED"], + }).run(); + expect(result.reviews[0]!.changelog.statements!.items).toHaveLength( + all ? 2 : 0, + ); + expect( + result.reviews[0]!.changelog.statements!.coverage[0]! + .statementsNotAssessed, + ).toBe(2); + } + }); + it("accepts zero parsed units with honest missing-note coverage", async () => { + const body = initial([review(page(0))]); + const r = body.data.packageUpgradeReview.reviews[0]!; + r.changelog.statements.coverage[0]!.status = "WITHOUT_NOTES"; + Object.assign(r.changelog.riskCoverage, { + versionsClassified: 0, + versionsWithoutNotes: 1, + }); + expect( + (await setup([body]).run()).reviews[0]!.changelog.statements!.items, + ).toEqual([]); + }); + it("retains oversize statement truncation and nullable confidence without interpreting it", async () => { + const body = initial([review(page(1))]); + const r = body.data.packageUpgradeReview.reviews[0]!; + const item = r.changelog.statements.items[0]!; + item.textTruncated = true; + item.classification.tier = "UNCLASSIFIED"; + Reflect.set(item.classification, "tierConfidence", null); + Object.assign(r.changelog.riskCoverage, { + itemsMustActConfident: 0, + itemsUnclassified: 1, + }); + r.changelog.statements.coverage[0]!.statementsUnclassified = 1; + const result = await setup([body]).run(); + expect(result.reviews[0]!.changelog.statements!.items[0]).toMatchObject({ + textTruncated: true, + fullText: item.fullText, + classification: { tier: "UNCLASSIFIED", ambiguous: false }, + }); + }); + it("triage omits selected arrays without fabricating empty evidence", async () => { + const body = initial([review(page(1))]); + const changelog = body.data.packageUpgradeReview.reviews[0]!.changelog; + for (const field of [ + "statements", + "entries", + "sampledEntries", + "keywordEntries", + ]) + Reflect.deleteProperty(changelog, field); + const { run, requests } = setup([body], { changelogView: "triage" }); + const result = await run(); + expect(result.reviews[0]!.changelog.statements).toBeUndefined(); + expect(result.reviews[0]!.changelog.entries).toBeUndefined(); + expect(requests).toHaveLength(1); + expect(requests[0]!.query).not.toContain("statements("); + }); + it("keeps all nullable classification and no-impact evidence distinct", async () => { + const body = initial([review(page(2))]); + const r = body.data.packageUpgradeReview.reviews[0]!; + r.changelog.statements.items[0]!.classification.tier = "NO_IMPACT"; + Reflect.set(r.changelog.statements.items[1]!, "classification", null); + Object.assign(r.changelog.statements.coverage[0]!, { + status: "NOT_ASSESSED", + statementsAssessed: 1, + statementsNotAssessed: 1, + statementsNoImpact: 1, + }); + Object.assign(r.changelog.riskCoverage, { + versionsClassified: 0, + versionsNotAssessed: 1, + itemsMustActConfident: 0, + unitsNoImpact: 1, + }); + const { run } = setup([body], { statementTiers: undefined }); + const items = (await run()).reviews[0]!.changelog.statements!.items; + expect(items[0]!.classification?.tier).toBe("NO_IMPACT"); + expect(items[1]!.classification).toBeNull(); + }); + for (const filtered of [false, true]) + it(`rejects changing assessment coverage even when filtered=${filtered}`, async () => { + const next = continuation(); + next.data.packageUpgradeReview.reviews[0]!.changelog.statements + .coverage[0]!.statementsAssessed--; + const { run } = setup([initial(), next], { + statementTiers: filtered ? ["MUST_ACT"] : undefined, + }); + await expect(run()).rejects.toThrow(PackageIntelligenceBackendError); + }); + for (const field of ["totalCount", "matchingCount"] as const) + it(`reports observed ${field} changes as retryable evidence changes`, async () => { + const next = continuation(); + const p = next.data.packageUpgradeReview.reviews[0]!.changelog.statements; + p[field]++; + if (field === "matchingCount") { + p.remainingCount++; + p.hasMore = true; + p.nextOffset = 101; + } + const { run } = setup([initial(), next]); + await expect(run()).rejects.toThrow("evidence changed"); + }); + for (const mutate of [ + (p: ReturnType) => { + p.nextOffset = 0; + }, + (p: ReturnType) => { + p.remainingCount = 50; + }, + (p: ReturnType) => { + p.items.push(statement(100)); + }, + (p: ReturnType) => { + p.items[1]!.position = 0; + }, + (p: ReturnType) => { + p.items[0]!.version = "missing"; + }, + (p: ReturnType) => { + p.items[0]!.classification.tier = "NO_IMPACT"; + }, + ]) + it(`rejects malformed selected page %#`, async () => { + const body = initial(); + mutate(body.data.packageUpgradeReview.reviews[0]!.changelog.statements); + const { run } = setup([body, continuation()]); + await expect(run()).rejects.toThrow( + MalformedPackageIntelligenceResponseError, + ); + }); + it("rejects continuation identity mismatch", async () => { + const next = continuation(); + Reflect.set(next.data.packageUpgradeReview.reviews[0]!, "name", "wrong"); + await expect(setup([initial(), next]).run()).rejects.toThrow( + "identity mismatch", + ); + }); + it("counter disagreement without observed page change is malformed, not a retry", async () => { + const body = initial(); + body.data.packageUpgradeReview.reviews[0]!.changelog.riskCoverage + .itemsMustActConfident--; + await expect(setup([body, continuation()]).run()).rejects.toThrow( + MalformedPackageIntelligenceResponseError, + ); + }); + for (const field of [ + "fullText", + "sourcePosition", + "model", + "formulation", + "tierConfidence", + "kindConfidence", + ]) + it(`requires selected JSON ${field}`, async () => { + const body = initial([review(page(1))]); + const item = + body.data.packageUpgradeReview.reviews[0]!.changelog.statements + .items[0]!; + Reflect.deleteProperty( + ["fullText", "sourcePosition"].includes(field) + ? item + : item.classification, + field, + ); + await expect(setup([body]).run()).rejects.toThrow("evidence fields"); + }); +}); + +describe("upgrade statement projections", () => { + for (const view of ["text", "json", "triage"] as const) + it(`fetches only consumed changelog fields for ${view}`, () => { + const params: PackageUpgradeReviewParams = { + packages: [identity], + includeTransitiveSecurity: true, + includeDependencyIssues: true, + changelogLimit: 20, + changelogView: view, + }; + const query = packageUpgradeReviewQuery(params); + expect(query).not.toContain("riskItems"); + expect(query).not.toContain("itemsOmitted"); + expect(query.includes("statements(")).toBe(view !== "triage"); + expect(query.includes("fullText")).toBe(view === "json"); + expect(query.includes("tierConfidence")).toBe(view === "json"); + expect(query.includes("sourcePosition")).toBe(view === "json"); + if (view === "text") { + expect(query.match(/entries \{([^}]+)\}/)?.[1]).not.toContain("body"); + expect(query.match(/keywordEntries \{([^}]+)\}/)?.[1]).toContain( + "body", + ); + expect(query).not.toContain("bodyPreview"); + } + if (view === "triage") + for (const field of [ + "entries {", + "keywordEntries {", + "sampledEntries {", + ]) + expect( + query.slice( + query.indexOf("changelog {"), + query.indexOf("compatibility {"), + ), + ).not.toContain(field); + }); +}); diff --git a/packages/mcp/src/services/test-helpers.ts b/packages/mcp/src/services/test-helpers.ts index 4be4f425..320420d7 100644 --- a/packages/mcp/src/services/test-helpers.ts +++ b/packages/mcp/src/services/test-helpers.ts @@ -806,7 +806,12 @@ export const defaultPackageUpgradeReviewResponse: PackageUpgradeReviewResponse = hasReleaseNoteBodies: true, breakingSignals: [], migrationSignals: [], - riskItems: [], + statements: { + totalCount: 0, + matchingCount: 0, + items: [], + coverage: [], + }, riskCoverage: { versionsClassified: 0, versionsNotAssessed: 0, @@ -818,7 +823,6 @@ export const defaultPackageUpgradeReviewResponse: PackageUpgradeReviewResponse = itemsShouldKnowConfident: 0, itemsShouldKnowAmbiguous: 0, itemsUnclassified: 0, - itemsOmitted: 0, }, }, compatibility: { peerDependencyChanges: [], notes: [] }, diff --git a/packages/mcp/src/shared/changelog-changes.ts b/packages/mcp/src/shared/changelog-changes.ts new file mode 100644 index 00000000..7f76b8bd --- /dev/null +++ b/packages/mcp/src/shared/changelog-changes.ts @@ -0,0 +1,16 @@ +import { InvalidPackageSpecError } from "./package-spec.js"; + +export type ChangelogChangesView = "actionable" | "important" | "all"; + +/** Normalize the shared public changelog filter values. */ +export function normaliseChangelogChanges( + 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/changelog-risk-presentation.ts b/packages/mcp/src/shared/changelog-risk-presentation.ts index 012832cf..68596395 100644 --- a/packages/mcp/src/shared/changelog-risk-presentation.ts +++ b/packages/mcp/src/shared/changelog-risk-presentation.ts @@ -1,61 +1,5 @@ -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, diff --git a/packages/mcp/src/shared/fixtures/upgrade-review-express-changelog.json b/packages/mcp/src/shared/fixtures/upgrade-review-express-changelog.json index b476a768..267d82c8 100644 --- a/packages/mcp/src/shared/fixtures/upgrade-review-express-changelog.json +++ b/packages/mcp/src/shared/fixtures/upgrade-review-express-changelog.json @@ -1,318 +1,461 @@ { - "riskItems": [ - { - "version": "5.2.0", - "tier": "must_act", - "tierConfidence": 0.38, - "kind": "changes_behavior_or_default", - "kindConfidence": 0.85, - "text": "* chore: enforce explicit `Buffer` import and add lint rule by @shivarm in https://github.com/expressjs/express/pull/6525", - "textTruncated": false, - "heading": "What's Changed", - "source": "releases", - "model": "jev-1.13.0", - "formulation": "s-hier-v2", - "ambiguous": true - }, - { - "version": "5.1.0", - "tier": "must_act", - "tierConfidence": 0.28, - "kind": "raises_runtime_or_platform_requirement", - "kindConfidence": 1, - "text": "* build: Node.js 23.0 by @bjohansebas in https://github.com/expressjs/express/pull/6075", - "textTruncated": false, - "heading": "What's Changed", - "source": "releases", - "model": "jev-1.13.0", - "formulation": "s-hier-v2", - "ambiguous": true - }, - { - "version": "5.2.1", - "tier": "must_act", - "tierConfidence": 0.23, - "kind": "changes_behavior_or_default", - "kindConfidence": 1, - "text": "* Revert security fix for [CVE-2024-51999](https://www.cve.org/CVERecord?id=CVE-2024-51999) ([GHSA-pj86-cfqh-vqx6](https://github.com/expressjs/express/security/advisories/GHSA-pj86-cfqh-vqx6))", - "textTruncated": false, - "source": "changelog_file", - "model": "jev-1.13.0", - "formulation": "s-hier-v2", - "ambiguous": true - }, - { - "version": "5.1.0", - "tier": "must_act", - "tierConfidence": 0.23, - "text": "* deps: remove `safe-buffer`", - "textTruncated": false, - "source": "changelog_file", - "model": "jev-1.13.0", - "formulation": "s-hier-v2", - "ambiguous": true - }, - { - "version": "5.1.0", - "tier": "must_act", - "tierConfidence": 0.23, - "text": "* deps: remove `utils-merge`", - "textTruncated": false, - "source": "changelog_file", - "model": "jev-1.13.0", - "formulation": "s-hier-v2", - "ambiguous": true - }, - { - "version": "5.1.0", - "tier": "must_act", - "tierConfidence": 0.23, - "text": "* deps: remove `methods`", - "textTruncated": false, - "source": "changelog_file", - "model": "jev-1.13.0", - "formulation": "s-hier-v2", - "ambiguous": true - }, - { - "version": "5.1.0", - "tier": "must_act", - "tierConfidence": 0.2, - "text": "* deps: remove `setprototypeof`", - "textTruncated": false, - "source": "changelog_file", - "model": "jev-1.13.0", - "formulation": "s-hier-v2", - "ambiguous": true - }, - { - "version": "5.1.0", - "tier": "must_act", - "tierConfidence": 0.19, - "text": "* deps: remove `depd`", - "textTruncated": false, - "source": "changelog_file", - "model": "jev-1.13.0", - "formulation": "s-hier-v2", - "ambiguous": true - }, - { - "version": "5.2.0", - "tier": "should_know", - "tierConfidence": 1, - "kind": "security_fix", - "kindConfidence": 1, - "text": "* Security fix for [CVE-2024-51999](https://www.cve.org/CVERecord?id=CVE-2024-51999) ([GHSA-pj86-cfqh-vqx6](https://github.com/expressjs/express/security/advisories/GHSA-pj86-cfqh-vqx6))", - "textTruncated": false, - "source": "changelog_file", - "model": "jev-1.13.0", - "formulation": "s-hier-v2", - "ambiguous": false - }, - { - "version": "5.2.0", - "tier": "should_know", - "tierConfidence": 1, - "text": "* A deprecation warning was added when using `res.redirect` with undefined arguments, Express now emits a warning to help detect calls that pass undefined as the status or URL and make them easier to fix.", - "textTruncated": false, - "source": "changelog_file", - "model": "jev-1.13.0", - "formulation": "s-hier-v2", - "ambiguous": false - }, - { - "version": "5.2.0", - "tier": "should_know", - "tierConfidence": 1, - "kind": "deprecates_without_removal", - "kindConfidence": 0.83, - "text": "* add deprecation warnings for redirect arguments undefined by @bjohansebas in https://github.com/expressjs/express/pull/6405", - "textTruncated": false, - "heading": "What's Changed", - "source": "releases", - "model": "jev-1.13.0", - "formulation": "s-hier-v2", - "ambiguous": false - }, - { - "version": "5.2.0", - "tier": "should_know", - "tierConfidence": 0.98, - "kind": "security_fix", - "kindConfidence": 1, - "text": "* fix: upgrade body-parser to 2.2.1 to address CVE-2025-13466 by @shivarm in https://github.com/expressjs/express/pull/6922", - "textTruncated": false, - "heading": "What's Changed", - "source": "releases", - "model": "jev-1.13.0", - "formulation": "s-hier-v2", - "ambiguous": false - }, - { - "version": "5.0.1", - "tier": "should_know", - "tierConfidence": 0.97, - "kind": "security_fix", - "kindConfidence": 1, - "text": "* Update `cookie` semver lock to address [CVE-2024-47764](https://nvd.nist.gov/vuln/detail/CVE-2024-47764)", - "textTruncated": false, - "source": "changelog_file", - "model": "jev-1.13.0", - "formulation": "s-hier-v2", - "ambiguous": false - }, - { - "version": "5.1.0", - "tier": "should_know", - "tierConfidence": 0.69, - "kind": "security_fix", - "kindConfidence": 0.98, - "text": "* fix(securite): fix vulnerabilities by @Abdel-Monaam-Aouini in https://github.com/expressjs/express/pull/6211", - "textTruncated": false, - "heading": "What's Changed", - "source": "releases", - "model": "jev-1.13.0", - "formulation": "s-hier-v2", - "ambiguous": false - }, - { - "version": "5.2.0", - "tier": "should_know", - "tierConfidence": 0.25, - "kind": "notable_change", - "kindConfidence": 0.69, - "text": "* lib: use req.socket over deprecated req.connection by @bjohansebas in https://github.com/expressjs/express/pull/6705", - "textTruncated": false, - "heading": "What's Changed", - "source": "releases", - "model": "jev-1.13.0", - "formulation": "s-hier-v2", - "ambiguous": true - }, - { - "version": "5.2.1", - "tier": "should_know", - "tierConfidence": 0.09, - "kind": "notable_change", - "kindConfidence": 0.92, - "text": "> [!IMPORTANT] \n> The prior release (5.2.0) included an erroneous breaking change related to the extended query parser. There is no actual security vulnerability associated with this behavior (CVE-2024-51999 has been rejected). The change has been fully reverted in this release.", - "textTruncated": false, - "heading": "What's Changed", - "source": "releases", - "model": "jev-1.13.0", - "formulation": "s-hier-v2", - "ambiguous": true - } - ], - "riskCoverage": { - "versionsClassified": 4, - "versionsNotAssessed": 0, - "versionsWithoutNotes": 0, - "versionsUnparseable": 0, - "unitsNoImpact": 125, - "itemsOmitted": 0, - "itemsMustActConfident": 0, - "itemsMustActAmbiguous": 8, - "itemsShouldKnowConfident": 6, - "itemsShouldKnowAmbiguous": 2, - "itemsUnclassified": 0 - }, - "source": "changelog_file", + "breakingSignals": ["breaking"], "entries": [ { - "detailSource": "releases", - "version": "5.2.1", - "publishedAt": "2025-12-01T20:49:43.268Z", - "htmlUrl": "https://github.com/expressjs/express/releases/tag/v5.2.1", "body": "## What's Changed\r\n\r\n> [!IMPORTANT] \r\n> The prior release (5.2.0) included an erroneous breaking change related to the extended query parser. There is no actual security vulnerability associated with this behavior (CVE-2024-51999 has been rejected). The change has been fully reverted in this release.\r\n\r\n* Release: 5.2.1 by @UlisesGascon in https://github.com/expressjs/express/pull/6933\r\n\r\n**Full Changelog**: https://github.com/expressjs/express/compare/v5.2.0...v5.2.1", "bodyPreview": "## What's Changed > [!IMPORTANT] > The prior release (5.2.0) included an erroneous breaking change related to the extended query parser. There is no actual security vulnerability associated with this behavior (CVE-2024-51999 has been rejected). The change has been fully reverted ...", + "detailSource": "releases", "headline": "> [!IMPORTANT] > The prior release (5.2.0) included an erroneous breaking change related to the extended query parser. There is no actual security vulnerability associated with this behavior (CVE-2024-51999 has been rejected). The change has been fully reverted in this release.", - "signals": ["breaking"] + "htmlUrl": "https://github.com/expressjs/express/releases/tag/v5.2.1", + "publishedAt": "2025-12-01T20:49:43.268Z", + "signals": ["breaking"], + "version": "5.2.1" }, { - "detailSource": "changelog_file", - "version": "5.2.0", - "publishedAt": "2025-12-01T16:23:57.541Z", - "htmlUrl": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L6-L12", "body": "* Security fix for [CVE-2024-51999](https://www.cve.org/CVERecord?id=CVE-2024-51999) ([GHSA-pj86-cfqh-vqx6](https://github.com/expressjs/express/security/advisories/GHSA-pj86-cfqh-vqx6))\n* deps: `body-parser@^2.2.1`\n* A deprecation warning was added when using `res.redirect` with undefined arguments, Express now emits a warning to help detect calls that pass undefined as the status or URL and make them easier to fix.", "bodyPreview": "* Security fix for [CVE-2024-51999](https://www.cve.org/CVERecord?id=CVE-2024-51999) ([GHSA-pj86-cfqh-vqx6](https://github.com/expressjs/express/security/advisories/GHSA-pj86-cfqh-vqx6)) * deps: `body-parser@^2.2.1` * A deprecation warning was added when using `res.redirect` with...", - "headline": "* Security fix for [CVE-2024-51999](https://www.cve.org/CVERecord?id=CVE-2024-51999) ([GHSA-pj86-cfqh-vqx6](https://github.com/expressjs/express/security/advisories/GHSA-pj86-cfqh-vqx6)) * deps: `body-parser@^2.2.1` * A deprecation warning was added when using `res.redirect` with..." + "detailSource": "changelog_file", + "headline": "* Security fix for [CVE-2024-51999](https://www.cve.org/CVERecord?id=CVE-2024-51999) ([GHSA-pj86-cfqh-vqx6](https://github.com/expressjs/express/security/advisories/GHSA-pj86-cfqh-vqx6)) * deps: `body-parser@^2.2.1` * A deprecation warning was added when using `res.redirect` with...", + "htmlUrl": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L6-L12", + "publishedAt": "2025-12-01T16:23:57.541Z", + "signals": [], + "version": "5.2.0" }, { - "detailSource": "changelog_file", - "version": "5.1.0", - "publishedAt": "2025-03-31T14:01:22.509Z", - "htmlUrl": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L13-L35", "body": "* Add support for `Uint8Array` in `res.send()`\n* Add support for ETag option in `res.sendFile()`\n* Add support for multiple links with the same rel in `res.links()`\n* Add funding field to package.json\n* perf: use loop for acceptParams\n* refactor: prefix built-in node module imports\n* deps: remove `setprototypeof`\n* deps: remove `safe-buffer`\n* deps: remove `utils-merge`\n* deps: remove `methods`\n* deps: remove `depd`\n* deps: `debug@^4.4.0`\n* deps: `body-parser@^2.2.0`\n* deps: `router@^2.2.0`\n* deps: `content-type@^1.0.5`\n* deps: `finalhandler@^2.1.0`\n* deps: `qs@^6.14.0`\n* deps: `server-static@2.2.0`\n* deps: `type-is@2.0.1`", "bodyPreview": "* Add support for `Uint8Array` in `res.send()` * Add support for ETag option in `res.sendFile()` * Add support for multiple links with the same rel in `res.links()` * Add funding field to package.json * perf: use loop for acceptParams * refactor: prefix built-in node module impor...", - "headline": "* Add support for `Uint8Array` in `res.send()` * Add support for ETag option in `res.sendFile()` * Add support for multiple links with the same rel in `res.links()` * Add funding field to package.json * perf: use loop for acceptParams * refactor: prefix built-in node module impor..." + "detailSource": "changelog_file", + "headline": "* Add support for `Uint8Array` in `res.send()` * Add support for ETag option in `res.sendFile()` * Add support for multiple links with the same rel in `res.links()` * Add funding field to package.json * perf: use loop for acceptParams * refactor: prefix built-in node module impor...", + "htmlUrl": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L13-L35", + "publishedAt": "2025-03-31T14:01:22.509Z", + "signals": [], + "version": "5.1.0" }, { - "detailSource": "changelog_file", - "version": "5.0.1", - "publishedAt": "2024-10-08T19:42:44.129Z", - "htmlUrl": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L36-L40", "body": "* Update `cookie` semver lock to address [CVE-2024-47764](https://nvd.nist.gov/vuln/detail/CVE-2024-47764)", "bodyPreview": "* Update `cookie` semver lock to address [CVE-2024-47764](https://nvd.nist.gov/vuln/detail/CVE-2024-47764)", - "headline": "* Update `cookie` semver lock to address [CVE-2024-47764](https://nvd.nist.gov/vuln/detail/CVE-2024-47764)" + "detailSource": "changelog_file", + "headline": "* Update `cookie` semver lock to address [CVE-2024-47764](https://nvd.nist.gov/vuln/detail/CVE-2024-47764)", + "htmlUrl": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L36-L40", + "publishedAt": "2024-10-08T19:42:44.129Z", + "signals": [], + "version": "5.0.1" } ], - "sampledEntries": [ + "hasReleaseNoteBodies": true, + "keywordEntries": [ { + "body": "## What's Changed\r\n\r\n> [!IMPORTANT] \r\n> The prior release (5.2.0) included an erroneous breaking change related to the extended query parser. There is no actual security vulnerability associated with this behavior (CVE-2024-51999 has been rejected). The change has been fully reverted in this release.\r\n\r\n* Release: 5.2.1 by @UlisesGascon in https://github.com/expressjs/express/pull/6933\r\n\r\n**Full Changelog**: https://github.com/expressjs/express/compare/v5.2.0...v5.2.1", + "bodyPreview": "## What's Changed > [!IMPORTANT] > The prior release (5.2.0) included an erroneous breaking change related to the extended query parser. There is no actual security vulnerability associated with this behavior (CVE-2024-51999 has been rejected). The change has been fully reverted ...", "detailSource": "releases", - "version": "5.2.1", - "publishedAt": "2025-12-01T20:49:43.268Z", + "headline": "> [!IMPORTANT] > The prior release (5.2.0) included an erroneous breaking change related to the extended query parser. There is no actual security vulnerability associated with this behavior (CVE-2024-51999 has been rejected). The change has been fully reverted in this release.", "htmlUrl": "https://github.com/expressjs/express/releases/tag/v5.2.1", + "publishedAt": "2025-12-01T20:49:43.268Z", + "signals": ["breaking"], + "version": "5.2.1" + } + ], + "migrationSignals": [], + "riskCoverage": { + "itemsMustActAmbiguous": 8, + "itemsMustActConfident": 0, + "itemsShouldKnowAmbiguous": 1, + "itemsShouldKnowConfident": 6, + "itemsUnclassified": 0, + "unitsNoImpact": 126, + "versionsClassified": 4, + "versionsNotAssessed": 0, + "versionsUnparseable": 0, + "versionsWithoutNotes": 0 + }, + "sampledEntries": [ + { "body": "## What's Changed\r\n\r\n> [!IMPORTANT] \r\n> The prior release (5.2.0) included an erroneous breaking change related to the extended query parser. There is no actual security vulnerability associated with this behavior (CVE-2024-51999 has been rejected). The change has been fully reverted in this release.\r\n\r\n* Release: 5.2.1 by @UlisesGascon in https://github.com/expressjs/express/pull/6933\r\n\r\n**Full Changelog**: https://github.com/expressjs/express/compare/v5.2.0...v5.2.1", "bodyPreview": "## What's Changed > [!IMPORTANT] > The prior release (5.2.0) included an erroneous breaking change related to the extended query parser. There is no actual security vulnerability associated with this behavior (CVE-2024-51999 has been rejected). The change has been fully reverted ...", + "detailSource": "releases", "headline": "> [!IMPORTANT] > The prior release (5.2.0) included an erroneous breaking change related to the extended query parser. There is no actual security vulnerability associated with this behavior (CVE-2024-51999 has been rejected). The change has been fully reverted in this release.", - "signals": ["breaking"] + "htmlUrl": "https://github.com/expressjs/express/releases/tag/v5.2.1", + "publishedAt": "2025-12-01T20:49:43.268Z", + "signals": ["breaking"], + "version": "5.2.1" }, { - "detailSource": "changelog_file", - "version": "5.2.0", - "publishedAt": "2025-12-01T16:23:57.541Z", - "htmlUrl": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L6-L12", "body": "* Security fix for [CVE-2024-51999](https://www.cve.org/CVERecord?id=CVE-2024-51999) ([GHSA-pj86-cfqh-vqx6](https://github.com/expressjs/express/security/advisories/GHSA-pj86-cfqh-vqx6))\n* deps: `body-parser@^2.2.1`\n* A deprecation warning was added when using `res.redirect` with undefined arguments, Express now emits a warning to help detect calls that pass undefined as the status or URL and make them easier to fix.", "bodyPreview": "* Security fix for [CVE-2024-51999](https://www.cve.org/CVERecord?id=CVE-2024-51999) ([GHSA-pj86-cfqh-vqx6](https://github.com/expressjs/express/security/advisories/GHSA-pj86-cfqh-vqx6)) * deps: `body-parser@^2.2.1` * A deprecation warning was added when using `res.redirect` with...", - "headline": "* Security fix for [CVE-2024-51999](https://www.cve.org/CVERecord?id=CVE-2024-51999) ([GHSA-pj86-cfqh-vqx6](https://github.com/expressjs/express/security/advisories/GHSA-pj86-cfqh-vqx6)) * deps: `body-parser@^2.2.1` * A deprecation warning was added when using `res.redirect` with..." + "detailSource": "changelog_file", + "headline": "* Security fix for [CVE-2024-51999](https://www.cve.org/CVERecord?id=CVE-2024-51999) ([GHSA-pj86-cfqh-vqx6](https://github.com/expressjs/express/security/advisories/GHSA-pj86-cfqh-vqx6)) * deps: `body-parser@^2.2.1` * A deprecation warning was added when using `res.redirect` with...", + "htmlUrl": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L6-L12", + "publishedAt": "2025-12-01T16:23:57.541Z", + "signals": [], + "version": "5.2.0" }, { - "detailSource": "changelog_file", - "version": "5.1.0", - "publishedAt": "2025-03-31T14:01:22.509Z", - "htmlUrl": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L13-L35", "body": "* Add support for `Uint8Array` in `res.send()`\n* Add support for ETag option in `res.sendFile()`\n* Add support for multiple links with the same rel in `res.links()`\n* Add funding field to package.json\n* perf: use loop for acceptParams\n* refactor: prefix built-in node module imports\n* deps: remove `setprototypeof`\n* deps: remove `safe-buffer`\n* deps: remove `utils-merge`\n* deps: remove `methods`\n* deps: remove `depd`\n* deps: `debug@^4.4.0`\n* deps: `body-parser@^2.2.0`\n* deps: `router@^2.2.0`\n* deps: `content-type@^1.0.5`\n* deps: `finalhandler@^2.1.0`\n* deps: `qs@^6.14.0`\n* deps: `server-static@2.2.0`\n* deps: `type-is@2.0.1`", "bodyPreview": "* Add support for `Uint8Array` in `res.send()` * Add support for ETag option in `res.sendFile()` * Add support for multiple links with the same rel in `res.links()` * Add funding field to package.json * perf: use loop for acceptParams * refactor: prefix built-in node module impor...", - "headline": "* Add support for `Uint8Array` in `res.send()` * Add support for ETag option in `res.sendFile()` * Add support for multiple links with the same rel in `res.links()` * Add funding field to package.json * perf: use loop for acceptParams * refactor: prefix built-in node module impor..." + "detailSource": "changelog_file", + "headline": "* Add support for `Uint8Array` in `res.send()` * Add support for ETag option in `res.sendFile()` * Add support for multiple links with the same rel in `res.links()` * Add funding field to package.json * perf: use loop for acceptParams * refactor: prefix built-in node module impor...", + "htmlUrl": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L13-L35", + "publishedAt": "2025-03-31T14:01:22.509Z", + "signals": [], + "version": "5.1.0" }, { - "detailSource": "changelog_file", - "version": "5.0.1", - "publishedAt": "2024-10-08T19:42:44.129Z", - "htmlUrl": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L36-L40", "body": "* Update `cookie` semver lock to address [CVE-2024-47764](https://nvd.nist.gov/vuln/detail/CVE-2024-47764)", "bodyPreview": "* Update `cookie` semver lock to address [CVE-2024-47764](https://nvd.nist.gov/vuln/detail/CVE-2024-47764)", - "headline": "* Update `cookie` semver lock to address [CVE-2024-47764](https://nvd.nist.gov/vuln/detail/CVE-2024-47764)" - } - ], - "keywordEntries": [ - { - "detailSource": "releases", - "version": "5.2.1", - "publishedAt": "2025-12-01T20:49:43.268Z", - "htmlUrl": "https://github.com/expressjs/express/releases/tag/v5.2.1", - "body": "## What's Changed\r\n\r\n> [!IMPORTANT] \r\n> The prior release (5.2.0) included an erroneous breaking change related to the extended query parser. There is no actual security vulnerability associated with this behavior (CVE-2024-51999 has been rejected). The change has been fully reverted in this release.\r\n\r\n* Release: 5.2.1 by @UlisesGascon in https://github.com/expressjs/express/pull/6933\r\n\r\n**Full Changelog**: https://github.com/expressjs/express/compare/v5.2.0...v5.2.1", - "bodyPreview": "## What's Changed > [!IMPORTANT] > The prior release (5.2.0) included an erroneous breaking change related to the extended query parser. There is no actual security vulnerability associated with this behavior (CVE-2024-51999 has been rejected). The change has been fully reverted ...", - "headline": "> [!IMPORTANT] > The prior release (5.2.0) included an erroneous breaking change related to the extended query parser. There is no actual security vulnerability associated with this behavior (CVE-2024-51999 has been rejected). The change has been fully reverted in this release.", - "signals": ["breaking"] + "detailSource": "changelog_file", + "headline": "* Update `cookie` semver lock to address [CVE-2024-47764](https://nvd.nist.gov/vuln/detail/CVE-2024-47764)", + "htmlUrl": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L36-L40", + "publishedAt": "2024-10-08T19:42:44.129Z", + "signals": [], + "version": "5.0.1" } ], - "totalKeywordEntries": 1, + "source": "changelog_file", + "statements": { + "coverage": [ + { + "statementsAssessed": 3, + "statementsNoImpact": 1, + "statementsNotAssessed": 0, + "statementsTotal": 3, + "statementsUnclassified": 0, + "status": "classified", + "version": "5.2.1" + }, + { + "statementsAssessed": 55, + "statementsNoImpact": 50, + "statementsNotAssessed": 0, + "statementsTotal": 55, + "statementsUnclassified": 0, + "status": "classified", + "version": "5.2.0" + }, + { + "statementsAssessed": 78, + "statementsNoImpact": 71, + "statementsNotAssessed": 0, + "statementsTotal": 78, + "statementsUnclassified": 0, + "status": "classified", + "version": "5.1.0" + }, + { + "statementsAssessed": 5, + "statementsNoImpact": 4, + "statementsNotAssessed": 0, + "statementsTotal": 5, + "statementsUnclassified": 0, + "status": "classified", + "version": "5.0.1" + } + ], + "items": [ + { + "classification": { + "ambiguous": true, + "formulation": "s-hier-v2", + "kind": "notable_change", + "kindConfidence": 0.92, + "model": "jev-1.13.0", + "tier": "should_know", + "tierConfidence": 0.09 + }, + "fullText": "> [!IMPORTANT] \n> The prior release (5.2.0) included an erroneous breaking change related to the extended query parser. There is no actual security vulnerability associated with this behavior (CVE-2024-51999 has been rejected). The change has been fully reverted in this release.", + "heading": "What's Changed", + "position": 0, + "source": "releases", + "sourceIndex": 0, + "sourcePosition": 0, + "text": "> [!IMPORTANT] \n> The prior release (5.2.0) included an erroneous breaking change related to the extended query parser. There is no actual security vulnerability associated with this behavior (CVE-2024-51999 has been rejected). The change has been fully reverted in this release.", + "textTruncated": false, + "url": "https://github.com/expressjs/express/releases/tag/v5.2.1", + "version": "5.2.1" + }, + { + "classification": { + "ambiguous": true, + "formulation": "s-hier-v2", + "kind": "changes_behavior_or_default", + "kindConfidence": 1, + "model": "jev-1.13.0", + "tier": "must_act", + "tierConfidence": 0.23 + }, + "fullText": "* Revert security fix for [CVE-2024-51999](https://www.cve.org/CVERecord?id=CVE-2024-51999) ([GHSA-pj86-cfqh-vqx6](https://github.com/expressjs/express/security/advisories/GHSA-pj86-cfqh-vqx6))", + "position": 2, + "source": "changelog_file", + "sourceIndex": 1, + "sourcePosition": 0, + "text": "* Revert security fix for [CVE-2024-51999](https://www.cve.org/CVERecord?id=CVE-2024-51999) ([GHSA-pj86-cfqh-vqx6](https://github.com/expressjs/express/security/advisories/GHSA-pj86-cfqh-vqx6))", + "textTruncated": false, + "url": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L1-L5", + "version": "5.2.1" + }, + { + "classification": { + "ambiguous": false, + "formulation": "s-hier-v5", + "kind": "deprecates_without_removal", + "kindConfidence": 0.48, + "model": "jev-1.13.0", + "tier": "should_know", + "tierConfidence": 1 + }, + "fullText": "* A deprecation warning was added when using `res.redirect` with undefined arguments, Express now emits a warning to help detect calls that pass undefined as the status or URL and make them easier to fix.", + "position": 1, + "source": "changelog_file", + "sourceIndex": 0, + "sourcePosition": 2, + "text": "* A deprecation warning was added when using `res.redirect` with undefined arguments, Express now emits a warning to help detect calls that pass undefined as the status or URL and make them easier to fix.", + "textTruncated": false, + "url": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L6-L12", + "version": "5.2.0" + }, + { + "classification": { + "ambiguous": false, + "formulation": "s-hier-v5", + "kind": "security_fix", + "kindConfidence": 1, + "model": "jev-1.13.0", + "tier": "should_know", + "tierConfidence": 1 + }, + "fullText": "* Security fix for [CVE-2024-51999](https://www.cve.org/CVERecord?id=CVE-2024-51999) ([GHSA-pj86-cfqh-vqx6](https://github.com/expressjs/express/security/advisories/GHSA-pj86-cfqh-vqx6))", + "heading": "Important: Security", + "position": 2, + "source": "releases", + "sourceIndex": 1, + "sourcePosition": 0, + "text": "* Security fix for [CVE-2024-51999](https://www.cve.org/CVERecord?id=CVE-2024-51999) ([GHSA-pj86-cfqh-vqx6](https://github.com/expressjs/express/security/advisories/GHSA-pj86-cfqh-vqx6))", + "textTruncated": false, + "url": "https://github.com/expressjs/express/releases/tag/v5.2.0", + "version": "5.2.0" + }, + { + "classification": { + "ambiguous": true, + "formulation": "s-hier-v5", + "kind": "changes_behavior_or_default", + "kindConfidence": 0.8, + "model": "jev-1.13.0", + "tier": "must_act", + "tierConfidence": 0.38 + }, + "fullText": "* chore: enforce explicit `Buffer` import and add lint rule by @shivarm in https://github.com/expressjs/express/pull/6525", + "heading": "What's Changed", + "position": 20, + "source": "releases", + "sourceIndex": 1, + "sourcePosition": 18, + "text": "* chore: enforce explicit `Buffer` import and add lint rule by @shivarm in https://github.com/expressjs/express/pull/6525", + "textTruncated": false, + "url": "https://github.com/expressjs/express/releases/tag/v5.2.0", + "version": "5.2.0" + }, + { + "classification": { + "ambiguous": false, + "formulation": "s-hier-v5", + "kind": "deprecates_without_removal", + "kindConfidence": 0.92, + "model": "jev-1.13.0", + "tier": "should_know", + "tierConfidence": 1 + }, + "fullText": "* add deprecation warnings for redirect arguments undefined by @bjohansebas in https://github.com/expressjs/express/pull/6405", + "heading": "What's Changed", + "position": 24, + "source": "releases", + "sourceIndex": 1, + "sourcePosition": 22, + "text": "* add deprecation warnings for redirect arguments undefined by @bjohansebas in https://github.com/expressjs/express/pull/6405", + "textTruncated": false, + "url": "https://github.com/expressjs/express/releases/tag/v5.2.0", + "version": "5.2.0" + }, + { + "classification": { + "ambiguous": false, + "formulation": "s-hier-v5", + "kind": "security_fix", + "kindConfidence": 1, + "model": "jev-1.13.0", + "tier": "should_know", + "tierConfidence": 0.94 + }, + "fullText": "* fix: upgrade body-parser to 2.2.1 to address CVE-2025-13466 by @shivarm in https://github.com/expressjs/express/pull/6922", + "heading": "What's Changed", + "position": 50, + "source": "releases", + "sourceIndex": 1, + "sourcePosition": 48, + "text": "* fix: upgrade body-parser to 2.2.1 to address CVE-2025-13466 by @shivarm in https://github.com/expressjs/express/pull/6922", + "textTruncated": false, + "url": "https://github.com/expressjs/express/releases/tag/v5.2.0", + "version": "5.2.0" + }, + { + "classification": { + "ambiguous": true, + "formulation": "s-hier-v2", + "model": "jev-1.13.0", + "tier": "must_act", + "tierConfidence": 0.2 + }, + "fullText": "* deps: remove `setprototypeof`", + "position": 6, + "source": "changelog_file", + "sourceIndex": 0, + "sourcePosition": 6, + "text": "* deps: remove `setprototypeof`", + "textTruncated": false, + "url": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L13-L35", + "version": "5.1.0" + }, + { + "classification": { + "ambiguous": true, + "formulation": "s-hier-v2", + "model": "jev-1.13.0", + "tier": "must_act", + "tierConfidence": 0.23 + }, + "fullText": "* deps: remove `safe-buffer`", + "position": 7, + "source": "changelog_file", + "sourceIndex": 0, + "sourcePosition": 7, + "text": "* deps: remove `safe-buffer`", + "textTruncated": false, + "url": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L13-L35", + "version": "5.1.0" + }, + { + "classification": { + "ambiguous": true, + "formulation": "s-hier-v2", + "model": "jev-1.13.0", + "tier": "must_act", + "tierConfidence": 0.23 + }, + "fullText": "* deps: remove `utils-merge`", + "position": 8, + "source": "changelog_file", + "sourceIndex": 0, + "sourcePosition": 8, + "text": "* deps: remove `utils-merge`", + "textTruncated": false, + "url": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L13-L35", + "version": "5.1.0" + }, + { + "classification": { + "ambiguous": true, + "formulation": "s-hier-v2", + "model": "jev-1.13.0", + "tier": "must_act", + "tierConfidence": 0.23 + }, + "fullText": "* deps: remove `methods`", + "position": 9, + "source": "changelog_file", + "sourceIndex": 0, + "sourcePosition": 9, + "text": "* deps: remove `methods`", + "textTruncated": false, + "url": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L13-L35", + "version": "5.1.0" + }, + { + "classification": { + "ambiguous": true, + "formulation": "s-hier-v2", + "model": "jev-1.13.0", + "tier": "must_act", + "tierConfidence": 0.19 + }, + "fullText": "* deps: remove `depd`", + "position": 10, + "source": "changelog_file", + "sourceIndex": 0, + "sourcePosition": 10, + "text": "* deps: remove `depd`", + "textTruncated": false, + "url": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L13-L35", + "version": "5.1.0" + }, + { + "classification": { + "ambiguous": true, + "formulation": "s-hier-v2", + "kind": "raises_runtime_or_platform_requirement", + "kindConfidence": 1, + "model": "jev-1.13.0", + "tier": "must_act", + "tierConfidence": 0.28 + }, + "fullText": "* build: Node.js 23.0 by @bjohansebas in https://github.com/expressjs/express/pull/6075", + "heading": "What's Changed", + "position": 20, + "source": "releases", + "sourceIndex": 1, + "sourcePosition": 1, + "text": "* build: Node.js 23.0 by @bjohansebas in https://github.com/expressjs/express/pull/6075", + "textTruncated": false, + "url": "https://github.com/expressjs/express/releases/tag/v5.1.0", + "version": "5.1.0" + }, + { + "classification": { + "ambiguous": false, + "formulation": "s-hier-v2", + "kind": "security_fix", + "kindConfidence": 0.98, + "model": "jev-1.13.0", + "tier": "should_know", + "tierConfidence": 0.69 + }, + "fullText": "* fix(securite): fix vulnerabilities by @Abdel-Monaam-Aouini in https://github.com/expressjs/express/pull/6211", + "heading": "What's Changed", + "position": 40, + "source": "releases", + "sourceIndex": 1, + "sourcePosition": 21, + "text": "* fix(securite): fix vulnerabilities by @Abdel-Monaam-Aouini in https://github.com/expressjs/express/pull/6211", + "textTruncated": false, + "url": "https://github.com/expressjs/express/releases/tag/v5.1.0", + "version": "5.1.0" + }, + { + "classification": { + "ambiguous": false, + "formulation": "s-hier-v2", + "kind": "security_fix", + "kindConfidence": 1, + "model": "jev-1.13.0", + "tier": "should_know", + "tierConfidence": 0.97 + }, + "fullText": "* Update `cookie` semver lock to address [CVE-2024-47764](https://nvd.nist.gov/vuln/detail/CVE-2024-47764)", + "position": 0, + "source": "changelog_file", + "sourceIndex": 0, + "sourcePosition": 0, + "text": "* Update `cookie` semver lock to address [CVE-2024-47764](https://nvd.nist.gov/vuln/detail/CVE-2024-47764)", + "textTruncated": false, + "url": "https://github.com/expressjs/express/blob/dbac741a49a5a64336b70c06e85c2e2706e36336/History.md#L36-L40", + "version": "5.0.1" + } + ], + "matchingCount": 15, + "totalCount": 141, + "count": 15, + "selection": "focused" + }, "totalEntries": 4, "totalEntriesWithBodies": 4, - "truncated": false, - "hasReleaseNoteBodies": true, - "breakingSignals": ["breaking"], - "migrationSignals": [] + "totalKeywordEntries": 1, + "truncated": false } diff --git a/packages/mcp/src/shared/package-changelog-request.test.ts b/packages/mcp/src/shared/package-changelog-request.test.ts index 35e111d4..5c6dd760 100644 --- a/packages/mcp/src/shared/package-changelog-request.test.ts +++ b/packages/mcp/src/shared/package-changelog-request.test.ts @@ -294,7 +294,7 @@ describe("classified statement views", () => { }); } } - it("defaults empty agent strings to all without an explicit filter echo", () => { + it("retains changelog changes defaults", () => { for (const changes of [undefined, "", " "]) { const result = buildPackageChangelogParams({ target: "npm:express", @@ -304,6 +304,14 @@ describe("classified statement views", () => { expect(result.params.statementTiers).toBeUndefined(); expect(result.explicitFilterFields.has("changes")).toBe(false); } + + const explicitAll = buildPackageChangelogParams({ + target: "npm:express", + changes: "all", + }); + expect(explicitAll.changes).toBe("all"); + expect(explicitAll.params.statementTiers).toBeUndefined(); + expect(explicitAll.explicitFilterFields.has("changes")).toBe(true); }); it("ignores the changes knob entirely for timeline-only requests", () => { const result = buildPackageChangelogParams({ diff --git a/packages/mcp/src/shared/package-changelog-request.ts b/packages/mcp/src/shared/package-changelog-request.ts index 08634ab8..3c167507 100644 --- a/packages/mcp/src/shared/package-changelog-request.ts +++ b/packages/mcp/src/shared/package-changelog-request.ts @@ -19,10 +19,14 @@ import type { PackageChangelogView, } from "@githits/core-internal"; import { toPkgseerRegistry } from "@githits/core-internal"; +import type { ChangelogChangesView } from "./changelog-changes.js"; +import { normaliseChangelogChanges } from "./changelog-changes.js"; import { parsePackageChangelogTarget } from "./package-changelog-target.js"; import { InvalidPackageSpecError } from "./package-spec.js"; import { normalisePackageVersion } from "./package-version.js"; +export type { ChangelogChangesView } from "./changelog-changes.js"; + export interface PackageChangelogRequestInput { /** Compact package target. Required. */ target?: string; @@ -45,8 +49,6 @@ export type ExplicitFilterField = | "version" | "changes"; -export type ChangelogChangesView = "actionable" | "important" | "all"; - export type PackageChangelogRequestMode = "latest" | "exact" | "range"; export interface PackageChangelogRequestBuildResult { @@ -78,7 +80,9 @@ export function buildPackageChangelogParams( const limit = normaliseLimit(input.limit); const view = input.view ?? "statements"; const changes = - view === "statements" ? normaliseChanges(input.changes) : undefined; + view === "statements" + ? normaliseChangelogChanges(input.changes) + : undefined; const statementTiers: PackageChangelogParams["statementTiers"] = changes === "actionable" ? ["MUST_ACT"] @@ -204,17 +208,6 @@ function normaliseLimit(raw: number | undefined): number | undefined { return raw; } -/** Empty agent strings mean the default; omission mode does not validate this knob. */ -function normaliseChanges(raw: string | undefined): ChangelogChangesView { - const value = raw?.trim().toLowerCase() || "all"; - if (value !== "actionable" && value !== "important" && value !== "all") { - throw new InvalidPackageSpecError( - "changes must be actionable, important (actionable + should know), or all.", - ); - } - return value; -} - export interface PackageChangelogViewInput { omitBodies?: boolean; verbatim?: boolean; diff --git a/packages/mcp/src/shared/package-changelog-response.ts b/packages/mcp/src/shared/package-changelog-response.ts index 8b57ebc2..b18b7ec0 100644 --- a/packages/mcp/src/shared/package-changelog-response.ts +++ b/packages/mcp/src/shared/package-changelog-response.ts @@ -133,28 +133,7 @@ export function buildPackageChangelogSuccessPayload( }; if (options.view === "statements" && report.statements) { - envelope.statements = { - ...report.statements, - count: report.statements.items.length, - items: report.statements.items.map((item) => ({ - ...item, - ...(item.source ? { source: item.source.toLowerCase() } : {}), - classification: - item.classification === null - ? null - : { - ...item.classification, - tier: item.classification.tier.toLowerCase(), - ...(item.classification.kind - ? { kind: item.classification.kind.toLowerCase() } - : {}), - }, - })), - coverage: report.statements.coverage.map((c) => ({ - ...c, - status: c.status.toLowerCase(), - })), - }; + envelope.statements = normaliseChangelogStatements(report.statements); } if (report.source) envelope.source = report.source; @@ -167,6 +146,34 @@ export function buildPackageChangelogSuccessPayload( return envelope; } +/** Shared lossless statement envelope; the hosts only normalize enum spelling. */ +export function normaliseChangelogStatements( + statements: ChangelogStatements, +): LeanChangelogStatements { + return { + ...statements, + count: statements.items.length, + items: 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: statements.coverage.map((c) => ({ + ...c, + status: c.status.toLowerCase(), + })), + }; +} + function buildFilterBlock( options: BuildChangelogPayloadOptions, ): LeanChangelogFilter | undefined { @@ -319,91 +326,115 @@ export function formatPackageChangelogTerminal( ); continue; } - let sourceIndex: number | undefined; - let heading: string | undefined; - for (const item of items) { - if (sourceIndex !== item.sourceIndex) { - sourceIndex = item.sourceIndex; - heading = undefined; - const source = noteSourceLabel(item.source); - prose( - `${source}${item.url ? `: ${sanitizeTerminalText(item.url)}` : " (source URL unavailable)"}`, - " ", - ); - } - if (item.heading !== heading) { - heading = item.heading; - if (heading) prose(sanitizeTerminalText(heading), " "); - } - if (item.context) { - if (item.context.heading && item.context.heading !== heading) - prose( - `Context for next statement - ${sanitizeTerminalText(item.context.heading)}`, - " ", - ); - else prose("Context for next statement", " "); - renderStatementText(lines, item.context.text, " ", width); - if (item.context.textTruncated) - prose( - `Context truncated; ${options.fullStatementHint ?? "use --json for full text"}.`, - " ", - ); - } - const classification = item.classification; - const tier = classification?.tier; - const kind = classification?.kind - ? RISK_KIND_LABELS[classification.kind as keyof typeof RISK_KIND_LABELS] - : undefined; - const annotation = - tier === "no_impact" - ? undefined - : tier === "unclassified" - ? "[too long to classify - read it]" - : !classification - ? "[not assessed]" - : kind - ? `[${tier === "must_act" ? "!" : ""}${kind}]` - : tier === "must_act" - ? "[!]" - : "[should know]"; - const start = lines.length; - renderStatementText( - lines, - item.text, - ` * ${annotation ? `${annotation} ` : ""}${classification?.ambiguous ? "(uncertain) " : ""}`, - width, + lines.push(...formatChangelogVersionStatements(items, options)); + } + return `${lines.join("\n")}\n`; +} + +/** Source ordering and trusted tags belong to the shared statement presentation. */ +export function formatChangelogVersionStatements( + items: LeanChangelogStatement[], + options: FormatChangelogTerminalOptions & { compactNonAction?: boolean }, +): string[] { + const lines: string[] = []; + const width = Math.max(20, options.terminalWidth ?? 80); + const prose = (text: string, indent = ""): void => { + lines.push(...wrapTerminalProse(`${indent}${text}`, width, indent)); + }; + 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)"}`, + " ", ); - // Style only the trusted prefix after sanitation and wrapping. Missing - // kinds retain explicit tier meaning; plain bullets cannot style a quote. - if (annotation && lines[start]?.startsWith(` * ${annotation}`)) { - const styled = - classification?.ambiguous || !classification - ? dim(annotation, options.useColors) - : colorize( - annotation, - tier === "must_act" - ? "red" - : tier === "should_know" - ? "yellow" - : "cyan", - options.useColors, - ); - lines[start] = - ` * ${styled}${lines[start].slice(6 + annotation.length)}`; - } - if (classification?.ambiguous && lines[start]) - lines[start] = lines[start].replace( - "(uncertain)", - dim("(uncertain)", options.useColors), + } + if (item.heading !== heading) { + heading = item.heading; + if (heading) prose(sanitizeTerminalText(heading), " "); + } + if (item.context) { + if (item.context.heading && item.context.heading !== heading) + prose( + `Context for next statement - ${sanitizeTerminalText(item.context.heading)}`, + " ", ); - if (item.textTruncated) + else prose("Context for next statement", " "); + renderStatementText(lines, item.context.text, " ", width); + if (item.context.textTruncated) prose( - `Statement truncated; ${options.fullStatementHint ?? "use --json for full text"}.`, + `Context truncated; ${options.fullStatementHint ?? "use --json for full text"}.`, " ", ); } + const classification = item.classification; + const tier = classification?.tier; + const kind = classification?.kind + ? RISK_KIND_LABELS[classification.kind as keyof typeof RISK_KIND_LABELS] + : undefined; + const annotation = + tier === "no_impact" + ? undefined + : tier === "unclassified" + ? "[too long to classify - read it]" + : !classification + ? "[not assessed]" + : kind + ? `[${tier === "must_act" ? "!" : ""}${kind}]` + : tier === "must_act" + ? "[!]" + : "[should know]"; + const start = lines.length; + renderStatementText( + lines, + options.compactNonAction && + tier !== "must_act" && + Array.from(item.text).length > 240 + ? `${Array.from(item.text).slice(0, 240).join("")}...` + : item.text, + ` * ${annotation ? `${annotation} ` : ""}${classification?.ambiguous ? "(uncertain) " : ""}`, + width, + ); + // Style only the trusted prefix after sanitation and wrapping. Missing + // kinds retain explicit tier meaning; plain bullets cannot style a quote. + if (annotation && lines[start]?.startsWith(` * ${annotation}`)) { + const styled = + classification?.ambiguous || !classification + ? dim(annotation, options.useColors) + : colorize( + annotation, + tier === "must_act" + ? "red" + : tier === "should_know" + ? "yellow" + : "cyan", + options.useColors, + ); + lines[start] = + ` * ${styled}${lines[start].slice(6 + annotation.length)}`; + } + if (classification?.ambiguous && lines[start]) + lines[start] = lines[start].replace( + "(uncertain)", + dim("(uncertain)", options.useColors), + ); + if ( + options.compactNonAction && + tier !== "must_act" && + Array.from(item.text).length > 240 + ) + prose("Excerpt; use verbose for the full bounded quote.", " "); + if (item.textTruncated) + prose( + `Statement truncated; ${options.fullStatementHint ?? "use --json for full text"}.`, + " ", + ); } - return `${lines.join("\n")}\n`; + return lines; } /** Preserve nested lines and code, wrap prose without flattening the unit. */ diff --git a/packages/mcp/src/shared/package-upgrade-review-request.test.ts b/packages/mcp/src/shared/package-upgrade-review-request.test.ts index 8c2f4fc9..a013bac0 100644 --- a/packages/mcp/src/shared/package-upgrade-review-request.test.ts +++ b/packages/mcp/src/shared/package-upgrade-review-request.test.ts @@ -3,6 +3,7 @@ import { InvalidPackageSpecError } from "./package-spec.js"; import { buildPackageUpgradeReviewRequest, PACKAGE_UPGRADE_REVIEW_MAX_PACKAGES, + type PackageUpgradeReviewRequestInput, type UpgradeReviewPackageInput, } from "./package-upgrade-review-request.js"; @@ -16,6 +17,135 @@ function batchPackage(index: number): UpgradeReviewPackageInput { } describe("buildPackageUpgradeReviewRequest", () => { + const changelogViewCases: Array<{ + name: string; + input: PackageUpgradeReviewRequestInput; + expectedView: "text" | "json" | "triage"; + expectedPackageCount?: number; + }> = [ + { + name: "single default/text", + input: { + registry: "npm", + packageName: "express", + currentVersion: "4.18.0", + targetVersion: "5.0.0", + }, + expectedView: "text", + }, + { + name: "batch compact", + input: { packages: [batchPackage(0), batchPackage(1)] }, + expectedView: "triage", + }, + { + name: "batch verbose", + input: { + packages: [batchPackage(0), batchPackage(1)], + verbose: true, + }, + expectedView: "text", + }, + { + name: "JSON batch verbose", + input: { + packages: [batchPackage(0), batchPackage(1)], + format: "json", + verbose: true, + }, + expectedView: "json", + }, + { + name: "normalized blank-batch entries do not affect count", + input: { + packages: [ + { + registry: " ", + packageName: "", + currentVersion: "\t", + targetVersion: "", + }, + batchPackage(0), + ], + }, + expectedView: "text", + expectedPackageCount: 1, + }, + ]; + + it.each(changelogViewCases)( + "derives the changelog view: $name", + ({ input, expectedView, expectedPackageCount }) => { + const request = buildPackageUpgradeReviewRequest(input); + + expect(request.options.changelogView).toBe(expectedView); + if (expectedPackageCount !== undefined) { + expect(request.packages).toHaveLength(expectedPackageCount); + } + }, + ); + + it.each([ + [ + "omitted", + undefined, + "focused", + undefined, + ["MUST_ACT", "SHOULD_KNOW", "UNCLASSIFIED"], + ], + [ + "blank", + " ", + "focused", + undefined, + ["MUST_ACT", "SHOULD_KNOW", "UNCLASSIFIED"], + ], + [ + "mixed-case actionable", + " AcTiOnAbLe ", + "actionable", + "actionable", + ["MUST_ACT"], + ], + [ + "important", + "important", + "important", + "important", + ["MUST_ACT", "SHOULD_KNOW"], + ], + ["all", "all", "all", "all", undefined], + ] as const)( + "changelog filter selection: %s", + (_case, changes, changesSelection, explicitChanges, statementTiers) => { + const { options } = buildPackageUpgradeReviewRequest({ + registry: "npm", + packageName: "express", + currentVersion: "4.18.0", + targetVersion: "5.0.0", + changes, + }); + + expect(options.changesSelection).toBe(changesSelection); + expect(options.explicitChanges).toBe(explicitChanges); + expect(options.statementTiers).toEqual( + statementTiers === undefined ? undefined : [...statementTiers], + ); + }, + ); + + it("changelog filter selection: invalid", () => { + expect(() => + buildPackageUpgradeReviewRequest({ + registry: "npm", + packageName: "express", + currentVersion: "4.18.0", + targetVersion: "5.0.0", + changes: "unknown", + }), + ).toThrow(InvalidPackageSpecError); + }); + it("treats an empty packages array as absent for single-package mode", () => { const { packages } = buildPackageUpgradeReviewRequest({ registry: "npm", diff --git a/packages/mcp/src/shared/package-upgrade-review-request.ts b/packages/mcp/src/shared/package-upgrade-review-request.ts index 05b20fe9..ea854d66 100644 --- a/packages/mcp/src/shared/package-upgrade-review-request.ts +++ b/packages/mcp/src/shared/package-upgrade-review-request.ts @@ -1,4 +1,7 @@ -import type { PackageUpgradeDependencyProbeParams } from "@githits/core-internal"; +import type { + ChangelogStatementTier, + PackageUpgradeDependencyProbeParams, +} from "@githits/core-internal"; import { isKnownPkgseerRegistryArg, PKGSEER_REGISTRY_LIST, @@ -6,6 +9,10 @@ import { type PkgseerRegistryArg, toPkgseerRegistry, } from "@githits/core-internal"; +import { + type ChangelogChangesView, + normaliseChangelogChanges, +} from "./changelog-changes.js"; import { InvalidPackageSpecError, UnsupportedRegistryError, @@ -32,8 +39,13 @@ export interface PackageUpgradeReviewRequestInput { includeTransitiveSecurity?: boolean; includeDependencyIssues?: boolean; minSeverity?: string; + changes?: string; + format?: "text" | "json"; + verbose?: boolean; } +export type UpgradeChangelogSelection = ChangelogChangesView | "focused"; + export interface UpgradeReviewPackageRequest { registry: PkgseerRegistry; registryLabel: string; @@ -44,7 +56,10 @@ export interface UpgradeReviewPackageRequest { export interface PackageUpgradeReviewOptions { /** Internal selection control; not a public tool argument. */ - includeChangelogFullText?: boolean; + changelogView: "text" | "json" | "triage"; + changesSelection: UpgradeChangelogSelection; + explicitChanges?: ChangelogChangesView; + statementTiers?: ChangelogStatementTier[]; includeTransitiveSecurity: boolean; includeDependencyIssues: boolean; includeDependencyChanges: boolean; @@ -95,10 +110,34 @@ export function buildPackageUpgradeReviewRequest( ]; const minSeverityLabel = resolveMinSeverityLabel(input.minSeverity); + const hasExplicitChanges = Boolean(input.changes?.trim()); + const explicitChanges = hasExplicitChanges + ? normaliseChangelogChanges(input.changes) + : undefined; + const changesSelection: UpgradeChangelogSelection = + explicitChanges ?? "focused"; + const statementTiers: ChangelogStatementTier[] | undefined = + changesSelection === "focused" + ? ["MUST_ACT", "SHOULD_KNOW", "UNCLASSIFIED"] + : changesSelection === "actionable" + ? ["MUST_ACT"] + : changesSelection === "important" + ? ["MUST_ACT", "SHOULD_KNOW"] + : undefined; + const changelogView = + input.format === "json" + ? "json" + : packages.length > 1 && input.verbose !== true + ? "triage" + : "text"; return { packages, options: { + changelogView, + changesSelection, + explicitChanges, + statementTiers, includeTransitiveSecurity: input.includeTransitiveSecurity !== false, includeDependencyIssues: input.includeDependencyIssues === true, includeDependencyChanges: true, 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 444f9071..5e812886 100644 --- a/packages/mcp/src/shared/package-upgrade-review-response.test.ts +++ b/packages/mcp/src/shared/package-upgrade-review-response.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it, mock } from "bun:test"; import type { PackageUpgradeReviewResponse } from "@githits/core-internal"; import { createMockPackageIntelligenceService } from "../services/test-helpers.js"; import expressChangelog from "./fixtures/upgrade-review-express-changelog.json"; +import type { LeanChangelogStatement } from "./package-changelog-response.js"; +import { normaliseChangelogStatements } from "./package-changelog-response.js"; +import type { UpgradeChangelogSelection } from "./package-upgrade-review-request.js"; import { buildPackageUpgradeReviewRequest } from "./package-upgrade-review-request.js"; import type { UpgradeChangelog, - UpgradeChangelogRiskItem, UpgradeReview, UpgradeReviewResponse, } from "./package-upgrade-review-response.js"; @@ -129,7 +131,22 @@ const backendResponse: PackageUpgradeReviewResponse = { hasReleaseNoteBodies: true, breakingSignals: ["breaking"], migrationSignals: [], - riskItems: [], + statements: { + totalCount: 0, + matchingCount: 0, + items: [], + coverage: [ + { + version: "4.4.3", + status: "CLASSIFIED", + statementsTotal: 0, + statementsAssessed: 0, + statementsNotAssessed: 0, + statementsNoImpact: 0, + statementsUnclassified: 0, + }, + ], + }, riskCoverage: { versionsClassified: 0, versionsNotAssessed: 0, @@ -141,7 +158,6 @@ const backendResponse: PackageUpgradeReviewResponse = { itemsShouldKnowConfident: 0, itemsShouldKnowAmbiguous: 0, itemsUnclassified: 0, - itemsOmitted: 0, }, }, compatibility: { peerDependencyChanges: [], notes: [] }, @@ -181,8 +197,8 @@ function formatterReview( const review = backendResponse.reviews[0]!; const transitive = review.security.transitive!; const normalizeEntry = ( - entry: (typeof review.changelog.entries)[number], - ): UpgradeReview["changelog"]["entries"][number] => ({ + entry: NonNullable[number], + ): NonNullable[number] => ({ ...entry, version: entry.version ?? null, signals: entry.signals.length > 0 ? entry.signals : undefined, @@ -214,15 +230,16 @@ function formatterReview( }, changelog: { ...review.changelog, - riskItems: review.changelog.riskItems.map((item) => ({ - ...item, - tier: item.tier.toLowerCase() as UpgradeChangelogRiskItem["tier"], - })), + riskCoverage: { ...review.changelog.riskCoverage }, + statements: { + ...normaliseChangelogStatements(review.changelog.statements!), + selection: "focused", + }, source: "releases", fallback: undefined, - entries: review.changelog.entries.map(normalizeEntry), - sampledEntries: review.changelog.sampledEntries.map(normalizeEntry), - keywordEntries: review.changelog.keywordEntries.map(normalizeEntry), + entries: review.changelog.entries!.map(normalizeEntry), + sampledEntries: review.changelog.sampledEntries!.map(normalizeEntry), + keywordEntries: review.changelog.keywordEntries!.map(normalizeEntry), }, ...overrides, }; @@ -361,7 +378,7 @@ describe("package upgrade review response", () => { expect(text).toContain("Target: deprecated: bad release"); expect(text).toContain("Changes"); expect(text).not.toContain("Heuristic keywords:"); - expect(text).toContain('[breaking, removed] "Breaking: removed an API."'); + expect(text).toContain("[breaking, removed] Breaking: removed an API."); expect(text).toContain("Dependencies"); expect(text).toContain("Direct: 1 added | 0 removed | 0 changed"); expect(text).toContain("Dependency issues"); @@ -422,39 +439,6 @@ describe("package upgrade review response", () => { expect(single).not.toContain("Across packages:"); }); - it("maps changelog sources without inferring providers", () => { - const base = formatterReview(); - const makeText = (changelog: typeof base.changelog): string => - formatPackageUpgradeReviewTerminal({ - summary: backendResponse.summary, - reviews: [{ ...base, changelog }], - }); - const packageVersions = makeText({ - ...base.changelog, - source: undefined, - fallback: "package_versions", - totalEntries: 0, - totalEntriesWithBodies: 0, - keywordEntries: [], - sampledEntries: [], - }); - expect(packageVersions).toContain( - "Release notes unavailable; using package versions.", - ); - const entry = { - ...base.changelog.entries[0]!, - detailSource: "hexdocs", - htmlUrl: "https://example.com/notes", - }; - const hexdocs = makeText({ - ...base.changelog, - entries: [entry], - keywordEntries: [entry], - }); - expect(hexdocs).toContain("hexdocs: https://example.com/notes"); - expect(hexdocs).not.toContain("Repository releases"); - }); - it("renders zero-valued dependency issues and omits undefined evidence", () => { const base = formatterReview(); const zero = formatPackageUpgradeReviewTerminal({ @@ -638,13 +622,13 @@ describe("package upgrade review response", () => { ...formatterReview().changelog, entries: [ { - ...formatterReview().changelog.entries[0]!, + ...formatterReview().changelog.entries![0]!, htmlUrl: "https://example.com/releases/4.4.3", }, ], keywordEntries: [ { - ...formatterReview().changelog.keywordEntries[0]!, + ...formatterReview().changelog.keywordEntries![0]!, htmlUrl: "https://example.com/releases/4.4.3", }, ], @@ -666,486 +650,6 @@ describe("package upgrade review response", () => { expect(narrow).toContain("Upgrade review - 1 package"); }); - it("bounds multi-locator rows while preserving overlong single locators", () => { - const base = formatterReview(); - const transitive = base.security.transitive!; - const response = formatterResponse([ - formatterReview({ - security: { - ...base.security, - transitive: { - ...transitive, - introducedPackageDetails: [ - { - ...transitive.introducedPackageDetails[0]!, - registry: "npm", - name: "qs", - versions: ["6.13.0", "6.15.3"], - advisoryIds: [ - "GHSA-6rw7-vpxm-498p", - "GHSA-q8mj-m7cp-5q26", - "GHSA-w7fw-mjwx-w883", - "GHSA-extra-long-locator", - ], - maxSeverityLabel: "medium", - }, - ], - introducedPackageDetailsTotalCount: 1, - introducedPackageDetailsTruncated: false, - }, - }, - changelog: { - ...base.changelog, - entries: [ - { - ...base.changelog.entries[0]!, - version: "5.2.1", - publishedAt: "2025-12-01T20:49:43.268Z", - htmlUrl: - "https://github.com/expressjs/express/releases/tag/v5.2.1", - }, - ], - keywordEntries: [ - { - ...base.changelog.keywordEntries[0]!, - version: "5.2.1", - publishedAt: "2025-12-01T20:49:43.268Z", - htmlUrl: - "https://github.com/expressjs/express/releases/tag/v5.2.1", - }, - ], - }, - }), - ]); - const text = formatPackageUpgradeReviewTerminal(response, { - terminalWidth: 80, - }); - const plain = stripAnsi(text); - const lines = plain.split("\n").filter((line) => line.length > 0); - - expect(plain).toContain(" - npm:qs@6.13.0|6.15.3 affected=1 medium(4)"); - expect(plain).toContain( - " Advisories: GHSA-6rw7-vpxm-498p, GHSA-q8mj-m7cp-5q26, GHSA-w7fw-mjwx-w883,", - ); - expect(plain).toContain(" GHSA-extra-long-locator"); - expect(plain).toContain( - " [1] https://github.com/expressjs/express/releases/tag/v5.2.1", - ); - expect(Math.max(...lines.map((line) => line.length))).toBeLessThanOrEqual( - 80, - ); - - const ordinary = stripAnsi( - formatPackageUpgradeReviewTerminal( - formatterResponse([ - formatterReview({ - changelog: { - ...base.changelog, - entries: [ - { - ...base.changelog.entries[0]!, - version: "5.2.1", - publishedAt: "2025-12-01T20:49:43.268Z", - htmlUrl: - "https://github.com/expressjs/express/releases/tag/v5.2.1", - }, - ], - sampledEntries: [ - { - ...base.changelog.entries[0]!, - version: "5.2.1", - publishedAt: "2025-12-01T20:49:43.268Z", - htmlUrl: - "https://github.com/expressjs/express/releases/tag/v5.2.1", - }, - ], - keywordEntries: [], - totalKeywordEntries: 0, - breakingSignals: [], - migrationSignals: [], - riskItems: [], - riskCoverage: { - versionsClassified: 0, - versionsNotAssessed: 0, - versionsWithoutNotes: 0, - versionsUnparseable: 0, - unitsNoImpact: 0, - itemsMustActConfident: 0, - itemsMustActAmbiguous: 0, - itemsShouldKnowConfident: 0, - itemsShouldKnowAmbiguous: 0, - itemsUnclassified: 0, - itemsOmitted: 0, - }, - }, - }), - ]), - { terminalWidth: 80, verbose: true }, - ), - ); - expect(ordinary).toContain( - " [1] https://github.com/expressjs/express/releases/tag/v5.2.1", - ); - expect( - Math.max( - ...ordinary - .split("\n") - .filter((line) => line.length > 0) - .map((line) => line.length), - ), - ).toBeLessThanOrEqual(80); - - const overlongUrl = `https://example.com/releases/${"x".repeat(80)}`; - const overlongCoordinate = `transitive-${"x".repeat(80)}`; - const overlong = stripAnsi( - formatPackageUpgradeReviewTerminal( - formatterResponse([ - formatterReview({ - security: { - ...base.security, - transitive: { - ...transitive, - introducedPackageDetails: [ - { - ...transitive.introducedPackageDetails[0]!, - name: overlongCoordinate, - advisoryIds: [], - }, - ], - introducedPackageDetailsTotalCount: 1, - introducedPackageDetailsTruncated: false, - }, - }, - changelog: { - ...base.changelog, - entries: [ - { - ...base.changelog.entries[0]!, - htmlUrl: overlongUrl, - }, - ], - keywordEntries: [ - { - ...base.changelog.keywordEntries[0]!, - htmlUrl: overlongUrl, - }, - ], - }, - }), - ]), - { terminalWidth: 80 }, - ), - ); - expect(overlong).toContain(overlongUrl); - expect(overlong).toContain(overlongCoordinate); - expect( - overlong - .split("\n") - .some((line) => line.includes(overlongUrl) && line.length > 80), - ).toBe(true); - expect( - overlong - .split("\n") - .some((line) => line.includes(overlongCoordinate) && line.length > 80), - ).toBe(true); - expect( - overlong - .split("\n") - .filter((line) => line.length > 80) - .every( - (line) => - line.includes(overlongUrl) || line.includes(overlongCoordinate), - ), - ).toBe(true); - }); - - it("hides no-impact-only previews by default and keeps their locators in verbose", () => { - const review = riskReview([]); - const entry = { - version: "4.4.2", - headline: "Routine fixes.", - htmlUrl: "https://example.com/notes", - signals: [], - }; - review.changelog.entries = [entry]; - review.changelog.sampledEntries = [entry]; - review.changelog.keywordEntries = []; - review.changelog.breakingSignals = []; - review.changelog.totalKeywordEntries = 0; - const response = formatterResponse([review]); - const text = formatPackageUpgradeReviewTerminal(response); - expect(text.replace(/\s+/g, " ")).toContain( - "56 statements labeled no impact", - ); - expect(text).not.toContain("Routine fixes."); - expect(text).not.toContain("https://example.com/notes"); - const verbose = formatPackageUpgradeReviewTerminal(response, { - verbose: true, - }); - expect(verbose).toContain('"Routine fixes."'); - expect(verbose.split("https://example.com/notes")).toHaveLength(2); - expect(verbose.match(/^ {2}4\.4\.2(?: \(|$)/gm)).toHaveLength(1); - expect(verbose).not.toContain("Sampled release entries"); - }); - - it("keeps unassessed entry locators in verbose without attributing per-version status", () => { - const review = riskReview([]); - review.changelog.riskCoverage.versionsClassified = 0; - review.changelog.riskCoverage.versionsNotAssessed = 2; - review.changelog.entries = [ - { - version: "1.0.post2", - htmlUrl: "https://example.com/post2", - signals: [], - }, - { - version: "1.0.post1", - htmlUrl: "https://example.com/post1", - signals: [], - }, - { version: null, signals: [] }, - ]; - review.changelog.keywordEntries = []; - review.changelog.sampledEntries = [...review.changelog.entries]; - const response = formatterResponse([review]); - const text = formatPackageUpgradeReviewTerminal(response); - expect(text).toContain("2 not assessed"); - expect(text.replace(/\s+/g, " ")).toContain( - "Rerun in a few seconds to a minute", - ); - expect(text).not.toContain("1.0.post"); - const verbose = formatPackageUpgradeReviewTerminal(response, { - verbose: true, - }); - expect(verbose.indexOf(" 1.0.post2")).toBeLessThan( - verbose.indexOf(" 1.0.post1"), - ); - expect(verbose).toContain("Unversioned notes"); - expect(verbose.split("https://example.com/post2")).toHaveLength(2); - expect(verbose).not.toContain("post2 not assessed"); - }); - - it("combines overlapping entry views without losing keyword bodies or inventing sources", () => { - const review = riskReview([]); - const entry = { - version: "4.4.3", - detailSource: "releases", - htmlUrl: "https://example.com/release", - signals: ["breaking"], - }; - review.changelog.entries = [entry]; - review.changelog.sampledEntries = [entry]; - review.changelog.keywordEntries = [ - { - ...entry, - body: "## Commits\n- abcdef123 breaking commit noise\n## Changes\n- Breaking: removed an API.", - }, - ]; - const response = formatterResponse([review]); - const before = JSON.stringify(response); - const text = formatPackageUpgradeReviewTerminal(response); - expect(text.match(/^ {2}4\.4\.3(?: \(|$)/gm)).toHaveLength(1); - expect(text.split("https://example.com/release")).toHaveLength(2); - expect(text).toContain('[breaking] "Breaking: removed an API."'); - expect(text).not.toContain("commit noise"); - expect(text).not.toContain("Sampled release entries"); - expect(text).not.toContain("Heuristic release entries"); - expect(JSON.stringify(response)).toBe(before); - }); - - it("reports missing notes and entry sampling without implying statement coverage is sampled", () => { - const review = riskReview([]); - review.changelog.entries = []; - review.changelog.keywordEntries = []; - review.changelog.sampledEntries = []; - review.changelog.riskCoverage.versionsWithoutNotes = 2; - review.changelog.truncated = true; - review.changelog.fallback = "package_versions"; - const text = formatPackageUpgradeReviewTerminal( - formatterResponse([review]), - ); - expect(text).toContain("2 without notes"); - expect(text.replace(/\s+/g, " ")).toContain( - "Release-note entries and links are sampled; statement coverage spans the upgrade range.", - ); - expect(text).toContain("Release notes unavailable"); - expect(text).not.toContain(" Sources"); - expect(text).not.toMatch(/\bsafe\b/); - }); - - it("keeps no-color text ASCII-authored and colors attention without changing words", () => { - const plain = formatPackageUpgradeReviewTerminal(formatterResponse(), { - useColors: false, - }); - const colored = formatPackageUpgradeReviewTerminal(formatterResponse(), { - useColors: true, - }); - expect(colored.replace(ANSI_SGR_PATTERN, "")).toBe(plain); - expect(colored).toContain("\x1b[33m"); - expect(colored).toContain("\x1b[1mUpgrade review - 1 package\x1b[0m"); - expect(colored).toContain("\x1b[1mSecurity\x1b[0m"); - expect(colored).toContain( - "\x1b[1m\x1b[36mnpm:zod 4.3.6 -> 4.4.3 (minor)\x1b[0m", - ); - expect(colored).not.toContain("\x1b[36mUpgrade review"); - expect(colored).not.toContain("\x1b[36mSecurity"); - expect(colored).toContain( - "\x1b[33m Added direct advisories\x1b[0m\n - GHSA-new", - ); - expect(colored).toContain( - "\x1b[33m Introduced deprecated\x1b[0m\n - npm:left-pad@1.0.0", - ); - expect(colored).toContain( - "\x1b[1m\x1b[33mUnknown evidence\x1b[0m\n - changelog evidence incomplete", - ); - expect(colored).toContain( - "[\x1b[33mbreaking\x1b[0m, \x1b[33mremoved\x1b[0m]", - ); - expect(plain).toContain("* [breaking, removed]"); - expect(colored).not.toContain("\x1b[33m Heuristic keywords:"); - expect(colored).not.toContain("\x1b[33m Heuristic release entries"); - expect(colored).not.toContain("\x1b[33m - GHSA-new"); - expect(colored).not.toContain("\x1b[33m - npm:left-pad@1.0.0"); - expect(colored).not.toContain("\x1b[33m - changelog evidence incomplete"); - expect(plain).not.toContain("⚠"); - expect(plain).toMatch(/^[\x20-\x7e\n]*$/); - - const styledBase = formatterReview(); - const styledTransitive = styledBase.security.transitive!; - const styledDetail = styledTransitive.introducedPackageDetails[0]!; - const styled = formatPackageUpgradeReviewTerminal( - formatterResponse([ - formatterReview({ - security: { - ...styledBase.security, - transitive: { - ...styledTransitive, - introducedPackageDetails: [styledDetail], - introducedPackageDetailsTotalCount: 1, - introducedPackageDetailsTruncated: false, - stillAffectedPackageDetails: [styledDetail], - stillAffectedPackageDetailsTotalCount: 1, - stillAffectedPackageDetailsTruncated: false, - fixedPackageDetails: [styledDetail], - fixedPackageDetailsTotalCount: 1, - fixedPackageDetailsTruncated: false, - }, - }, - }), - ]), - { useColors: true }, - ); - expect(styled).toContain("\x1b[33m Added transitive vulnerable packages"); - expect(styled).toContain("\x1b[33m Still affected transitive packages"); - expect(styled).toContain(" Fixed transitive vulnerable packages"); - expect(styled).not.toContain( - "\x1b[33m Fixed transitive vulnerable packages", - ); - - const unicodeSummary = "安全 review 🚀"; - const unicodeExcerpt = "breaking: 修复 parser 🚀"; - const unicodeBase = formatterReview(); - const unicodeEntry = { - ...unicodeBase.changelog.entries[0]!, - body: unicodeExcerpt, - bodyPreview: unicodeExcerpt, - headline: unicodeExcerpt, - signals: ["breaking"], - }; - const unicodeResponse = formatterResponse([ - formatterReview({ - security: { - ...unicodeBase.security, - added: [ - { - ...unicodeBase.security.added[0]!, - summary: unicodeSummary, - }, - ], - }, - changelog: { - ...unicodeBase.changelog, - entries: [unicodeEntry], - keywordEntries: [unicodeEntry], - }, - }), - ]); - const unicodePlain = formatPackageUpgradeReviewTerminal(unicodeResponse, { - useColors: false, - }); - const unicodeColored = formatPackageUpgradeReviewTerminal(unicodeResponse, { - useColors: true, - }); - expect(unicodePlain).toContain(unicodeSummary); - expect(unicodePlain).toContain(unicodeExcerpt); - expect(stripAnsi(unicodeColored)).toBe(unicodePlain); - expect(unicodePlain).toContain('* [breaking] "breaking: 修复 parser 🚀'); - expect(unicodeColored).toContain("[\x1b[33mbreaking\x1b[0m]"); - }); - - it("preserves default samples and expands them only in verbose mode", () => { - const base = formatterReview(); - const advisories = Array.from({ length: 6 }, (_, index) => ({ - id: `GHSA-${index + 1}`, - aliases: [], - summary: `advisory ${index + 1}`, - severity: 5, - severityLabel: "medium", - fixedIn: [], - isMalicious: false, - })); - const transitivePackages = Array.from({ length: 6 }, (_, index) => ({ - id: `npm:transitive-${index + 1}`, - registry: "npm", - name: `transitive-${index + 1}`, - versions: ["1.0.0"], - affectedCount: 1, - maxSeverityScore: 5, - maxSeverityLabel: "medium", - advisoryIds: [`GHSA-transitive-${index + 1}`], - })); - const dependencyChanges = Array.from({ length: 6 }, (_, index) => ({ - name: `dependency-${index + 1}`, - registry: "npm", - fromVersions: ["1.0.0"], - toVersions: ["2.0.0"], - type: "runtime", - })); - const review = formatterReview({ - security: { - ...base.security, - added: advisories, - transitive: { - ...base.security.transitive!, - introducedPackageDetails: transitivePackages, - introducedPackageDetailsTotalCount: transitivePackages.length, - introducedPackageDetailsTruncated: false, - }, - }, - dependencyChanges: { - direct: { added: dependencyChanges, removed: [], changed: [] }, - transitive: { added: dependencyChanges, removed: [], changed: [] }, - }, - }); - const response = formatterResponse([review]); - const compact = formatPackageUpgradeReviewTerminal(response); - const verbose = formatPackageUpgradeReviewTerminal(response, { - verbose: true, - }); - - expect(compact).toContain("GHSA-1"); - expect(compact).toContain("... +1 more with verbose output"); - expect(compact).not.toContain("GHSA-6 medium"); - expect(compact).not.toContain("npm:transitive-6"); - expect(compact).not.toContain("dependency-6"); - expect(compact).toContain( - "More transitive dependency details are available with verbose output.", - ); - expect(verbose).toContain("GHSA-6 medium(5): advisory 6"); - expect(verbose).toContain("npm:transitive-6@1.0.0"); - expect(verbose).toContain("npm:dependency-6 1.0.0 -> 2.0.0"); - }); - it("passes min_severity=low as an unfiltered backend request", async () => { const packageUpgradeReview = mock((_params: unknown) => Promise.resolve(backendResponse), @@ -1169,857 +673,403 @@ describe("package upgrade review response", () => { }); }); -function riskItem( - overrides: Partial = {}, -): UpgradeChangelogRiskItem { +function statementReview( + items: LeanChangelogStatement[], + selection: UpgradeChangelogSelection = "focused", +): UpgradeReview { + const review = formatterReview(); + review.changelog.statements = { + selection, + count: items.length, + totalCount: items.length, + matchingCount: items.length, + items, + coverage: [ + { + version: "4.4.3", + status: "classified", + statementsTotal: items.length, + statementsAssessed: items.length, + statementsNotAssessed: 0, + statementsNoImpact: 0, + statementsUnclassified: 0, + }, + ], + }; + review.changelog.entries = []; + review.changelog.sampledEntries = []; + review.changelog.keywordEntries = []; + review.changelog.breakingSignals = []; + review.changelog.migrationSignals = []; + return review; +} +function unit( + position = 0, + tier = "must_act", + ambiguous = false, +): LeanChangelogStatement { return { version: "4.4.3", - tier: "must_act", - ambiguous: false, - tierConfidence: 0.98, - kind: "removes_or_renames_api", - kindConfidence: 0.99, - text: "Removed an API.", + position, + sourceIndex: 0, + sourcePosition: position, + text: `* Evidence ${position}`, textTruncated: false, - heading: "Removed", + fullText: `Full evidence ${position}`, + heading: "Changes", source: "releases", - model: "jev-1.13.0", - formulation: "d-hier-v1", - ...overrides, - }; -} - -function riskReview( - items: UpgradeChangelogRiskItem[] = [riskItem()], -): UpgradeReview { - const base = formatterReview(); - return { - ...base, - changelog: { - ...base.changelog, - riskItems: items, - riskCoverage: { - versionsClassified: 1, - versionsNotAssessed: 0, - versionsWithoutNotes: 0, - versionsUnparseable: 0, - unitsNoImpact: 56, - itemsMustActConfident: items.filter( - (item) => item.tier === "must_act" && !item.ambiguous, - ).length, - itemsMustActAmbiguous: items.filter( - (item) => item.tier === "must_act" && item.ambiguous, - ).length, - itemsShouldKnowConfident: items.filter( - (item) => item.tier === "should_know" && !item.ambiguous, - ).length, - itemsShouldKnowAmbiguous: items.filter( - (item) => item.tier === "should_know" && item.ambiguous, - ).length, - itemsUnclassified: items.filter((item) => item.tier === "unclassified") - .length, - itemsOmitted: 0, - }, - entries: [ - { - version: "4.4.3", - detailSource: "releases", - htmlUrl: "https://example.com/release", - signals: [], - }, - ], + url: "https://example.com/notes", + classification: { + tier, + ambiguous, + kind: "changes_behavior_or_default", + tierConfidence: 0.01, + model: "jev", + formulation: "v3", }, }; } -describe("upgrade review model statement evidence", () => { - it("puts all available note links beside versions, preferring each statement's locator", () => { - const review = riskReview([ - riskItem({ url: "https://example.com/actual-release" }), - riskItem({ - source: "changelog_file", - url: "https://example.com/changelog#version", - text: "Removed another API.", - }), - riskItem({ - version: "4.4.2", - url: "https://example.com/unsampled-release", - text: "Changed an older API.", - }), - ]); - review.changelog.keywordEntries = []; - review.changelog.sampledEntries = []; - review.changelog.breakingSignals = []; - review.changelog.migrationSignals = []; - const response = formatterResponse([review]); - const before = JSON.stringify(response); - for (const verbose of [false, true]) { - const text = formatPackageUpgradeReviewTerminal(response, { - verbose, - terminalWidth: 200, - }); - expect(text).toContain("Changes (classified by agent)"); - expect(text).toContain( - " 4.4.3 (Release notes [1]; Changelog [2]; Release notes [3])", - ); - expect(text).toContain(" 4.4.2 (Release notes [4])"); - expect(text).toContain('"Removed an API." [1]'); - expect(text).toContain('"Removed another API." [2]'); - expect(text).toContain('"Changed an older API." [4]'); - expect(text).not.toContain(" Notes:"); - expect(text).not.toMatch(/Requires action \(\d+\)/); - expect(text).not.toContain("Classified by an agent."); - const urls = text.match(/https?:\/\/\S+/g) ?? []; - expect(urls).toHaveLength(4); - expect(new Set(urls).size).toBe(4); - } - const narrow = formatPackageUpgradeReviewTerminal(response, { - terminalWidth: 40, - }); - expect(narrow.replace(/\s+/g, " ")).toContain( - "4.4.3 (Release notes [1]; Changelog [2]; Release notes [3])", - ); - const changes = narrow - .split("Changes (classified by agent)\n")[1]! - .split("\n\n")[0]!; - for (const line of changes.split("\n")) { - if (line.includes("http")) continue; - expect(Array.from(line).length).toBeLessThanOrEqual(40); - } - expect(JSON.stringify(response)).toBe(before); - }); - - it("does not repeat a breaking keyword from an already quoted reverted change", () => { - const text = - "The prior release included an erroneous breaking change. The change has been fully reverted."; - const review = riskReview([ - riskItem({ ambiguous: true, text, url: "https://example.com/release" }), - ]); - review.changelog.keywordEntries = []; - review.changelog.sampledEntries = []; - review.changelog.migrationSignals = []; - review.changelog.entries[0]!.body = text; - review.changelog.entries[0]!.signals = ["breaking"]; - review.changelog.breakingSignals = ["breaking"]; - const response = formatterResponse([review]); - const before = JSON.stringify(response); - for (const verbose of [false, true]) { - const output = formatPackageUpgradeReviewTerminal(response, { verbose }); - expect(output.replace(/\s+/g, " ")).toContain(`"${text}"`); - expect(output).not.toContain("Keyword matches"); - expect(output).not.toContain("matched quoted statement"); - } - expect(JSON.stringify(response)).toBe(before); - }); - - it("uses pre-cap coverage totals and keeps version section headings free of counts", () => { - const review = riskReview([ - ...Array.from({ length: 13 }, () => riskItem()), - ...Array.from({ length: 28 }, () => riskItem({ ambiguous: true })), - ...Array.from({ length: 9 }, () => riskItem({ tier: "should_know" })), - ]); - Object.assign(review.changelog.riskCoverage, { - itemsMustActConfident: 13, - itemsMustActAmbiguous: 28, - itemsShouldKnowConfident: 9, - itemsShouldKnowAmbiguous: 10, - itemsUnclassified: 3, - itemsOmitted: 13, +describe("complete upgrade statement presentation", () => { + it("renders captured complete Express evidence with source order and whole-range counts", () => { + const review = formatterReview({ + changelog: expressChangelog as UpgradeChangelog, }); - const before = JSON.stringify(review); - const detail = formatPackageUpgradeReviewTerminal( + const text = formatPackageUpgradeReviewTerminal( formatterResponse([review]), - { - terminalWidth: 200, - }, - ); - expect(detail).toContain( - "13 require action (+28 uncertain) | 9 should know (+10 uncertain) | 3 too long to classify", - ); - expect(detail).toContain(" Requires action\n"); - expect(detail).toContain(" Possibly requires action\n"); - expect(detail).toContain(" Should know\n"); - expect(detail).not.toMatch(/Should know \(\d+\)/); - expect(detail).not.toContain("Too long to classify - read it ("); - expect(detail).toContain("13 statements omitted by backend"); - const batch = formatPackageUpgradeReviewTerminal( - formatterResponse([riskReview([]), review]), - { terminalWidth: 200 }, ); - expect(batch).toContain( - "13 act (+28 uncertain) | 9 know (+10 uncertain) | 3 too long to classify", - ); - expect(batch).toContain("13 omitted"); - expect(batch).toContain("Totals include omitted statements"); - expect(JSON.stringify(review)).toBe(before); + expect(text).toContain("0 require action (+8 uncertain)"); + expect(text).toContain("6 should know (+1 uncertain)"); + expect(text).toContain("15 matching statements of 141 parsed"); + expect(text.match(/^ {2}5\.2\.1/gm)).toHaveLength(1); + expect(text).toContain("[!behavior] (uncertain)"); }); - - it("ranks capped packages by backend totals even when returned action counts tie", () => { - const lower = riskReview(Array.from({ length: 50 }, () => riskItem())); - lower.name = "lower"; - lower.changelog.riskCoverage.itemsMustActConfident = 52; - lower.changelog.riskCoverage.itemsOmitted = 2; - const higher = structuredClone(lower); - higher.name = "higher"; - higher.changelog.riskCoverage.itemsMustActConfident = 61; - higher.changelog.riskCoverage.itemsOmitted = 11; - const response = formatterResponse([lower, higher]); - for (const verbose of [false, true]) { - const text = formatPackageUpgradeReviewTerminal(response, { verbose }); - expect(text.indexOf("npm:higher")).toBeLessThan( - text.indexOf("npm:lower"), - ); - expect(text).toContain("61 act"); - expect(text).toContain("52 act"); - if (verbose) expect(text).toContain(" Requires action\n"); - } - expect(response.reviews.map((review) => review.name)).toEqual([ - "lower", - "higher", - ]); - }); - - it("uses backend ambiguity alone for action and knowledge uncertainty", () => { - const review = riskReview([ - riskItem({ ambiguous: true, tierConfidence: 0, text: "Action at zero." }), - riskItem({ - ambiguous: true, - tierConfidence: 0.95, - kind: undefined, - kindConfidence: undefined, - text: "Possibly removed API.", - }), - riskItem({ - ambiguous: false, - tierConfidence: 0.1, - kindConfidence: 0.2, - text: "Confident low-confidence action.", - }), - riskItem({ tierConfidence: 1, text: "Action at one." }), - riskItem({ - tier: "should_know", - ambiguous: true, - tierConfidence: 0, - text: "Knowledge at zero.", - }), - riskItem({ - tier: "should_know", - ambiguous: true, - tierConfidence: 0.95, - text: "Uncertain knowledge.", - }), - riskItem({ - tier: "should_know", - ambiguous: false, - tierConfidence: 0.1, - text: "Confident knowledge.", - }), - riskItem({ - tier: "should_know", - tierConfidence: 1, - text: "Knowledge at one.", - }), - riskItem({ - tier: "unclassified", - tierConfidence: undefined, - kind: undefined, - kindConfidence: undefined, - text: "Oversize statement.", - textTruncated: true, - }), - ]); - const response = formatterResponse([review]); - const before = JSON.stringify(response); - const text = formatPackageUpgradeReviewTerminal(response, { - terminalWidth: 200, - }); - expect(text).toContain( - "2 require action (+2 uncertain) | 2 should know (+2 uncertain) | 1 too long to classify", + it("keeps one version and nearby sources in backend source order, without preview dumps", () => { + const a = unit(); + const b = { + ...unit(1, "should_know"), + sourceIndex: 1, + source: "changelog_file", + url: "https://example.com/History.md", + heading: "Security", + }; + const review = statementReview([a, b]); + review.changelog.entries = [ + { version: "4.4.3", bodyPreview: "DUPLICATE PREVIEW", signals: [] }, + ]; + const text = formatPackageUpgradeReviewTerminal( + formatterResponse([review]), ); - expect(text).toContain(" Requires action\n"); - expect(text).toContain(" Possibly requires action\n"); - expect(text).toContain('(uncertain) "Possibly removed API."'); - expect(text).toContain('[removal] "Confident low-confidence action."'); - expect(text).toContain('(uncertain) "Uncertain knowledge."'); - expect(text.indexOf("Confident knowledge.")).toBeLessThan( - text.indexOf("Uncertain knowledge."), + expect(text.match(/^ {2}4\.4\.3/gm)).toHaveLength(1); + expect(text).not.toContain("DUPLICATE PREVIEW"); + expect( + text.indexOf("Release notes: https://example.com/notes"), + ).toBeLessThan(text.indexOf("Evidence 0")); + expect(text.indexOf("Evidence 0")).toBeLessThan( + text.indexOf("Changelog: https://example.com/History.md"), ); - expect(text).toContain(" Too long to classify - read it\n"); - expect(text).toContain('"Oversize statement."'); - expect(text).not.toContain('(uncertain) "Oversize statement."'); - expect(text).toContain("statement truncated by backend"); - expect(text).not.toContain("Unclassified - read if relevant"); - const colored = formatPackageUpgradeReviewTerminal(response, { - useColors: true, - terminalWidth: 200, - }); - expect(colored).toContain("\x1b[2m(uncertain)\x1b[0m"); - expect(colored.replace(ANSI_SGR_PATTERN, "")).toBe(text); - expect(JSON.stringify(response)).toBe(before); + expect(text).not.toContain("Sources\n"); + expect(text).not.toContain("Requires action ("); }); - - it("ranks batches only by confident actions, preserving zero-count ties and JSON order", () => { - const zero = riskReview([]); - zero.name = "zero"; - const uncertain = riskReview([ - riskItem({ ambiguous: true, tierConfidence: 0.9 }), - riskItem({ ambiguous: true, tierConfidence: 0.1 }), - ]); - uncertain.name = "uncertain"; - const confident = riskReview([ - riskItem({ ambiguous: false, tierConfidence: 0.1 }), - ]); - confident.name = "confident"; - const response = formatterResponse([zero, uncertain, confident]); - for (const verbose of [false, true]) { - const text = formatPackageUpgradeReviewTerminal(response, { verbose }); - expect(text.indexOf("npm:confident")).toBeLessThan( - text.indexOf("npm:zero"), - ); - expect(text.indexOf("npm:zero")).toBeLessThan( - text.indexOf("npm:uncertain"), - ); - expect(text).toContain("0 act (+2 uncertain)"); - expect(text.replace(/\s+/g, " ")).toContain( - "ranking uses only confident action totals", - ); - } - expect(response.reviews.map((review) => review.name)).toEqual([ - "zero", - "uncertain", - "confident", - ]); + it("uses ambiguity alone, with optional kinds and no-impact plain bullets", () => { + const a = unit(); + const b = unit(1, "should_know", true); + b.classification!.tierConfidence = 0.99; + const c = unit(2); + delete c.classification!.kind; + const d = unit(3, "should_know"); + delete d.classification!.kind; + const n = unit(4, "no_impact"); + const u = unit(5); + u.classification = null; + const o = unit(6, "unclassified"); + const text = formatPackageUpgradeReviewTerminal( + formatterResponse([statementReview([a, b, c, d, n, u, o], "all")]), + ); + expect(text).toContain("* [!behavior] Evidence 0"); + expect(text).toContain("* [behavior] (uncertain) Evidence 1"); + expect(text).toContain("* [!] Evidence 2"); + expect(text).toContain("* [should know] Evidence 3"); + expect(text).toContain("* Evidence 4"); + expect(text).toContain("* [not assessed] Evidence 5"); + expect(text).toContain("* [too long to classify - read it] Evidence 6"); + expect(text.match(/\(uncertain\)/g)).toHaveLength(1); }); - - it("combines coverage, brackets and colors optional kinds, and separates lexical matches", () => { - const review = riskReview([ - riskItem({ - tier: "should_know", - kind: "security_fix", - text: "Fixed a vulnerability.", - }), - riskItem({ - tier: "should_know", - kind: undefined, - text: "Added a warning.", - }), + it("uses color as an aid while uncertain annotations stay muted", () => { + const response = formatterResponse([ + statementReview([ + unit(), + unit(1, "should_know"), + unit(2, "must_act", true), + ]), ]); - const response = formatterResponse([review]); - const plain = formatPackageUpgradeReviewTerminal(response, { - terminalWidth: 200, - }); - expect(plain).toContain( - "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."'); - expect(plain).not.toContain("[notable change]"); - expect(plain).toContain(" Keyword matches\n * [breaking, removed]"); - expect(plain.indexOf("Keyword matches")).toBeGreaterThan( - plain.indexOf("Added a warning."), - ); - const colored = formatPackageUpgradeReviewTerminal(response, { - terminalWidth: 200, + const plain = formatPackageUpgradeReviewTerminal(response); + const color = formatPackageUpgradeReviewTerminal(response, { useColors: true, }); - expect(colored).toContain( - '\x1b[33m[security fix]\x1b[0m "Fixed a vulnerability."', - ); - expect(colored.replace(ANSI_SGR_PATTERN, "")).toBe(plain); + expect(stripAnsi(color)).toBe(plain); + expect(color).toContain("\x1b[31m[!behavior]"); + expect(color).toContain("\x1b[33m[behavior]"); + expect(color).toContain("\x1b[2m[!behavior]"); }); - - it("renders captured Express evidence with mocked backend flags once per version and unique sources", () => { - const review = formatterReview({ - changelog: expressChangelog as UpgradeChangelog, + it("keeps action quotes full, excerpts other tiers by Unicode characters and expands bounded text with verbose", () => { + const action = unit(); + action.text = `* ${"🚀".repeat(260)}ACTION_END`; + const note = unit(1, "should_know"); + note.text = `* ${"🚀".repeat(260)}NOTE_END`; + const response = formatterResponse([statementReview([action, note])]); + const compact = formatPackageUpgradeReviewTerminal(response); + const verbose = formatPackageUpgradeReviewTerminal(response, { + verbose: true, }); - const response = formatterResponse([review]); - const before = JSON.stringify(response); - for (const verbose of [false, true]) { - const text = formatPackageUpgradeReviewTerminal(response, { verbose }); - const compact = text.replace(/\s+/g, " "); - expect( - [...text.matchAll(/^ {2}(5\.\d+\.\d+)(?: \(|$)/gm)].map( - (match) => match[1], - ), - ).toEqual(["5.2.1", "5.2.0", "5.1.0", "5.0.1"]); - const urls = text.match(/https?:\/\/\S+/g) ?? []; - expect(new Set(urls).size).toBe(urls.length); - expect(text.split("IMPORTANT:")).toHaveLength(2); - expect(text).not.toContain("matched quoted statement"); - expect(text).not.toContain(" Keyword matches\n"); - expect(text).not.toContain("Heuristic keywords:"); - expect(text).not.toContain("Keyword matches without excerpts:"); - expect(text.match(/^ {6}\* (?=.*")/gm)).toHaveLength(16); - expect(compact).toContain( - "0 require action (+8 uncertain) | 6 should know (+2 uncertain) | 0 too long to classify", - ); - expect(text).toContain(" Possibly requires action\n"); - expect(text).not.toContain(" Requires action\n"); - expect(compact).toContain('(uncertain) "deps: remove safe-buffer"'); - for (const entry of expressChangelog.entries.filter( - (entry) => entry.htmlUrl, - )) { - expect(text).toContain(entry.htmlUrl!); - } - expect(compact).toContain( - "There is no actual security vulnerability associated with this behavior", - ); - expect(text).not.toContain("Sampled release entries"); - expect(text).not.toContain("Heuristic release entries"); - expect(text).not.toContain("Other release entries"); - expect(text).not.toContain("jev-1.13.0"); - expect(text).not.toContain("d-hier-v1"); - expect(text).toContain("Release notes (entry URL not returned)"); - expect(text).toContain("Changelog (entry URL not returned)"); - expect(compact).toContain( - "4 classified | 0 not assessed | 0 without notes", - ); - expect(compact).toContain("125 statements labeled no impact"); - } - expect(JSON.stringify(response)).toBe(before); + expect(compact).toContain("ACTION_END"); + expect(compact).not.toContain("NOTE_END"); + expect(compact).toContain("Excerpt; use verbose"); + expect(compact).not.toContain("�"); + expect(verbose).toContain("NOTE_END"); + expect(verbose).not.toContain("Full evidence"); }); - - it("suppresses only full keyword chunks covered by a same-source statement", () => { - const body = - "> [!IMPORTANT]\n> Breaking: removed an API. Read the migration guide."; - const review = riskReview([riskItem({ text: body })]); - const entry = { - ...review.changelog.entries[0]!, - body, - signals: ["breaking", "removed"], + it("marks backend truncation separately and preserves predecessor context", () => { + const item = unit(); + item.textTruncated = true; + item.context = { + heading: "Important", + text: "* Parent context\n * Nested detail", + textTruncated: true, + fullText: "UNSELECTED FULL CONTEXT", }; - review.changelog.keywordEntries = [entry]; const text = formatPackageUpgradeReviewTerminal( - formatterResponse([review]), + formatterResponse([statementReview([item])]), ); - expect( - text.replace(/\s+/g, " ").split("Read the migration guide."), - ).toHaveLength(2); - expect(text).not.toContain(" Keyword matches\n"); - expect( - text.replace(/\s+/g, " ").split("Read the migration guide."), - ).toHaveLength(2); - const otherSource = { - ...entry, - detailSource: "changelog_file", - htmlUrl: "https://example.com/file", - }; - review.changelog.keywordEntries = [entry, otherSource]; - const both = formatPackageUpgradeReviewTerminal( - formatterResponse([review]), - ); - expect( - both.replace(/\s+/g, " ").split("Read the migration guide."), - ).toHaveLength(3); - expect(both).toContain("[breaking, removed]"); - expect(both).toContain("Changelog: https://example.com/file"); + expect(text).toContain("Parent context"); + expect(text).toContain("Nested detail"); + expect(text).toContain("Statement truncated; use --json for full text"); + expect(text).toContain("Context truncated"); + expect(text).not.toContain("UNSELECTED FULL CONTEXT"); }); - - it("retains aggregate keyword evidence when its matching excerpts were not returned", () => { - const review = riskReview([]); - review.changelog.entries = []; - review.changelog.sampledEntries = []; - review.changelog.keywordEntries = []; - review.changelog.breakingSignals = ["breaking"]; - review.changelog.migrationSignals = ["migration"]; + it.each(["focused", "actionable", "important", "all"] as const)( + "always discloses effective selection %s even with no items", + (selection) => { + const review = statementReview([], selection); + review.changelog.riskCoverage.versionsNotAssessed = 4; + review.changelog.riskCoverage.unitsNoImpact = 20; + review.changelog.riskCoverage.itemsUnclassified = 2; + review.changelog.statements!.coverage[0]!.status = "not_assessed"; + review.changelog.statements!.coverage[0]!.statementsNotAssessed = 6; + const text = formatPackageUpgradeReviewTerminal( + formatterResponse([review]), + ); + expect(text).toContain(`Selection: ${selection}`); + expect(text).toContain("4 not assessed"); + expect(text).toContain("Rerun"); + expect(text.includes("Filter excludes")).toBe(selection !== "all"); + expect(text).not.toMatch(/\bsafe\b/i); + }, + ); + it("shows zero-note and unparseable versions without treating them as no impact", () => { + const review = statementReview([]); + const cov = review.changelog.statements!.coverage[0]!; + cov.status = "without_notes"; + review.changelog.riskCoverage.versionsWithoutNotes = 1; const text = formatPackageUpgradeReviewTerminal( formatterResponse([review]), ); - expect(text).toContain( - "Keyword matches without excerpts: breaking, migration", - ); - expect(text).not.toContain("Heuristic keywords:"); - review.changelog.breakingSignals = []; - review.changelog.migrationSignals = []; - review.changelog.totalKeywordEntries = 2; + expect(text).toContain("No release notes available"); + expect(text).toContain("not evidence of no risk"); + cov.status = "unparseable"; expect( formatPackageUpgradeReviewTerminal(formatterResponse([review])), - ).toContain("Keyword matches without excerpts: 2 entries"); + ).toContain("Notes contain no parsed statements"); }); - - it("keeps additional keyword evidence and risk versions beyond the entry sample", () => { - const review = riskReview([ - riskItem(), - riskItem({ version: "4.0.0", text: "Old API removed." }), - ]); + it("preserves beyond-preview keyword excerpts and suppresses only quoted same-source chunks", () => { + const item = unit(); + item.text = "* Removed the old API"; + const review = statementReview([item]); review.changelog.keywordEntries = [ { - ...review.changelog.entries[0]!, - body: "Removed different config setting.", - signals: ["removed"], + version: "4.4.3", + detailSource: "releases", + bodyPreview: "Unrelated", + body: "* Removed the old API\n* Breaking default after a long note", + signals: ["removed", "breaking"], + htmlUrl: "https://example.com/notes", }, ]; const text = formatPackageUpgradeReviewTerminal( formatterResponse([review]), ); - expect(text).toContain('[removed] "Removed different config setting."'); - expect(text).toContain('"Old API removed."'); - expect(text.match(/^ {2}4\.0\.0$/gm)).toHaveLength(1); - expect(text).toContain("Release notes (entry URL not returned)"); - }); - - it("renders link labels as evidence and lists each URL once, retaining raw JSON", () => { - const text = - "- Removed `API` documented in [migration guide](https://example.com/guide). See https://example.com/guide."; - const review = riskReview([riskItem({ text })]); - review.changelog.keywordEntries = []; - const before = JSON.stringify(review); - const output = formatPackageUpgradeReviewTerminal( - formatterResponse([review]), - ); - expect(output).toContain( - '"Removed API documented in migration guide [2]. See [2]."', - ); - expect(output.split("https://example.com/guide")).toHaveLength(2); - expect(JSON.stringify(review)).toBe(before); - expect(review.changelog.riskItems[0]!.text).toBe(text); + expect(text.match(/Removed the old API/g)).toHaveLength(1); + expect(text).toContain("Keyword matches (lexical evidence)"); + expect(text).toContain("Breaking default after a long note"); + review.changelog.keywordEntries[0]!.detailSource = "changelog_file"; + expect( + formatPackageUpgradeReviewTerminal(formatterResponse([review])).match( + /Removed the old API/g, + ), + ).toHaveLength(2); }); - - it("distinguishes note-authored references from source citations in every note path", () => { - const text = `See the [migration guide][1] and [reference][named] before upgrading. Footnote [3]. Link [2](https://example.com/numeric). ${"context ".repeat(50)}See https://example.com/clipped.`; - const review = riskReview([ - riskItem({ tier: "should_know", text, heading: "Migration [3]" }), - ]); + it("preserves unversioned keyword facts, bounded quotes and aggregate missing-excerpt counts", () => { + const review = statementReview([]); review.changelog.keywordEntries = [ { - version: "4.4.2", - detailSource: "changelog_file", - signals: ["removed"], - body: "Removed API; see [migration guide][1]. Footnote [3].", + version: null, + body: `* Breaking ${"x".repeat(280)}END`, + signals: ["breaking"], + htmlUrl: "https://example.com/unversioned", }, ]; - review.changelog.entries.push({ - version: "4.4.1", - detailSource: "releases", - signals: [], - bodyPreview: "See [documentation][docs]. Footnote [3].", - }); - const before = JSON.stringify(review); const response = formatterResponse([review]); const compact = formatPackageUpgradeReviewTerminal(response); - expect(compact.replace(/\s+/g, " ")).toContain( - "See the migration guide and reference before upgrading. Footnote (3).", - ); - expect(compact).toContain( - '"Removed API; see migration guide. Footnote (3)."', + expect(compact).toContain("(unversioned)"); + expect(compact).toContain("https://example.com/unversioned"); + expect(compact).not.toContain("END"); + expect(compact).toContain("full keyword quote"); + expect( + formatPackageUpgradeReviewTerminal(response, { verbose: true }), + ).toContain("END"); + review.changelog.keywordEntries = []; + review.changelog.totalKeywordEntries = 4; + expect(formatPackageUpgradeReviewTerminal(response)).toContain( + "Keyword matches without excerpts: 4 matching entries", ); - expect(compact).not.toContain("https://example.com/clipped"); - expect(compact).toContain("Link 2 [2]."); - const verbose = formatPackageUpgradeReviewTerminal(response, { - verbose: true, - }); - expect(verbose).toContain("heading: Migration (3)"); - expect(verbose).toContain('"See documentation. Footnote (3)."'); - expect(verbose).toContain("https://example.com/clipped"); - expect(JSON.stringify(review)).toBe(before); }); - - it("omits in-note URLs clipped out of compact statement and keyword quotes", () => { - const text = `Important change. ${"context ".repeat(50)}See [details](https://example.com/late).`; - const review = riskReview([riskItem({ tier: "should_know", text })]); + it("nests source groups under versions and shows keyword-only sources once", () => { + const review = statementReview([unit()]); + review.changelog.entries = [ + { version: "4.4.4", htmlUrl: "https://example.com/keyword-only" }, + ]; review.changelog.keywordEntries = [ { - version: "4.4.2", - detailSource: "changelog_file", - signals: ["removed"], - body: `Removed something. ${"context ".repeat(50)}See https://example.com/late-keyword.`, + version: "4.4.4", + htmlUrl: "https://example.com/keyword-only", + body: "* Breaking keyword-only change", + signals: ["breaking"], }, ]; - const response = formatterResponse([review]); - const compact = formatPackageUpgradeReviewTerminal(response); - expect(compact).toContain("https://example.com/release"); - expect(compact).not.toContain("https://example.com/late"); - const verbose = formatPackageUpgradeReviewTerminal(response, { - verbose: true, - }); - expect(verbose).toContain("https://example.com/late"); - expect(verbose).toContain("https://example.com/late-keyword"); - expect(review.changelog.riskItems[0]!.text).toBe(text); - }); - - it("omits classifier identifiers from batch text while preserving per-item JSON provenance", () => { - const review = riskReview([ - riskItem({ formulation: "other-formulation" }), - riskItem({ model: "other-model" }), - ]); - const response = formatterResponse([review, { ...review, name: "other" }]); - for (const verbose of [false, true]) { - const text = formatPackageUpgradeReviewTerminal(response, { verbose }); - expect(text).toContain("Classified by an agent."); - expect(text).not.toContain("jev-1.13.0"); - expect(text).not.toContain("other-model"); - expect(text).not.toContain("d-hier-v1"); - expect(text).not.toContain("other-formulation"); - } - expect(response.reviews[0]!.changelog.riskItems[0]!.model).toBe( - "jev-1.13.0", - ); - expect(response.reviews[0]!.changelog.riskItems[1]!.model).toBe( - "other-model", - ); - }); - - it("shows tier, optional kind, full action quotes, matching source links and provenance", () => { - const longText = `Removed an API. ${"evidence ".repeat(100)}END`; - const review = riskReview([ - riskItem({ text: longText }), - riskItem({ - tier: "should_know", - kind: "deprecates_without_removal", - text: `Deprecated API. ${"detail ".repeat(50)}KNOW_END`, - }), - riskItem({ - tier: "unclassified", - tierConfidence: undefined, - kind: undefined, - kindConfidence: undefined, - text: `Too long. ${"detail ".repeat(50)}READ_END`, - }), - ]); - const text = formatPackageUpgradeReviewTerminal( - formatterResponse([review]), - ); - const compact = text.replace(/\s+/g, " "); - expect(compact).toContain(longText.trim()); - expect(text).toContain(" Requires action\n"); - expect(text).toContain(" Should know\n"); - expect(text).toContain(" Too long to classify - read it\n"); - expect(text.indexOf("Requires action")).toBeLessThan( - text.indexOf("Should know"), - ); - expect(text.indexOf("Should know")).toBeLessThan( - text.indexOf("Too long to classify - read it"), - ); - expect(compact).toContain('[removal] "Removed an API.'); - expect(text).toContain("https://example.com/release"); - expect(text).toContain("Changes (classified by agent)"); - expect(text).not.toContain("Classified by an agent."); - expect(text).not.toContain("jev-1.13.0"); - expect(text).not.toContain("d-hier-v1"); - expect(compact).not.toContain("Not a compatibility verdict."); - expect(text).not.toContain("KNOW_END"); - expect(text).not.toContain("READ_END"); - expect(text.replace(/\s+/g, " ")).toContain( - "Quotes ending in ... are excerpts; use verbose for full text.", - ); - const verbose = formatPackageUpgradeReviewTerminal( - formatterResponse([review]), - { verbose: true }, - ); - expect(verbose).toContain("KNOW_END"); - expect(verbose).toContain("READ_END"); - expect(verbose).toContain("tier confidence: 0.98"); - expect(verbose).toContain("heading: Removed"); - }); - - it("keeps source links unique, matches version and source exactly, and preserves Unicode in excerpts", () => { - const review = riskReview([ - riskItem(), - riskItem({ text: "Removed another API." }), - riskItem({ source: "changelog_file", text: "Other source." }), - riskItem({ version: "4.4.2", text: "Other version." }), - riskItem({ - tier: "unclassified", - tierConfidence: undefined, - kind: undefined, - kindConfidence: undefined, - text: `${"x".repeat(239)}😀TAIL`, - }), - ]); - const text = formatPackageUpgradeReviewTerminal( - formatterResponse([review]), - ).split("\nDependencies\n")[0]!; - expect(text.match(/https:\/\/example.com\/release/g)).toHaveLength(1); - expect(text).not.toContain("Statement sources"); - expect(text).toContain("Changelog (entry URL not returned)"); - expect(text).toContain('😀..."'); - expect(text).not.toContain("TAIL"); - expect(text).not.toContain("4.4.2 [releases] https:"); - expect(text).not.toContain("4.4.3 [changelog_file] https:"); - }); - - it("keeps reported unknowns separate from pending classification in the batch summary", () => { - const pending = riskReview([]); - pending.unknowns = []; - pending.changelog.riskCoverage.versionsNotAssessed = 4; - const complete = riskReview(); - complete.unknowns = []; - const response = formatterResponse([pending, complete]); - response.summary.withUnknowns = 0; - const text = formatPackageUpgradeReviewTerminal(response).replace( - /\s+/g, - " ", - ); - expect(text).toContain("0 with reported unknowns"); - expect(text).toContain("1 with classification coverage gaps"); - expect(text).not.toContain("0 with evidence gaps"); - expect(response.summary.withUnknowns).toBe(0); - }); - - it("reports required coverage, zero items and entirely unassessed ranges", () => { - const review = riskReview([]); - review.changelog.riskCoverage = { - versionsClassified: 0, - versionsNotAssessed: 20, - versionsWithoutNotes: 2, - versionsUnparseable: 1, - unitsNoImpact: 0, - itemsMustActConfident: 0, - itemsMustActAmbiguous: 0, - itemsShouldKnowConfident: 0, - itemsShouldKnowAmbiguous: 0, - itemsUnclassified: 0, - itemsOmitted: 0, - }; - const text = formatPackageUpgradeReviewTerminal( - formatterResponse([review]), - ).replace(/\s+/g, " "); - expect(text).toContain( - "0 classified | 20 not assessed | 2 without notes | 1 unparseable", - ); - expect(text).toContain("0 statements labeled no impact"); - expect(text.replace(/\s+/g, " ")).toContain( - "Rerun in a few seconds to a minute", - ); - expect(text).toContain( - "Missing or unparseable notes are not evidence of no risk", - ); - expect(text).not.toContain("Requires action"); - expect(text).not.toMatch(/\bsafe\b/); - review.changelog.riskCoverage.versionsNotAssessed = 0; - const empty = formatPackageUpgradeReviewTerminal( - formatterResponse([review]), - ); - expect(empty).not.toContain("Rerun to fill"); - 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({ - text: "X".repeat(1000), - textTruncated: true, - kind: undefined, - kindConfidence: undefined, - }), - ]); - review.changelog.riskCoverage.itemsOmitted = 7; - const text = formatPackageUpgradeReviewTerminal( - formatterResponse([review]), - ); - expect(text).toContain("7 statements omitted by backend"); - expect(text).toContain("[statement truncated by backend]"); - expect(text).toContain("X".repeat(1000)); - expect(text).not.toContain("[excerpt;"); - expect(text).not.toContain("undefined"); - }); - - it("sanitizes new release-note text and metadata only at presentation, without mismatched links", () => { - const terminalEscape = String.fromCharCode(27); - const review = riskReview([ - riskItem({ - text: `quote ${terminalEscape}[31mred${terminalEscape}[0m 漢字\nnext\tword\u0000`, - version: `4.4.3${terminalEscape}[2J`, - heading: `Removed${terminalEscape}[2J`, - source: `changelog_file${terminalEscape}[2J`, - model: `jev${terminalEscape}[2J`, - formulation: `d${terminalEscape}[2J`, - }), - ]); - const before = JSON.stringify(review); const text = formatPackageUpgradeReviewTerminal( formatterResponse([review]), - { verbose: true, terminalWidth: 40 }, ); - expect(text).not.toContain(terminalEscape); - expect(text).not.toContain("\u0000"); - expect(text.replace(/\s+/g, " ")).toContain("quote red 漢字 next word"); - expect(text).toContain("changelog_file (entry URL not returned)"); - expect(text.replace(/\s+/g, " ")).toMatch( - /4\.4\.3 \(Release notes \[\d+\]\)/, - ); - expect(text).not.toContain(" Notes:"); - expect(JSON.stringify(review)).toBe(before); + expect(text).toContain("\n 4.4.3\n Release notes:"); + expect(text.match(/https:\/\/example.com\/keyword-only/g)).toHaveLength(1); + expect(text).toContain("* [breaking] Breaking keyword-only change"); + expect(text).not.toContain("[breaking] * Breaking"); }); - - it("retains peer changes and compatibility notes in default batch triage", () => { - const changed = riskReview(); - changed.compatibility = { - peerDependencyChanges: ["react: ^18 -> ^19"], - notes: ["Runtime requirement changed", "Peer support changed"], - }; - const unchecked = riskReview([]); - unchecked.compatibility = undefined; - const text = formatPackageUpgradeReviewTerminal( - formatterResponse([changed, unchecked]), - ); - expect(text).toContain("1 peer dependency changes | 2 compatibility notes"); - expect(text).toContain( - "not checked peer dependency changes | not checked compatibility notes", - ); - expect(text).not.toContain("Runtime requirement changed"); - const verbose = formatPackageUpgradeReviewTerminal( - formatterResponse([changed, unchecked]), - { verbose: true }, - ); - expect(verbose).toContain("react: ^18 -> ^19"); - expect(verbose).toContain("Runtime requirement changed"); + it("sanitizes release text, metadata, context and URLs while JSON remains faithful", () => { + const item = unit(); + item.text = "* raw\x1b[31m quote"; + item.url = "https://example.com/notes\x1b[31m"; + item.heading = "heading\x1b[31m"; + item.context = { text: "context\x1b[31m", textTruncated: false }; + const review = statementReview([item]); + review.changelog.entries = [ + { + version: "4.4.4", + htmlUrl: "https://example.com/entry\x1b]8;;evil\x07", + }, + ]; + review.changelog.keywordEntries = [ + { + version: "4.4.4", + htmlUrl: "https://example.com/keyword\x1b[2J", + body: "* Breaking \x1b[31m keyword quote", + signals: ["breaking", "removed\x1b[2J"], + }, + ]; + review.changelog.breakingSignals = ["breaking", "extra\x1b[2J"]; + const response = formatterResponse([review]); + const text = formatPackageUpgradeReviewTerminal(response); + expect(text).toContain("keyword quote"); + expect(text).toContain("https://example.com/entry"); + expect(text).toContain("https://example.com/keyword"); + expect(text).toContain("extra"); + expect(text).not.toContain("\x07"); + expect(text).not.toContain("\x1b"); + expect(JSON.stringify(response)).toContain("\\u001b"); }); - - it("renders sorted batch triage once per package, honest coverage and verbose detail without mutating JSON order", () => { - const noItems = riskReview([]); - noItems.name = "none"; - noItems.changelog.riskCoverage.versionsClassified = 0; - noItems.changelog.riskCoverage.versionsNotAssessed = 2; - const one = riskReview(); - one.name = "one"; - const two = riskReview([ - riskItem(), - riskItem({ text: "Removed other API." }), - ]); - two.name = "two"; - two.changelog.riskCoverage.itemsOmitted = 4; - const response = formatterResponse([noItems, one, two]); + it("uses whole-range counters for batch ranking and never mutates JSON order", () => { + const first = statementReview([unit()]); + first.name = "first"; + first.changelog.riskCoverage.itemsMustActConfident = 1; + first.changelog.riskCoverage.itemsMustActAmbiguous = 20; + const second = statementReview([], "actionable"); + second.name = "second"; + second.changelog.riskCoverage.itemsMustActConfident = 13; + second.changelog.riskCoverage.itemsMustActAmbiguous = 28; + const response = formatterResponse([first, second]); + response.filter = { changes: "actionable" }; const text = formatPackageUpgradeReviewTerminal(response); - expect(text.indexOf("npm:two")).toBeLessThan(text.indexOf("npm:one")); - expect(text.indexOf("npm:one")).toBeLessThan(text.indexOf("npm:none")); + expect(text.indexOf("npm:second")).toBeLessThan(text.indexOf("npm:first")); + expect(text).toContain("13 act (+28 uncertain)"); + expect(text).toContain("applies to detailed reports"); + expect(text).not.toContain("Evidence 0"); + expect(response.reviews[0]!.name).toBe("first"); expect( - text.split("\n").filter((line) => line.includes("npm:two")), - ).toHaveLength(1); - expect(text).toContain("2 act | 0 know | 0 too long to classify"); - expect(text).toContain("0 classified"); // pending coverage independently returned - expect(text.replace(/\s+/g, " ")).toContain( - "Rerun in a few seconds to a minute", - ); - expect(text.replace(/\s+/g, " ")).toContain( - "1 with classification coverage gaps", - ); - expect(text).not.toContain("Requires action ("); - expect(text).toContain("verbose"); - expect(response.reviews.map((review) => review.name)).toEqual([ - "none", - "one", - "two", - ]); - const verbose = formatPackageUpgradeReviewTerminal(response, { - verbose: true, + formatPackageUpgradeReviewTerminal(response, { verbose: true }), + ).toContain("Evidence 0"); + }); + it("retains complete normalized JSON evidence and explicit-only filter echo", async () => { + const backend = structuredClone(backendResponse); + backend.reviews[0]!.changelog.statements = { + totalCount: 2, + matchingCount: 2, + items: [ + { + ...unit(), + classification: { + tier: "MUST_ACT", + ambiguous: false, + kind: "CHANGES_BEHAVIOR_OR_DEFAULT", + tierConfidence: 0.01, + model: "old", + formulation: "v2", + }, + }, + { ...unit(1), classification: null }, + ], + coverage: [], + }; + const service = createMockPackageIntelligenceService({ + packageUpgradeReview: mock(async () => backend), + }); + const request = buildPackageUpgradeReviewRequest({ + registry: "npm", + packageName: "zod", + currentVersion: "4.3.6", + targetVersion: "4.4.3", + changes: "all", + }); + const result = await buildPackageUpgradeReview(service, request.packages, { + ...request.options, + changelogView: "json", }); - expect(verbose).toContain('"Removed other API."'); - expect(verbose).toContain("Security"); + expect(result.filter).toEqual({ changes: "all" }); + expect(result.reviews[0]!.changelog.statements).toMatchObject({ + selection: "all", + count: 2, + items: [ + { + source: "releases", + classification: { + tier: "must_act", + tierConfidence: 0.01, + model: "old", + formulation: "v2", + }, + }, + { classification: null }, + ], + }); + expect(JSON.stringify(result)).not.toContain('"riskItems"'); + expect(JSON.stringify(result)).not.toContain('"itemsOmitted"'); + delete request.options.explicitChanges; + expect( + ( + await buildPackageUpgradeReview( + service, + request.packages, + request.options, + ) + ).filter, + ).toBeUndefined(); }); }); diff --git a/packages/mcp/src/shared/package-upgrade-review-response.ts b/packages/mcp/src/shared/package-upgrade-review-response.ts index 78f480dd..603abddc 100644 --- a/packages/mcp/src/shared/package-upgrade-review-response.ts +++ b/packages/mcp/src/shared/package-upgrade-review-response.ts @@ -4,18 +4,22 @@ import type { 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 { + formatChangelogVersionStatements, + type LeanChangelogStatements, + normaliseChangelogStatements, +} from "./package-changelog-response.js"; import type { PackageUpgradeReviewOptions, + UpgradeChangelogSelection, UpgradeReviewPackageRequest, } from "./package-upgrade-review-request.js"; +import { sanitizeTerminalText, wrapTerminalProse } from "./terminal-text.js"; export type VersionDelta = | "patch" @@ -97,19 +101,16 @@ export interface UpgradeChangelogEntry { signals?: string[]; } -export interface UpgradeChangelogRiskItem extends ClassifiedChangelogItem { - model: string; - formulation: string; -} - export interface UpgradeChangelog { - riskItems: UpgradeChangelogRiskItem[]; + statements?: LeanChangelogStatements & { + selection: UpgradeChangelogSelection; + }; riskCoverage: PackageUpgradeChangelogRiskCoverage; source?: string; fallback?: "package_versions"; - entries: UpgradeChangelogEntry[]; - sampledEntries: UpgradeChangelogEntry[]; - keywordEntries: UpgradeChangelogEntry[]; + entries?: UpgradeChangelogEntry[]; + sampledEntries?: UpgradeChangelogEntry[]; + keywordEntries?: UpgradeChangelogEntry[]; totalKeywordEntries: number; totalEntries: number; totalEntriesWithBodies: number; @@ -170,6 +171,7 @@ export interface UpgradeReview { } export interface UpgradeReviewResponse { + filter?: { changes: "actionable" | "important" | "all" }; summary: { total: number; withUnknowns: number; @@ -199,17 +201,25 @@ export async function buildPackageUpgradeReview( currentVersion: pkg.currentVersion, targetVersion: pkg.targetVersion, })), - includeChangelogFullText: options.includeChangelogFullText === true, + changelogView: options.changelogView, + statementTiers: options.statementTiers, includeTransitiveSecurity: options.includeTransitiveSecurity, includeDependencyIssues: options.includeDependencyIssues, changelogLimit: options.changelogLimit, minSeverity: options.minSeverity, }); - return normaliseBackendUpgradeReviewResponse(response); + const result = normaliseBackendUpgradeReviewResponse( + response, + options.changesSelection, + ); + if (options.explicitChanges) + result.filter = { changes: options.explicitChanges }; + return result; } function normaliseBackendUpgradeReviewResponse( response: BackendPackageUpgradeReviewResponse, + selection: UpgradeChangelogSelection, ): UpgradeReviewResponse { return { summary: response.summary, @@ -221,7 +231,7 @@ function normaliseBackendUpgradeReviewResponse( latestVersion: review.latestVersion, versionDelta: lowerEnum(review.versionDelta) as VersionDelta, security: normaliseBackendSecurity(review.security), - changelog: normaliseBackendChangelog(review.changelog), + changelog: normaliseBackendChangelog(review.changelog, selection), compatibility: review.compatibility, dependencyChanges: review.dependencyChanges ? normaliseBackendDependencyChanges(review.dependencyChanges) @@ -384,22 +394,25 @@ function normaliseBackendTransitivePackage( function normaliseBackendChangelog( changelog: BackendPackageUpgradeReviewResponse["reviews"][number]["changelog"], + selection: UpgradeChangelogSelection, ): UpgradeChangelog { return { - riskItems: changelog.riskItems.map((item) => ({ - ...item, - tier: item.tier.toLowerCase() as UpgradeChangelogRiskItem["tier"], - kind: lowerEnum(item.kind), - source: lowerEnum(item.source), - })), + ...(changelog.statements + ? { + statements: { + ...normaliseChangelogStatements(changelog.statements), + selection, + }, + } + : {}), riskCoverage: { ...changelog.riskCoverage }, source: lowerEnum(changelog.source), fallback: lowerEnum(changelog.fallback) as "package_versions" | undefined, - entries: changelog.entries.map(normaliseBackendChangelogEntry), - sampledEntries: changelog.sampledEntries.map( + entries: changelog.entries?.map(normaliseBackendChangelogEntry), + sampledEntries: changelog.sampledEntries?.map( normaliseBackendChangelogEntry, ), - keywordEntries: changelog.keywordEntries.map( + keywordEntries: changelog.keywordEntries?.map( normaliseBackendChangelogEntry, ), totalKeywordEntries: changelog.totalKeywordEntries, @@ -413,7 +426,9 @@ function normaliseBackendChangelog( } function normaliseBackendChangelogEntry( - entry: BackendPackageUpgradeReviewResponse["reviews"][number]["changelog"]["entries"][number], + entry: NonNullable< + BackendPackageUpgradeReviewResponse["reviews"][number]["changelog"]["entries"] + >[number], ): UpgradeChangelogEntry { return { detailSource: lowerEnum(entry.detailSource), @@ -496,6 +511,14 @@ export function formatPackageUpgradeReviewTerminal( : response.reviews; if (reviews.length > 1) { appendSection(lines, formatBatchTriage(reviews, options)); + if (response.filter) + appendWrappedText( + lines, + " ", + `Changes filter: ${response.filter.changes} (applies to detailed reports; triage shows whole-range totals).`, + width, + " ", + ); if (!options.verbose) return `${lines.join("\n").trimEnd()}\n`; } for (const review of reviews) { @@ -883,47 +906,54 @@ function formatTransitivePackageLines( return lines; } -interface ChangelogVersionEvidence { - version: string | null; - items: UpgradeChangelogRiskItem[]; - entries: UpgradeChangelogEntry[]; -} - -/** Combine the overlapping backend views for presentation only; JSON stays raw. */ +/** Whole-range totals lead; detailed evidence stays in release/source order. */ function formatChangesSection( changelog: UpgradeChangelog, options: FormatPackageUpgradeReviewTerminalOptions, ): string[] { const width = normaliseTerminalWidth(options.terminalWidth); + const statements = changelog.statements; + const coverage = changelog.riskCoverage; const lines = [ sectionTitle("Changes (classified by agent)", options.useColors === true), ]; appendWrappedText( lines, " ", - `${formatRiskTierCount(changelog, "must_act", "require action")} | ${formatRiskTierCount(changelog, "should_know", "should know")} | ${changelog.riskCoverage.itemsUnclassified} too long to classify`, + `${formatRiskTierCount(changelog, "must_act", "require action")} | ${formatRiskTierCount(changelog, "should_know", "should know")} | ${coverage.itemsUnclassified} too long to classify`, width, " ", ); appendWrappedText( lines, " Classification coverage: ", - `${riskCoverageText(changelog)} | ${changelog.riskCoverage.unitsNoImpact} statements labeled no impact`, + `${riskCoverageText(changelog)} | ${coverage.unitsNoImpact} statements labeled no impact`, width, " ", ); - const coverage = changelog.riskCoverage; - if (coverage.itemsOmitted > 0) + if (!statements) return lines; + appendWrappedText( + lines, + " ", + `Selection: ${statements.selection} | ${statements.count} matching statements of ${statements.totalCount} parsed.`, + width, + " ", + ); + const pending = statements.coverage.reduce( + (n, c) => n + c.statementsNotAssessed, + 0, + ); + if (statements.selection !== "all") appendWrappedText( lines, " ", - `${coverage.itemsOmitted} statements omitted by backend (lowest priority first).`, + `Filter excludes ${coverage.unitsNoImpact} no-impact and ${pending} not-assessed statements${statements.selection !== "focused" ? `, and ${coverage.itemsUnclassified} too-long-to-classify statements` : ""}${statements.selection === "actionable" ? ", and should-know statements" : ""}.`, width, " ", ); - if (coverage.versionsNotAssessed > 0) + if (coverage.versionsNotAssessed) appendWrappedText(lines, " ", PENDING_CLASSIFICATION_MESSAGE, width, " "); - if (coverage.versionsWithoutNotes > 0 || coverage.versionsUnparseable > 0) + if (coverage.versionsWithoutNotes || coverage.versionsUnparseable) appendWrappedText( lines, " ", @@ -939,310 +969,154 @@ function formatChangesSection( width, " ", ); - if (changelog.truncated) - appendWrappedText( - lines, - " ", - "Release-note entries and links are sampled; statement coverage spans the upgrade range.", - width, - " ", - ); - - const sources = new Map(); - const sourceName = (source?: string): string | undefined => - source === "releases" - ? "Release notes" - : source === "changelog_file" - ? "Changelog" - : source - ? safeRiskText(source) - : undefined; - const reference = (url: string | undefined, source?: string): string => { - const cleanUrl = url ? safeRiskText(url) : undefined; - const key = cleanUrl ?? `missing:${source ?? "unknown"}`; - let record = sources.get(key); - if (!record) { - const name = sourceName(source); - record = { - number: sources.size + 1, - label: cleanUrl - ? `${name ? `${name}: ` : ""}${cleanUrl}` - : `${name ?? "Source unavailable"} (entry URL not returned)`, - }; - sources.set(key, record); - } - return `[${record.number}]`; - }; - const groups = new Map(); - const groupFor = (version: string | null): ChangelogVersionEvidence => { - let group = groups.get(version); - if (!group) { - group = { version, items: [], entries: [] }; - groups.set(version, group); - } - return group; - }; - // Keep backend release order, then append versions outside its entry sample. - const entries = new Map(); - for (const entry of [ - ...changelog.entries, - ...changelog.sampledEntries, - ...changelog.keywordEntries, - ]) { - const key = JSON.stringify([ - entry.version, - entry.detailSource, - entry.htmlUrl, - entry.publishedAt, - ]); - const previous = entries.get(key); - entries.set( - key, - previous - ? { - ...previous, - body: previous.body ?? entry.body, - headline: previous.headline ?? entry.headline, - bodyPreview: previous.bodyPreview ?? entry.bodyPreview, - signals: [ - ...new Set([ - ...(previous.signals ?? []), - ...(entry.signals ?? []), - ]), - ], - } - : entry, + appendWrappedText( + lines, + " ", + "! requires action | kind tags: should know | plain: no impact identified", + width, + " ", + ); + const entries = [ + ...(changelog.entries ?? []), + ...(changelog.sampledEntries ?? []), + ...(changelog.keywordEntries ?? []), + ]; + const releases: Array<{ + version: string | null; + statementsTotal?: number; + status?: string; + }> = [...statements.coverage]; + // Keyword metadata can contain unversioned notes; preserve those facts without + // inventing an assessment or altering backend statement coverage. + for (const entry of changelog.keywordEntries ?? []) + if ( + entry.signals?.length && + !releases.some((release) => release.version === entry.version) + ) + releases.push({ version: entry.version }); + for (const release of releases) { + const items = statements.items.filter( + (item) => item.version === release.version, ); - } - for (const entry of entries.values()) - groupFor(entry.version).entries.push(entry); - for (const item of changelog.riskItems) - groupFor(item.version).items.push(item); - let excerpted = false; - for (const group of groups.values()) { - const keywords: Array<{ - text: string; - signals: string[]; - entry: UpgradeChangelogEntry; - }> = []; - for (const entry of group.entries) { + const metadata = entries.find((entry) => entry.version === release.version); + const keywords: { text: string; signals: string[]; url?: string }[] = []; + for (const entry of changelog.keywordEntries ?? []) { + if (entry.version !== release.version) continue; const chunks = changelogExcerptChunks( entry.body ?? entry.headline ?? entry.bodyPreview ?? "", ); const matches = new Map(); for (const signal of entry.signals ?? []) { - const chunk = chunks.find((chunk) => matchesSignalTerm(chunk, signal)); - const text = chunk ?? ""; + const text = + chunks.find((chunk) => matchesSignalTerm(chunk, signal)) ?? ""; matches.set(text, [...(matches.get(text) ?? []), signal]); } for (const [text, signals] of matches) { const plain = releaseNoteText(text, () => ""); - const item = - plain && entry.detailSource !== undefined - ? group.items.find( - (item) => - item.source === entry.detailSource && - releaseNoteText(item.text, () => "").includes(plain), - ) - : undefined; - // The classified quote already carries this lexical evidence. - if (!item) keywords.push({ text, signals, entry }); + if ( + !plain || + !items.some( + (item) => + item.source === entry.detailSource && + releaseNoteText(item.text, () => "").includes(plain), + ) + ) + keywords.push({ text, signals, url: entry.htmlUrl }); } } - if (group.items.length === 0 && keywords.length === 0 && !options.verbose) + if (!items.length && !keywords.length && (release.statementsTotal ?? 0) > 0) continue; - const sourceForItem = (item: UpgradeChangelogRiskItem): string => { - const entry = group.entries.find( - (entry) => - item.source !== undefined && - entry.detailSource === item.source && - entry.htmlUrl, - ); - return reference(item.url ?? entry?.htmlUrl, item.source); - }; - const noteReferences = new Map(); - for (const item of group.items) { - if (!item.url) continue; - noteReferences.set( - sourceForItem(item), - sourceName(item.source) ?? "Notes", - ); - } - for (const entry of group.entries) { - if (!entry.htmlUrl) continue; - noteReferences.set( - reference(entry.htmlUrl, entry.detailSource), - sourceName(entry.detailSource) ?? "Notes", - ); - } - const version = - group.version === null - ? "Unversioned notes" - : safeRiskText(group.version); - // All note locators sit beside the version; each URL is listed once below. - const notes = [...noteReferences].map( - ([citation, label]) => `${label} ${citation}`, + lines.push(""); + lines.push( + ...wrapTerminalProse( + sanitizeTerminalText( + ` ${release.version ?? "(unversioned)"}${metadata?.publishedAt ? ` (${metadata.publishedAt.slice(0, 10)})` : ""}`, + ), + width, + " ", + ).map((line) => highlight(line, options.useColors === true)), ); - appendWrappedText( - lines, - " ", - `${version}${notes.length ? ` (${notes.join("; ")})` : ""}`, - width, - " ", + lines.push( + ...formatChangelogVersionStatements(items, { + useColors: options.useColors === true, + terminalWidth: width - 2, + compactNonAction: !options.verbose, + }).map((line) => ` ${line}`), ); - const sections = changelogRiskSections(group.items); - for (const { label, items } of sections) { - if (items.length === 0) continue; - lines.push(` ${label}`); - for (const item of items) { - const source = sourceForItem(item); - const quote = releaseNoteText(item.text, (url) => reference(url)); - const limit = - item.tier === "must_act" || options.verbose ? Infinity : 240; - const characters = Array.from(quote); - const shortened = characters.length > limit; - excerpted ||= shortened; - const shown = shortened - ? `${characters.slice(0, limit).join("").trimEnd()}...` - : quote; - const kind = item.kind - ? `[${RISK_KIND_LABELS[item.kind] ?? safeRiskText(item.kind)}]` - : ""; - const uncertain = isUncertainRiskItem(item); - appendWrappedText( - lines, - " * ", - `${kind ? `${kind} ` : ""}${uncertain ? "(uncertain) " : ""}"${shown}" ${source}${item.textTruncated ? " [statement truncated by backend]" : ""}`, - width, - " ", - (line) => { - const labeled = kind - ? line.replace( - kind, - colorize(kind, "yellow", options.useColors === true), - ) - : line; - return uncertain - ? labeled.replace( - "(uncertain)", - colorize("(uncertain)", "dim", options.useColors === true), - ) - : labeled; - }, - ); - 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) - appendWrappedText(lines, " ", details, width, " "); - } - } + const shownUrls = new Set( + items.flatMap((item) => (item.url ? [safeRiskText(item.url)] : [])), + ); + if (!items.length && metadata?.htmlUrl) { + const url = safeRiskText(metadata.htmlUrl); + appendWrappedText(lines, " ", url, width, " "); + shownUrls.add(url); } - if (keywords.length > 0) lines.push(" Keyword matches"); - for (const keyword of keywords) { - const source = reference( - keyword.entry.htmlUrl, - keyword.entry.detailSource, - ); - const quote = releaseNoteText(keyword.text, (url) => reference(url)); - const characters = Array.from(quote); - const shortened = !options.verbose && characters.length > 240; - excerpted ||= shortened; - const shown = shortened - ? `${characters.slice(0, 240).join("").trimEnd()}...` - : quote; + if (!items.length && !keywords.length) appendWrappedText( lines, - " * ", - `[${safeRiskText(keyword.signals.join(", "))}] ${shown ? `"${shown}"` : "no excerpt returned"} ${source}`, + " ", + release.status === "without_notes" + ? "No release notes available." + : "Notes contain no parsed statements.", width, - " ", - (line) => - colorizeSignalKeywords( - line, - keyword.signals, - options.useColors === true, - ), + " ", ); - } - if (options.verbose && group.items.length === 0 && keywords.length === 0) { - for (const entry of group.entries) { - const source = reference(entry.htmlUrl, entry.detailSource); - const quote = releaseNoteText( - entry.bodyPreview ?? entry.headline ?? "", - (url) => reference(url), - ); + if (keywords.length) { + lines.push(" Keyword matches (lexical evidence)"); + for (const keyword of keywords) { + const url = keyword.url ? safeRiskText(keyword.url) : undefined; + if (url && !shownUrls.has(url)) { + appendWrappedText(lines, " ", url, width, " "); + shownUrls.add(url); + } + const quote = releaseNoteText(keyword.text, (sourceUrl) => sourceUrl); + const signals = keyword.signals.map(safeRiskText); + const characters = Array.from(quote); + const excerpted = !options.verbose && characters.length > 240; + const text = excerpted + ? `${characters.slice(0, 240).join("").trimEnd()}...` + : quote; appendWrappedText( lines, - " * ", - `${quote ? `"${quote}"` : "No statement text returned."} ${source}`, + " * ", + `[${signals.join(", ")}] ${text || "Entry keyword matched; excerpt unavailable."}`, width, - " ", + " ", + (line) => + colorizeSignalKeywords(line, signals, options.useColors === true), ); + if (excerpted) + appendWrappedText( + lines, + " ", + "Excerpt; use verbose for the full keyword quote.", + width, + " ", + ); } } } const entrySignals = new Set( - [...entries.values()].flatMap((entry) => entry.signals ?? []), + (changelog.keywordEntries ?? []).flatMap((entry) => entry.signals ?? []), ); - const keywords = changelogKeywordSummary(changelog).filter( + const unmatched = changelogKeywordSummary(changelog).filter( (signal) => !entrySignals.has(signal), ); if ( - keywords.length || - (entries.size === 0 && changelog.totalKeywordEntries > 0) + unmatched.length || + (!changelog.keywordEntries?.length && changelog.totalKeywordEntries > 0) ) appendWrappedText( lines, " Keyword matches without excerpts: ", - safeRiskText( - keywords.join(", ") || `${changelog.totalKeywordEntries} entries`, - ), + unmatched.map(safeRiskText).join(", ") || + `${changelog.totalKeywordEntries} matching entries`, width, " ", - (line) => - colorizeSignalKeywords(line, keywords, options.useColors === true), - ); - // Excerpting may remove an in-note link: list only references still visible. - const cited = new Set( - [...lines.join("\n").matchAll(/\[(\d+)\](?!\()/g)].map((match) => - Number(match[1]), - ), - ); - const visibleSources = [...sources.values()].filter((source) => - cited.has(source.number), - ); - if (visibleSources.length) { - lines.push(" Sources"); - for (const source of visibleSources) - lines.push(` [${source.number}] ${source.label}`); - } - if (excerpted) - appendWrappedText( - lines, - " ", - "Quotes ending in ... are excerpts; use verbose for full text.", - width, - " ", ); return lines; } -function isUncertainRiskItem(item: UpgradeChangelogRiskItem): boolean { - return item.ambiguous; -} - function formatRiskTierCount( changelog: UpgradeChangelog, tier: "must_act" | "should_know", @@ -1302,7 +1176,7 @@ function formatBatchTriage( : undefined; // Rows stay intact as a table; prose footers use the caller's width. lines.push( - ` ${safeRiskText(`${review.registry}:${review.name} ${review.currentVersion} -> ${review.targetVersion} (${review.versionDelta})`)} | ${formatRiskTierCount(c, "must_act", "act")} | ${formatRiskTierCount(c, "should_know", "know")} | ${c.riskCoverage.itemsUnclassified} too long to classify | versions: ${riskCoverageText(c)} | ${c.riskCoverage.unitsNoImpact} labeled no impact | ${c.riskCoverage.itemsOmitted} omitted | ${deprecation} | ${security} | ${transitiveText} | ${c.totalKeywordEntries} keyword entries | ${review.compatibility?.peerDependencyChanges.length ?? "not checked"} peer dependency changes | ${review.compatibility?.notes.length ?? "not checked"} compatibility notes | ${dependencyCount ?? "not checked"} direct dependency changes | ${issueCount ?? "not checked"} dependency issues | ${review.unknowns.length} unknowns`, + ` ${safeRiskText(`${review.registry}:${review.name} ${review.currentVersion} -> ${review.targetVersion} (${review.versionDelta})`)} | ${formatRiskTierCount(c, "must_act", "act")} | ${formatRiskTierCount(c, "should_know", "know")} | ${c.riskCoverage.itemsUnclassified} too long to classify | versions: ${riskCoverageText(c)} | ${c.riskCoverage.unitsNoImpact} labeled no impact | ${deprecation} | ${security} | ${transitiveText} | ${c.totalKeywordEntries} keyword entries | ${review.compatibility?.peerDependencyChanges.length ?? "not checked"} peer dependency changes | ${review.compatibility?.notes.length ?? "not checked"} compatibility notes | ${dependencyCount ?? "not checked"} direct dependency changes | ${issueCount ?? "not checked"} dependency issues | ${review.unknowns.length} unknowns`, ); } const width = normaliseTerminalWidth(options.terminalWidth); @@ -1315,7 +1189,7 @@ function formatBatchTriage( appendWrappedText( lines, " ", - "Counts separate backend-confident statements from uncertain ones; ranking uses only confident action totals. Totals include omitted statements; quotes are capped by the backend.", + "Counts separate backend-confident statements from uncertain ones; ranking uses only confident action totals. Totals cover the whole range independently of the detailed statement filter.", width, " ", ); diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index 1a71de00..d49ca8b4 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -624,6 +624,45 @@ describe("runMcpSmoke", () => { }); }); + it("upgrade statement smoke accepts null classification and rejects missing statements", async () => { + const createUpgradeCaller = (omitStatements: boolean): McpSmokeCaller => + createCaller(async (name, args) => { + const response = smokeResponse(name, args); + if (name !== "pkg_upgrade_review" || args.format !== "json") { + return response; + } + + const payload = JSON.parse( + resultText(response, "upgrade statement smoke fixture"), + ) as { + reviews: Array<{ changelog: Record }>; + }; + const changelog = payload.reviews[0]?.changelog; + if (!changelog) throw new Error("missing smoke changelog fixture"); + + if (omitStatements) { + delete changelog.statements; + } else { + const statements = changelog.statements as { + items: Array<{ classification: unknown }>; + }; + const firstItem = statements.items[0]; + if (!firstItem) throw new Error("missing smoke statement fixture"); + firstItem.classification = null; + } + + return jsonResult(payload); + }); + const logger = { log: () => {}, error: () => {} }; + + await expect( + runMcpSmoke(createUpgradeCaller(false), { logger }), + ).resolves.toBeUndefined(); + await expect( + runMcpSmoke(createUpgradeCaller(true), { logger }), + ).rejects.toThrow("pkg_upgrade_review statements: expected object"); + }); + it("rejects hosted documentation follow-ups that replay search bounds", async () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.source === "docs") { @@ -1565,19 +1604,31 @@ function smokeJsonResponse( reviews: [ { changelog: { - riskItems: [ - { - version: "5.0.0", - tier: "must_act", - ambiguous: false, - text: "Removed an API.", - textTruncated: false, - fullText: "Removed an API.", - url: "https://example.com/releases/v5.0.0", - model: "jev-1.13.0", - formulation: "s-hier-v3", - }, - ], + statements: { + selection: "focused", + count: 1, + totalCount: 2, + matchingCount: 1, + items: [ + { + version: "5.0.0", + position: 0, + sourceIndex: 0, + sourcePosition: 0, + text: "Removed an API.", + textTruncated: false, + fullText: "Removed an API.", + url: "https://example.com/releases/v5.0.0", + classification: { + tier: "must_act", + ambiguous: false, + model: "jev-1.13.0", + formulation: "s-hier-v3", + }, + }, + ], + coverage: [], + }, riskCoverage: { versionsClassified: 1, versionsNotAssessed: 0, @@ -1589,7 +1640,6 @@ function smokeJsonResponse( itemsShouldKnowConfident: 0, itemsShouldKnowAmbiguous: 0, itemsUnclassified: 0, - itemsOmitted: 0, }, }, }, diff --git a/packages/mcp/src/smoke-test.ts b/packages/mcp/src/smoke-test.ts index 4cfcdb90..9d5ba11c 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -1473,27 +1473,75 @@ async function runLiveSmoke(caller: McpSmokeCaller): Promise { assert(firstUpgradeReview, "pkg_upgrade_review json missing first review"); assertRecord(firstUpgradeReview.changelog, "pkg_upgrade_review changelog"); assert( - Array.isArray(firstUpgradeReview.changelog.riskItems), - "pkg_upgrade_review missing riskItems", + !("riskItems" in firstUpgradeReview.changelog), + "pkg_upgrade_review returned deprecated riskItems", ); - for (const item of firstUpgradeReview.changelog.riskItems) { - assertRecord(item, "pkg_upgrade_review risk item"); + const upgradeStatements = firstUpgradeReview.changelog.statements; + assertRecord(upgradeStatements, "pkg_upgrade_review statements"); + assert( + upgradeStatements.selection === "focused", + "pkg_upgrade_review statements selection must be focused", + ); + assert( + Array.isArray(upgradeStatements.items), + "pkg_upgrade_review statements missing items array", + ); + assert( + Array.isArray(upgradeStatements.coverage), + "pkg_upgrade_review statements missing coverage array", + ); + assert( + typeof upgradeStatements.count === "number" && + upgradeStatements.count === upgradeStatements.items.length, + "pkg_upgrade_review statements count does not match items", + ); + assert( + typeof upgradeStatements.totalCount === "number", + "pkg_upgrade_review statements missing totalCount", + ); + assert( + typeof upgradeStatements.matchingCount === "number", + "pkg_upgrade_review statements missing matchingCount", + ); + for (const item of upgradeStatements.items) { + assertRecord(item, "pkg_upgrade_review statement item"); assert( - typeof item.ambiguous === "boolean", - "pkg_upgrade_review missing item ambiguous flag", + typeof item.fullText === "string", + "pkg_upgrade_review missing statement fullText", ); assert( - typeof item.fullText === "string", - "pkg_upgrade_review missing item fullText", + typeof item.sourcePosition === "number", + "pkg_upgrade_review missing statement sourcePosition", ); assert( item.url === undefined || typeof item.url === "string", - "pkg_upgrade_review invalid item URL", + "pkg_upgrade_review invalid statement URL", ); + if (item.classification !== null) { + assertRecord(item.classification, "pkg_upgrade_review classification"); + assert( + typeof item.classification.tier === "string", + "pkg_upgrade_review missing classification tier", + ); + assert( + typeof item.classification.ambiguous === "boolean", + "pkg_upgrade_review missing classification ambiguous flag", + ); + assert( + typeof item.classification.model === "string", + "pkg_upgrade_review missing classification model", + ); + assert( + typeof item.classification.formulation === "string", + "pkg_upgrade_review missing classification formulation", + ); + } } - assertRecord( - firstUpgradeReview.changelog.riskCoverage, - "pkg_upgrade_review riskCoverage", + const riskCoverage = firstUpgradeReview.changelog.riskCoverage; + assertRecord(riskCoverage, "pkg_upgrade_review riskCoverage"); + assert( + !("itemsOmitted" in riskCoverage), + "pkg_upgrade_review returned deprecated itemsOmitted counter", ); for (const counter of [ "versionsClassified", @@ -1506,10 +1554,9 @@ async function runLiveSmoke(caller: McpSmokeCaller): Promise { "itemsShouldKnowConfident", "itemsShouldKnowAmbiguous", "itemsUnclassified", - "itemsOmitted", ]) { assert( - typeof firstUpgradeReview.changelog.riskCoverage[counter] === "number", + typeof riskCoverage[counter] === "number", `pkg_upgrade_review missing coverage counter ${counter}`, ); } diff --git a/packages/mcp/src/tools/package-upgrade-review.test.ts b/packages/mcp/src/tools/package-upgrade-review.test.ts index 15f762fc..30f2a879 100644 --- a/packages/mcp/src/tools/package-upgrade-review.test.ts +++ b/packages/mcp/src/tools/package-upgrade-review.test.ts @@ -9,6 +9,22 @@ function parseText(result: { content: Array<{ text: string }> }): unknown { } describe("createPackageUpgradeReviewTool", () => { + it("distinguishes oversize from unassessed", () => { + const tool = createPackageUpgradeReviewTool( + createMockPackageIntelligenceService(), + ); + const changesProperty = z.toJSONSchema(z.object(tool.schema)).properties + ?.changes; + const changesDescription = + typeof changesProperty === "object" && changesProperty !== null + ? changesProperty.description + : undefined; + + expect(changesDescription).toContain("too-long-to-classify"); + expect(changesDescription).toContain("oversize"); + expect(changesDescription).not.toContain("UNCLASSIFIED"); + }); + it("registers metadata and schema keys", () => { const tool = createPackageUpgradeReviewTool( createMockPackageIntelligenceService(), @@ -22,6 +38,7 @@ describe("createPackageUpgradeReviewTool", () => { expect(tool.description).toContain("pkg_changelog and code_diff"); expect(tool.annotations?.readOnlyHint).toBe(true); expect(Object.keys(tool.schema).sort()).toEqual([ + "changes", "current_version", "format", "include_dependency_issues", @@ -42,6 +59,159 @@ describe("createPackageUpgradeReviewTool", () => { expect(tool.description).toContain("at most 30 upgrades"); }); + it.each([ + [ + "single text", + { + registry: "npm", + package_name: "express", + current_version: "4.18.0", + target_version: "5.0.0", + }, + "text", + ["MUST_ACT", "SHOULD_KNOW", "UNCLASSIFIED"], + ], + [ + "verbose single", + { + registry: "npm", + package_name: "express", + current_version: "4.18.0", + target_version: "5.0.0", + verbose: true, + changes: " AcTiOnAbLe ", + }, + "text", + ["MUST_ACT"], + ], + [ + "batch triage", + { + packages: [ + { + registry: "npm", + package_name: "express", + current_version: "4.18.0", + target_version: "5.0.0", + }, + { + registry: "npm", + package_name: "lodash", + current_version: "4.0.0", + target_version: "5.0.0", + }, + ], + changes: "important", + }, + "triage", + ["MUST_ACT", "SHOULD_KNOW"], + ], + [ + "verbose batch", + { + packages: [ + { + registry: "npm", + package_name: "express", + current_version: "4.18.0", + target_version: "5.0.0", + }, + { + registry: "npm", + package_name: "lodash", + current_version: "4.0.0", + target_version: "5.0.0", + }, + ], + verbose: true, + changes: "all", + }, + "text", + undefined, + ], + [ + "batch JSON", + { + packages: [ + { + registry: "npm", + package_name: "express", + current_version: "4.18.0", + target_version: "5.0.0", + }, + { + registry: "npm", + package_name: "lodash", + current_version: "4.0.0", + target_version: "5.0.0", + }, + ], + changes: " ", + format: "json", + }, + "json", + ["MUST_ACT", "SHOULD_KNOW", "UNCLASSIFIED"], + ], + [ + "single JSON", + { + registry: "npm", + package_name: "express", + current_version: "4.18.0", + target_version: "5.0.0", + changes: "all", + format: "json", + }, + "json", + undefined, + ], + ] as const)( + "selects MCP upgrade changelog host projection: %s", + async (_case, args, changelogView, statementTiers) => { + const packageUpgradeReview = mock((params) => + Promise.resolve({ + summary: { + total: params.packages.length, + withUnknowns: 0, + withAddedAdvisories: 0, + withBreakingSignals: 0, + withDirectDependencyChanges: 0, + withTransitiveVulnerabilityAdditions: 0, + }, + reviews: [], + }), + ); + const tool = createPackageUpgradeReviewTool( + createMockPackageIntelligenceService({ + packageUpgradeReview: packageUpgradeReview as never, + }), + ); + + expect(z.object(tool.schema).safeParse({ changes: " " }).success).toBe( + true, + ); + const changesSchema = JSON.stringify( + z.toJSONSchema(z.object(tool.schema)).properties?.changes, + ); + expect(changesSchema).toContain("focused"); + expect(changesSchema).toMatch(/whole-range/i); + + const mutableArgs = + "packages" in args + ? { + ...args, + packages: args.packages.map((pkg) => ({ ...pkg })), + } + : args; + const result = await tool.handler(mutableArgs, {}); + + expect(result.isError).toBeUndefined(); + expect(packageUpgradeReview).toHaveBeenCalledWith( + expect.objectContaining({ changelogView, statementTiers }), + ); + }, + ); + it("calls the aggregate service method with normalized single-package params", async () => { const packageUpgradeReview = mock((params) => Promise.resolve({ diff --git a/packages/mcp/src/tools/package-upgrade-review.ts b/packages/mcp/src/tools/package-upgrade-review.ts index 6ddba543..3ac1f327 100644 --- a/packages/mcp/src/tools/package-upgrade-review.ts +++ b/packages/mcp/src/tools/package-upgrade-review.ts @@ -33,6 +33,7 @@ export interface PackageUpgradeReviewArgs { skip_transitive_security?: boolean; include_dependency_issues?: boolean; min_severity?: string; + changes?: string; verbose?: boolean; format?: "text" | "json"; } @@ -101,11 +102,17 @@ const schema: ZodRawShape = { .describe( "Minimum direct-advisory severity: low, medium, high, or critical.", ), + changes: z + .string() + .optional() + .describe( + "Filter detailed statement quotes only: actionable (MUST_ACT), important (MUST_ACT + SHOULD_KNOW), or all parsed units, including no-impact and unassessed. Omit or pass blank to use focused (MUST_ACT + SHOULD_KNOW + too-long-to-classify (oversize) statements). Whole-range counters and batch ranking do not change.", + ), verbose: z .boolean() .optional() .describe( - "Text output only. Batches default to triage rows; verbose adds full per-package reports. Expand statement quotes, headings/confidence and dependency change examples.", + "Text output only. Batches default to triage rows; verbose adds full per-package reports, expands statement quotes up to backend bounds, and expands dependency change examples.", ), format: z .enum(["text", "json"]) @@ -118,7 +125,7 @@ const schema: ZodRawShape = { const DESCRIPTION = "Review a package upgrade: vulnerabilities, releases, peers, dependency changes. " + "Compares current and target versions using direct vulnerability checks, changelog ranges, target deprecation metadata, peer dependency changes, and optional transitive evidence diffs. " + - "The tool reports facts only, including agent-classified release-note quotes grouped by version, with referenced sources and coverage. JSON retains classifier provenance. It does not assign package-level risk or decide whether to accept an upgrade. Batches default to triage rows; use verbose for full per-package evidence. " + + "The tool reports facts only, including agent-classified release-note statements in backend source order, with nearby authoritative URLs and assessment coverage. JSON includes complete selected statement pages, source positions and context, and classifier provenance. It does not assign package-level risk or decide whether to accept an upgrade. Batches default to triage rows; use verbose for full per-package evidence. " + "Use this instead of inferring acceptability from semver, including patch bumps. " + "Use signals as a starting point to evaluate the impact of changes on the codebase. " + "You can use pkg_changelog and code_diff to obtain full release-note and source-change details. " + @@ -150,14 +157,14 @@ export function createPackageUpgradeReviewTool( includeTransitiveSecurity: args.skip_transitive_security !== true, includeDependencyIssues: args.include_dependency_issues, minSeverity: args.min_severity, + changes: args.changes, + format: args.format, + verbose: args.verbose === true, }); const response = await buildPackageUpgradeReview( service, request.packages, - { - ...request.options, - includeChangelogFullText: args.format === "json", - }, + request.options, ); if (args.format === "json") return textResult(JSON.stringify(response)); return textResult( diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index a4e58677..4b7480fc 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -2047,27 +2047,75 @@ async function runLiveSmoke(env: Record): Promise { assert(firstUpgradeReview, "pkg upgrade-review json missing first review"); assertRecord(firstUpgradeReview.changelog, "pkg upgrade-review changelog"); assert( - Array.isArray(firstUpgradeReview.changelog.riskItems), - "pkg upgrade-review missing riskItems", + !("riskItems" in firstUpgradeReview.changelog), + "pkg upgrade-review returned deprecated riskItems", ); - for (const item of firstUpgradeReview.changelog.riskItems) { - assertRecord(item, "pkg upgrade-review risk item"); + const upgradeStatements = firstUpgradeReview.changelog.statements; + assertRecord(upgradeStatements, "pkg upgrade-review statements"); + assert( + upgradeStatements.selection === "focused", + "pkg upgrade-review statements selection must be focused", + ); + assert( + Array.isArray(upgradeStatements.items), + "pkg upgrade-review statements missing items array", + ); + assert( + Array.isArray(upgradeStatements.coverage), + "pkg upgrade-review statements missing coverage array", + ); + assert( + typeof upgradeStatements.count === "number" && + upgradeStatements.count === upgradeStatements.items.length, + "pkg upgrade-review statements count does not match items", + ); + assert( + typeof upgradeStatements.totalCount === "number", + "pkg upgrade-review statements missing totalCount", + ); + assert( + typeof upgradeStatements.matchingCount === "number", + "pkg upgrade-review statements missing matchingCount", + ); + for (const item of upgradeStatements.items) { + assertRecord(item, "pkg upgrade-review statement item"); assert( - typeof item.ambiguous === "boolean", - "pkg upgrade-review missing item ambiguous flag", + typeof item.fullText === "string", + "pkg upgrade-review missing statement fullText", ); assert( - typeof item.fullText === "string", - "pkg upgrade-review missing item fullText", + typeof item.sourcePosition === "number", + "pkg upgrade-review missing statement sourcePosition", ); assert( item.url === undefined || typeof item.url === "string", - "pkg upgrade-review invalid item URL", + "pkg upgrade-review invalid statement URL", ); + if (item.classification !== null) { + assertRecord(item.classification, "pkg upgrade-review classification"); + assert( + typeof item.classification.tier === "string", + "pkg upgrade-review missing classification tier", + ); + assert( + typeof item.classification.ambiguous === "boolean", + "pkg upgrade-review missing classification ambiguous flag", + ); + assert( + typeof item.classification.model === "string", + "pkg upgrade-review missing classification model", + ); + assert( + typeof item.classification.formulation === "string", + "pkg upgrade-review missing classification formulation", + ); + } } - assertRecord( - firstUpgradeReview.changelog.riskCoverage, - "pkg upgrade-review riskCoverage", + const riskCoverage = firstUpgradeReview.changelog.riskCoverage; + assertRecord(riskCoverage, "pkg upgrade-review riskCoverage"); + assert( + !("itemsOmitted" in riskCoverage), + "pkg upgrade-review returned deprecated itemsOmitted counter", ); for (const counter of [ "versionsClassified", @@ -2080,10 +2128,9 @@ async function runLiveSmoke(env: Record): Promise { "itemsShouldKnowConfident", "itemsShouldKnowAmbiguous", "itemsUnclassified", - "itemsOmitted", ]) { assert( - typeof firstUpgradeReview.changelog.riskCoverage[counter] === "number", + typeof riskCoverage[counter] === "number", `pkg upgrade-review missing coverage counter ${counter}`, ); } diff --git a/src/commands/pkg/upgrade-review.test.ts b/src/commands/pkg/upgrade-review.test.ts index 8745cc74..eb0a96f7 100644 --- a/src/commands/pkg/upgrade-review.test.ts +++ b/src/commands/pkg/upgrade-review.test.ts @@ -120,6 +120,95 @@ describe("pkg upgrade-review help", () => { }); describe("pkgUpgradeReviewAction", () => { + it.each([ + [ + "single text", + "npm:express@4.18.0", + { to: "5.0.0" }, + "text", + ["MUST_ACT", "SHOULD_KNOW", "UNCLASSIFIED"], + ], + [ + "verbose single", + "npm:express@4.18.0", + { to: "5.0.0", verbose: true, changes: " AcTiOnAbLe " }, + "text", + ["MUST_ACT"], + ], + [ + "batch triage", + undefined, + { + package: ["npm:express@4.18.0..5.0.0", "npm:lodash@4.0.0..5.0.0"], + changes: "important", + }, + "triage", + ["MUST_ACT", "SHOULD_KNOW"], + ], + [ + "verbose batch", + undefined, + { + package: ["npm:express@4.18.0..5.0.0", "npm:lodash@4.0.0..5.0.0"], + verbose: true, + changes: "all", + }, + "text", + undefined, + ], + [ + "batch JSON", + undefined, + { + package: ["npm:express@4.18.0..5.0.0", "npm:lodash@4.0.0..5.0.0"], + json: true, + changes: " ", + }, + "json", + ["MUST_ACT", "SHOULD_KNOW", "UNCLASSIFIED"], + ], + [ + "single JSON", + "npm:express@4.18.0", + { to: "5.0.0", json: true, changes: "all" }, + "json", + undefined, + ], + ] as const)( + "selects upgrade changelog host projection: %s", + async (_case, spec, options, changelogView, statementTiers) => { + process.stdout.write = (() => true) as typeof process.stdout.write; + const service = createMockPackageIntelligenceService(); + + if (_case === "single text") { + const help = registerPkgUpgradeReviewCommand( + new Command().command("pkg"), + ) + .helpInformation() + .replace(/\s+/g, " "); + expect(help).toContain("--changes "); + expect(help).toContain("actionable, important, all"); + expect(help).toContain("default: focused"); + } + + const mutableOptions = + "package" in options + ? { ...options, package: [...options.package] } + : options; + + await pkgUpgradeReviewAction(spec, mutableOptions, { + packageIntelligenceService: service, + codeNavigationUrl: "https://pkgseer.dev", + hasValidToken: true, + mcpUrl: "https://mcp.githits.com", + }); + + expect(service.packageUpgradeReview).toHaveBeenCalledWith( + expect.objectContaining({ changelogView, statementTiers }), + ); + }, + ); + it("accepts a scoped positional double-dot range", async () => { const service = createMockPackageIntelligenceService(); process.stdout.write = (() => true) as typeof process.stdout.write; diff --git a/src/commands/pkg/upgrade-review.ts b/src/commands/pkg/upgrade-review.ts index 1bec3394..22678f37 100644 --- a/src/commands/pkg/upgrade-review.ts +++ b/src/commands/pkg/upgrade-review.ts @@ -23,6 +23,7 @@ export interface PkgUpgradeReviewCommandOptions { transitiveSecurity?: boolean; dependencyIssues?: boolean; minSeverity?: string; + changes?: string; verbose?: boolean; json?: boolean; } @@ -59,11 +60,14 @@ export async function pkgUpgradeReviewAction( includeTransitiveSecurity: options.transitiveSecurity, includeDependencyIssues: options.dependencyIssues, minSeverity: options.minSeverity, + changes: options.changes, + format: options.json ? "json" : "text", + verbose: options.verbose === true, }); const response = await buildPackageUpgradeReview( deps.packageIntelligenceService, request.packages, - { ...request.options, includeChangelogFullText: options.json === true }, + request.options, ); if (options.json) { @@ -283,6 +287,10 @@ export function registerPkgUpgradeReviewCommand(pkgCommand: Command): Command { "--min-severity