From f431aa187aa0da395c3536790bf445d17af1b694 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Wed, 14 Oct 2026 13:36:38 +0300 Subject: [PATCH 1/5] docs: plan complete upgrade-review statement adoption Record the reviewed one-increment migration, authenticated schema and complexity evidence, effective filters, minimal projections and complete collection rules. Correct the backlog to distinguish resolved backend data support from remaining supersession questions. --- docs/plans/open-backlog.md | 15 +- docs/plans/upgrade-review-statements.md | 442 ++++++++++++++++++++++++ 2 files changed, 451 insertions(+), 6 deletions(-) create mode 100644 docs/plans/upgrade-review-statements.md diff --git a/docs/plans/open-backlog.md b/docs/plans/open-backlog.md index 8ab74896..2255375b 100644 --- a/docs/plans/open-backlog.md +++ b/docs/plans/open-backlog.md @@ -32,9 +32,12 @@ 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 covered by the [upgrade statement migration plan](upgrade-review-statements.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#known-output-limitations). +Consumer applicability remains the calling agent's job. diff --git a/docs/plans/upgrade-review-statements.md b/docs/plans/upgrade-review-statements.md new file mode 100644 index 00000000..85039bee --- /dev/null +++ b/docs/plans/upgrade-review-statements.md @@ -0,0 +1,442 @@ +# Upgrade review: complete changelog statements + +## Status and destination + +Overall: **READY**. One implementation increment; no production changes +are made during planning. Branch `jlitola/upgrade-review-statements`, planning base +`acd717488569485bf4af68bc09dab6c0ef2be7e4` (origin/main, merged PR476). + +Outcome: CLI `pkg upgrade-review` and package-owned MCP `pkg_upgrade_review` use +complete backend-parsed statement evidence, with useful filters, honest coverage, +uncertainty and nearby source links. Detailed results have no 50-item cap. Compact +batch output retains whole-range confident/uncertain counts and ranking without +fetching quotes. Security, dependencies and keyword evidence remain functional. + +Dependencies: the new aggregate statements contract, verified on dev below; +existing shared changelog collection/normalization and approved compact tags. +Assumptions: the owner accepted the preceding recommendation to preserve the +focused default and reuse explicit changelog filter meanings. No client confidence +threshold, package verdict, backend parsing or local deduplication is introduced. +Open product decisions: **none**. Production deployment is a release prerequisite, +not verified by the dev checks; verify before publishing (outside this increment). + +Overall acceptance: complete selected evidence and CLI/MCP text/JSON parity; +mode-specific queries under500 at the public30-package maximum; existing security, +dependency and heuristic semantics intact; documented JSON migration and both +public-artifact release impact; clean review and green CI on the implementation PR. + +## Problem and verified evidence + +Current aggregate query and service are in +`packages/core-internal/src/services/package-intelligence-service.ts`. It selects +`riskItems`, `riskCoverage`, entries, sampledEntries and keywordEntries for every +host/view, including compact batch text. `riskItems` is priority ordered and capped +at50. The client has no changelog tier filter. JSON selects complete quote text; +text/verbose consume bounded quotes. Global counters already separate confident +and ambiguous tiers; batch ranking uses confident MUST_ACT only. The shared +upgrade formatter owns version groups, keyword excerpts and source references. + +Merged PR476 supplies statement schemas, normalizers, complete collection/progress +checks and source-ordered changelog rendering with `[!behavior]`, `[deprecation]`, +kindless `[!]` / `[should know]`, explicit uncertainty and plain no-impact bullets. +No existing active plan covers this migration. The internal plan assessment found +that keyword excerpts require keywordEntries.body, not just its preview; this +projection exception and a beyond-preview regression are explicit below. + +Backend SDL: `~/proj/githits/pkgseer-backend/priv/graphql/schema.graphql`, +`PackageUpgradeChangelog` at6469; changelog entry2026-10-14, schema hash +`sha256:3d8a7e9f6602`. It adds `statements(first: Int!, offset: Int!, +tiers: [ChangelogStatementTier!])`. Explicit first100 and offset0 are sent; the +changelog mentions defaults but the SDL requires these arguments. The field: + +- covers the complete upgrade range independently of changelogLimit; +- filters before pagination; missing/null/empty tiers mean all, including + NO_IMPACT and `classification: null`; +- supplies source/version positions, heading, source URL, bounded/full text, + predecessor context, nullable classification with authoritative ambiguous; +- supplies full-range totalCount/matchingCount, progress metadata and per-version + assessment coverage before filtering; +- shares the lazy label path/bounded wait with risk siblings; text-only selections + parse without labels, but classifications/assessment fields/active filters load + labels; labels may complete in background; +- continues per review through a single-package aggregate request, with current + offsets rather than a persisted snapshot; coverage/notes changes require restart; +- deprecates riskItems while retaining riskCoverage and keyword siblings. + +Planning used the existing authenticated backend `scripts/graphql-call.mjs` +wrapper and normal built CLI auth, explicit dev environment; no credential value, +storage, token output or authorization header was inspected/exported. Dev queries +worked. Evidence files/scripts are under `/tmp/upgrade-review-statements-planning/`; +this plan records durable results so implementation does not depend on temp files. + +### Complexity measurements + +Admission-only probes add an independently measured rejection control. Its +510 packageVulnerabilities aliases cost5100 on current dev; subtract5100 from +combined costs. Root __typename aliases cost0 and are not a valid control. Raw +responses and the calibrated measurements are retained; no probe aliases belong +in product queries and rejection executes no resolvers. + +| Operation, transitive security and dependency issues enabled | Admission total | Actual cost | +| --- | --- | --- | +| Current operation,1 package | 5389 | 289 | +| Current operation,30 packages | 5534 | 434 | +| Proposed initial detailed JSON,1 package | 5430 | 330 | +| Proposed initial detailed JSON,3 packages | 5440 | 340 | +| Proposed initial detailed JSON,30 packages, focused or all | 5575 | 475 | +| Proposed compact triage,30 packages | 5491 | 391 | +| Full JSON statement-only continuation,1 package | 5188 | 88 | + +Detailed prototype replaces riskItems with first100 statements including full +JSON context/provenance/page/coverage fields, removes legacy itemsOmitted, and +retains current entry/security/dependency selections. Triage additionally omits +statements and all three entry arrays. Continuations select identity and statement +pages only, includeTransitiveSecurity:false, changelogLimit:1 (the backend clamps0 to1). Thus even the +conservative maximum JSON projection (focused or unfiltered all) fits under500 with25 headroom. Final query +builders must be remeasured against these exact cases; directives cannot be assumed +to remove complexity. Do not split the initial batch or reduce its public maximum. + +### Live completeness and counter agreement + +Three ranges were fully assessed on these requests. Stored labels are not a cold +classification benchmark; no background completion or initially cold claim. + +| Range | Parsed | New focused / old returned | Confident / uncertain act | Confident / uncertain know | No impact | Versions classified / pending / without notes / unparseable | +| --- | --- | --- | --- | --- | --- | --- | +| npm:express5.0.0..5.2.1 | 141 | 15 / 15 | 0 / 8 | 6 / 1 | 126 | 4 / 0 / 0 / 0 | +| npm:express4.19.2..4.21.2 | 57 | 14 / 14 | 3 / 4 | 7 / 0 | 43 | 4 / 0 / 0 / 0 | +| npm:@biomejs/biome2.4.2..2.4.15 | 875 | 62 / 50 | 13 / 27 | 12 / 10 | 813 | 13 / 0 / 0 / 0 | + +The five riskCoverage tier counters equal classification counts over complete +focused pages in every case. Each focused selection fits one100-unit page. +Biome all returns875 across9 pages with stable coverage, unique version/position +keys, valid offsets/counts,813 no-impact labels and full-text/context provenance. +Oversize, pending and missing notes were not observed here; fixtures must cover +these documented states. Old cap now hides12 Biome quotes, rather than earlier +historical13; current stored labels are authoritative. + +For the same three-package selection with both optional evidence sections, compact +JSON serialization of raw GraphQL responses measured current443,505bytes, +proposed detailed467,275bytes, and proposed triage16,202bytes. Normal-auth helper +walltimes were4.486s /6.111s /3.932s, respectively; one observation each including +CLI auth/startup, not isolated rendering/network benchmarks or a speedup claim. +Current built CLI JSON was440,795bytes /4.055s; its envelope is different and is +not a like-for-like wire comparison. The decision rests on completeness, omitted +selections and complexity, not these timing differences. Initial all-Biome page +plus eight continuations took40.252s across separate auth-helper processes; the +implementation reuses its service/token flow within one operation, so this is +not projected CLI latency. Re-run the same narrow projection comparison after +implementation; do not schedule a broad benchmark sweep. + +Contradictions: planning corrects the stale backlog claim now; implementation +corrects the CLI reference. Specifically, cli-commands still describes the old +524-cost backend violation as open, although production maximum-batch support was +verified in pkg-upgrade-review.md. The backlog also says coverage membership and +omitted quotes need backend design; the new contract closes those two limitations. +Supersession/reverts and consumer applicability remain unresolved backend/domain +questions and are explicitly outside this migration. + +## Scope and architecture + +One PR, root CLI and packages/core-internal/packages/mcp only. No backend or +remote-mcp edits, releases, merges, snapshots, caches, jobs, automatic retry/polling, +schema fallback, custom classifier policy, package verdict or dependency comparison +redesign. No new raw-note flag or repurposing of global verbose. Public skills are +staged for release in permanent docs, not modified in this feature PR. + +Ownership: core owns GraphQL projection, request/response validation and complete +collection because both hosts need identical completeness and errors. MCP shared +request/response modules own public filter normalization and presentation; commands +and tool handlers only adapt host inputs. The backend owns parsing, restatement +suppression, labels, interpretation and whole-range counters. Keep these levels; +extract existing small pure helpers, not a general pager or new service layer. + +Data flow: host options -> shared request builder (packages, tiers, view) -> one +initial aggregate request -> only needed sequential single-package statement continuations +-> complete validated service evidence -> shared JSON builder/formatter. Do not +compose packageChangelog or dependency probes to obtain these statements. + +### Public behavior + +- Omitted/blank changes preserves focused `[MUST_ACT, SHOULD_KNOW, UNCLASSIFIED]`. + This differs deliberately from changelog's all default and must be explicit in + help/tool schema/docs; explicit filters have identical meanings on both tools. +- `--changes actionable` / MCP changes:"actionable" sends `[MUST_ACT]`, including + uncertain action labels. `important` sends `[MUST_ACT, SHOULD_KNOW]`. `all` + omits tiers and includes no-impact/unassessed/oversize units. Trim and lowercase + input through the shared normalizer; invalid values fail locally before package-intelligence service calls. +- Filtering limits detailed quote evidence, not whole-range triage/security counts. + Compact batches echo an explicitly requested filter and explain it applies to + detailed reports; no quote selection is fetched merely to apply a filter there. +- Keep compact batch triage as default for multiple packages. Whole-range act/know + counts separate uncertain labels and sort only by confident action totals, + regardless of filter. JSON and verbose batches retrieve matching quotes for each + review; single-package text retrieves matching quotes by default. +- Keep upgrade identity, security, compatibility, dependency and unknown groups. + Only Changes uses the approved source-ordered version/source bullets and nearby + authoritative URLs, compact bracket tags and explicit uncertainty/context. The + old tier sections/reference table are replaced by this evidence grouping; no + duplicate sampled-note previews or separate global changelog Sources table. + Source order is backend order, not local semver sorting or tier reranking. +- Preserve breakingSignals, migrationSignals, entry signals and separate keyword + excerpts not already present in displayed quotes. Keyword matches stay lexical + evidence, never classification. Filtered-out model quotes cannot silently hide + keyword-only evidence; that separately labelled evidence may still appear. +- Global verbose remains useful for full batch reports, security/dependency + examples and quote expansion; it is not a verbatim alias. Keep existing compact + quote bounds for every non-action bullet (240 Unicode characters), including + should-know, oversize, no-impact and unassessed in all mode. Action is full + backend-bounded text; verbose expands every tier to full backend-bounded text. + Client excerpts are marked with a verbose hint; backend truncation + is always disclosed with a JSON full-text hint. No extra fullText fetch in text. +- Coverage always shows classified/pending/missing-note versions, no-impact totals + and positive oversize/unparseable counts, including filtered/zero-item results. + Pending jobs get existing rerun guidance; missing/unparseable never implies safe. + Zero matching items with outstanding assessment is not no-impact evidence. + +### View projections and JSON migration + +| View | Changelog fields consumed | +| --- | --- | +| Compact batch text | Whole-range riskCoverage counters except itemsOmitted; source/fallback, entry totals/truncation/body availability, keyword totals/signals; no item pages or entry arrays | +| Detailed text, single or verbose batch | first100 matching statement pages + assessment coverage; needed source/entry locator metadata, keywordEntries.body and signals for actual matching excerpts; global counters; no fullText/confidence/model/formulation, ordinary raw-note bodies or duplicate sampled previews | +| JSON | Complete matching pages, full statement/context text, source positions, per-item confidence/model/formulation; retained ordinary entry and keyword metadata/bodies/signals and other upgrade blocks | +| Continuation | One package identity + matching statement page/progress/coverage; same tiers, no security, dependencies, summary, riskCoverage or entry arrays | + +Reuse ChangelogStatement/Classification/Context/Coverage schemas and normalization. +Do not assert coverage versions equal sampled entry versions: upgrade entries are +bounded by changelogLimit whereas statement coverage is the whole range. +Selected required fields must validate; omitted triage-only fields are absent, +not fabricated empty arrays. JSON always uses the detailed contract. + +JSON keeps the top-level upgrade envelope and security/dependency/entry/keyword +facts. Replace `changelog.riskItems` with `changelog.statements` using the existing +changelog collection shape: count, totalCount, matchingCount, items and per-version +coverage; classifications remain nullable, enum spelling normalized consistently. +Keep riskCoverage's whole-range tier/coverage counters but remove itemsOmitted: +it describes the deprecated50-item API, not omissions from complete pages. Do not +select both APIs or synthesize a compatibility riskItems array. Document the +breaking JSON/type-export migration and pending minor impact for both public0.x +artifacts, including the public @githits/mcp/smoke-test assertions. Hosted MCP +adoption of that helper/package requires the production statement contract first; +no remote-mcp edits are in scope. +Keep explicit-only changes in the top-level filter echo, but always include +`changelog.statements.selection: "focused" | "actionable" | "important" | "all"` +in detailed JSON. This output-only metadata names the effective tier selection; +focused means action/should-know/oversize, excluding no-impact/unassessed. All +means every parsed unit, including null classification. Detailed text always names +the effective view and its exclusions, even when the caller omitted changes. +Do not drive default disclosure solely from explicitFilterFields. + +### Consistency and failure behavior + +Reuse the existing pure page-progress/order/count/field validation and collector, +with thin host-specific query loops. Max100 per page, monotonic next offsets, +counts/hasMore/nextOffset agreement, version membership and position order are +mandatory. Continuations run sequentially in original review order, one page at +a time per review; no concurrency pool, fanout or deduplicating cache. Explicit +all on large batches can require many requests and may hit caller tool timeouts; +there is no promised fixed latency. The3–6.6s helper observations include repeated +CLI authentication and cannot be called per-page product latency. Record the +built CLI walltime for the same single Biome all case after implementation, +alongside actual request counts and auth/runtime-inclusive limitations. Source-position gaps are valid after suppression +or filters; positions +are contiguous per version only for an unfiltered complete collection. + +Upgrade-review must compare complete assessment coverage between pages even in +all mode: its header/triage counters come from the initial aggregate snapshot. +This prevents returning initial totals beside later, newly classified quotes. +Standalone changelog's all mode remains tolerant of arriving labels because it +derives its own final assessment coverage; do not change that behavior. The shared +collector need not enforce one universal loop policy. On an observed count/coverage +change, use the existing retryable changed-evidence error and explicit rerun hint; +do not return a partial "complete" collection or add automatic restarts/polling. +Complete collected all-tier counts must agree with the initial global counters; +for filtered views check the selected tiers' totals/confident/ambiguous counts. +A final collected-versus-initial counter disagreement without observed page +change is a malformed backend response (the existing protocol error), not the +retryable changed-evidence error. Contiguity refers to retained position; +sourcePosition can have gaps after suppression even without a tier filter. + +Compare continuations with the initial returned review identity, preserving +canonical Go versions and duplicates in batch input. Unexpected review membership, +malformed pages or missing selected fields produce existing protocol errors; +HTTP/auth/transient/cancellation mapping stays in the established layers. Preserve +`classification:null`, ambiguous:false and numeric zero. The API is current evidence, +not an immutable source snapshot; same-count/same-coverage source edits cannot be +proved unchanged. Do not invent a digest, lock or snapshot mechanism. With more than100 units, +all on a cold range may need a rerun until background answers settle, because +assessment changes abort the collection. Document this bounded trade-off with +the existing pending-classification guidance, not as a safety verdict. + +## Phase map + +**Phase1 — complete, filtered upgrade evidence reaches both hosts without cap or +unused quote fetching (READY).** Expected outcome: default focused +reviews and explicit filters return all selected quotes; compact batches stay +small, counts/coverage stay honest and old security/keyword behavior works. +Assumptions: approved defaults/projections above; dev contract and counters verified. +Unknowns/product decisions: none for implementation. Dependencies: currentmain, +existing changelog primitives and deployeddev schema. Acceptance: overall criteria, +mode wire tests, fixtures/live checks below, clean review/greenCI and permanent docs. + +One phase is sufficient: no independent data-only phase or preparatory abstraction +PR. If implementing the narrowly shared helpers starts adding machinery or exceeds +1.5–2k implementation lines, stop and reassess the smallest boundary correction. +After this increment merges, use $next-steps against currentorigin/main before any +subsequent work; no speculative later phase is planned here. + +## Phase1 implementation detail + +Likely files verified by exploration: + +- `packages/core-internal/src/services/package-intelligence-service.ts`, its tests + and `package-changelog-statements.test.ts`: extract existing statement primitives + into a small core helper only where both callers actually share them; add + triage/text/JSON initial projections and statement-only continuation. Update + service interfaces/validators, including current test factories/exports consumed + through packages/mcp/client without leaking private aliases. +- `packages/mcp/src/shared/package-upgrade-review-request.ts` and tests: shared + changes normalization and focused default, effective view options. Reuse/extract + the existing changelog changes normalizer without copying package addressing. +- `packages/mcp/src/shared/package-upgrade-review-response.ts` and tests, + package-changelog-response.ts and tests: JSON migration and narrow shared + statement presentation primitives (kind annotation, nested text, context/source + groups). Update `packages/mcp/src/shared/fixtures/upgrade-review-express-changelog.json`, + which the upgrade formatter tests import and which still contains riskItems. + Retire obsolete helpers in `packages/mcp/src/shared/changelog-risk-presentation.ts` + while preserving its used kind labels, lexical text/sanitation and pending message. Each tool retains its own surrounding formatter. Keep changelog output + unchanged, including its all default and raw-note/timeline flag rules. +- `src/commands/pkg/upgrade-review.ts`, `packages/mcp/src/tools/package-upgrade-review.ts` + and their tests: new changes option and view-aware service params; preserve tool + discovery's first sentence, negative transitive opt-out and read-only annotations. +- `src/tools/package-upgrade-review-parity.test.ts`, shared service mock factories, + `packages/mcp/src/smoke-test.ts` / its tests (public @githits/mcp/smoke-test), + `scripts/cli-smoke.ts` / smoke-script tests, relevant MCP server/catalog cases + and packed public consumer tests: update canonical + contract and assert no accidental host/provider divergence. +- Permanent docs `docs/implementation/pkg-upgrade-review.md`, cli-commands.md, + pkg-changelog.md for shared ownership notes, tools.md input table; backlog correction; one new + changes/ fragment with pendingminor for githits and @githits/mcp. + +Ordered work: + +1. Red tests for current50-cap loss, selected filters, compact-batch no quote + selections and continuation avoiding repeated aggregate evidence. +2. Extract only existing shared pure schemas/normalization/page checks. Introduce + mode-specific query/response projections, collect needed pages sequentially, validate raw + coverage/identity and global-counter agreement. Remove riskItems selection and + legacy omission consumption. Delete verified-unused risk-item types/schemas, + normalizers/tier-section helpers and includeChangelogFullText options after + their consumers migrate; move the shared kind enum to neutral statement-owned + typing/schema first. Retain used coverage/keyword/sanitation/pending primitives + and document removed public type exports with the JSON migration. In particular, + retire ChangelogRiskItem/PackageUpgradeChangelogRiskItem and their risk-item + schemas, UpgradeChangelogRiskItem/old version-item types, ClassifiedChangelogItem, + normaliseChangelogRiskItems/changelogRiskSections, unused risk summary helpers + and isUncertainRiskItem; retain the neutral kind enum and used coverage types. + Retain keywordEntries.body in detailed text and JSON ordinary-entry facts. +3. Add shared input filter normalization, host view propagation and parity. +4. Render the approved Changes bullets/context/nearby sources with existing + surrounding upgrade groups and lexical evidence. Keep shared standalone + changelog behavior stable. Update JSON consumers/smoke fixtures and docs. +5. Execute verification, review/closure and final documentation transfer. Keep + this plan available through implementation review; delete in the final clean + implementation PR commit, not in a post-merge follow-up. Commit/push/open own + PR and runCI, but never merge/publish/deploy without owner approval. + +Tests and verification: + +- Core wire fixtures for triage versus detailed text versus JSON, first-page + field projections/variables,100-unit pages and one-package continuations; assert + no deprecated riskItems, no ordinary body/provenance over-fetch in text; retain keywordEntries.body for a verified keyword hit beyond its preview, no statements or + entry arrays in compact triage, no security/deps metadata in continuations. +- Coverage/collection fixtures:51+ focused items,101+ all, exactly100 and empty, + selected version with zero units, missing/unparseable notes, all pending, + mixed-null classifications, oversize units/truncated statement and context, + source-suppression position gaps, mixed formulations, invalid progress/identity, + arriving labels or source/count changes between pages and counter disagreements. + Upgrade all/filtered rejects changed assessment; standalone changelog all still + accepts label arrival and derives collected coverage. Confidence never controls UI. +- Shared formatter/CLI/MCP/parity tests: every filter and blank/invalid input, always-present effective selection in detailed JSON/text (focused/important/all), + all's plain no-impact/null tags, bracketed known/kindless tiers and uncertainty, + keyword-only evidence and quote overlap, version/source URLs outside sampled + entries, long links/nested code/Unicode/terminal control sanitation, width/color + equivalence, JSON full provenance, summary ranking unaffected by filters, and + original security/dependency/30-package/batch-duplicate behavior. +- Run `bun test` on affected core service/statement, shared request/response, + CLI/tool/parity/catalog/smoke contract files; `bun run typecheck`, `bun run build`, + changed-fileBiome and `bun run validate:packages`. Run source CLI/MCP smokes; + use their credential-free modes if the documented isolatedHOME keychain + limitation persists, plus normal-auth affected dev checks. Built smokes are + required if smoke launch/CI validation changes; no credential export workaround. +- Reuse the exact three planning ranges for built CLI and localstdio MCP text/JSON + parity: focused default, actionable, important, all; single and batch/verbose. + Biome focused must exceed50 with no omissions; all must retrieve875/current + backend total over9/current required pages. Counts are observations, not immutable + fixtures; validate totals/provenance/coverage rather than hardcode stored labels. +- Remeasure maximum JSON initial query at1/3/30 packages, compact30 and JSON + continuation against a separately measured rejection control: all under500. + Repeat the same three-package raw projection comparison using identical enabled + optional evidence sections and compact serialization; report bytes/page counts + and auth/runtime-inclusive timings without a rendering speedup claim. No broad + package sweep or repeated tests without changes/failures. +- Targeted descriptor-intent `package-upgrade-safety` workload with local MCP on + dev using Codex; inspect tool-calls/final/metrics/isolation, report absence of + actual GitHits calls or lack of grading honestly. Discovery first-sentence/80 + contracts and packed MCP consumers remain intact; no broad instruction change. + +## Reorientation, completion and cleanup + +Planning review must judge direction before implementation guesses. Internal +code_reviewer then one Opus plan reviewer; adjudicate under agent-feedback and +reround only for material corrections. Plan review is its own scope and its +reviewer closes after clean settlement. Implementation starts a fresh code loop. + +Phase completion requires all acceptance criteria verified, no unresolved findings, +implementation PR prepared with actual evidence and greenCI. Transfer contracts, +query ownership, consistency rules, JSON migration, operational limits and release +skill-staging notes to permanent documentation, then delete this temporary plan +inside the final clean implementation PR commit. Retain code reviewer until owner +confirms merge. No new deferred work is authorized: close resolved backlog parts, +preserve supersession/applicability limits without expanding this increment. + +## Plan review record + +Internal technical review: direction sound. Accepted keyword-body finding; retained +keywordEntries.body for detailed text, omitted ordinary raw bodies, added a +beyond-preview wire/render check. Bounded projection/step/test/docs scan and full +recheck found no further issues. + +External Opus round1: direction sound; five findings accepted within scope: + +1. Implicit focused filtering was not observable -> add always-present effective + statements.selection metadata and detailed header/exclusion disclosure; test + focused/important/all regardless of explicit input echo. +2. Old risk-item plumbing would become dead -> remove verified obsolete types, + schemas/options/helpers after migrating consumers; move shared kind typing first, + preserve used keyword/sanitation/coverage helpers and document public exports. +3. Public smoke/helper and tool-table surfaces were implicit -> name MCP smoke-test, + CLI smoke and tools.md, with hosted production-contract adoption requirement. +4. Continuation scheduling/latency was implicit -> sequential review order, + actual same-case built-CLI all timing and request counts, operational timeout + limits and cold-all rerun behavior. Reject only treating auth-inclusive helper + timing as transport/product latency; the supplied evidence does not isolate it. + Send changelogLimit1 explicitly (existing0 is clamped to1). +5. Extra all-tier quote limits were unspecified -> reuse240-character compact + bounds for every non-action bullet; verbose expands bounded text for every tier, + with client/backend truncation markers and stable source/full-text locators. + +No new phases, infrastructure, domain verdicts or backend requirements. Re-read +complete revised plan and directly related body/filter/counter/projection/tests/ +public-smoke/documentation paths for contradictions. Material changes require the +internal closure pass and another plan round before READY. + +External round2: clean after minor wording corrections, including its one +fresh-context final check. Named the imported Express fixture and the shared risk +presentation helper file; clarified that unobserved final counter disagreement +is malformed protocol evidence while observed page changes are retryable; clarified +retained position versus sourcePosition gaps. Internal closure review was clean. +Minimum changelogLimit1 continuation cost was independently confirmed88. All +review notes are adjudicated, none remain open. No production changes were made. +This plan is READY for its single implementation increment. From 5558cde53baaff1f390332c1e7ff3926385f9aa8 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Wed, 14 Oct 2026 16:13:09 +0300 Subject: [PATCH 2/5] feat: collect complete upgrade review statements Replace capped risk items with source-ordered statements and shared tier filters. Fetch mode-specific projections, retain whole-range triage counters and keyword evidence, and document the JSON migration with CLI/MCP parity coverage. --- ...rade-review-complete-statements.changed.md | 6 + docs/implementation/cli-commands.md | 99 +- docs/implementation/pkg-changelog.md | 4 +- docs/implementation/pkg-upgrade-review.md | 1281 ++++--------- docs/implementation/tools.md | 63 +- docs/plans/open-backlog.md | 18 +- docs/plans/upgrade-review-statements.md | 104 +- .../package-intelligence-service.test.ts | 477 ----- .../services/package-intelligence-service.ts | 624 ++++--- .../package-upgrade-review-statements.test.ts | 518 +++++ packages/mcp/src/services/test-helpers.ts | 8 +- packages/mcp/src/shared/changelog-changes.ts | 16 + .../src/shared/changelog-risk-presentation.ts | 56 - .../upgrade-review-express-changelog.json | 693 ++++--- .../shared/package-changelog-request.test.ts | 10 +- .../src/shared/package-changelog-request.ts | 21 +- .../src/shared/package-changelog-response.ts | 231 ++- .../package-upgrade-review-request.test.ts | 61 + .../shared/package-upgrade-review-request.ts | 34 +- .../package-upgrade-review-response.test.ts | 1664 ++++------------- .../shared/package-upgrade-review-response.ts | 479 ++--- packages/mcp/src/smoke-test.test.ts | 78 +- packages/mcp/src/smoke-test.ts | 75 +- .../src/tools/package-upgrade-review.test.ts | 154 ++ .../mcp/src/tools/package-upgrade-review.ts | 20 +- scripts/cli-smoke.ts | 75 +- src/commands/pkg/upgrade-review.test.ts | 89 + src/commands/pkg/upgrade-review.ts | 13 +- src/services/test-helpers.ts | 8 +- .../package-upgrade-review-parity.test.ts | 500 ++--- 30 files changed, 3305 insertions(+), 4174 deletions(-) create mode 100644 changes/upgrade-review-complete-statements.changed.md create mode 100644 packages/core-internal/src/services/package-upgrade-review-statements.test.ts create mode 100644 packages/mcp/src/shared/changelog-changes.ts 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..ec254437 100644 --- a/docs/implementation/pkg-upgrade-review.md +++ b/docs/implementation/pkg-upgrade-review.md @@ -1,983 +1,344 @@ # 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 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. 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. + +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 2255375b..0a3635f7 100644 --- a/docs/plans/open-backlog.md +++ b/docs/plans/open-backlog.md @@ -34,10 +34,24 @@ Single-run token changes and self-reported confidence are not quality scores. The new aggregate `changelog.statements` contract exposes per-version coverage membership and complete pages beyond the legacy risk-item cap. Client adoption -is covered by the [upgrade statement migration plan](upgrade-review-statements.md). +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#known-output-limitations). +[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/docs/plans/upgrade-review-statements.md b/docs/plans/upgrade-review-statements.md index 85039bee..956a21b4 100644 --- a/docs/plans/upgrade-review-statements.md +++ b/docs/plans/upgrade-review-statements.md @@ -2,7 +2,7 @@ ## Status and destination -Overall: **READY**. One implementation increment; no production changes +Overall: **IMPLEMENTING**. One implementation increment; no production changes are made during planning. Branch `jlitola/upgrade-review-statements`, planning base `acd717488569485bf4af68bc09dab6c0ef2be7e4` (origin/main, merged PR476). @@ -272,7 +272,7 @@ the existing pending-classification guidance, not as a safety verdict. ## Phase map **Phase1 — complete, filtered upgrade evidence reaches both hosts without cap or -unused quote fetching (READY).** Expected outcome: default focused +unused quote fetching (IMPLEMENTING).** Expected outcome: default focused reviews and explicit filters return all selected quotes; compact batches stay small, counts/coverage stay honest and old security/keyword behavior works. Assumptions: approved defaults/projections above; dev contract and counters verified. @@ -439,4 +439,102 @@ is malformed protocol evidence while observed page changes are retryable; clarif retained position versus sourcePosition gaps. Internal closure review was clean. Minimum changelogLimit1 continuation cost was independently confirmed88. All review notes are adjudicated, none remain open. No production changes were made. -This plan is READY for its single implementation increment. +The planning gate was READY; its single implementation increment is now underway. + +## Orchestration ledger + +Actual sandbox: danger-full-access, approval never; workers inherit it. One callable +luna_implementor, sequential bounded dispatches; coordinator owns judgment, all +network evidence, full verification, commits and reviews. Currentmain stillacd7174. + +Sequence before first dispatch (re-slice at each verified return): +1. Worker: shared changes parsing and upgrade request filter mapping only; prove + named default/blank/actionable/important/all/invalid request tests. +2. Coordinator: mode-specific core queries/schemas and complete sequential + collection/counter consistency; ordering/attribution judgment stays inline. +3. Coordinator: shared statement renderer, upgrade JSON/Changes migration and + keyword preservation; evidence-contract judgment stays inline. +4. Worker: CLI changes/view wiring only against settled service contract; prove + named command-param tests. +5. Worker: MCP changes/view wiring only, same contract; prove named tool-param tests. +6. Worker: mechanical migration of canonical fixtures/test factories and existing + smoke assertions to the settled statement shape, separate artifact dispatches. +7. Worker: settled docs/release-fragment updates, separate documentation concern; + coordinator checks assertions against final evidence. +8. Coordinator: affected regressions, live dev/parity/complexity/built timing/eval, + one fresh luna_reviewer preflight, own full review, internal code_reviewer and + external Opus loop, PR/CI and final-plan retirement after clean review. + +Workers return verified uncommitted changes at each checkpoint; never commit/push +or edit outside the exhaustive slice list. Interrupts are normal. No broad extra +validation/platform/infrastructure invented by workers. No coordinator tests/builds +while a worker is active. Rewrite the next slice if scope or evidence changes. + +### Implementation checkpoint (2026-10-14) + +Core complete collection/projections and shared JSON/source bullet rendering are +implemented. Initial batches remain unsplit, continuations sequential; all mode +compares assessment coverage, and stable counter disagreement is malformed. +Standalone all still tolerates label arrival. Legacy risk item schemas/types and +presentation helpers are removed; the kind schema is neutral before that removal. +Keyword bodies, unversioned/aggregate matches, 240-character compact bounds and +same-source quote suppression remain functional. No backend changes were needed. + +Luna dispatches: shared parser/filter mapping; CLI wiring; MCP wiring; MCP proof +wording correction; readonly table correction; union correction; default fixtures; +public smoke contract; reference docs; release fragment (10 total). Coordinator +owns canonical fixture migration, pagination/renderer semantics/tests and durable +implementation doc. Runtime proofs passed, but host table fixes still failed +TypeScript; coordinator explicitly took over those two tables and resolved them. +The documentation proof guessed an absent row prefix; coordinator ran and passed +the explicit two-section wording check. These were evidence-contract/slicing costs, +not product decisions. No interrupts, worker commits, scope fanouts or extra +platform validation. Final worker edits are accepted after diff/proof inspection. + +Final query complexity: JSON1/3/30=330/340/475; all30=475; triage30=391; +JSON continuation=88. Independently calibrated control5100. Exact requested dev +URLs were used for final built CLI/local MCP parity; the repository's dev preset +now routes code navigation to oss-dev.githits.dev. Initial preset smoke parity +also passed, but explicit endpoint evidence is authoritative for this task. + +Coverage remains the planning counts: Express141/15 (0+8 act,6+1 know,126 no +impact,4 classified); Express57/14 (3+4 act,7 know,43 no impact,4 classified); +Biome875/62 (13+27 act,12+10 know,813 no impact,13 classified). All have zero +pending/missing/unparseable versions. No cold claim. Biome all875 collected in +9 observed requests and13.683s built Node walltime including auth/startup/observer. +Raw same-case projection bytes: baseline443505, detailed467275, triage16202. + +262 affected tests initially passed; expanded regression run618 passed with one +source launch timeout. A diagnostic and an unchanged default-timeout rerun passed +that exact case; no timeout or product change was made. Core final32 and renderer +final26 passed after edge-case and keyword checks. Typecheck/public-package +validation and four source/built smoke modes passed before the last keyword fix; +final affected checks/build/smokes are pending below. + +Agent workload: first preset run timed out302s after starting pkg_upgrade_review, +no final, cause unproven. Exact arguments succeeded directly on Fly dev via built +Node/source Bun MCP. Correct-endpoint descriptor/intent rerun succeeded177.3s, +9 logical calls including2 completed upgrade reviews; final/metrics/trace inspected, +no isolation violations recorded. One missing-release follow-up failed and research +was unfinished in the trace. No grading ran; no quality claim. Permanent facts are +in docs/implementation/pkg-upgrade-review.md. Final preflight/internal/external +reviews, stable commits, PR/CI and plan retirement remain. + +Final local gates: public package validation (both builds/packed consumers) passed; +624 affected/regression tests and2858 assertions passed; all four source/built +CLI/MCP smoke modes passed; changed TypeScript files pass Biome with no warnings. +The final typecheck found one test-only indexed access after wire-fixture types +were tightened; corrected it and the canonical-Go input fixture, then32 core cases +and typecheck passed. No product fallback/timeout change. Implementation churn is +1628 changed lines (tests/docs excluded), below the agreed2k stop threshold. +The exact release fragment has pending minor impacts for both artifacts. + +Preflight direction is sound and acceptance evidence meets the plan. Its three +minor documentation findings are closed: sibling changelog ownership notes no +longer claim upgrade review is unchanged, and the JSON example marks fullText, +sourcePosition and classification provenance as required where JSON requires +them. Bounded scan covered the changelog/upgrade implementation and tool/CLI +references. Internal correctness review, external review and PR/CI remain. + +Internal code_reviewer full-delta pass: direction sound, no findings. Production +rollout remains a public-release prerequisite. External Opus review follows. 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..53c7064d --- /dev/null +++ b/packages/core-internal/src/services/package-upgrade-review-statements.test.ts @@ -0,0 +1,518 @@ +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", () => { + 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..7b7122ee 100644 --- a/packages/mcp/src/shared/package-upgrade-review-request.test.ts +++ b/packages/mcp/src/shared/package-upgrade-review-request.test.ts @@ -16,6 +16,67 @@ function batchPackage(index: number): UpgradeReviewPackageInput { } describe("buildPackageUpgradeReviewRequest", () => { + 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..42cc0cab 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,11 @@ export interface PackageUpgradeReviewRequestInput { includeTransitiveSecurity?: boolean; includeDependencyIssues?: boolean; minSeverity?: string; + changes?: string; } +export type UpgradeChangelogSelection = ChangelogChangesView | "focused"; + export interface UpgradeReviewPackageRequest { registry: PkgseerRegistry; registryLabel: string; @@ -44,7 +54,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 +108,27 @@ 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; return { packages, options: { + 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..6cc50f65 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,361 @@ 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]), - ); - 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]), + formatterResponse([statementReview([item])]), ); - 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); - }); - - 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]" }), - ]); - review.changelog.keywordEntries = [ - { - version: "4.4.2", - detailSource: "changelog_file", - signals: ["removed"], - body: "Removed API; see [migration guide][1]. Footnote [3].", - }, - ]; - 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).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); + 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("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("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 something. ${"context ".repeat(50)}See https://example.com/late-keyword.`, + version: null, + body: `* Breaking ${"x".repeat(280)}END`, + signals: ["breaking"], + htmlUrl: "https://example.com/unversioned", }, ]; 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(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(text).not.toContain(" Notes:"); - expect(JSON.stringify(review)).toBe(before); }); - - 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 response = formatterResponse([statementReview([item])]); + const text = formatPackageUpgradeReviewTerminal(response); + 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), }); - expect(verbose).toContain('"Removed other API."'); - expect(verbose).toContain("Security"); + 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(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..3903eaa5 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,29 @@ export async function buildPackageUpgradeReview( currentVersion: pkg.currentVersion, targetVersion: pkg.targetVersion, })), - includeChangelogFullText: options.includeChangelogFullText === true, + changelogView: options.changelogView ?? "text", + statementTiers: + options.statementTiers ?? + (options.changesSelection === "all" + ? undefined + : ["MUST_ACT", "SHOULD_KNOW", "UNCLASSIFIED"]), includeTransitiveSecurity: options.includeTransitiveSecurity, includeDependencyIssues: options.includeDependencyIssues, changelogLimit: options.changelogLimit, minSeverity: options.minSeverity, }); - return normaliseBackendUpgradeReviewResponse(response); + const result = normaliseBackendUpgradeReviewResponse( + response, + options.changesSelection ?? "focused", + ); + if (options.explicitChanges) + result.filter = { changes: options.explicitChanges }; + return result; } function normaliseBackendUpgradeReviewResponse( response: BackendPackageUpgradeReviewResponse, + selection: UpgradeChangelogSelection, ): UpgradeReviewResponse { return { summary: response.summary, @@ -221,7 +235,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 +398,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 +430,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 +515,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 +910,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 +973,147 @@ 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, + compactNonAction: !options.verbose, + }), ); - 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, " "); - } - } - } - 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 && metadata?.htmlUrl) + appendWrappedText(lines, " ", metadata.htmlUrl, width, " "); + 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) { + if (keyword.url) + appendWrappedText(lines, " ", keyword.url, width, " "); + const characters = Array.from(keyword.text); + const excerpted = !options.verbose && characters.length > 240; + const text = excerpted + ? `${characters.slice(0, 240).join("").trimEnd()}...` + : keyword.text; appendWrappedText( lines, - " * ", - `${quote ? `"${quote}"` : "No statement text returned."} ${source}`, + " * ", + `[${keyword.signals.join(", ")}] ${text || "Entry keyword matched; excerpt unavailable."}`, width, - " ", + " ", + (line) => + colorizeSignalKeywords( + line, + keyword.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.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 +1173,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 +1186,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..78a81ff8 100644 --- a/packages/mcp/src/tools/package-upgrade-review.test.ts +++ b/packages/mcp/src/tools/package-upgrade-review.test.ts @@ -22,6 +22,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 +43,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..9f023df9 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 + UNCLASSIFIED). 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,13 +157,20 @@ export function createPackageUpgradeReviewTool( includeTransitiveSecurity: args.skip_transitive_security !== true, includeDependencyIssues: args.include_dependency_issues, minSeverity: args.min_severity, + changes: args.changes, }); + const changelogView = + args.format === "json" + ? "json" + : request.packages.length > 1 && args.verbose !== true + ? "triage" + : "text"; const response = await buildPackageUpgradeReview( service, request.packages, { ...request.options, - includeChangelogFullText: args.format === "json", + changelogView, }, ); if (args.format === "json") return textResult(JSON.stringify(response)); 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..b9359d38 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,17 @@ export async function pkgUpgradeReviewAction( includeTransitiveSecurity: options.transitiveSecurity, includeDependencyIssues: options.dependencyIssues, minSeverity: options.minSeverity, + changes: options.changes, }); + const changelogView = options.json + ? "json" + : request.packages.length > 1 && options.verbose !== true + ? "triage" + : "text"; const response = await buildPackageUpgradeReview( deps.packageIntelligenceService, request.packages, - { ...request.options, includeChangelogFullText: options.json === true }, + { ...request.options, changelogView }, ); if (options.json) { @@ -283,6 +290,10 @@ export function registerPkgUpgradeReviewCommand(pkgCommand: Command): Command { "--min-severity