deps_cli/update/mod.rs
1//! `deps-cli update`: default-mode planning and plan application.
2//!
3//! Also hosts the shared plan/outcome types both planners (this module's [`plan_updates`]
4//! and [`security`]'s `plan_security_updates`) produce (spec 068, #1329).
5
6pub mod ignore;
7pub mod security;
8
9use deps_core::ConcreteVersion;
10use deps_core::PackageName;
11use deps_core::VersionReq;
12use deps_core::edit::{
13 EditSpan, ManifestEdit, ManifestReparse, UnplannableReason, UpdateCandidate, UpdateKind,
14 apply_edits, classify_update, collect_update_candidates, dedup_overlapping_edits,
15};
16use deps_core::lsp_helpers::{
17 CooldownDisposition, EcosystemFormatter, FallbackEditVerdict, LatestVerdict, PackageVersions,
18 cooldown_disposition, fallback_edit_excludes_newer, latest_verdict,
19};
20use std::collections::HashMap;
21
22use crate::analyze::ManifestAnalysis;
23use ignore::IgnoreRules;
24
25/// A dependency's "current" version for `deps-cli update`'s report.
26///
27/// Replaces the historical three-way `String`/`""` convention (issue #1593) with an
28/// exhaustive state, so an unresolved current version can no longer be silently confused with
29/// a resolved empty-string one.
30///
31/// # Examples
32///
33/// ```
34/// use deps_cli::update::CurrentVersion;
35/// use deps_core::ConcreteVersion;
36/// use deps_core::edit::UpdateKind;
37///
38/// let current = CurrentVersion::Resolved(ConcreteVersion::from("1.0.0"));
39/// assert_eq!(
40/// current.update_kind_to(&ConcreteVersion::from("2.0.0")),
41/// UpdateKind::Major
42/// );
43/// assert_eq!(
44/// CurrentVersion::Unknown.update_kind_to(&ConcreteVersion::from("2.0.0")),
45/// UpdateKind::Unknown
46/// );
47/// ```
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum CurrentVersion {
50 /// A lockfile/registry-resolved in-use version.
51 Resolved(ConcreteVersion),
52 /// No in-use version could be resolved; falls back to the dependency's declared
53 /// requirement text (`--security-only` mode only — see `security`'s `security_current`).
54 Declared(VersionReq),
55 /// Neither a resolved in-use version nor a declared requirement was available.
56 Unknown,
57}
58
59impl From<Option<ConcreteVersion>> for CurrentVersion {
60 /// `Some` maps to [`Self::Resolved`], `None` to [`Self::Unknown`] — the default planner's
61 /// only two possible states (it never produces [`Self::Declared`]; only `--security-only`
62 /// does, via `security_current`).
63 fn from(value: Option<ConcreteVersion>) -> Self {
64 match value {
65 Some(version) => Self::Resolved(version),
66 None => Self::Unknown,
67 }
68 }
69}
70
71impl CurrentVersion {
72 /// Classifies the update from this current version to `target`.
73 ///
74 /// [`Self::Declared`] and [`Self::Unknown`] both classify as [`UpdateKind::Unknown`] —
75 /// behavior-identical to pre-#1593, though for two different reasons per variant: the
76 /// default planner (the only caller that ever classifies `current`) never produced
77 /// [`Self::Declared`] to begin with, and `--security-only` (the only caller that could)
78 /// never called [`classify_update`] on `current` at all before this refactor. Neither
79 /// caller's classification behavior changes; this method exists so a *future* caller
80 /// cannot classify a [`Self::Declared`] requirement range as if it were a resolved version.
81 ///
82 /// # Examples
83 ///
84 /// ```
85 /// use deps_cli::update::CurrentVersion;
86 /// use deps_core::{ConcreteVersion, VersionReq};
87 /// use deps_core::edit::UpdateKind;
88 ///
89 /// let declared = CurrentVersion::Declared(VersionReq::new("^1.0"));
90 /// assert_eq!(
91 /// declared.update_kind_to(&ConcreteVersion::from("2.0.0")),
92 /// UpdateKind::Unknown
93 /// );
94 /// ```
95 #[must_use]
96 pub fn update_kind_to(&self, target: &ConcreteVersion) -> UpdateKind {
97 match self {
98 Self::Resolved(version) => classify_update(version.as_str(), target.as_str()),
99 Self::Declared(_) | Self::Unknown => UpdateKind::Unknown,
100 }
101 }
102
103 /// Renders this version for the `--format json`/`table` wire boundary — [`Self::Unknown`]
104 /// renders as an empty string, matching this field's pre-#1593 `""` convention.
105 ///
106 /// Deliberately not [`std::fmt::Display`] (issue #1593 critic M3): a `Display` impl invites
107 /// a caller to reach for `.to_string().is_empty()` as an `Unknown` check, reintroducing the
108 /// same stringly-typed comparison this type exists to remove. This method is the one
109 /// sanctioned render boundary, used by `format::json`/`format::table` only.
110 pub(crate) fn render_text(&self) -> String {
111 match self {
112 Self::Resolved(version) => version.to_string(),
113 Self::Declared(req) => req.as_str().to_string(),
114 Self::Unknown => String::new(),
115 }
116 }
117}
118
119/// One completed `update` run's per-dependency plan.
120#[derive(Debug, Clone, Default)]
121pub struct UpdatePlan {
122 /// One entry per candidate dependency this planner considered.
123 pub items: Vec<PlannedUpdateItem>,
124}
125
126impl UpdatePlan {
127 /// Every item's [`ManifestEdit`], for items whose [`Outcome`] is [`Outcome::Applied`] —
128 /// what [`apply_plan`] writes.
129 fn applied_edits(&self) -> Vec<ManifestEdit> {
130 self.items
131 .iter()
132 .filter_map(|item| match &item.outcome {
133 Outcome::Applied { edit, .. } => Some(edit.clone()),
134 _ => None,
135 })
136 .collect()
137 }
138}
139
140/// One dependency's disposition in an [`UpdatePlan`].
141#[derive(Debug, Clone)]
142pub struct PlannedUpdateItem {
143 /// The dependency's declared (raw) name.
144 pub name: String,
145 /// The version this dependency is currently pinned to, its declared requirement text when
146 /// no concrete in-use version could be resolved, or [`CurrentVersion::Unknown`] when
147 /// neither is available.
148 pub current: CurrentVersion,
149 /// This item's disposition — the target version considered (even when no edit was
150 /// written) lives inside [`Self::outcome`]'s own variant (#1615: folding it into `Outcome`
151 /// makes a target only representable where the variant actually carries one, rather than
152 /// as a second, independently settable item-level field). Read it via [`Self::target`].
153 pub outcome: Outcome,
154 /// OSV advisory ids this item resolves. Populated in `--security-only` mode, and also in
155 /// default mode for a cooldown-fallback decision that names a `Flagged` `latest`/fallback
156 /// verdict (spec 075 FR-011/FR-012) — empty for an `Unverified` verdict, which carries no
157 /// advisory list to report.
158 pub advisory_ids: Vec<String>,
159 /// Whether a matching `[update].ignore` rule exists but was overridden (FR-008,
160 /// `--security-only` mode only — the rule never applies in default mode, since a match
161 /// there is reported via [`Outcome::Skipped`] with reason [`SkipReason::IgnoreRule`]
162 /// instead).
163 pub ignore_rule_overridden: bool,
164 /// A newer version excluded from this item's [`Self::target`] by an active GOSSIP cooldown
165 /// finding (issue #1521 item 1) — mirrors `check`'s identical
166 /// [`deps_core::lsp_helpers::PackageVersions::gossip_excluded_version`] attribution
167 /// (`crate::report::to_finding`). Populated only for [`Outcome::Applied`] items in default
168 /// mode; always `None` under `--security-only`, whose fix target never reads a
169 /// GOSSIP-filtered `latest` at all.
170 pub gossip_excluded_version: Option<deps_core::ConcreteVersion>,
171 /// Spec 075 FR-013: attribution for a cooldown-fallback decision — one field so
172 /// [`CooldownFallbackNote::AppliedInsteadOf`] and [`CooldownFallbackNote::Blocked`] can
173 /// never both be set for the same item. `None` when no fallback candidate was ever
174 /// consulted for this occurrence (`CooldownDisposition::NotEvaluated`/`Cleared`, or
175 /// `Blocked { fallback: None }`).
176 pub cooldown_fallback: Option<CooldownFallbackNote>,
177}
178
179/// Why [`PlannedUpdateItem::cooldown_fallback`] is set (spec 075 FR-013) — mirrors
180/// [`PlannedUpdateItem::gossip_excluded_version`]'s existing attribution-field shape.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum CooldownFallbackNote {
183 /// The fallback candidate was written; names the fresher, cooldown-blocked `latest` this
184 /// occurrence targeted instead.
185 AppliedInsteadOf(deps_core::ConcreteVersion),
186 /// A fallback candidate existed but was itself OSV-`Flagged`/`Unverified` (FR-011) and was
187 /// never written; names the blocked fallback version.
188 Blocked {
189 /// The blocked fallback candidate's version.
190 version: deps_core::ConcreteVersion,
191 },
192}
193
194/// A dependency's disposition within an [`UpdatePlan`].
195///
196/// # The invalid state this makes unrepresentable (#1349, #1615)
197///
198/// Before this type carried [`ManifestEdit`] directly, [`PlannedUpdateItem`] stored `outcome`
199/// and `edit: Option<ManifestEdit>` as two independent fields the caller had to keep in sync by
200/// convention (#1349) — that combination made `apply_plan` report success (`Ok(())`) without
201/// writing anything. `edit` was folded into [`Self::Applied`] to close that gap, but the
202/// dependency's target version stayed a second, independently settable field on
203/// [`PlannedUpdateItem`] itself, which reopened the same class of bug: an `Applied` item with no
204/// target. #1615 folds the target into each variant that actually carries one instead, so
205/// [`Self::Applied`] with no target fails to compile:
206///
207/// ```compile_fail
208/// use deps_cli::update::Outcome;
209/// use deps_core::edit::ManifestEdit;
210/// use deps_core::position::{Position, Range};
211///
212/// let outcome = Outcome::Applied {
213/// edit: ManifestEdit {
214/// range: Range::new(Position::new(0, 9), Position::new(0, 14)),
215/// new_text: "1.2.0".to_string(),
216/// },
217/// };
218/// ```
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub enum Outcome {
221 /// A fix plan existed and its edit was written (or would be, under `--dry-run`).
222 /// Contributes to exit 0. Carries the edit and its target version — #1349/#1615: this
223 /// makes "applied, but no edit/target to write" unrepresentable, where separate
224 /// `PlannedUpdateItem` fields previously let them drift out of sync (`apply_plan` would
225 /// report success without writing anything).
226 Applied {
227 /// The edit [`apply_plan`] writes.
228 edit: ManifestEdit,
229 /// The version this edit moves the dependency to.
230 target: ConcreteVersion,
231 },
232 /// Excluded from this run, for [`SkipReason`].
233 Skipped {
234 /// Why this candidate was skipped.
235 reason: SkipReason,
236 /// The target version considered for this occurrence, when one was known — `None` when
237 /// no concrete target exists (an unplannable latest, an ignore-rule skip of an
238 /// OSV-blocked fallback, or — under `--security-only` — a not-requested item).
239 target: Option<ConcreteVersion>,
240 },
241 /// (`--security-only` only) The dependency is `Vulnerable`, but its declared requirement
242 /// already admits the fix target, so no requirement-level edit exists — needs #1116.
243 /// Contributes to exit 1.
244 RequiresLockfileUpdate {
245 /// The already-admitted fix target.
246 target: ConcreteVersion,
247 },
248 /// (`--security-only` only) No verified fix could be written, for [`UnfixableReason`].
249 /// Contributes to exit 1.
250 Unfixable(UnfixableReason),
251}
252
253/// Why a default-mode candidate was skipped.
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum SkipReason {
256 /// A matching `[update].ignore` rule excluded this dependency (FR-006).
257 IgnoreRule,
258 /// `--package` was given and this dependency was not named (FR-005).
259 NotRequested,
260 /// The dependency is outdated but could not be safely rewritten — the registry's cached
261 /// `latest` value failed the safety gate, the declared span is not the literal
262 /// requirement text (e.g. a Maven `${property}` reference or a Gradle DSL variable/
263 /// version-catalog alias), or the formatter has no single unambiguous rewrite for it.
264 /// See [`deps_core::edit::UnplannableReason`] for which of the three applies.
265 NotSafelyEditable(deps_core::edit::UnplannableReason),
266 /// The edit overlapped another item's edit and was dropped by the apply-time dedup pass
267 /// (see [`dedup_applied_items`]) — a report never claims `applied` for something that was
268 /// not actually written (critic finding M2).
269 OverlapsAnotherEdit,
270 /// Spec 075 FR-014: nothing available clears the freshness cooldown for this occurrence —
271 /// either no fallback candidate exists at all (no lockfile-resolved in-use version to floor
272 /// the search, spec 075 OQ1's documented no-floor limitation; or the newest
273 /// cooldown-cleared candidate failed an ecosystem-safety/in-use-floor/requirement-floor
274 /// guard, FR-001/FR-002/FR-003), or a candidate exists but the fallback view shows it
275 /// already satisfies the declared requirement (FR-009, so there is nothing to rewrite), or
276 /// it was itself blocked for a non-OSV structural reason (FR-007's decision table). A
277 /// fallback candidate blocked by its own OSV verdict is reported separately as
278 /// [`Self::NotSafelyEditable`] (exit 1, FR-011) — never demoted to this routine, exit-0
279 /// pause. Since the engine (`deps-engine`) now computes a fallback candidate whenever one
280 /// exists, this is no longer the unconditional starvation risk it was before spec 075: a
281 /// package publishing faster than the cooldown window can still resolve via its fallback,
282 /// provided one clears every guard.
283 WithinFreshnessCooldown,
284}
285
286/// Why a `--security-only` candidate could not be fixed.
287///
288/// The three variants that reject a specific, known fix target (#1614) carry it inline, so
289/// `--security-only` output can report the rejected version instead of nothing; the two that
290/// mean no fix target was ever established carry none.
291#[derive(Debug, Clone, PartialEq, Eq)]
292pub enum UnfixableReason {
293 /// No independently-verified fix target exists: no advisory has a claimable fix, the fix
294 /// target failed the safety gate, or `deps_core::edit`'s internal fix-target verification
295 /// could not confirm it — see [`deps_core::edit::VulnFixSkip`] (FR-010).
296 NoVerifiedFix,
297 /// The dependency's registry fetch failed/timed out, or produced no `PackageVersions`
298 /// entry at all — the FR-011 two-signal, load-bearing rule.
299 FetchFailedOrAbsent,
300 /// The fix target is present in the registry's yanked list with a status that
301 /// [`deps_core::RemovalStatus::blocks_resolution`] (FR-012).
302 Yanked {
303 /// The yanked fix target that was rejected.
304 target: ConcreteVersion,
305 },
306 /// The declared requirement has a shape the formatter has no single unambiguous rewrite
307 /// for (e.g. a compound comma-separated Cargo requirement, #1566) *and* a requirement
308 /// matcher exists that has already confirmed the declared requirement does not admit the
309 /// fix target — distinct from [`Outcome::RequiresLockfileUpdate`], which means the
310 /// requirement already admits the fix and nothing needs rewriting at all. Conflating the
311 /// two would tell the operator to regenerate the lock file for a dependency that is still
312 /// vulnerable (#1566 S1).
313 UnsupportedRequirementShape {
314 /// The confirmed-excluded fix target.
315 target: ConcreteVersion,
316 },
317 /// The declared requirement's raw text exceeds `deps_core::lsp_helpers::MAX_REQUIREMENT_LEN`
318 /// (`deps_core::lsp_helpers::requirement_is_oversized`, #1472's CWE-400 defense-in-depth
319 /// bound) — a size-based fail-closed guard applied *before* a requirement matcher is ever
320 /// compiled, never itself a confirmed exclusion. Distinct from
321 /// [`Self::UnsupportedRequirementShape`] (#1578 S1): that variant means a matcher actually
322 /// ran and confirmed the declared requirement excludes the fix target, while this one means
323 /// the matcher never ran at all, so an oversized requirement that would in fact have
324 /// admitted the fix is still reported here rather than as
325 /// [`Outcome::RequiresLockfileUpdate`] — conflating the two would let a false "confirmed
326 /// excluded" claim reach the operator for a case that was never actually checked.
327 OversizedRequirement {
328 /// The fix target the oversized requirement was never checked against.
329 target: ConcreteVersion,
330 },
331}
332
333impl Outcome {
334 /// The FR-021 wire token (`--format json`'s `outcome` field, and the table's `[..]`
335 /// prefix): one of exactly `applied` / `skipped` / `requires-lockfile-update` /
336 /// `unfixable` — a [`SkipReason`]/[`UnfixableReason`]'s own detail is carried in
337 /// [`PlannedUpdateItem::reason`] instead, not folded into this token.
338 #[must_use]
339 pub const fn wire_token(&self) -> &'static str {
340 match self {
341 Self::Applied { .. } => "applied",
342 Self::Skipped { .. } => "skipped",
343 Self::RequiresLockfileUpdate { .. } => "requires-lockfile-update",
344 Self::Unfixable(_) => "unfixable",
345 }
346 }
347
348 /// This outcome's target version, when its variant carries one — the sole read path for a
349 /// dependency's considered/applied target (#1615), used by [`PlannedUpdateItem::target`] and
350 /// every formatter.
351 #[must_use]
352 pub const fn target(&self) -> Option<&ConcreteVersion> {
353 match self {
354 Self::Applied { target, .. } | Self::RequiresLockfileUpdate { target } => Some(target),
355 Self::Skipped { target, .. } => target.as_ref(),
356 Self::Unfixable(
357 UnfixableReason::Yanked { target }
358 | UnfixableReason::UnsupportedRequirementShape { target }
359 | UnfixableReason::OversizedRequirement { target },
360 ) => Some(target),
361 Self::Unfixable(
362 UnfixableReason::NoVerifiedFix | UnfixableReason::FetchFailedOrAbsent,
363 ) => None,
364 }
365 }
366}
367
368impl PlannedUpdateItem {
369 /// Builds a `PlannedUpdateItem` from its already-computed fields.
370 ///
371 /// Replaces the near-identical hand-rolled struct literals `security.rs`'s four
372 /// terminal-outcome builders and this module's own `resolve_occurrence` `build` closure
373 /// previously each constructed independently (issue #1551 finding 5) — a field change
374 /// affecting those two call sites now only requires touching this constructor. Fields stay
375 /// `pub` and other construction sites remain plain struct literals (e.g. `deps-cli`'s
376 /// `exit.rs`, this module's own tests, and the doctest above) — this constructor does not
377 /// make the type `#[non_exhaustive]` or migrate every existing literal to it.
378 #[must_use]
379 pub fn new(
380 name: String,
381 current: CurrentVersion,
382 outcome: Outcome,
383 advisory_ids: Vec<String>,
384 ignore_rule_overridden: bool,
385 gossip_excluded_version: Option<deps_core::ConcreteVersion>,
386 cooldown_fallback: Option<CooldownFallbackNote>,
387 ) -> Self {
388 Self {
389 name,
390 current,
391 outcome,
392 advisory_ids,
393 ignore_rule_overridden,
394 gossip_excluded_version,
395 cooldown_fallback,
396 }
397 }
398
399 /// This item's considered/applied target version — delegates to [`Outcome::target`], the
400 /// sole read path since #1615 folded the target into [`Self::outcome`]'s own variant.
401 #[must_use]
402 pub const fn target(&self) -> Option<&ConcreteVersion> {
403 self.outcome.target()
404 }
405
406 /// A one-line human-readable reason for [`Self::outcome`] (FR-021's `reason` field).
407 #[must_use]
408 pub fn reason(&self) -> String {
409 let base = match &self.outcome {
410 Outcome::Applied { .. } => "update applied",
411 Outcome::Skipped {
412 reason: SkipReason::IgnoreRule,
413 ..
414 } => "matched an [update].ignore rule",
415 Outcome::Skipped {
416 reason: SkipReason::NotRequested,
417 ..
418 } => "not named by --package",
419 Outcome::Skipped {
420 reason:
421 SkipReason::NotSafelyEditable(
422 deps_core::edit::UnplannableReason::UnsafeLatestVersion,
423 ),
424 ..
425 } => "the registry-reported latest version failed a safety check",
426 Outcome::Skipped {
427 reason:
428 SkipReason::NotSafelyEditable(deps_core::edit::UnplannableReason::NonLiteralSpan),
429 ..
430 } => {
431 "the declared version is not a plain literal (e.g. a property reference or variable) and cannot be safely rewritten"
432 }
433 Outcome::Skipped {
434 reason:
435 SkipReason::NotSafelyEditable(deps_core::edit::UnplannableReason::NoOpRewrite),
436 ..
437 } => "no single unambiguous rewrite exists for this dependency's requirement syntax",
438 // Fix-cycle item 6/M1 minor: `NotSafelyEditable(LatestFlaggedByOsv|LatestUnverified)`
439 // is reused for a blocked FALLBACK candidate (row 10, FR-011) as well as a blocked
440 // `latest` — `UpdateCandidate` carries no marker distinguishing which view produced
441 // it, so the base text branches on `cooldown_fallback` instead of naming "latest"
442 // unconditionally (which previously read as self-contradictory once the `Blocked`
443 // attribution suffix below named the fallback candidate specifically).
444 Outcome::Skipped {
445 reason:
446 SkipReason::NotSafelyEditable(
447 deps_core::edit::UnplannableReason::LatestFlaggedByOsv,
448 ),
449 ..
450 } => {
451 if matches!(
452 self.cooldown_fallback,
453 Some(CooldownFallbackNote::Blocked { .. })
454 ) {
455 "the freshness-cooldown fallback candidate is flagged by OSV.dev — refusing to write it"
456 } else {
457 "the registry's latest version is flagged by OSV.dev — refusing to write it"
458 }
459 }
460 Outcome::Skipped {
461 reason:
462 SkipReason::NotSafelyEditable(deps_core::edit::UnplannableReason::LatestUnverified),
463 ..
464 } => {
465 if matches!(
466 self.cooldown_fallback,
467 Some(CooldownFallbackNote::Blocked { .. })
468 ) {
469 "the freshness-cooldown fallback candidate could not be verified against OSV.dev — refusing to write it"
470 } else {
471 "the registry's latest version could not be verified against OSV.dev — refusing to write it"
472 }
473 }
474 Outcome::Skipped {
475 reason: SkipReason::OverlapsAnotherEdit,
476 ..
477 } => "this edit's span overlapped another item's and was dropped",
478 Outcome::Skipped {
479 reason: SkipReason::WithinFreshnessCooldown,
480 ..
481 } => "the selected update target was published within the freshness cooldown window",
482 Outcome::RequiresLockfileUpdate { .. } => {
483 "declared requirement already admits the fix target; regenerate the lock file (see #1116)"
484 }
485 Outcome::Unfixable(UnfixableReason::NoVerifiedFix) => {
486 "no independently-verified fix target is available"
487 }
488 Outcome::Unfixable(UnfixableReason::FetchFailedOrAbsent) => {
489 "registry fetch for this dependency failed or returned no data"
490 }
491 Outcome::Unfixable(UnfixableReason::Yanked { .. }) => "the fix target is yanked",
492 Outcome::Unfixable(UnfixableReason::UnsupportedRequirementShape { .. }) => {
493 "the declared requirement's syntax has no safe single-value rewrite and does not already admit the fix target — manual edit required"
494 }
495 Outcome::Unfixable(UnfixableReason::OversizedRequirement { .. }) => {
496 "the declared requirement is too large to safely evaluate; treating as unfixable — manual edit required"
497 }
498 };
499 let mut reason = base.to_string();
500 if self.ignore_rule_overridden {
501 reason.push_str(" (a matching [update].ignore rule was overridden by --security-only)");
502 }
503 // Issue #1521 item 1: mirrors `crate::report::to_finding`'s identical GOSSIP-cooldown
504 // message attribution for `check`.
505 if self.gossip_excluded_version.is_some() {
506 reason.push_str(
507 " (a newer version was excluded from this pick by an active GOSSIP cooldown finding)",
508 );
509 }
510 // Spec 075 FR-013: attributes a cooldown-fallback decision on top of the base reason —
511 // `AppliedInsteadOf` names the fresher version this item bypassed; `Blocked` names the
512 // specific candidate the base text above already describes as blocked (see the
513 // `NotSafelyEditable` match arms' own fallback-aware wording).
514 match &self.cooldown_fallback {
515 Some(CooldownFallbackNote::AppliedInsteadOf(latest)) => {
516 reason.push_str(&format!(
517 " (targeted a cooldown-cleared fallback instead of {latest}, which is still \
518 within its freshness cooldown window)"
519 ));
520 }
521 Some(CooldownFallbackNote::Blocked { version }) => {
522 reason.push_str(&format!(" (candidate: {version})"));
523 }
524 None => {}
525 }
526 reason
527 }
528}
529
530/// `--package` narrowing input, matched after `formatter.normalize_package_name` on both
531/// sides (FR-005).
532#[must_use]
533pub fn is_requested(
534 package_filter: &[String],
535 normalized_name: &str,
536 formatter: &dyn EcosystemFormatter,
537) -> bool {
538 package_filter.is_empty()
539 || package_filter.iter().any(|name| {
540 formatter.normalize_package_name(&PackageName::new(name.clone())) == normalized_name
541 })
542}
543
544/// The cached registry data for `normalized_name` (or, failing that, `raw_name`) — the shared
545/// lookup [`gossip_excluded_version`] and the unified planner both need (code-review finding:
546/// previously each ran this same two-step `HashMap` lookup independently).
547fn cached_package_versions<'a>(
548 analysis: &'a ManifestAnalysis,
549 normalized_name: &str,
550 raw_name: &str,
551) -> Option<&'a deps_core::lsp_helpers::PackageVersions> {
552 analysis
553 .cached_versions
554 .get(normalized_name)
555 .or_else(|| analysis.cached_versions.get(raw_name))
556}
557
558/// The version [`deps_core::lsp_helpers::PackageVersions::gossip_excluded_version`] recorded
559/// for `normalized_name` (or, failing that, `raw_name`), when the registry fetch's spec 074
560/// GOSSIP-cooldown filter held one back from being `latest` (issue #1521 item 1) — mirrors
561/// `crate::report::to_finding`'s identical lookup for `check`'s own attribution.
562fn gossip_excluded_version(
563 analysis: &ManifestAnalysis,
564 normalized_name: &str,
565 raw_name: &str,
566) -> Option<deps_core::ConcreteVersion> {
567 cached_package_versions(analysis, normalized_name, raw_name)
568 .and_then(|v| v.gossip_excluded_version.clone())
569}
570
571/// Spec 075 FR-007/FR-008/FR-010: the fallback-substituted view [`plan_updates`] feeds through
572/// [`collect_update_candidates`] alongside the real `latest` view — only `latest` swapped for
573/// each dependency's stored cooldown-fallback candidate, when one exists.
574///
575/// Reuses [`ManifestAnalysis::cooldown_fallback_view`] when `analyze_manifest` already built it
576/// for its own FR-010 OSV round, rather than recomputing an identical view from scratch (issue
577/// #1551 finding 3) — that field already carries the same "something actually changed" gate
578/// `analyze_manifest`'s own OSV round applies (NFR-004: zero extra work when nothing is
579/// cooldown-blocked with a usable fallback). When the field was never populated (a caller that
580/// built [`ManifestAnalysis`] directly, e.g. this module's own tests), the identical gate is
581/// applied here instead of computing the view unconditionally — `None` either way means
582/// `plan_updates` skips [`collect_update_candidates`]'s full-manifest pass over the fallback
583/// view entirely, since [`resolve_occurrence`] only ever consults it when a dependency's own
584/// [`cooldown_disposition`] is `Blocked { fallback: Some(_), .. }`.
585///
586/// The reuse path (impl-critic m3) does *not* re-apply `freshness`/`now` to the already-built
587/// view — it relies on the caller passing the same values `analyze_manifest` used to build it,
588/// the same invariant [`ManifestAnalysis::now`]'s own doc already requires of every
589/// `plan_updates` caller. A caller that violates it only affects this reuse path, never the
590/// recompute-and-gate fallback below, which always uses its own `freshness`/`now` arguments.
591fn resolve_cooldown_fallback_view(
592 analysis: &ManifestAnalysis,
593 freshness: deps_core::FreshnessSettings,
594 now: deps_core::PublishTime,
595) -> Option<HashMap<PackageName, PackageVersions>> {
596 if let Some(view) = analysis.cooldown_fallback_view.as_ref() {
597 return Some(view.clone());
598 }
599 let view = crate::analyze::cooldown_fallback_view(
600 &analysis.cached_versions,
601 Some(&analysis.gossip_findings),
602 freshness,
603 now,
604 );
605 view.iter()
606 .any(|(name, v)| {
607 analysis
608 .cached_versions
609 .get(name)
610 .is_none_or(|c| c.latest != v.latest)
611 })
612 .then_some(view)
613}
614
615/// Default-mode planner: every dependency [`deps_core::edit::collect_update_candidates`]
616/// considers, narrowed by `--package` and `[update].ignore` (FR-003, FR-005, FR-006, FR-007).
617///
618/// An outdated dependency `collect_update_candidates` could not safely rewrite (a
619/// [`deps_core::edit::UpdateCandidate::Unplannable`] — an unsafe registry value, a
620/// non-literal span, or a formatter with no unambiguous rewrite) is still reported here as
621/// [`Outcome::Skipped`] with reason [`SkipReason::NotSafelyEditable`] rather than silently
622/// vanishing from the plan (spec 068 S4) — a `--package <NAME>` run naming exactly that
623/// dependency must not exit 0 with no signal.
624///
625/// `[update].ignore` rules are honored only when `ignore_rules` was actually built from an
626/// explicit `--config <path>` (FR-007) — callers pass [`IgnoreRules::empty`] otherwise, never
627/// an auto-discovered config's rules.
628///
629/// # Examples
630///
631/// ```
632/// use deps_cli::analyze::ManifestAnalysis;
633/// use deps_cli::update::ignore::IgnoreRules;
634/// use deps_cli::update::{Outcome, plan_updates};
635/// use deps_core::lsp_helpers::{
636/// DiagnosticMessages, DiagnosticPolicy, DependencyOutcomes, OsvNaming, PackageNaming,
637/// PackageRendering, PackageVersions, RequirementResolution, SourcePolicy,
638/// };
639/// use deps_core::parser::DependencySource;
640/// use deps_core::position::{Position, Range};
641/// use deps_core::{ConcreteVersion, Dependency, EcosystemId, ParseResult, PackageName, VersionReq};
642/// use std::any::Any;
643/// use std::collections::{HashMap, HashSet};
644///
645/// struct MockFormatter;
646/// impl PackageNaming for MockFormatter {}
647/// impl PackageRendering for MockFormatter {
648/// fn format_version_for_text_edit(&self, v: &ConcreteVersion) -> String { v.to_string() }
649/// fn package_url(&self, name: &PackageName) -> String { name.as_str().to_string() }
650/// }
651/// impl RequirementResolution for MockFormatter {}
652/// impl DiagnosticMessages for MockFormatter {}
653/// impl DiagnosticPolicy for MockFormatter {}
654/// impl SourcePolicy for MockFormatter {}
655/// impl OsvNaming for MockFormatter {}
656///
657/// struct MockDep { name: PackageName, version_req: VersionReq, version_range: Range }
658/// impl Dependency for MockDep {
659/// fn name(&self) -> &PackageName { &self.name }
660/// fn name_range(&self) -> Range { Range::default() }
661/// fn version_requirement(&self) -> Option<&VersionReq> { Some(&self.version_req) }
662/// fn version_range(&self) -> Option<Range> { Some(self.version_range) }
663/// fn source(&self) -> DependencySource { DependencySource::Registry }
664/// fn as_any(&self) -> &dyn Any { self }
665/// }
666///
667/// struct MockParseResult { deps: Vec<MockDep>, uri: url::Url }
668/// impl ParseResult for MockParseResult {
669/// fn dependencies(&self) -> Vec<&dyn Dependency> {
670/// self.deps.iter().map(|d| d as &dyn Dependency).collect()
671/// }
672/// fn workspace_root(&self) -> Option<&std::path::Path> { None }
673/// fn uri(&self) -> &url::Url { &self.uri }
674/// fn as_any(&self) -> &dyn Any { self }
675/// }
676///
677/// let content = r#"serde = "1.0.0""#;
678/// let mut cached_versions = HashMap::new();
679/// cached_versions.insert(PackageName::new("serde"), PackageVersions::latest_only("1.2.0"));
680///
681/// let analysis = ManifestAnalysis {
682/// parse_result: Box::new(MockParseResult {
683/// deps: vec![MockDep {
684/// name: PackageName::new("serde"),
685/// version_req: VersionReq::new("1.0.0"),
686/// version_range: Range::new(Position::new(0, 9), Position::new(0, 14)),
687/// }],
688/// uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
689/// }),
690/// uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
691/// now: deps_core::PublishTime::now(),
692/// ecosystem_id: EcosystemId::Cargo,
693/// cached_versions,
694/// resolved_versions: HashMap::new(),
695/// resolved_version_candidates: HashMap::new(),
696/// outcomes: DependencyOutcomes::new(),
697/// vulnerabilities: None,
698/// latest_status: None,
699/// fallback_status: None,
700/// cooldown_fallback_view: None,
701/// gossip_findings: HashMap::new(),
702/// licenses: HashMap::new(),
703/// license_policy: deps_core::licenses::LicensePolicy::default(),
704/// license_source: deps_core::LicenseSource::default(),
705/// network: deps_core::NetworkMode::Online,
706/// fetch_failed: HashSet::new(),
707/// registry_unreachable: false,
708/// license_fetch_incomplete: false,
709/// };
710///
711/// // Freshness is disabled by default, so this fixture never reaches the fallback-edit guard
712/// // — the closure is never actually called.
713/// let reparse = |_content: &str| -> Option<Box<dyn ParseResult>> { None };
714///
715/// let plan = plan_updates(
716/// &analysis,
717/// content,
718/// &MockFormatter,
719/// &reparse,
720/// &[],
721/// &IgnoreRules::empty(),
722/// deps_core::FreshnessSettings::default(),
723/// deps_core::PublishTime::now(),
724/// );
725///
726/// assert_eq!(plan.items.len(), 1);
727/// assert!(matches!(plan.items[0].outcome, Outcome::Applied { .. }));
728/// assert_eq!(plan.items[0].target(), Some(&ConcreteVersion::from("1.2.0")));
729/// ```
730#[must_use]
731#[expect(
732 clippy::too_many_arguments,
733 reason = "spec 076 T004 adds `reparse` (FR-024) to the existing FR-007 planner surface; \
734 grouping into a struct would only move, not reduce, churn (mirrors \
735 resolve_occurrence's own identical rationale)"
736)]
737pub fn plan_updates(
738 analysis: &ManifestAnalysis,
739 content: &str,
740 formatter: &dyn EcosystemFormatter,
741 reparse: &dyn ManifestReparse,
742 package_filter: &[String],
743 ignore_rules: &IgnoreRules,
744 freshness: deps_core::FreshnessSettings,
745 now: deps_core::PublishTime,
746) -> UpdatePlan {
747 let latest_candidates = collect_update_candidates(
748 analysis.parse_result.as_ref(),
749 content,
750 analysis.version_data(),
751 formatter,
752 );
753
754 // Spec 075 FR-007/FR-008: a fallback view of the registry data (only `latest` swapped for
755 // each dependency's stored cooldown-fallback candidate, when one exists) fed through the
756 // exact same planner — so a fallback candidate is verified/planned identically to `latest`,
757 // never through a separate code path. Only occurrences already present in
758 // `latest_candidates` (i.e. `Outdated` against real `latest`) ever look this up (FR-008).
759 // `resolve_cooldown_fallback_view` reuses `analysis.cooldown_fallback_view` when
760 // `analyze_manifest` already built it (issue #1551 finding 3), and gates the
761 // `collect_update_candidates` pass below on the same "something actually changed" check
762 // either way — `None` means no dependency is cooldown-blocked with a usable fallback.
763 let fallback_view = resolve_cooldown_fallback_view(analysis, freshness, now);
764 // Issue #1561 item 1 (defense-in-depth, library-API callers only — `deps-cli`'s own
765 // `analyze_manifest` always populates both together): an empty map, never a bare `None`,
766 // stands in for a missing `fallback_status` whenever `latest_status` IS populated — a bare
767 // `None` here would make `latest_verdict` treat every fallback candidate as
768 // `NotApplicable` ("no check needed"), silently bypassing OSV verification instead of
769 // failing closed to `Unverified` the way a genuinely never-checked candidate does.
770 let empty_fallback_status = deps_core::osv::LatestStatusMap::new();
771 let fallback_candidates = match fallback_view.as_ref() {
772 Some(view) => {
773 let mut fallback_version_data =
774 deps_core::VersionData::new(view, &analysis.resolved_versions)
775 .with_resolved_version_candidates(&analysis.resolved_version_candidates)
776 .with_ecosystem(analysis.ecosystem_id);
777 fallback_version_data = match analysis.fallback_status.as_ref() {
778 Some(fallback_status) => fallback_version_data.with_latest_status(fallback_status),
779 None if analysis.latest_status.is_some() => {
780 fallback_version_data.with_latest_status(&empty_fallback_status)
781 }
782 None => fallback_version_data,
783 };
784 collect_update_candidates(
785 analysis.parse_result.as_ref(),
786 content,
787 fallback_version_data,
788 formatter,
789 )
790 }
791 None => Vec::new(),
792 };
793
794 // Base identity every dependency carries unconditionally — `(normalized_name, name_range)`
795 // — grouped (not collapsed) so a genuine collision (two dependencies sharing both, e.g.
796 // Gradle/Composer's degraded-position parsing) stays visible instead of one silently
797 // shadowing the other. `occurrence_key` resolves each occurrence's `version_range` via
798 // `Dependency::version_range()` uniformly, rather than from whichever `UpdateCandidate`
799 // variant (`Planned`'s `edit.range`, or nothing at all for `Unplannable`) a given view
800 // happened to produce — but only when the group has exactly one member: code review traced
801 // a path where the earlier single-dep-per-pair map let a collision's downgrade guard
802 // (`fallback_satisfies_requirement`) silently run against the WRONG occurrence's declared
803 // requirement. `None` on a detected collision is deliberate — every lookup keyed on it
804 // (the downgrade guard, OSV advisory attribution) then fails closed instead of guessing.
805 let mut deps_by_name_range: HashMap<
806 (String, deps_core::position::Range),
807 Vec<&dyn deps_core::Dependency>,
808 > = HashMap::new();
809 for dep in analysis.parse_result.dependencies() {
810 deps_by_name_range
811 .entry((
812 formatter.normalize_package_name(dep.name()),
813 dep.name_range(),
814 ))
815 .or_default()
816 .push(dep);
817 }
818 let occurrence_key = |normalized_name: &str, name_range: deps_core::position::Range| {
819 let version_range = match deps_by_name_range
820 .get(&(normalized_name.to_string(), name_range))
821 .map(Vec::as_slice)
822 {
823 Some([dep]) => dep.version_range(),
824 _ => None,
825 };
826 (normalized_name.to_string(), name_range, version_range)
827 };
828
829 // Same collision guard as `dep_by_key` below: a colliding occurrence's fallback candidate is
830 // excluded rather than collapsed with the other's, so both occurrences fall through to the
831 // same "fallback absent" (row 6) handling instead of one silently stealing the other's
832 // `SkipReason`/attribution in the `Unplannable` branch (impl-critic, rows 8/10/11).
833 let mut fallback_by_key: HashMap<OccurrenceKey, UpdateCandidate> = fallback_candidates
834 .into_iter()
835 .filter(|c| {
836 let (normalized_name, name_range) = candidate_identity(c);
837 deps_by_name_range
838 .get(&(normalized_name, name_range))
839 .is_some_and(|deps| deps.len() == 1)
840 })
841 .map(|c| {
842 let (normalized_name, name_range) = candidate_identity(&c);
843 (occurrence_key(&normalized_name, name_range), c)
844 })
845 .collect();
846
847 let dep_by_key: HashMap<OccurrenceKey, &dyn deps_core::Dependency> = deps_by_name_range
848 .iter()
849 .filter_map(
850 |(&(ref normalized_name, name_range), deps)| match deps.as_slice() {
851 [dep] => Some((occurrence_key(normalized_name, name_range), *dep)),
852 _ => None,
853 },
854 )
855 .collect();
856
857 let vuln_keys = deps_core::osv::vulnerability_keys(
858 analysis.parse_result.as_ref(),
859 &analysis.resolved_versions,
860 Some(&analysis.resolved_version_candidates),
861 formatter,
862 analysis.ecosystem_id,
863 );
864
865 let mut items: Vec<PlannedUpdateItem> = latest_candidates
866 .into_iter()
867 .map(|latest_candidate| {
868 let (normalized_name, name_range) = candidate_identity(&latest_candidate);
869 let key = occurrence_key(&normalized_name, name_range);
870 resolve_occurrence(
871 latest_candidate,
872 key,
873 &mut fallback_by_key,
874 &dep_by_key,
875 analysis,
876 content,
877 formatter,
878 reparse,
879 package_filter,
880 ignore_rules,
881 freshness,
882 &vuln_keys,
883 now,
884 )
885 })
886 .collect();
887
888 // FR-015: the overlap-collapsing pass runs AFTER per-occurrence view selection, over the
889 // chosen `PlannedUpdate`s only — never over both views' raw candidates. This also makes
890 // `SkipReason::OverlapsAnotherEdit` reachable from this planner for the first time (it
891 // previously only ever came from `security::plan_security_updates`'s own items, since this
892 // planner's pre-#1543 pre-classification dedup dropped an overlapping candidate silently,
893 // with no `PlannedUpdateItem` at all).
894 dedup_applied_items(&mut items);
895
896 UpdatePlan { items }
897}
898
899/// Spec 075 FR-007: one manifest occurrence's join key between the latest and fallback views —
900/// `(normalized_name, name_range, version_range)` rather than `name_range` alone (fix-cycle
901/// item 6/M2 minor, strengthened per code review): some formatters' degraded parsing paths
902/// (Gradle's `find_name_range`, Composer's AST-degraded path) can return `Range::default()` for
903/// more than one occurrence in the same manifest — a bare `name_range` collision would hand one
904/// occurrence another package's fallback edit, and for two occurrences of the *same* package
905/// name, that could approve a downgrade against the WRONG occurrence's declared requirement
906/// (FR-003's guard would run with a `&dyn Dependency` borrowed from a different span).
907///
908/// `version_range` is always resolved from [`deps_core::Dependency::version_range`] itself (via
909/// [`plan_updates`]'s `occurrence_key` closure), never from a specific [`UpdateCandidate`]
910/// variant's own field — an occurrence that is `Planned` in one view and `Unplannable` in the
911/// other (rows 7/10, this feature's own main additions) must still resolve to the identical key
912/// in both, or the cross-view join breaks. This discriminates two occurrences of the same
913/// package whenever their version text sits at different positions — true in every realistic
914/// case, since that is where the difference between two declarations actually lives even when
915/// both degrade to the same synthetic name range. The residual gap this cannot close is two
916/// occurrences of one package whose version text ALSO sits at the same (degraded) position;
917/// closing that fully needs the deeper per-occurrence identity plan.md's `OccurrenceCandidate`
918/// design implies.
919type OccurrenceKey = (
920 String,
921 deps_core::position::Range,
922 Option<deps_core::position::Range>,
923);
924
925/// [`UpdateCandidate`]'s `(normalized_name, name_range)` — the two fields every variant carries
926/// unconditionally, fed into [`plan_updates`]'s `occurrence_key` closure to resolve the full
927/// [`OccurrenceKey`] (including `version_range`) uniformly regardless of Planned/Unplannable.
928fn candidate_identity(candidate: &UpdateCandidate) -> (String, deps_core::position::Range) {
929 match candidate {
930 UpdateCandidate::Planned(p) => (p.normalized_name.clone(), p.name_range),
931 UpdateCandidate::Unplannable {
932 normalized_name,
933 name_range,
934 ..
935 } => (normalized_name.clone(), *name_range),
936 }
937}
938
939/// `(current, target)` for a `WithinFreshnessCooldown`/`NotRequested` item built directly from
940/// the latest view — a `Planned` candidate carries both; an `Unplannable` one carries neither
941/// (matches this planner's pre-#1543 convention for an item with no concrete write target).
942fn latest_current_target(candidate: &UpdateCandidate) -> (CurrentVersion, Option<ConcreteVersion>) {
943 match candidate {
944 UpdateCandidate::Planned(p) => (
945 CurrentVersion::from(p.current.clone()),
946 Some(p.target.clone()),
947 ),
948 UpdateCandidate::Unplannable { .. } => (CurrentVersion::Unknown, None),
949 }
950}
951
952/// Spec 075 FR-004 rows 1/2/3/5/8 (§6): resolves an occurrence purely from the latest view —
953/// `disposition` is `NotEvaluated`/`Cleared`, or `Blocked` with no usable fallback and `latest`
954/// is itself OSV-unplannable ("never demoted" by a cooldown skip). Mirrors this planner's
955/// pre-#1543 `planned`/`unplannable` loop bodies exactly, minus the `is_requested` check (the
956/// caller already ran it once for both views) and minus any cooldown check (the caller's
957/// disposition match already decided this occurrence reaches this function at all).
958fn resolve_from_latest(
959 latest_candidate: UpdateCandidate,
960 ignore_rules: &IgnoreRules,
961) -> (CurrentVersion, Outcome, Vec<String>) {
962 match latest_candidate {
963 UpdateCandidate::Planned(p) => {
964 let target = p.target.clone();
965 let current = CurrentVersion::from(p.current);
966 let kind = current.update_kind_to(&p.target);
967 if let Some(reason) = ignore_rules.skip_reason(&p.normalized_name, kind) {
968 (
969 current,
970 Outcome::Skipped {
971 reason,
972 target: Some(target),
973 },
974 Vec::new(),
975 )
976 } else {
977 (
978 current,
979 Outcome::Applied {
980 edit: p.edit,
981 target,
982 },
983 Vec::new(),
984 )
985 }
986 }
987 UpdateCandidate::Unplannable {
988 normalized_name,
989 reason,
990 ..
991 } => {
992 let outcome = if let Some(rule_reason) =
993 ignore_rules.skip_reason(&normalized_name, UpdateKind::Unknown)
994 {
995 Outcome::Skipped {
996 reason: rule_reason,
997 target: None,
998 }
999 } else {
1000 Outcome::Skipped {
1001 reason: SkipReason::NotSafelyEditable(reason),
1002 target: None,
1003 }
1004 };
1005 (CurrentVersion::Unknown, outcome, Vec::new())
1006 }
1007 }
1008}
1009
1010/// Spec 075 FR-011/FR-012: `version`'s OSV verdict advisory ids, looked up against `status` —
1011/// empty unless the verdict is `Flagged` (an `Unverified` verdict has no advisory list; the two
1012/// call sites below never reach this helper for a `Verified`/`NotApplicable` version).
1013fn osv_advisory_ids(
1014 status: Option<&deps_core::osv::LatestStatusMap>,
1015 dep: Option<&dyn deps_core::Dependency>,
1016 vuln_keys: &deps_core::osv::VulnKeys,
1017 normalized_name: &str,
1018 version: &str,
1019 formatter: &dyn EcosystemFormatter,
1020) -> Vec<String> {
1021 let Some(dep) = dep else {
1022 return Vec::new();
1023 };
1024 match latest_verdict(
1025 status,
1026 dep,
1027 Some(vuln_keys),
1028 normalized_name,
1029 version,
1030 formatter,
1031 ) {
1032 LatestVerdict::Flagged { advisory_ids, .. } => advisory_ids,
1033 LatestVerdict::Unverified | LatestVerdict::Verified | LatestVerdict::NotApplicable => {
1034 Vec::new()
1035 }
1036 }
1037}
1038
1039/// Spec 075 FR-007: the unified per-occurrence planner pipeline. Builds `latest_candidate`'s
1040/// identity once, resolves this occurrence's [`CooldownDisposition`], and — only when it is
1041/// `Blocked` — consults `fallback_by_key` (removed so each fallback occurrence is used at most
1042/// once) to pick between `latest` and the fallback candidate per spec 075 §6's decision table,
1043/// before running `is_requested`/`ignore_rules` exactly once against the SELECTED target.
1044#[expect(
1045 clippy::too_many_arguments,
1046 reason = "every parameter is either the two views' shared lookup data or a planning \
1047 knob already threaded through plan_updates; grouping into a struct would only \
1048 move, not reduce, churn (mirrors deps-engine::classify::fetch's identical call)"
1049)]
1050fn resolve_occurrence(
1051 latest_candidate: UpdateCandidate,
1052 key: OccurrenceKey,
1053 fallback_by_key: &mut HashMap<OccurrenceKey, UpdateCandidate>,
1054 dep_by_key: &HashMap<OccurrenceKey, &dyn deps_core::Dependency>,
1055 analysis: &ManifestAnalysis,
1056 content: &str,
1057 formatter: &dyn EcosystemFormatter,
1058 reparse: &dyn ManifestReparse,
1059 package_filter: &[String],
1060 ignore_rules: &IgnoreRules,
1061 freshness: deps_core::FreshnessSettings,
1062 vuln_keys: &deps_core::osv::VulnKeys,
1063 now: deps_core::PublishTime,
1064) -> PlannedUpdateItem {
1065 let (name, normalized_name) = match &latest_candidate {
1066 UpdateCandidate::Planned(p) => (p.name.clone(), p.normalized_name.clone()),
1067 UpdateCandidate::Unplannable {
1068 name,
1069 normalized_name,
1070 ..
1071 } => (name.clone(), normalized_name.clone()),
1072 };
1073 let gossip_excluded = gossip_excluded_version(analysis, &normalized_name, &name);
1074 let fallback_candidate = fallback_by_key.remove(&key);
1075
1076 let build = |current: CurrentVersion,
1077 outcome: Outcome,
1078 advisory_ids: Vec<String>,
1079 cooldown_fallback: Option<CooldownFallbackNote>| {
1080 PlannedUpdateItem::new(
1081 name.clone(),
1082 current,
1083 outcome,
1084 advisory_ids,
1085 false,
1086 gossip_excluded.clone(),
1087 cooldown_fallback,
1088 )
1089 };
1090
1091 if !is_requested(package_filter, &normalized_name, formatter) {
1092 let (current, target) = latest_current_target(&latest_candidate);
1093 return build(
1094 current,
1095 Outcome::Skipped {
1096 reason: SkipReason::NotRequested,
1097 target,
1098 },
1099 Vec::new(),
1100 None,
1101 );
1102 }
1103
1104 let package_versions = cached_package_versions(analysis, &normalized_name, &name);
1105 // Security M1: the RAW name — matches `gossip_findings`/`apply_outdated_rule`'s own keying;
1106 // the normalized name silently hid GOSSIP data for case-changing ecosystems (#1529).
1107 let gossip_name = PackageName::new(name.clone());
1108 let disposition = package_versions.map_or(CooldownDisposition::NotEvaluated, |versions| {
1109 cooldown_disposition(
1110 versions,
1111 &gossip_name,
1112 freshness,
1113 Some(&analysis.gossip_findings),
1114 now,
1115 )
1116 });
1117 let latest_is_osv_unplannable = matches!(
1118 &latest_candidate,
1119 UpdateCandidate::Unplannable {
1120 reason: UnplannableReason::LatestFlaggedByOsv | UnplannableReason::LatestUnverified,
1121 ..
1122 }
1123 );
1124 // Fix-cycle item 6 (DRY): "never demote a flagged/unverified latest" is the shared shape
1125 // behind rows 5/6/8/FR-003-reject — one closure instead of four hand-rolled copies.
1126 let never_demoted = |latest_candidate: UpdateCandidate| {
1127 let (current, outcome, advisory_ids) = resolve_from_latest(latest_candidate, ignore_rules);
1128 build(current, outcome, advisory_ids, None)
1129 };
1130 // DRY (code review finding): the routine "cooldown pause, targeting whatever `latest`
1131 // itself carries" shape recurs across rows 4/6/(fb.target-mismatch)/(requirement rejected)/11
1132 // — one closure instead of five hand-rolled copies.
1133 let cooldown_skip_from_latest = |latest_candidate: &UpdateCandidate| {
1134 let (current, target) = latest_current_target(latest_candidate);
1135 build(
1136 current,
1137 Outcome::Skipped {
1138 reason: SkipReason::WithinFreshnessCooldown,
1139 target,
1140 },
1141 Vec::new(),
1142 None,
1143 )
1144 };
1145
1146 match disposition {
1147 CooldownDisposition::NotEvaluated | CooldownDisposition::Cleared => {
1148 let (current, outcome, advisory_ids) =
1149 resolve_from_latest(latest_candidate, ignore_rules);
1150 build(current, outcome, advisory_ids, None)
1151 }
1152 CooldownDisposition::Blocked { fallback: None, .. } => {
1153 if latest_is_osv_unplannable {
1154 // Row 5: never demoted by a routine cooldown pause.
1155 never_demoted(latest_candidate)
1156 } else {
1157 // Row 4.
1158 cooldown_skip_from_latest(&latest_candidate)
1159 }
1160 }
1161 // `by` (GOSSIP vs. local) is deliberately unread here: the wording difference is
1162 // `cooldown_disposition`'s own read-time concern (`apply_outdated_rule`), not this
1163 // planner's — it only ever decides fallback-vs-latest, never which blocker to name.
1164 CooldownDisposition::Blocked {
1165 by: _,
1166 fallback: Some(fallback),
1167 } => {
1168 let latest_version = package_versions.map(|v| v.latest.clone());
1169 let available: &[deps_core::ConcreteVersion] =
1170 package_versions.map_or(&[], |v| &v.available);
1171 match fallback_candidate {
1172 // Row 6 (FR-009): absent from the fallback view means the fallback already
1173 // satisfies the declared requirement — never silently treat that as "use
1174 // latest anyway". Fix-cycle item 2/S2: never demote a flagged/unverified
1175 // latest to a routine cooldown pause just because no fallback view entry
1176 // exists — that regresses #1517's "never masked by cooldown" invariant.
1177 None => {
1178 if latest_is_osv_unplannable {
1179 never_demoted(latest_candidate)
1180 } else {
1181 cooldown_skip_from_latest(&latest_candidate)
1182 }
1183 }
1184 Some(UpdateCandidate::Planned(fb)) => {
1185 // Issue #1561 item 2 (defense-in-depth): `fb.target` (the fallback view's
1186 // own planned edit) and `fallback.version` (this occurrence's own
1187 // `cooldown_disposition` pick, computed independently above) must always
1188 // agree by construction — both ultimately derive from the same stored
1189 // `CooldownFallback`. Never write an edit neither computation fully
1190 // vouches for; a divergence falls through to the same fail-closed handling
1191 // as "fallback absent" above.
1192 if fb.target != fallback.version {
1193 return if latest_is_osv_unplannable {
1194 never_demoted(latest_candidate)
1195 } else {
1196 cooldown_skip_from_latest(&latest_candidate)
1197 };
1198 }
1199 // Spec 076 FR-022/FR-023/FR-025: one uniform, re-parse-based guard applied
1200 // identically to the `Located` (this occurrence has a lockfile-resolved
1201 // in-use version) and `Absent` (spec 076's no-lockfile) paths — no per-
1202 // ecosystem override, no Go exception (removed, FR-022: `ExactMatcher`
1203 // alone is sufficient once this guard ships).
1204 let requirement_ok = dep_by_key.get(&key).copied().is_some_and(|dep| {
1205 let verdict = fallback_edit_excludes_newer(
1206 formatter, reparse, content, dep, &fb.edit, &fb.target, available,
1207 );
1208 if let FallbackEditVerdict::Rejected(reason) = verdict {
1209 tracing::debug!(
1210 package = %normalized_name,
1211 fallback = %fb.target,
1212 ?reason,
1213 "fallback edit rejected by fallback_edit_excludes_newer"
1214 );
1215 }
1216 matches!(verdict, FallbackEditVerdict::Writable)
1217 });
1218 if requirement_ok {
1219 // Rows 7 & 9 (fix-cycle item 3/S3): `ignore_rules` now runs against
1220 // the SELECTED (fallback) target unconditionally, regardless of
1221 // whether latest is OSV-blocked — previously row 7 wrote the fallback
1222 // edit without ever consulting `[update].ignore`.
1223 let current = CurrentVersion::from(fb.current);
1224 let kind = current.update_kind_to(&fb.target);
1225 if let Some(reason) = ignore_rules.skip_reason(&fb.normalized_name, kind) {
1226 build(
1227 current,
1228 Outcome::Skipped {
1229 reason,
1230 target: Some(fb.target.clone()),
1231 },
1232 Vec::new(),
1233 None,
1234 )
1235 } else if latest_is_osv_unplannable {
1236 // Row 7 (FR-012/OQ3): target the fallback, keep the
1237 // flagged/unverified latest's own attribution in the same row.
1238 let advisory_ids =
1239 latest_version.as_ref().map_or_else(Vec::new, |latest| {
1240 osv_advisory_ids(
1241 analysis.latest_status.as_ref(),
1242 dep_by_key.get(&key).copied(),
1243 vuln_keys,
1244 &normalized_name,
1245 latest.as_str(),
1246 formatter,
1247 )
1248 });
1249 build(
1250 current,
1251 Outcome::Applied {
1252 edit: fb.edit,
1253 target: fb.target,
1254 },
1255 advisory_ids,
1256 latest_version.map(CooldownFallbackNote::AppliedInsteadOf),
1257 )
1258 } else {
1259 // Row 9: the ordinary cooldown-fallback substitution.
1260 build(
1261 current,
1262 Outcome::Applied {
1263 edit: fb.edit,
1264 target: fb.target,
1265 },
1266 Vec::new(),
1267 latest_version.map(CooldownFallbackNote::AppliedInsteadOf),
1268 )
1269 }
1270 } else {
1271 // FR-003 (A1) / fix-cycle item 2/S2: the compiled matcher rejects the
1272 // fallback as a downgrade (or is unavailable) — never write it, and
1273 // never demote a flagged/unverified latest here either.
1274 if latest_is_osv_unplannable {
1275 never_demoted(latest_candidate)
1276 } else {
1277 cooldown_skip_from_latest(&latest_candidate)
1278 }
1279 }
1280 }
1281 // FR-011: an OSV-flagged/unverified fallback must surface here even when
1282 // `fallback_edit_excludes_newer` (never consulted in this arm) would also reject it.
1283 Some(UpdateCandidate::Unplannable {
1284 reason: fb_reason, ..
1285 }) => {
1286 if latest_is_osv_unplannable {
1287 // Row 8: both blocked — defer to latest's own attribution, exit 1,
1288 // never demoted regardless of why the fallback also failed.
1289 never_demoted(latest_candidate)
1290 } else if let Some(rule_reason) =
1291 ignore_rules.skip_reason(&normalized_name, UpdateKind::Unknown)
1292 {
1293 // Fix-cycle item 4/S4: an ignored package's OSV-blocked fallback must
1294 // not exit non-zero — mirrors `resolve_from_latest`'s identical
1295 // Unplannable-candidate ignore-rule check (there is no concrete
1296 // current/target pair here either, the same fail-closed
1297 // `UpdateKind::Unknown` treatment applies).
1298 build(
1299 CurrentVersion::Unknown,
1300 Outcome::Skipped {
1301 reason: rule_reason,
1302 target: None,
1303 },
1304 Vec::new(),
1305 None,
1306 )
1307 } else if matches!(
1308 fb_reason,
1309 UnplannableReason::LatestFlaggedByOsv | UnplannableReason::LatestUnverified
1310 ) {
1311 // Row 10 (FR-011): the fallback itself is OSV-blocked — exit 1, naming
1312 // the fallback version, never a silent cooldown skip.
1313 let advisory_ids = osv_advisory_ids(
1314 analysis.fallback_status.as_ref(),
1315 dep_by_key.get(&key).copied(),
1316 vuln_keys,
1317 &normalized_name,
1318 fallback.version.as_str(),
1319 formatter,
1320 );
1321 let (current, _) = latest_current_target(&latest_candidate);
1322 build(
1323 current,
1324 Outcome::Skipped {
1325 reason: SkipReason::NotSafelyEditable(fb_reason),
1326 target: Some(fallback.version.clone()),
1327 },
1328 advisory_ids,
1329 Some(CooldownFallbackNote::Blocked {
1330 version: fallback.version.clone(),
1331 }),
1332 )
1333 } else {
1334 // Row 11: fallback blocked by a non-OSV structural reason.
1335 cooldown_skip_from_latest(&latest_candidate)
1336 }
1337 }
1338 }
1339 }
1340 }
1341}
1342
1343/// Error applying an [`UpdatePlan`] to disk.
1344#[derive(Debug, thiserror::Error)]
1345pub enum ApplyError {
1346 /// The manifest changed on disk between planning and writing (FR-019) — the byte-compare
1347 /// against the content the plan's ranges were computed against failed.
1348 #[error("{path} changed on disk since it was read; aborting without writing")]
1349 StaleManifest {
1350 /// The manifest path.
1351 path: std::path::PathBuf,
1352 },
1353 /// Re-reading the manifest for the TOCTOU check failed.
1354 #[error("failed to re-read {path}: {source}")]
1355 Read {
1356 /// The manifest path.
1357 path: std::path::PathBuf,
1358 /// The underlying I/O error.
1359 #[source]
1360 source: std::io::Error,
1361 },
1362 /// [`deps_core::fs_probe::write_atomic`] failed (including a symlink refusal).
1363 #[error("failed to write {path}: {source}")]
1364 Write {
1365 /// The manifest path.
1366 path: std::path::PathBuf,
1367 /// The underlying I/O error.
1368 #[source]
1369 source: std::io::Error,
1370 },
1371}
1372
1373/// Deduplicates `items`' [`Outcome::Applied`] edits by span in place, demoting any item whose
1374/// edit was dropped as an overlap to
1375/// [`Outcome::Skipped`] with reason [`SkipReason::OverlapsAnotherEdit`].
1376///
1377/// Spec 075 FR-015: [`plan_updates`] now calls this itself, after per-occurrence view
1378/// selection, over its own chosen `PlannedUpdate`s — the correct place for this pass to run
1379/// (never over both the latest and fallback views' raw candidates together). Still
1380/// load-bearing for [`security::plan_security_updates`], which does not dedup its own
1381/// `Applied` items (two vulnerable occurrences of one name can share a span), and for
1382/// `main.rs`'s own defensive call after either planner returns. Without this,
1383/// [`apply_plan`]'s own dedup pass could silently drop an edit a planner had already reported
1384/// `applied`, breaking the "`applied` implies written" invariant `--format json` consumers
1385/// rely on (critic finding M2). Call this on a planner's output before reporting/rendering
1386/// it, not after — the whole point is that the *reported* outcome must match what
1387/// [`apply_plan`] will actually write.
1388pub fn dedup_applied_items(items: &mut [PlannedUpdateItem]) {
1389 struct Indexed {
1390 index: usize,
1391 edit: ManifestEdit,
1392 }
1393 impl EditSpan for Indexed {
1394 fn start(&self) -> (u32, u32) {
1395 self.edit.start()
1396 }
1397 fn end(&self) -> (u32, u32) {
1398 self.edit.end()
1399 }
1400 }
1401
1402 let indexed: Vec<Indexed> = items
1403 .iter()
1404 .enumerate()
1405 .filter_map(|(index, item)| match &item.outcome {
1406 Outcome::Applied { edit, .. } => Some(Indexed {
1407 index,
1408 edit: edit.clone(),
1409 }),
1410 _ => None,
1411 })
1412 .collect();
1413 let kept_indices: std::collections::HashSet<usize> =
1414 dedup_overlapping_edits(indexed, "deps-cli update dedup_applied_items")
1415 .into_iter()
1416 .map(|indexed| indexed.index)
1417 .collect();
1418
1419 for (index, item) in items.iter_mut().enumerate() {
1420 if !kept_indices.contains(&index)
1421 && let Outcome::Applied { target, .. } = &item.outcome
1422 {
1423 item.outcome = Outcome::Skipped {
1424 reason: SkipReason::OverlapsAnotherEdit,
1425 target: Some(target.clone()),
1426 };
1427 }
1428 }
1429}
1430
1431/// Applies `plan`'s [`Outcome::Applied`] edits to `path`, whose content the plan's ranges
1432/// were computed against was `original_content` (FR-016 through FR-020).
1433///
1434/// Always re-reads and byte-compares `path` against `original_content` before writing (FR-019)
1435/// — even under `dry_run`, so a `--dry-run` report never claims success for a plan a following
1436/// real run would actually reject. [`crate::format::DryRun::Yes`] skips the
1437/// [`deps_core::fs_probe::write_atomic`] call itself; so does a plan whose edits would produce
1438/// byte-identical content (no `Applied` items, or all-no-op edits) — skipping an unnecessary
1439/// rewrite avoids churning the file's mtime/inode for file watchers and rebuild systems (critic
1440/// finding M3).
1441///
1442/// # Errors
1443///
1444/// Returns [`ApplyError::StaleManifest`] if `path`'s content no longer matches
1445/// `original_content`, [`ApplyError::Read`] if the re-read fails, or [`ApplyError::Write`] if
1446/// [`deps_core::fs_probe::write_atomic`] fails (including refusing a symlinked `path`).
1447pub fn apply_plan(
1448 plan: &UpdatePlan,
1449 path: &std::path::Path,
1450 original_content: &str,
1451 dry_run: crate::format::DryRun,
1452) -> Result<(), ApplyError> {
1453 // Defensive: `dedup_applied_items` should already have run over `plan` before this is
1454 // called, so this is normally a no-op — kept as a safety net, not the primary mechanism.
1455 let edits = dedup_overlapping_edits(plan.applied_edits(), "deps-cli update");
1456 let new_content = apply_edits(original_content, &edits);
1457
1458 let reread = deps_core::fs_probe::read_to_string_capped(path, crate::MAX_MANIFEST_FILE_SIZE)
1459 .map_err(|source| ApplyError::Read {
1460 path: path.to_path_buf(),
1461 source,
1462 })?;
1463 if reread.as_deref() != Some(original_content) {
1464 return Err(ApplyError::StaleManifest {
1465 path: path.to_path_buf(),
1466 });
1467 }
1468
1469 if dry_run == crate::format::DryRun::Yes || new_content == original_content {
1470 return Ok(());
1471 }
1472
1473 deps_core::fs_probe::write_atomic(path, &new_content).map_err(|source| ApplyError::Write {
1474 path: path.to_path_buf(),
1475 source,
1476 })
1477}
1478
1479#[cfg(test)]
1480mod tests {
1481 use super::*;
1482 use deps_core::licenses::LicensePolicy;
1483 use deps_core::parser::DependencySource;
1484 use deps_core::position::{Position, Range};
1485 use deps_core::{Dependency, EcosystemId, PackageVersions, ParseResult};
1486 use std::any::Any;
1487 use std::collections::{HashMap, HashSet};
1488
1489 const STUB_FORMATTER: deps_core::test_util::StubFormatter =
1490 deps_core::test_util::StubFormatter::new().with_package_url_prefix("");
1491
1492 /// Issue #1593 critic M5: a `Declared` current version must never classify as anything
1493 /// but `Unknown` — guards against a future caller feeding it into a classification path
1494 /// (e.g. `ignore_rules.skip_reason`) that starts treating a declared range as comparable.
1495 #[test]
1496 fn test_current_version_declared_update_kind_is_always_unknown() {
1497 let declared = CurrentVersion::Declared(deps_core::VersionReq::new("^1.0"));
1498 assert_eq!(
1499 declared.update_kind_to(&ConcreteVersion::from("2.0.0")),
1500 UpdateKind::Unknown
1501 );
1502 }
1503
1504 struct TestDep {
1505 name: PackageName,
1506 version_req: deps_core::VersionReq,
1507 version_range: Range,
1508 }
1509 impl Dependency for TestDep {
1510 fn name(&self) -> &PackageName {
1511 &self.name
1512 }
1513 fn name_range(&self) -> Range {
1514 Range::default()
1515 }
1516 fn version_requirement(&self) -> Option<&deps_core::VersionReq> {
1517 Some(&self.version_req)
1518 }
1519 fn version_range(&self) -> Option<Range> {
1520 Some(self.version_range)
1521 }
1522 fn source(&self) -> DependencySource {
1523 DependencySource::Registry
1524 }
1525 fn as_any(&self) -> &dyn Any {
1526 self
1527 }
1528 }
1529
1530 struct TestParseResult {
1531 deps: Vec<TestDep>,
1532 uri: url::Url,
1533 }
1534 impl ParseResult for TestParseResult {
1535 fn dependencies(&self) -> Vec<&dyn Dependency> {
1536 self.deps.iter().map(|d| d as &dyn Dependency).collect()
1537 }
1538 fn workspace_root(&self) -> Option<&std::path::Path> {
1539 None
1540 }
1541 fn uri(&self) -> &url::Url {
1542 &self.uri
1543 }
1544 fn as_any(&self) -> &dyn Any {
1545 self
1546 }
1547 }
1548
1549 fn test_dep(name: &str, req: &str, range: Range) -> TestDep {
1550 TestDep {
1551 name: PackageName::new(name),
1552 version_req: deps_core::VersionReq::new(req),
1553 version_range: range,
1554 }
1555 }
1556
1557 /// Spec 076 T004/M6: a [`ManifestReparse`] stub that must never actually be called — for
1558 /// fixtures whose disposition never reaches [`fallback_edit_excludes_newer`]'s re-parse
1559 /// phase (freshness disabled, no fallback candidate, or the fallback view's own OSV/
1560 /// structural rejection resolves the occurrence before the guard ever runs).
1561 fn never_reparse(_content: &str) -> Option<Box<dyn ParseResult>> {
1562 unreachable!("this fixture's disposition must never reach the fallback-edit re-parse phase")
1563 }
1564
1565 /// Spec 076 M6: shared `ManifestReparse` test stub for this module's quoted-version,
1566 /// one-dependency-per-line fixtures (`name = "X"`). Rebuilds each `deps` entry's occurrence
1567 /// by reading back whatever version text now sits between ITS OWN quotes in the edited
1568 /// content, at the SAME position its original `version_range.start` had — genuinely
1569 /// reflects the edit `plan_updates` applied, rather than hard-coding the expected target
1570 /// (mirrors production `EcosystemReparse`'s re-parse-the-actual-edit contract).
1571 fn reparse_quoted_deps(deps: Vec<(&'static str, Position)>) -> impl ManifestReparse {
1572 move |edited_content: &str| -> Option<Box<dyn ParseResult>> {
1573 let rebuilt: Vec<TestDep> = deps
1574 .iter()
1575 .map(|&(name, start)| {
1576 let line = edited_content.lines().nth(start.line as usize)?;
1577 let rest = line.get(start.character as usize..)?;
1578 let version_len = rest.find('"')?;
1579 let version = rest.get(..version_len)?;
1580 Some(TestDep {
1581 name: PackageName::new(name),
1582 version_req: deps_core::VersionReq::new(version),
1583 version_range: Range::new(
1584 start,
1585 Position::new(
1586 start.line,
1587 start.character + u32::try_from(version_len).ok()?,
1588 ),
1589 ),
1590 })
1591 })
1592 .collect::<Option<_>>()?;
1593 Some(Box::new(TestParseResult {
1594 deps: rebuilt,
1595 uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
1596 }) as Box<dyn ParseResult>)
1597 }
1598 }
1599
1600 /// [`plan_updates`] with freshness cooldown filtering disabled — the pre-#1525 behavior
1601 /// every test not specifically about that filter wants.
1602 ///
1603 /// Critique M5: actually passes [`deps_core::FreshnessSettings::Disabled`], not
1604 /// [`deps_core::FreshnessSettings::default`] (which is enabled — a prior version of this
1605 /// helper passed that and only happened to work because every fixture omitted
1606 /// `published_at`; a fixture that later set one via [`PackageVersions::with_published_at`]
1607 /// would have been silently skipped instead of applied).
1608 fn plan_updates_no_cooldown(
1609 analysis: &ManifestAnalysis,
1610 content: &str,
1611 formatter: &dyn EcosystemFormatter,
1612 package_filter: &[String],
1613 ignore_rules: &IgnoreRules,
1614 ) -> UpdatePlan {
1615 plan_updates(
1616 analysis,
1617 content,
1618 formatter,
1619 &never_reparse,
1620 package_filter,
1621 ignore_rules,
1622 deps_core::FreshnessSettings::Disabled,
1623 deps_core::PublishTime::now(),
1624 )
1625 }
1626
1627 fn test_analysis(
1628 deps: Vec<TestDep>,
1629 cached: HashMap<PackageName, PackageVersions>,
1630 ) -> ManifestAnalysis {
1631 ManifestAnalysis {
1632 parse_result: Box::new(TestParseResult {
1633 deps,
1634 uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
1635 }),
1636 uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
1637 now: deps_core::PublishTime::now(),
1638 ecosystem_id: EcosystemId::Cargo,
1639 cached_versions: cached,
1640 resolved_versions: HashMap::new(),
1641 resolved_version_candidates: HashMap::new(),
1642 outcomes: deps_core::lsp_helpers::DependencyOutcomes::new(),
1643 vulnerabilities: None,
1644 latest_status: None,
1645 fallback_status: None,
1646 cooldown_fallback_view: None,
1647 gossip_findings: HashMap::new(),
1648 licenses: HashMap::new(),
1649 license_policy: LicensePolicy::default(),
1650 license_source: deps_core::LicenseSource::default(),
1651 network: deps_core::NetworkMode::Online,
1652 fetch_failed: HashSet::new(),
1653 registry_unreachable: false,
1654 license_fetch_incomplete: false,
1655 }
1656 }
1657
1658 fn cached(name: &str, latest: &str) -> HashMap<PackageName, PackageVersions> {
1659 let mut map = HashMap::new();
1660 map.insert(PackageName::new(name), PackageVersions::latest_only(latest));
1661 map
1662 }
1663
1664 /// Issue #1525: a `latest` published within `freshness.cooldown_secs` of `now` must be
1665 /// skipped as the update target in default mode, not silently applied — the empirically
1666 /// verified bug (`deps-cli update --cooldown N --dry-run` had no effect at all).
1667 #[test]
1668 fn test_plan_updates_within_freshness_cooldown_is_skipped() {
1669 let content = "serde = \"1.0.0\"\n";
1670 let now = deps_core::PublishTime::from_unix_secs(10_000);
1671 let published_at = deps_core::PublishTime::from_unix_secs(9_000); // 1000s old
1672 let mut versions = cached("serde", "1.2.0");
1673 versions.insert(
1674 PackageName::new("serde"),
1675 PackageVersions::latest_only("1.2.0").with_published_at(published_at),
1676 );
1677 let analysis = test_analysis(
1678 vec![test_dep(
1679 "serde",
1680 "1.0.0",
1681 Range::new(Position::new(0, 9), Position::new(0, 14)),
1682 )],
1683 versions,
1684 );
1685
1686 let plan = plan_updates(
1687 &analysis,
1688 content,
1689 &STUB_FORMATTER,
1690 &never_reparse,
1691 &[],
1692 &IgnoreRules::empty(),
1693 deps_core::FreshnessSettings::Enabled {
1694 cooldown: deps_core::CooldownWindow::from_secs(2_000), // wider than the 1000s age above
1695 },
1696 now,
1697 );
1698
1699 assert_eq!(plan.items.len(), 1);
1700 assert!(matches!(
1701 plan.items[0].outcome,
1702 Outcome::Skipped {
1703 reason: SkipReason::WithinFreshnessCooldown,
1704 ..
1705 }
1706 ));
1707 assert_eq!(
1708 crate::exit::update_exit_code(&plan),
1709 crate::exit::EXIT_CLEAN,
1710 "an automatic freshness-cooldown pause must not fail the run"
1711 );
1712 }
1713
1714 /// The same fixture, but `freshness.enabled = false`: the cooldown filter must be a
1715 /// complete no-op, mirroring how the local heuristic is opt-out everywhere else.
1716 #[test]
1717 fn test_plan_updates_freshness_disabled_ignores_cooldown() {
1718 let content = "serde = \"1.0.0\"\n";
1719 let now = deps_core::PublishTime::from_unix_secs(10_000);
1720 let published_at = deps_core::PublishTime::from_unix_secs(9_000);
1721 let mut versions = cached("serde", "1.2.0");
1722 versions.insert(
1723 PackageName::new("serde"),
1724 PackageVersions::latest_only("1.2.0").with_published_at(published_at),
1725 );
1726 let analysis = test_analysis(
1727 vec![test_dep(
1728 "serde",
1729 "1.0.0",
1730 Range::new(Position::new(0, 9), Position::new(0, 14)),
1731 )],
1732 versions,
1733 );
1734
1735 let plan = plan_updates(
1736 &analysis,
1737 content,
1738 &STUB_FORMATTER,
1739 &never_reparse,
1740 &[],
1741 &IgnoreRules::empty(),
1742 deps_core::FreshnessSettings::Disabled,
1743 now,
1744 );
1745
1746 assert_eq!(plan.items.len(), 1);
1747 assert!(matches!(plan.items[0].outcome, Outcome::Applied { .. }));
1748 }
1749
1750 /// Issue #1521 item 1: `update`'s output must attribute a GOSSIP-cooldown-excluded newer
1751 /// version on its `PlannedUpdateItem`, mirroring `check`'s equivalent message attribution.
1752 #[test]
1753 fn test_plan_updates_surfaces_gossip_excluded_version() {
1754 let content = "serde = \"1.0.0\"\n";
1755 let mut versions = cached("serde", "1.2.0");
1756 versions.insert(
1757 PackageName::new("serde"),
1758 PackageVersions::latest_only("1.2.0")
1759 .with_gossip_excluded_version(deps_core::ConcreteVersion::new("2.0.0")),
1760 );
1761 let analysis = test_analysis(
1762 vec![test_dep(
1763 "serde",
1764 "1.0.0",
1765 Range::new(Position::new(0, 9), Position::new(0, 14)),
1766 )],
1767 versions,
1768 );
1769
1770 let plan = plan_updates_no_cooldown(
1771 &analysis,
1772 content,
1773 &STUB_FORMATTER,
1774 &[],
1775 &IgnoreRules::empty(),
1776 );
1777
1778 assert_eq!(plan.items.len(), 1);
1779 assert!(matches!(plan.items[0].outcome, Outcome::Applied { .. }));
1780 assert_eq!(
1781 plan.items[0].gossip_excluded_version,
1782 Some(deps_core::ConcreteVersion::new("2.0.0"))
1783 );
1784 // Tester finding: an exact-suffix assertion (not just `.contains`) catches future
1785 // wording drift between this literal and `crate::report::to_finding`'s identical one.
1786 assert_eq!(
1787 plan.items[0].reason(),
1788 "update applied (a newer version was excluded from this pick by an active GOSSIP cooldown finding)"
1789 );
1790 }
1791
1792 /// Critique M1: when both the local freshness cooldown and a GOSSIP cooldown exclusion
1793 /// apply to the same candidate, the freshness skip wins the `outcome` decision (no
1794 /// per-version publish-time list exists here to reconsider the pick), but the GOSSIP
1795 /// attribution must still surface on the item — previously it was hardcoded to `None` on
1796 /// every skip branch, silently dropping which version GOSSIP had already excluded.
1797 #[test]
1798 fn test_plan_updates_freshness_cooldown_takes_precedence_but_keeps_gossip_attribution() {
1799 let content = "serde = \"1.0.0\"\n";
1800 let now = deps_core::PublishTime::from_unix_secs(10_000);
1801 let published_at = deps_core::PublishTime::from_unix_secs(9_000); // 1000s old
1802 let mut versions = cached("serde", "1.2.0");
1803 versions.insert(
1804 PackageName::new("serde"),
1805 PackageVersions::latest_only("1.2.0")
1806 .with_published_at(published_at)
1807 .with_gossip_excluded_version(deps_core::ConcreteVersion::new("2.0.0")),
1808 );
1809 let analysis = test_analysis(
1810 vec![test_dep(
1811 "serde",
1812 "1.0.0",
1813 Range::new(Position::new(0, 9), Position::new(0, 14)),
1814 )],
1815 versions,
1816 );
1817
1818 let plan = plan_updates(
1819 &analysis,
1820 content,
1821 &STUB_FORMATTER,
1822 &never_reparse,
1823 &[],
1824 &IgnoreRules::empty(),
1825 deps_core::FreshnessSettings::Enabled {
1826 cooldown: deps_core::CooldownWindow::from_secs(2_000), // wider than the 1000s age above
1827 },
1828 now,
1829 );
1830
1831 assert_eq!(plan.items.len(), 1);
1832 assert!(
1833 matches!(
1834 plan.items[0].outcome,
1835 Outcome::Skipped {
1836 reason: SkipReason::WithinFreshnessCooldown,
1837 ..
1838 }
1839 ),
1840 "the local cooldown skip must win the outcome decision"
1841 );
1842 assert_eq!(
1843 plan.items[0].gossip_excluded_version,
1844 Some(deps_core::ConcreteVersion::new("2.0.0")),
1845 "the GOSSIP attribution must survive the cooldown skip, not be silently dropped"
1846 );
1847 let reason = plan.items[0].reason();
1848 assert!(
1849 reason.contains("freshness cooldown window") && reason.contains("GOSSIP cooldown"),
1850 "got: {reason}"
1851 );
1852 }
1853
1854 /// US-001: multiple outdated dependencies, one already at latest.
1855 #[test]
1856 fn test_plan_updates_us001_multiple_outdated_one_up_to_date() {
1857 let content = "serde = \"1.0.0\"\ntokio = \"1.0.0\"\nlibc = \"1.2.0\"\n";
1858 let mut versions = cached("serde", "1.2.0");
1859 versions.insert(
1860 PackageName::new("tokio"),
1861 PackageVersions::latest_only("1.3.0"),
1862 );
1863 versions.insert(
1864 PackageName::new("libc"),
1865 PackageVersions::latest_only("1.2.0"),
1866 );
1867 let analysis = test_analysis(
1868 vec![
1869 test_dep(
1870 "serde",
1871 "1.0.0",
1872 Range::new(Position::new(0, 9), Position::new(0, 14)),
1873 ),
1874 test_dep(
1875 "tokio",
1876 "1.0.0",
1877 Range::new(Position::new(1, 9), Position::new(1, 14)),
1878 ),
1879 test_dep(
1880 "libc",
1881 "1.2.0",
1882 Range::new(Position::new(2, 8), Position::new(2, 13)),
1883 ),
1884 ],
1885 versions,
1886 );
1887
1888 let plan = plan_updates_no_cooldown(
1889 &analysis,
1890 content,
1891 &STUB_FORMATTER,
1892 &[],
1893 &IgnoreRules::empty(),
1894 );
1895 let applied: Vec<&str> = plan
1896 .items
1897 .iter()
1898 .filter(|i| matches!(i.outcome, Outcome::Applied { .. }))
1899 .map(|i| i.name.as_str())
1900 .collect();
1901 assert_eq!(
1902 applied.len(),
1903 2,
1904 "serde and tokio are outdated, libc is not: {applied:?}"
1905 );
1906 assert!(applied.contains(&"serde"));
1907 assert!(applied.contains(&"tokio"));
1908 assert!(
1909 !plan.items.iter().any(|i| i.name == "libc"),
1910 "an up-to-date dependency must never appear as a candidate at all"
1911 );
1912 }
1913
1914 /// Issue #1517 critique S6: no test covered `plan_updates`/`collect_update_candidates`'s
1915 /// actual gate on a `Flagged` OSV verdict for the registry's cached "latest" — this pins
1916 /// the whole wiring end to end: the candidate must come back
1917 /// `Skipped(NotSafelyEditable(LatestFlaggedByOsv))`, never `Applied`, and
1918 /// `deps_cli::exit::update_exit_code` must treat that as a nonzero (policy-violation) exit,
1919 /// the same as any other `NotSafelyEditable` reason.
1920 #[test]
1921 fn test_plan_updates_refuses_a_flagged_latest() {
1922 use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
1923
1924 let content = "serde = \"1.0.0\"\n";
1925 let versions = cached("serde", "1.2.0");
1926 let mut analysis = test_analysis(
1927 vec![test_dep(
1928 "serde",
1929 "1.0.0",
1930 Range::new(Position::new(0, 9), Position::new(0, 14)),
1931 )],
1932 versions,
1933 );
1934 let mut latest_status = LatestStatusMap::new();
1935 latest_status.insert(
1936 deps_core::test_util::vuln_key("serde"),
1937 UpgradeStatus::CandidateVulnerable {
1938 version: ConcreteVersion::new("1.2.0"),
1939 advisory_ids: Capped::new(vec!["MAL-2026-00001".to_string()], 1),
1940 worst_severity: Some(VulnSeverity::Malicious),
1941 },
1942 );
1943 analysis.latest_status = Some(latest_status);
1944
1945 let plan = plan_updates_no_cooldown(
1946 &analysis,
1947 content,
1948 &STUB_FORMATTER,
1949 &[],
1950 &IgnoreRules::empty(),
1951 );
1952
1953 assert_eq!(plan.items.len(), 1);
1954 assert!(
1955 matches!(
1956 plan.items[0].outcome,
1957 Outcome::Skipped {
1958 reason: SkipReason::NotSafelyEditable(
1959 deps_core::edit::UnplannableReason::LatestFlaggedByOsv
1960 ),
1961 ..
1962 }
1963 ),
1964 "got: {:?}",
1965 plan.items[0].outcome
1966 );
1967 assert_eq!(
1968 crate::exit::update_exit_code(&plan),
1969 crate::exit::EXIT_POLICY_VIOLATION,
1970 "a flagged latest must never exit clean"
1971 );
1972 }
1973
1974 /// Critique M3 (the `LatestFlaggedByOsv` exception): a locally-fresh `latest` that is
1975 /// *also* OSV-flagged must still surface as `NotSafelyEditable(LatestFlaggedByOsv)` — the
1976 /// cooldown skip's "not a real recommendation yet, wait" framing must never demote a
1977 /// confirmed-malicious verdict to a routine, exit-0 pause.
1978 #[test]
1979 fn test_plan_updates_flagged_latest_is_never_masked_by_cooldown() {
1980 use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
1981
1982 let content = "serde = \"1.0.0\"\n";
1983 let now = deps_core::PublishTime::from_unix_secs(10_000);
1984 let published_at = deps_core::PublishTime::from_unix_secs(9_000); // 1000s old, in-window
1985 let mut versions = cached("serde", "1.2.0");
1986 versions.insert(
1987 PackageName::new("serde"),
1988 PackageVersions::latest_only("1.2.0").with_published_at(published_at),
1989 );
1990 let mut analysis = test_analysis(
1991 vec![test_dep(
1992 "serde",
1993 "1.0.0",
1994 Range::new(Position::new(0, 9), Position::new(0, 14)),
1995 )],
1996 versions,
1997 );
1998 let mut latest_status = LatestStatusMap::new();
1999 latest_status.insert(
2000 deps_core::test_util::vuln_key("serde"),
2001 UpgradeStatus::CandidateVulnerable {
2002 version: ConcreteVersion::new("1.2.0"),
2003 advisory_ids: Capped::new(vec!["MAL-2026-00001".to_string()], 1),
2004 worst_severity: Some(VulnSeverity::Malicious),
2005 },
2006 );
2007 analysis.latest_status = Some(latest_status);
2008
2009 let plan = plan_updates(
2010 &analysis,
2011 content,
2012 &STUB_FORMATTER,
2013 &never_reparse,
2014 &[],
2015 &IgnoreRules::empty(),
2016 deps_core::FreshnessSettings::Enabled {
2017 cooldown: deps_core::CooldownWindow::from_secs(2_000),
2018 },
2019 now,
2020 );
2021
2022 assert_eq!(plan.items.len(), 1);
2023 assert!(
2024 matches!(
2025 plan.items[0].outcome,
2026 Outcome::Skipped {
2027 reason: SkipReason::NotSafelyEditable(
2028 deps_core::edit::UnplannableReason::LatestFlaggedByOsv
2029 ),
2030 ..
2031 }
2032 ),
2033 "got: {:?}",
2034 plan.items[0].outcome
2035 );
2036 assert_eq!(
2037 plan.items[0].target(),
2038 None,
2039 "an Unplannable candidate has no concrete target"
2040 );
2041 assert_eq!(
2042 crate::exit::update_exit_code(&plan),
2043 crate::exit::EXIT_POLICY_VIOLATION,
2044 "a flagged latest must never exit clean, even when also within cooldown"
2045 );
2046 }
2047
2048 /// Code-review finding (post-M3): `UnplannableReason::LatestUnverified`'s own doc says it
2049 /// "fails closed the same way `LatestFlaggedByOsv` does... never distinguishable from a
2050 /// flagged one at write time" — the M3 exception must therefore cover both variants, not
2051 /// only `LatestFlaggedByOsv`. Before this fix, a locally-fresh `latest` with an unverified
2052 /// OSV check was silently reclassified from `NotSafelyEditable(LatestUnverified)` (exit 1)
2053 /// to `WithinFreshnessCooldown` (exit 0).
2054 #[test]
2055 fn test_plan_updates_unverified_latest_is_never_masked_by_cooldown() {
2056 use deps_core::osv::LatestStatusMap;
2057
2058 let content = "serde = \"1.0.0\"\n";
2059 let now = deps_core::PublishTime::from_unix_secs(10_000);
2060 let published_at = deps_core::PublishTime::from_unix_secs(9_000); // 1000s old, in-window
2061 let mut versions = cached("serde", "1.2.0");
2062 versions.insert(
2063 PackageName::new("serde"),
2064 PackageVersions::latest_only("1.2.0").with_published_at(published_at),
2065 );
2066 let mut analysis = test_analysis(
2067 vec![test_dep(
2068 "serde",
2069 "1.0.0",
2070 Range::new(Position::new(0, 9), Position::new(0, 14)),
2071 )],
2072 versions,
2073 );
2074 // `Some(&empty map)`: OSV checking is on, but nothing has verified this dependency's
2075 // latest yet — the pre-phase-B state, distinct from `None` (checking disabled/offline).
2076 analysis.latest_status = Some(LatestStatusMap::new());
2077
2078 let plan = plan_updates(
2079 &analysis,
2080 content,
2081 &STUB_FORMATTER,
2082 &never_reparse,
2083 &[],
2084 &IgnoreRules::empty(),
2085 deps_core::FreshnessSettings::Enabled {
2086 cooldown: deps_core::CooldownWindow::from_secs(2_000),
2087 },
2088 now,
2089 );
2090
2091 assert_eq!(plan.items.len(), 1);
2092 assert!(
2093 matches!(
2094 plan.items[0].outcome,
2095 Outcome::Skipped {
2096 reason: SkipReason::NotSafelyEditable(
2097 deps_core::edit::UnplannableReason::LatestUnverified
2098 ),
2099 ..
2100 }
2101 ),
2102 "got: {:?}",
2103 plan.items[0].outcome
2104 );
2105 assert_eq!(
2106 crate::exit::update_exit_code(&plan),
2107 crate::exit::EXIT_POLICY_VIOLATION,
2108 "an unverified latest must never exit clean, even when also within cooldown"
2109 );
2110 }
2111
2112 /// Critique M3: an unplannable candidate whose `latest` is locally fresh gets the same
2113 /// clean cooldown skip a `Planned` candidate would — whether an edit happens to be
2114 /// mechanically writable is orthogonal to whether the version is even a real
2115 /// recommendation yet.
2116 #[test]
2117 fn test_plan_updates_unplannable_candidate_within_cooldown_is_cooldown_skip_not_unsafe() {
2118 let content = "tokio = \"weird\"\n"; // literal mismatch -> NonLiteralSpan, absent this fix
2119 let now = deps_core::PublishTime::from_unix_secs(10_000);
2120 let published_at = deps_core::PublishTime::from_unix_secs(9_000);
2121 let versions_map = {
2122 let mut map = cached("tokio", "2.0.0");
2123 map.insert(
2124 PackageName::new("tokio"),
2125 PackageVersions::latest_only("2.0.0").with_published_at(published_at),
2126 );
2127 map
2128 };
2129 let analysis = test_analysis(
2130 vec![test_dep(
2131 "tokio",
2132 "1.0.0",
2133 Range::new(Position::new(0, 9), Position::new(0, 14)),
2134 )],
2135 versions_map,
2136 );
2137
2138 let plan = plan_updates(
2139 &analysis,
2140 content,
2141 &STUB_FORMATTER,
2142 &never_reparse,
2143 &[],
2144 &IgnoreRules::empty(),
2145 deps_core::FreshnessSettings::Enabled {
2146 cooldown: deps_core::CooldownWindow::from_secs(2_000),
2147 },
2148 now,
2149 );
2150
2151 assert_eq!(plan.items.len(), 1);
2152 assert!(
2153 matches!(
2154 plan.items[0].outcome,
2155 Outcome::Skipped {
2156 reason: SkipReason::WithinFreshnessCooldown,
2157 ..
2158 }
2159 ),
2160 "got: {:?}",
2161 plan.items[0].outcome
2162 );
2163 assert_eq!(
2164 crate::exit::update_exit_code(&plan),
2165 crate::exit::EXIT_CLEAN,
2166 "a cooldown-driven pause must exit clean even when the candidate was also unplannable"
2167 );
2168 }
2169
2170 /// Issue #1517 critique S6: same as the flagged case above, for an `Unverified` OSV
2171 /// verdict (no phase-B/scan result for this dependency at all) — the shared gate must
2172 /// fail closed the same way, not only for a confirmed-malicious `Flagged` verdict.
2173 #[test]
2174 fn test_plan_updates_refuses_an_unverified_latest() {
2175 use deps_core::osv::LatestStatusMap;
2176
2177 let content = "serde = \"1.0.0\"\n";
2178 let versions = cached("serde", "1.2.0");
2179 let mut analysis = test_analysis(
2180 vec![test_dep(
2181 "serde",
2182 "1.0.0",
2183 Range::new(Position::new(0, 9), Position::new(0, 14)),
2184 )],
2185 versions,
2186 );
2187 // `Some(&empty map)`: OSV checking is on, but nothing has verified this dependency's
2188 // latest yet — the pre-phase-B state, distinct from `None` (checking disabled/offline).
2189 analysis.latest_status = Some(LatestStatusMap::new());
2190
2191 let plan = plan_updates_no_cooldown(
2192 &analysis,
2193 content,
2194 &STUB_FORMATTER,
2195 &[],
2196 &IgnoreRules::empty(),
2197 );
2198
2199 assert_eq!(plan.items.len(), 1);
2200 assert!(
2201 matches!(
2202 plan.items[0].outcome,
2203 Outcome::Skipped {
2204 reason: SkipReason::NotSafelyEditable(
2205 deps_core::edit::UnplannableReason::LatestUnverified
2206 ),
2207 ..
2208 }
2209 ),
2210 "got: {:?}",
2211 plan.items[0].outcome
2212 );
2213 assert_eq!(
2214 crate::exit::update_exit_code(&plan),
2215 crate::exit::EXIT_POLICY_VIOLATION,
2216 "an unverified latest must never exit clean"
2217 );
2218 }
2219
2220 /// US-002: `--package` narrows the update set; the excluded dependency is still reported.
2221 #[test]
2222 fn test_plan_updates_us002_package_filter_narrows_to_one() {
2223 let content = "serde = \"1.0.0\"\ntokio = \"1.0.0\"\n";
2224 let mut versions = cached("serde", "1.2.0");
2225 versions.insert(
2226 PackageName::new("tokio"),
2227 PackageVersions::latest_only("1.3.0"),
2228 );
2229 let analysis = test_analysis(
2230 vec![
2231 test_dep(
2232 "serde",
2233 "1.0.0",
2234 Range::new(Position::new(0, 9), Position::new(0, 14)),
2235 ),
2236 test_dep(
2237 "tokio",
2238 "1.0.0",
2239 Range::new(Position::new(1, 9), Position::new(1, 14)),
2240 ),
2241 ],
2242 versions,
2243 );
2244
2245 let plan = plan_updates_no_cooldown(
2246 &analysis,
2247 content,
2248 &STUB_FORMATTER,
2249 &["serde".to_string()],
2250 &IgnoreRules::empty(),
2251 );
2252
2253 let serde_item = plan.items.iter().find(|i| i.name == "serde").unwrap();
2254 assert!(matches!(serde_item.outcome, Outcome::Applied { .. }));
2255 let tokio_item = plan.items.iter().find(|i| i.name == "tokio").unwrap();
2256 assert!(matches!(
2257 tokio_item.outcome,
2258 Outcome::Skipped {
2259 reason: SkipReason::NotRequested,
2260 ..
2261 }
2262 ));
2263 }
2264
2265 /// US-004 default-mode half: an `update_types`-scoped ignore rule skips a major bump.
2266 #[test]
2267 fn test_plan_updates_us004_ignore_rule_skips_major_bump() {
2268 let content = "tokio = \"1.0.0\"\n";
2269 let versions = cached("tokio", "2.0.0");
2270 let analysis = test_analysis(
2271 vec![test_dep(
2272 "tokio",
2273 "1.0.0",
2274 Range::new(Position::new(0, 9), Position::new(0, 14)),
2275 )],
2276 versions,
2277 );
2278 let ignore_rules = crate::update::ignore::IgnoreRules::new(
2279 vec![crate::config::IgnoreRule {
2280 name: "tokio".to_string(),
2281 update_types: Some(vec![crate::config::UpdateTypeToken::Major]),
2282 }],
2283 &STUB_FORMATTER,
2284 );
2285
2286 let plan =
2287 plan_updates_no_cooldown(&analysis, content, &STUB_FORMATTER, &[], &ignore_rules);
2288
2289 assert_eq!(plan.items.len(), 1);
2290 assert!(matches!(
2291 plan.items[0].outcome,
2292 Outcome::Skipped {
2293 reason: SkipReason::IgnoreRule,
2294 ..
2295 }
2296 ));
2297 }
2298
2299 /// Code review finding 1: an `Unplannable` candidate (here, a `NonLiteralSpan` — the
2300 /// content at `version_range` does not match the declared requirement text) that also
2301 /// matches an `[update].ignore` rule must be reported `Skipped(IgnoreRule)` — exit 0 — not
2302 /// `Skipped(NotSafelyEditable)` — exit 1. Before this fix, the unplannable-candidate loop
2303 /// never consulted `ignore_rules` at all, so this case always fell through to
2304 /// `NotSafelyEditable` regardless of a matching rule.
2305 #[test]
2306 fn test_plan_updates_unplannable_candidate_still_honors_ignore_rule() {
2307 let content = "tokio = \"weird\"\n";
2308 let versions = cached("tokio", "2.0.0");
2309 let analysis = test_analysis(
2310 vec![test_dep(
2311 "tokio",
2312 "1.0.0",
2313 Range::new(Position::new(0, 9), Position::new(0, 14)),
2314 )],
2315 versions,
2316 );
2317 let ignore_rules = crate::update::ignore::IgnoreRules::new(
2318 vec![crate::config::IgnoreRule {
2319 name: "tokio".to_string(),
2320 update_types: None,
2321 }],
2322 &STUB_FORMATTER,
2323 );
2324
2325 let plan =
2326 plan_updates_no_cooldown(&analysis, content, &STUB_FORMATTER, &[], &ignore_rules);
2327
2328 assert_eq!(plan.items.len(), 1);
2329 assert!(
2330 matches!(
2331 plan.items[0].outcome,
2332 Outcome::Skipped {
2333 reason: SkipReason::IgnoreRule,
2334 ..
2335 }
2336 ),
2337 "got {:?}",
2338 plan.items[0].outcome
2339 );
2340 }
2341
2342 /// Companion to the above: the same unplannable candidate with no matching ignore rule
2343 /// still reports `NotSafelyEditable`, proving the new `ignore_rules` check above is
2344 /// additive, not a blanket bypass of the unplannable-candidate reporting.
2345 #[test]
2346 fn test_plan_updates_unplannable_candidate_without_ignore_rule_is_not_safely_editable() {
2347 let content = "tokio = \"weird\"\n";
2348 let versions = cached("tokio", "2.0.0");
2349 let analysis = test_analysis(
2350 vec![test_dep(
2351 "tokio",
2352 "1.0.0",
2353 Range::new(Position::new(0, 9), Position::new(0, 14)),
2354 )],
2355 versions,
2356 );
2357
2358 let plan = plan_updates_no_cooldown(
2359 &analysis,
2360 content,
2361 &STUB_FORMATTER,
2362 &[],
2363 &IgnoreRules::empty(),
2364 );
2365
2366 assert_eq!(plan.items.len(), 1);
2367 assert!(matches!(
2368 plan.items[0].outcome,
2369 Outcome::Skipped {
2370 reason: SkipReason::NotSafelyEditable(
2371 deps_core::edit::UnplannableReason::NonLiteralSpan
2372 ),
2373 ..
2374 }
2375 ));
2376 }
2377
2378 /// FR-007 edge case: with no `--config` (empty ignore rules), an `Unknown`-kind update is
2379 /// still applied — `Unknown` only fails closed *under a matching scoped rule*, never on
2380 /// its own.
2381 #[test]
2382 fn test_plan_updates_fr007_no_config_no_ignore_rules_loaded() {
2383 let content = "tokio = \"1.0.0\"\n";
2384 let versions = cached("tokio", "2.0.0");
2385 let analysis = test_analysis(
2386 vec![test_dep(
2387 "tokio",
2388 "1.0.0",
2389 Range::new(Position::new(0, 9), Position::new(0, 14)),
2390 )],
2391 versions,
2392 );
2393
2394 let plan = plan_updates_no_cooldown(
2395 &analysis,
2396 content,
2397 &STUB_FORMATTER,
2398 &[],
2399 &IgnoreRules::empty(),
2400 );
2401
2402 assert_eq!(plan.items.len(), 1);
2403 assert!(matches!(plan.items[0].outcome, Outcome::Applied { .. }));
2404 }
2405
2406 #[test]
2407 fn test_is_requested_empty_filter_matches_everything() {
2408 assert!(is_requested(&[], "serde", &STUB_FORMATTER));
2409 assert!(is_requested(
2410 &["serde".to_string()],
2411 "serde",
2412 &STUB_FORMATTER
2413 ));
2414 assert!(!is_requested(
2415 &["tokio".to_string()],
2416 "serde",
2417 &STUB_FORMATTER
2418 ));
2419 }
2420
2421 #[test]
2422 fn test_apply_plan_stale_manifest_is_rejected() {
2423 let dir = tempfile::tempdir().unwrap();
2424 let path = dir.path().join("Cargo.toml");
2425 std::fs::write(&path, "serde = \"1.0.0\"\n").unwrap();
2426
2427 // Simulates a concurrent edit landing between planning and applying.
2428 std::fs::write(&path, "serde = \"1.0.1\"\n").unwrap();
2429
2430 let plan = UpdatePlan {
2431 items: vec![PlannedUpdateItem {
2432 name: "serde".to_string(),
2433 current: CurrentVersion::Resolved(ConcreteVersion::from("1.0.0")),
2434 outcome: Outcome::Applied {
2435 edit: ManifestEdit {
2436 range: Range::new(Position::new(0, 9), Position::new(0, 14)),
2437 new_text: "1.2.0".to_string(),
2438 },
2439 target: ConcreteVersion::from("1.2.0"),
2440 },
2441 advisory_ids: Vec::new(),
2442 ignore_rule_overridden: false,
2443 gossip_excluded_version: None,
2444 cooldown_fallback: None,
2445 }],
2446 };
2447
2448 let result = apply_plan(
2449 &plan,
2450 &path,
2451 "serde = \"1.0.0\"\n",
2452 crate::format::DryRun::No,
2453 );
2454 assert!(matches!(result, Err(ApplyError::StaleManifest { .. })));
2455 assert_eq!(
2456 std::fs::read_to_string(&path).unwrap(),
2457 "serde = \"1.0.1\"\n"
2458 );
2459 }
2460
2461 #[test]
2462 fn test_apply_plan_dry_run_does_not_write() {
2463 let dir = tempfile::tempdir().unwrap();
2464 let path = dir.path().join("Cargo.toml");
2465 std::fs::write(&path, "serde = \"1.0.0\"\n").unwrap();
2466
2467 let plan = UpdatePlan {
2468 items: vec![PlannedUpdateItem {
2469 name: "serde".to_string(),
2470 current: CurrentVersion::Resolved(ConcreteVersion::from("1.0.0")),
2471 outcome: Outcome::Applied {
2472 edit: ManifestEdit {
2473 range: Range::new(Position::new(0, 9), Position::new(0, 14)),
2474 new_text: "1.2.0".to_string(),
2475 },
2476 target: ConcreteVersion::from("1.2.0"),
2477 },
2478 advisory_ids: Vec::new(),
2479 ignore_rule_overridden: false,
2480 gossip_excluded_version: None,
2481 cooldown_fallback: None,
2482 }],
2483 };
2484
2485 apply_plan(
2486 &plan,
2487 &path,
2488 "serde = \"1.0.0\"\n",
2489 crate::format::DryRun::Yes,
2490 )
2491 .unwrap();
2492 assert_eq!(
2493 std::fs::read_to_string(&path).unwrap(),
2494 "serde = \"1.0.0\"\n"
2495 );
2496 }
2497
2498 #[test]
2499 fn test_apply_plan_writes_applied_edits() {
2500 let dir = tempfile::tempdir().unwrap();
2501 let path = dir.path().join("Cargo.toml");
2502 std::fs::write(&path, "serde = \"1.0.0\"\n").unwrap();
2503
2504 let plan = UpdatePlan {
2505 items: vec![PlannedUpdateItem {
2506 name: "serde".to_string(),
2507 current: CurrentVersion::Resolved(ConcreteVersion::from("1.0.0")),
2508 outcome: Outcome::Applied {
2509 edit: ManifestEdit {
2510 range: Range::new(Position::new(0, 9), Position::new(0, 14)),
2511 new_text: "1.2.0".to_string(),
2512 },
2513 target: ConcreteVersion::from("1.2.0"),
2514 },
2515 advisory_ids: Vec::new(),
2516 ignore_rule_overridden: false,
2517 gossip_excluded_version: None,
2518 cooldown_fallback: None,
2519 }],
2520 };
2521
2522 apply_plan(
2523 &plan,
2524 &path,
2525 "serde = \"1.0.0\"\n",
2526 crate::format::DryRun::No,
2527 )
2528 .unwrap();
2529 assert_eq!(
2530 std::fs::read_to_string(&path).unwrap(),
2531 "serde = \"1.2.0\"\n"
2532 );
2533 }
2534
2535 #[test]
2536 fn test_apply_plan_skipped_items_are_not_written() {
2537 let dir = tempfile::tempdir().unwrap();
2538 let path = dir.path().join("Cargo.toml");
2539 std::fs::write(&path, "serde = \"1.0.0\"\n").unwrap();
2540
2541 let plan = UpdatePlan {
2542 items: vec![PlannedUpdateItem {
2543 name: "serde".to_string(),
2544 current: CurrentVersion::Resolved(ConcreteVersion::from("1.0.0")),
2545 outcome: Outcome::Skipped {
2546 reason: SkipReason::IgnoreRule,
2547 target: Some(ConcreteVersion::from("1.2.0")),
2548 },
2549 advisory_ids: Vec::new(),
2550 ignore_rule_overridden: false,
2551 gossip_excluded_version: None,
2552 cooldown_fallback: None,
2553 }],
2554 };
2555
2556 apply_plan(
2557 &plan,
2558 &path,
2559 "serde = \"1.0.0\"\n",
2560 crate::format::DryRun::No,
2561 )
2562 .unwrap();
2563 assert_eq!(
2564 std::fs::read_to_string(&path).unwrap(),
2565 "serde = \"1.0.0\"\n"
2566 );
2567 }
2568
2569 // --- dedup_applied_items (previously untested; touched by #1349's `Outcome::Applied`
2570 // edit-extraction rewrite) ---
2571
2572 fn applied_item(name: &str, range: Range) -> PlannedUpdateItem {
2573 PlannedUpdateItem {
2574 name: name.to_string(),
2575 current: CurrentVersion::Resolved(ConcreteVersion::from("1.0.0")),
2576 outcome: Outcome::Applied {
2577 edit: ManifestEdit {
2578 range,
2579 new_text: "1.2.0".to_string(),
2580 },
2581 target: ConcreteVersion::from("1.2.0"),
2582 },
2583 advisory_ids: Vec::new(),
2584 ignore_rule_overridden: false,
2585 gossip_excluded_version: None,
2586 cooldown_fallback: None,
2587 }
2588 }
2589
2590 #[test]
2591 fn test_dedup_applied_items_keeps_non_overlapping_edits_applied() {
2592 let mut items = vec![
2593 applied_item(
2594 "serde",
2595 Range::new(Position::new(0, 9), Position::new(0, 14)),
2596 ),
2597 applied_item(
2598 "tokio",
2599 Range::new(Position::new(1, 9), Position::new(1, 14)),
2600 ),
2601 ];
2602 dedup_applied_items(&mut items);
2603 assert!(
2604 items
2605 .iter()
2606 .all(|i| matches!(i.outcome, Outcome::Applied { .. }))
2607 );
2608 }
2609
2610 /// M2/critic finding this dedup pass exists for: two vulnerable occurrences of one name
2611 /// (or any other overlap) both reported `Applied` must have the loser demoted to
2612 /// `Skipped(OverlapsAnotherEdit)` before `apply_plan` runs, not silently write only one.
2613 #[test]
2614 fn test_dedup_applied_items_demotes_the_later_overlap() {
2615 let mut items = vec![
2616 applied_item(
2617 "serde",
2618 Range::new(Position::new(0, 9), Position::new(0, 14)),
2619 ),
2620 applied_item(
2621 "serde",
2622 Range::new(Position::new(0, 11), Position::new(0, 16)),
2623 ),
2624 ];
2625 dedup_applied_items(&mut items);
2626 assert!(matches!(items[0].outcome, Outcome::Applied { .. }));
2627 assert!(matches!(
2628 items[1].outcome,
2629 Outcome::Skipped {
2630 reason: SkipReason::OverlapsAnotherEdit,
2631 ..
2632 }
2633 ));
2634 assert_eq!(
2635 items[1].target(),
2636 Some(&ConcreteVersion::from("1.2.0")),
2637 "a demoted item must keep its target, not silently drop it (#1615)"
2638 );
2639 }
2640
2641 // --- Spec 075 (`deps-cli update` cooldown fallback) ---
2642
2643 const FALLBACK_FORMATTER: deps_core::test_util::StubFormatter =
2644 deps_core::test_util::StubFormatter::new().with_manifest_requirement_as_resolved_version();
2645
2646 /// A formatter with a REAL `compile_bounded_requirement` — `semver::VersionReq`-backed, via the
2647 /// same `deps_core::lsp_helpers::compile_semver_requirement` deps-cargo/deps-swift use in
2648 /// production. Fix-cycle item 1/S1: the original test used a synthetic `AtLeastMajor3`
2649 /// matcher whose behavior happened to be self-consistent with the (wrong)
2650 /// `fallback_satisfies_requirement` implementation it was meant to catch — impl-critic
2651 /// proved the bug only surfaced with a real ecosystem comparator. Using the genuine semver
2652 /// matcher here (rather than adding a `deps-npm`/`deps-cargo` dev-dependency) closes that
2653 /// gap without a new workspace dependency.
2654 struct RealSemverFormatter;
2655 impl deps_core::lsp_helpers::PackageNaming for RealSemverFormatter {}
2656 impl deps_core::lsp_helpers::PackageRendering for RealSemverFormatter {
2657 fn format_version_for_text_edit(&self, v: &deps_core::ConcreteVersion) -> String {
2658 v.to_string()
2659 }
2660 fn package_url(&self, name: &PackageName) -> String {
2661 name.as_str().to_string()
2662 }
2663 }
2664 impl deps_core::lsp_helpers::RequirementResolution for RealSemverFormatter {
2665 fn compile_bounded_requirement(
2666 &self,
2667 requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
2668 ) -> Option<Box<dyn deps_core::lsp_helpers::RequirementMatcher>> {
2669 let requirement = requirement.get();
2670 deps_core::lsp_helpers::compile_semver_requirement(requirement)
2671 }
2672 }
2673 impl deps_core::lsp_helpers::DiagnosticMessages for RealSemverFormatter {}
2674 impl deps_core::lsp_helpers::DiagnosticPolicy for RealSemverFormatter {}
2675 impl deps_core::lsp_helpers::SourcePolicy for RealSemverFormatter {}
2676 impl deps_core::lsp_helpers::OsvNaming for RealSemverFormatter {}
2677
2678 /// Spec 076 FR-022/FR-023 (A1 repro, phase-1 d0), corrected for #1564/#1561: rejects a
2679 /// fallback whose UNEDITED requirement's own floor (the oldest `available` entry it still
2680 /// matches) is newer than the fallback — replaces spec 075's isolated
2681 /// `fallback_satisfies_requirement` unit test (that function is deleted, and with it the Go
2682 /// `manifest_requirement_is_resolved_version` bypass, FR-022: Go's `ExactMatcher` alone is
2683 /// sufficient — the bypass's own inversion test lives in `deps-go` as part of T005/SC-020,
2684 /// since `deps-cli` does not depend on `deps-go`).
2685 #[test]
2686 fn test_fallback_edit_excludes_newer_rejects_a1_repro_downgrade() {
2687 let dep = test_dep(
2688 "pkg",
2689 ">=3.0.0",
2690 Range::new(Position::new(0, 0), Position::new(0, 5)),
2691 );
2692 let edit = ManifestEdit {
2693 range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2694 new_text: "2.9.0".to_string(),
2695 };
2696
2697 // A1 repro: `latest` (3.2.0) already satisfies `>=3.0.0`, so falling back to the older
2698 // 2.9.0 would be a downgrade relative to what re-resolution already gives — reject at
2699 // d0, before any re-parse (`never_reparse` must never be consulted).
2700 let available_with_newer_match: Vec<deps_core::ConcreteVersion> =
2701 vec!["3.2.0".into(), "2.9.0".into()];
2702 assert_eq!(
2703 deps_core::lsp_helpers::fallback_edit_excludes_newer(
2704 &RealSemverFormatter,
2705 &never_reparse,
2706 "pkg = \">=3.0.0\"\n",
2707 &dep,
2708 &edit,
2709 &deps_core::ConcreteVersion::new("2.9.0"),
2710 &available_with_newer_match,
2711 ),
2712 deps_core::lsp_helpers::FallbackEditVerdict::Rejected(
2713 deps_core::lsp_helpers::FallbackEditRejection::OriginalResolvesPastFallback
2714 ),
2715 "2.9.0 is a downgrade relative to what >=3.0.0 already resolves to (3.2.0) and must \
2716 never be treated as a usable fallback"
2717 );
2718 }
2719
2720 /// Issue #1564, adapted to spec 076's two-phase guard — and DELIBERATELY corrected, not
2721 /// carried over verbatim, from #1565's own single-phase `Applied`/`Writable` expectation.
2722 /// R0 (unedited, "0.22.6", implicit caret) already resolves past the fallback (0.22.7) just
2723 /// as freely as it resolves past the cooldown-blocked `latest` (0.22.8) — #1565's
2724 /// floor-comparison fix (d0 here) correctly does NOT reject this alone, since the
2725 /// requirement's own floor (0.22.6) is not newer than the fallback. But spec 076 adds a
2726 /// SECOND phase (d1) #1565 never had: the WRITTEN edit ("0.22.7", also an implicit caret)
2727 /// auto-follows forward into the fresh 0.22.8, which is exactly spec 076's S1 anti-
2728 /// auto-follow protection this guard exists to add. So unlike #1565's own repro, this must
2729 /// resolve `Rejected(EditedAdmitsNewer)`, not `Writable` — d0 passing does not mean the edit
2730 /// is safe to write once the two-phase guard also checks what the WRITTEN text re-admits.
2731 #[test]
2732 fn test_fallback_edit_excludes_newer_rejects_auto_follow_after_d0_passes_1564() {
2733 let dep = test_dep(
2734 "pkg",
2735 "0.22.6",
2736 Range::new(Position::new(0, 7), Position::new(0, 13)),
2737 );
2738 let edit = ManifestEdit {
2739 range: Range::new(Position::new(0, 7), Position::new(0, 13)),
2740 new_text: "0.22.7".to_string(),
2741 };
2742 let available: Vec<deps_core::ConcreteVersion> =
2743 vec!["0.22.8".into(), "0.22.7".into(), "0.22.6".into()];
2744 let reparse = reparse_quoted_deps(vec![("pkg", Position::new(0, 7))]);
2745
2746 let verdict = deps_core::lsp_helpers::fallback_edit_excludes_newer(
2747 &RealSemverFormatter,
2748 &reparse,
2749 "pkg = \"0.22.6\"\n",
2750 &dep,
2751 &edit,
2752 &deps_core::ConcreteVersion::new("0.22.7"),
2753 &available,
2754 );
2755 assert_eq!(
2756 verdict,
2757 deps_core::lsp_helpers::FallbackEditVerdict::Rejected(
2758 deps_core::lsp_helpers::FallbackEditRejection::EditedAdmitsNewer
2759 ),
2760 "d0 passes (0.22.6's own floor is not newer than 0.22.7), but the written 0.22.7 \
2761 caret still auto-follows into the fresh 0.22.8 — d1 must reject: {verdict:?}"
2762 );
2763 }
2764
2765 /// Issue #1561 repro (CWE-1284), adapted: the declared exact pin (`=1.5.0`) is absent from
2766 /// `available` (unpublished/yanked/filtered) — the requirement's own floor (d0) cannot be
2767 /// evidenced by any listed entry, so this must fail closed rather than vacuously accept the
2768 /// fallback (1.4.0, strictly older than the declared pin).
2769 #[test]
2770 fn test_fallback_edit_excludes_newer_fails_closed_on_unlisted_pin_1561() {
2771 let dep = test_dep(
2772 "pkg",
2773 "=1.5.0",
2774 Range::new(Position::new(0, 7), Position::new(0, 14)),
2775 );
2776 let edit = ManifestEdit {
2777 range: Range::new(Position::new(0, 7), Position::new(0, 14)),
2778 new_text: "1.4.0".to_string(),
2779 };
2780 // 1.5.0 (the declared pin) is not listed — unpublished/yanked/filtered.
2781 let available: Vec<deps_core::ConcreteVersion> =
2782 vec!["2.0.0".into(), "1.4.0".into(), "1.0.0".into()];
2783
2784 let verdict = deps_core::lsp_helpers::fallback_edit_excludes_newer(
2785 &RealSemverFormatter,
2786 &never_reparse,
2787 "pkg = \"=1.5.0\"\n",
2788 &dep,
2789 &edit,
2790 &deps_core::ConcreteVersion::new("1.4.0"),
2791 &available,
2792 );
2793 assert_eq!(
2794 verdict,
2795 deps_core::lsp_helpers::FallbackEditVerdict::Rejected(
2796 deps_core::lsp_helpers::FallbackEditRejection::OriginalResolvesPastFallback
2797 ),
2798 "the declared pin 1.5.0 is unlisted; nothing evidences that 1.4.0 is not a \
2799 downgrade below it, so this must fail closed: {verdict:?}"
2800 );
2801 }
2802
2803 /// Issue #1561, second cause: the FALLBACK itself is absent from `available` (a stale
2804 /// `CooldownFallback` computed against a version list that has since changed) — distinct
2805 /// from the declared-pin-unlisted case above, which is why `fallback_edit_excludes_newer`
2806 /// reports it as its own `FallbackUnlisted` variant.
2807 #[test]
2808 fn test_fallback_edit_excludes_newer_fails_closed_on_unlisted_fallback() {
2809 let dep = test_dep(
2810 "pkg",
2811 "0.22.6",
2812 Range::new(Position::new(0, 7), Position::new(0, 13)),
2813 );
2814 let edit = ManifestEdit {
2815 range: Range::new(Position::new(0, 7), Position::new(0, 13)),
2816 new_text: "0.22.7".to_string(),
2817 };
2818 // 0.22.7 (the fallback) is not listed at all.
2819 let available: Vec<deps_core::ConcreteVersion> = vec!["0.22.8".into(), "0.22.6".into()];
2820
2821 let verdict = deps_core::lsp_helpers::fallback_edit_excludes_newer(
2822 &RealSemverFormatter,
2823 &never_reparse,
2824 "pkg = \"0.22.6\"\n",
2825 &dep,
2826 &edit,
2827 &deps_core::ConcreteVersion::new("0.22.7"),
2828 &available,
2829 );
2830 assert_eq!(
2831 verdict,
2832 deps_core::lsp_helpers::FallbackEditVerdict::Rejected(
2833 deps_core::lsp_helpers::FallbackEditRejection::FallbackUnlisted
2834 ),
2835 "got: {verdict:?}"
2836 );
2837 }
2838
2839 /// One dependency, cooldown-blocked by the local heuristic, with a stored fallback
2840 /// candidate — shared setup for the SC-005/SC-006 fallback-selection tests below.
2841 ///
2842 /// Spec 076: the declared requirement is an exact pin (`=1.0.0`) and `latest` is a MAJOR
2843 /// version bump (`2.0.0`) — outside the caret range a bare-rendered fallback edit (e.g.
2844 /// `"1.1.0"` compiling to `^1.1.0`) would admit, so the two-phase guard's phase 1/2 checks
2845 /// pass for any test using this fixture that actually reaches `fallback_edit_excludes_newer`
2846 /// (most of the tests below do not: their fallback view's own OSV/structural rejection, or
2847 /// the ignore-rule short-circuit inside `never_demoted`, resolves the occurrence before the
2848 /// guard is ever consulted — see each test's own doc for which case it is).
2849 fn fallback_scenario_analysis(
2850 fallback_version: &str,
2851 ) -> (
2852 ManifestAnalysis,
2853 deps_core::FreshnessSettings,
2854 deps_core::PublishTime,
2855 ) {
2856 let now = deps_core::PublishTime::from_unix_secs(10_000);
2857 let published_at = deps_core::PublishTime::from_unix_secs(9_900); // 100s old, within cooldown
2858 let mut versions = cached("pkg", "2.0.0");
2859 versions.insert(
2860 PackageName::new("pkg"),
2861 // `available` must list both the exact-pin R0's own floor ("1.0.0") and the
2862 // fallback candidate itself, or `fallback_edit_excludes_newer`'s fail-closed
2863 // `FallbackUnlisted`/floor checks reject before ever reaching a test's own
2864 // scenario under test — `latest_only`'s single-element `[latest]` list isn't
2865 // enough once that guard's two-phase, position-based checks are in play.
2866 PackageVersions::new(
2867 deps_core::ConcreteVersion::new("2.0.0"),
2868 std::sync::Arc::from(vec![
2869 deps_core::ConcreteVersion::new("2.0.0"),
2870 deps_core::ConcreteVersion::new("1.1.0"),
2871 deps_core::ConcreteVersion::new("1.0.0"),
2872 ]),
2873 )
2874 .with_published_at(published_at)
2875 .with_cooldown_fallback(deps_core::lsp_helpers::CooldownFallback::new(
2876 fallback_version.into(),
2877 deps_core::PublishTime::from_unix_secs(1_000),
2878 )),
2879 );
2880 let analysis = test_analysis(
2881 vec![test_dep(
2882 "pkg",
2883 "=1.0.0",
2884 // 6-char span ("=1.0.0"), matching the exact-pin requirement text — a test using
2885 // this fixture's fallback view as `Planned` (not short-circuited by its own OSV
2886 // status) needs `content`'s literal span to match, or `collect_update_candidates`
2887 // marks it `Unplannable(NonLiteralSpan)` before the guard is ever reached.
2888 Range::new(Position::new(0, 7), Position::new(0, 13)),
2889 )],
2890 versions,
2891 );
2892 let freshness = deps_core::FreshnessSettings::Enabled {
2893 cooldown: deps_core::CooldownWindow::from_secs(1_000),
2894 };
2895 (analysis, freshness, now)
2896 }
2897
2898 /// Like [`fallback_scenario_analysis`], but with a caller-controlled declared requirement
2899 /// and full `available` list — needed to exercise [`RealSemverFormatter`]'s real
2900 /// `compile_bounded_requirement` guard (fix-cycle item 1/S1, tester Gap C) end to end through
2901 /// [`plan_updates`]/[`resolve_occurrence`], not just the isolated helper.
2902 fn real_semver_scenario(
2903 req: &str,
2904 available: &[&str],
2905 fallback_version: &str,
2906 ) -> (
2907 ManifestAnalysis,
2908 deps_core::FreshnessSettings,
2909 deps_core::PublishTime,
2910 ) {
2911 let now = deps_core::PublishTime::from_unix_secs(10_000);
2912 let published_at = deps_core::PublishTime::from_unix_secs(9_900); // 100s old, within cooldown
2913 let latest = available.first().copied().expect("at least one version");
2914 let available_arc: std::sync::Arc<[deps_core::ConcreteVersion]> =
2915 available.iter().map(|v| (*v).into()).collect();
2916 let mut versions = HashMap::new();
2917 versions.insert(
2918 PackageName::new("pkg"),
2919 PackageVersions::new(latest.into(), available_arc)
2920 .with_published_at(published_at)
2921 .with_cooldown_fallback(deps_core::lsp_helpers::CooldownFallback::new(
2922 fallback_version.into(),
2923 deps_core::PublishTime::from_unix_secs(1_000),
2924 )),
2925 );
2926 let end = 7 + u32::try_from(req.len()).expect("short test literal");
2927 let analysis = test_analysis(
2928 vec![test_dep(
2929 "pkg",
2930 req,
2931 Range::new(Position::new(0, 7), Position::new(0, end)),
2932 )],
2933 versions,
2934 );
2935 let freshness = deps_core::FreshnessSettings::Enabled {
2936 cooldown: deps_core::CooldownWindow::from_secs(1_000),
2937 };
2938 (analysis, freshness, now)
2939 }
2940
2941 /// Spec 076 T006/FR-027 (verification candidate named by spec §10/tasks.md): under spec
2942 /// 075's guard this asserted `Applied`, because that guard only ever compiled the declared
2943 /// `=1.0.0` text itself, never the WRITTEN edit. Spec 076's two-phase guard re-parses the
2944 /// actual edit: `RealSemverFormatter`'s default rendering writes the fallback BARE
2945 /// (`"1.1.0"`), which Cargo-like semver compiles as a caret range `^1.1.0` — and that range
2946 /// still admits the fresh `1.2.0` it exists to exclude. d1 (`EditedAdmitsNewer`) now
2947 /// correctly rejects it — the documented, out-of-scope common-case fail-closed outcome for
2948 /// an auto-following ecosystem (spec 076 §1), not a regression. `1.0.0` (the exact pin's
2949 /// own value) must be listed for d0's fix-cycle floor-comparison scan to find it and let
2950 /// phase 1 pass, so this test actually reaches d1.
2951 #[test]
2952 fn test_plan_updates_real_semver_formatter_rejects_in_range_caret_admission() {
2953 let content = "pkg = \"=1.0.0\"\n";
2954 let (analysis, freshness, now) =
2955 real_semver_scenario("=1.0.0", &["1.2.0", "1.1.0", "1.0.0"], "1.1.0");
2956 let reparse = reparse_quoted_deps(vec![("pkg", Position::new(0, 7))]);
2957
2958 let plan = plan_updates(
2959 &analysis,
2960 content,
2961 &RealSemverFormatter,
2962 &reparse,
2963 &[],
2964 &IgnoreRules::empty(),
2965 freshness,
2966 now,
2967 );
2968
2969 assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
2970 assert!(
2971 matches!(
2972 plan.items[0].outcome,
2973 Outcome::Skipped {
2974 reason: SkipReason::WithinFreshnessCooldown,
2975 ..
2976 }
2977 ),
2978 "got: {:?}",
2979 plan.items[0].outcome
2980 );
2981
2982 // Fix-cycle M3 (impl-critic): `Skipped(WithinFreshnessCooldown)` alone doesn't
2983 // distinguish `EditedAdmitsNewer` from `ReparseFailed`/`OccurrenceNotUnique` — pin the
2984 // exact rejection variant directly.
2985 let dep = test_dep(
2986 "pkg",
2987 "=1.0.0",
2988 Range::new(Position::new(0, 7), Position::new(0, 13)),
2989 );
2990 let candidate = ManifestEdit {
2991 range: Range::new(Position::new(0, 7), Position::new(0, 13)),
2992 new_text: "1.1.0".to_string(),
2993 };
2994 let verdict = deps_core::lsp_helpers::fallback_edit_excludes_newer(
2995 &RealSemverFormatter,
2996 &reparse,
2997 content,
2998 &dep,
2999 &candidate,
3000 &deps_core::ConcreteVersion::new("1.1.0"),
3001 &[
3002 deps_core::ConcreteVersion::new("1.2.0"),
3003 deps_core::ConcreteVersion::new("1.1.0"),
3004 deps_core::ConcreteVersion::new("1.0.0"),
3005 ],
3006 );
3007 assert_eq!(
3008 verdict,
3009 deps_core::lsp_helpers::FallbackEditVerdict::Rejected(
3010 deps_core::lsp_helpers::FallbackEditRejection::EditedAdmitsNewer
3011 )
3012 );
3013 }
3014
3015 /// Spec 075 SC-004/FR-003 (A1 repro), end to end (tester Gap C): `resolve_occurrence`'s own
3016 /// `dep`/`req`/`fb.target` extraction (not just the isolated `fallback_satisfies_requirement`
3017 /// helper) must reject a fallback that is a downgrade relative to a real `>=3.0.0`
3018 /// requirement `latest` (3.2.0) already resolves past.
3019 #[test]
3020 fn test_plan_updates_real_semver_formatter_rejects_a1_repro_end_to_end() {
3021 let content = "pkg = \">=3.0.0\"\n";
3022 let (analysis, freshness, now) =
3023 real_semver_scenario(">=3.0.0", &["3.2.0", "2.9.0"], "2.9.0");
3024
3025 let plan = plan_updates(
3026 &analysis,
3027 content,
3028 &RealSemverFormatter,
3029 &never_reparse,
3030 &[],
3031 &IgnoreRules::empty(),
3032 freshness,
3033 now,
3034 );
3035
3036 assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3037 assert!(
3038 matches!(
3039 plan.items[0].outcome,
3040 Outcome::Skipped {
3041 reason: SkipReason::WithinFreshnessCooldown,
3042 ..
3043 }
3044 ),
3045 "got: {:?}",
3046 plan.items[0].outcome
3047 );
3048 assert!(plan.items[0].cooldown_fallback.is_none());
3049 }
3050
3051 /// Issue #1564, end to end (crates.io `bevy_brp_mcp`-shaped repro), corrected per T006/S1
3052 /// for spec 076: a three-version, live-registry-shaped fixture — `0.22.8` (newest,
3053 /// cooldown-blocked, would be `latest`), `0.22.7` (cleared, the stored fallback), `0.22.6`
3054 /// (declared/locked) — under a bare (implicit-caret) Cargo-style requirement.
3055 ///
3056 /// #1565 (shipped, spec-075-only scope) fixed d0 (the check against the UNEDITED
3057 /// requirement) to stop over-rejecting this shape and expected `Applied(0.22.7)`. Spec
3058 /// 076's independent, ADDITIONAL d1 check (against the WRITTEN edit, which #1565 never had
3059 /// since spec 075 had no re-parse mechanism at all) still correctly rejects it: writing the
3060 /// fallback bare renders `^0.22.7`, which — exactly like `^0.22.6` — still admits the
3061 /// fresh, cooldown-blocked `0.22.8`. This is spec 076 §1's documented, user-decided
3062 /// (OQ-C) common-case fail-closed outcome for an auto-following ecosystem, not a
3063 /// regression of #1565 — see this fix cycle's handoff for the full architectural note.
3064 #[test]
3065 fn test_plan_updates_real_semver_formatter_rejects_permissive_range_1564() {
3066 let content = "pkg = \"0.22.6\"\n";
3067 let (analysis, freshness, now) =
3068 real_semver_scenario("0.22.6", &["0.22.8", "0.22.7", "0.22.6"], "0.22.7");
3069 let reparse = reparse_quoted_deps(vec![("pkg", Position::new(0, 7))]);
3070
3071 let plan = plan_updates(
3072 &analysis,
3073 content,
3074 &RealSemverFormatter,
3075 &reparse,
3076 &[],
3077 &IgnoreRules::empty(),
3078 freshness,
3079 now,
3080 );
3081
3082 assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3083 assert!(
3084 matches!(
3085 plan.items[0].outcome,
3086 Outcome::Skipped {
3087 reason: SkipReason::WithinFreshnessCooldown,
3088 ..
3089 }
3090 ),
3091 "got: {:?}",
3092 plan.items[0].outcome
3093 );
3094 assert!(plan.items[0].cooldown_fallback.is_none());
3095 }
3096
3097 /// Issue #1561, end to end (CWE-1284): the declared exact pin (`=1.5.0`) is absent from the
3098 /// registry's version list (unpublished/yanked/filtered) and the fallback (`1.4.0`) is
3099 /// strictly older than it — must never be applied, regardless of how the fallback view
3100 /// itself classifies the occurrence. Rejected at d0 (`OriginalResolvesPastFallback`),
3101 /// before re-parse is ever consulted.
3102 #[test]
3103 fn test_plan_updates_fails_closed_on_unlisted_pin_downgrade_1561() {
3104 let content = "pkg = \"=1.5.0\"\n";
3105 let (analysis, freshness, now) =
3106 real_semver_scenario("=1.5.0", &["2.0.0", "1.4.0", "1.0.0"], "1.4.0");
3107
3108 let plan = plan_updates(
3109 &analysis,
3110 content,
3111 &RealSemverFormatter,
3112 &never_reparse,
3113 &[],
3114 &IgnoreRules::empty(),
3115 freshness,
3116 now,
3117 );
3118
3119 assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3120 assert!(
3121 matches!(
3122 plan.items[0].outcome,
3123 Outcome::Skipped {
3124 reason: SkipReason::WithinFreshnessCooldown,
3125 ..
3126 }
3127 ),
3128 "1.4.0 is a downgrade below the unlisted declared pin 1.5.0 and must never be \
3129 applied: {:?}",
3130 plan.items[0]
3131 );
3132 assert_eq!(
3133 plan.items[0].target(),
3134 Some(&ConcreteVersion::from("2.0.0")),
3135 "the rejected fallback must never leak into the reported target either"
3136 );
3137 assert!(plan.items[0].cooldown_fallback.is_none());
3138 }
3139
3140 /// Spec 075 SC-005/FR-012 (OQ3): a flagged latest with an independently Verified fallback
3141 /// candidate resolves to `Applied(fallback)`, keeping the flagged-latest attribution
3142 /// (advisory ids) in the same row.
3143 ///
3144 /// Issue #1561 item 1: `fallback_status` is populated alongside `latest_status` here (both,
3145 /// not just one) — a bare `None` for `fallback_status` while `latest_status` is `Some` now
3146 /// fails the fallback candidate closed to `Unverified` rather than silently bypassing OSV
3147 /// verification, so a test genuinely claiming an "independently Verified" fallback must set
3148 /// up that verification explicitly.
3149 #[test]
3150 fn test_plan_updates_flagged_latest_with_verified_fallback_applies_fallback() {
3151 use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
3152
3153 let content = "pkg = \"=1.0.0\"\n";
3154 let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
3155 let mut latest_status = LatestStatusMap::new();
3156 latest_status.insert(
3157 deps_core::test_util::vuln_key("pkg"),
3158 UpgradeStatus::CandidateVulnerable {
3159 version: ConcreteVersion::new("2.0.0"),
3160 advisory_ids: Capped::new(vec!["GHSA-xxxx".to_string()], 1),
3161 worst_severity: Some(VulnSeverity::High),
3162 },
3163 );
3164 analysis.latest_status = Some(latest_status);
3165 // Issue #1561 item 1: `latest_status` is populated, so `fallback_status` must be too —
3166 // a bare `None` now fails the fallback closed to `Unverified` (defense-in-depth).
3167 let mut fallback_status = LatestStatusMap::new();
3168 fallback_status.insert(
3169 deps_core::test_util::vuln_key("pkg"),
3170 UpgradeStatus::CandidateClean {
3171 version: ConcreteVersion::new("1.1.0"),
3172 },
3173 );
3174 analysis.fallback_status = Some(fallback_status);
3175 // Spec 076: reaching `Applied` needs a real `compile_bounded_requirement` (the Go-bypass
3176 // `FALLBACK_FORMATTER` no longer short-circuits the guard, FR-022) and a working
3177 // re-parse (FR-024).
3178 let reparse = reparse_quoted_deps(vec![("pkg", Position::new(0, 7))]);
3179
3180 let plan = plan_updates(
3181 &analysis,
3182 content,
3183 &RealSemverFormatter,
3184 &reparse,
3185 &[],
3186 &IgnoreRules::empty(),
3187 freshness,
3188 now,
3189 );
3190
3191 assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3192 assert!(
3193 matches!(plan.items[0].outcome, Outcome::Applied { .. }),
3194 "got: {:?}",
3195 plan.items[0].outcome
3196 );
3197 assert_eq!(
3198 plan.items[0].target(),
3199 Some(&ConcreteVersion::from("1.1.0"))
3200 );
3201 assert!(
3202 !plan.items[0].advisory_ids.is_empty(),
3203 "the flagged-latest attribution must be retained: {:?}",
3204 plan.items[0]
3205 );
3206 assert_eq!(
3207 plan.items[0].cooldown_fallback,
3208 Some(CooldownFallbackNote::AppliedInsteadOf("2.0.0".into()))
3209 );
3210 }
3211
3212 /// Issue #1561 item 1: `latest_status` is populated (OSV verification is in effect for
3213 /// this run) but `fallback_status` was never set — a caller bug distinct from an
3214 /// intentionally offline/no-OSV run. The fallback candidate must fail closed to
3215 /// `Unverified` (never written) rather than silently bypassing OSV verification as
3216 /// `NotApplicable` ("no check needed") the way a bare `None` for BOTH fields does.
3217 #[test]
3218 fn test_plan_updates_latest_status_without_fallback_status_treats_fallback_as_unverified() {
3219 use deps_core::osv::{LatestStatusMap, UpgradeStatus};
3220
3221 let content = "pkg = \"1.0.0\"\n";
3222 let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
3223 let mut latest_status = LatestStatusMap::new();
3224 latest_status.insert(
3225 deps_core::test_util::vuln_key("pkg"),
3226 UpgradeStatus::CandidateClean {
3227 version: ConcreteVersion::new("1.2.0"),
3228 },
3229 );
3230 analysis.latest_status = Some(latest_status);
3231 // `analysis.fallback_status` deliberately left `None`.
3232
3233 let plan = plan_updates(
3234 &analysis,
3235 content,
3236 &FALLBACK_FORMATTER,
3237 &never_reparse,
3238 &[],
3239 &IgnoreRules::empty(),
3240 freshness,
3241 now,
3242 );
3243
3244 assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3245 assert!(
3246 matches!(
3247 plan.items[0].outcome,
3248 Outcome::Skipped {
3249 reason: SkipReason::NotSafelyEditable(
3250 deps_core::edit::UnplannableReason::LatestUnverified
3251 ),
3252 ..
3253 }
3254 ),
3255 "got: {:?}",
3256 plan.items[0].outcome
3257 );
3258 }
3259
3260 /// Issue #1561 item 2: `resolve_occurrence`'s `fb.target != fallback.version` guard
3261 /// (update/mod.rs's `'occurrence` block). Simulates the divergence it defends against — a
3262 /// stale/inconsistent precomputed `analysis.cooldown_fallback_view` (`latest` = `1.9.0`)
3263 /// disagreeing with `cached_versions`' own stored `CooldownFallback` (`1.1.0`) that
3264 /// `cooldown_disposition` independently picks inside `resolve_occurrence` — and confirms
3265 /// the divergence falls through to the same fail-closed handling as "fallback absent",
3266 /// never silently writing either version.
3267 #[test]
3268 fn test_plan_updates_fb_target_fallback_version_divergence_fails_closed() {
3269 let content = "pkg = \"=1.0.0\"\n";
3270 let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
3271
3272 let mut divergent_view = HashMap::new();
3273 divergent_view.insert(
3274 PackageName::new("pkg"),
3275 PackageVersions::latest_only("1.9.0"),
3276 );
3277 analysis.cooldown_fallback_view = Some(divergent_view);
3278
3279 let plan = plan_updates(
3280 &analysis,
3281 content,
3282 &FALLBACK_FORMATTER,
3283 &never_reparse,
3284 &[],
3285 &IgnoreRules::empty(),
3286 freshness,
3287 now,
3288 );
3289
3290 assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3291 assert!(
3292 matches!(
3293 plan.items[0].outcome,
3294 Outcome::Skipped {
3295 reason: SkipReason::WithinFreshnessCooldown,
3296 ..
3297 }
3298 ),
3299 "a divergence between the fallback view's own planned target (1.9.0) and \
3300 `cooldown_disposition`'s independently computed pick (1.1.0) must never be \
3301 silently written: {:?}",
3302 plan.items[0]
3303 );
3304 assert_eq!(
3305 plan.items[0].target(),
3306 Some(&ConcreteVersion::from("2.0.0")),
3307 "must fall back to the real (unmodified) latest, never either divergent fallback \
3308 value"
3309 );
3310 assert!(plan.items[0].cooldown_fallback.is_none());
3311 }
3312
3313 /// Spec 075 SC-006/FR-011 (OQ5'): the fallback candidate is itself OSV-`Flagged` — exit 1,
3314 /// naming the blocked fallback version, never a silent cooldown skip.
3315 #[test]
3316 fn test_plan_updates_flagged_fallback_blocks_and_exits_nonzero() {
3317 use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
3318
3319 let content = "pkg = \"=1.0.0\"\n";
3320 let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
3321 let mut fallback_status = LatestStatusMap::new();
3322 fallback_status.insert(
3323 deps_core::test_util::vuln_key("pkg"),
3324 UpgradeStatus::CandidateVulnerable {
3325 version: ConcreteVersion::new("1.1.0"),
3326 advisory_ids: Capped::new(vec!["GHSA-yyyy".to_string()], 1),
3327 worst_severity: Some(VulnSeverity::High),
3328 },
3329 );
3330 analysis.fallback_status = Some(fallback_status);
3331
3332 let plan = plan_updates(
3333 &analysis,
3334 content,
3335 &FALLBACK_FORMATTER,
3336 &never_reparse,
3337 &[],
3338 &IgnoreRules::empty(),
3339 freshness,
3340 now,
3341 );
3342
3343 assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3344 assert!(
3345 matches!(
3346 plan.items[0].outcome,
3347 Outcome::Skipped {
3348 reason: SkipReason::NotSafelyEditable(
3349 deps_core::edit::UnplannableReason::LatestFlaggedByOsv
3350 ),
3351 ..
3352 }
3353 ),
3354 "got: {:?}",
3355 plan.items[0].outcome
3356 );
3357 assert_eq!(
3358 plan.items[0].target(),
3359 Some(&ConcreteVersion::from("1.1.0")),
3360 "must name the blocked fallback version, not latest"
3361 );
3362 assert!(!plan.items[0].advisory_ids.is_empty());
3363 assert_eq!(
3364 plan.items[0].cooldown_fallback,
3365 Some(CooldownFallbackNote::Blocked {
3366 version: "1.1.0".into()
3367 })
3368 );
3369 assert_eq!(
3370 crate::exit::update_exit_code(&plan),
3371 crate::exit::EXIT_POLICY_VIOLATION
3372 );
3373 }
3374
3375 /// Spec 075 SC-006/FR-011 (OQ5'): the fallback candidate is `Unverified` (never checked) —
3376 /// exit 1 for parity with the `Flagged` case, not a silent exit-0 skip.
3377 #[test]
3378 fn test_plan_updates_unverified_fallback_blocks_and_exits_nonzero() {
3379 use deps_core::osv::LatestStatusMap;
3380
3381 let content = "pkg = \"=1.0.0\"\n";
3382 let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
3383 // Present but empty: OSV checking is on, but this fallback was never verified.
3384 analysis.fallback_status = Some(LatestStatusMap::new());
3385
3386 let plan = plan_updates(
3387 &analysis,
3388 content,
3389 &FALLBACK_FORMATTER,
3390 &never_reparse,
3391 &[],
3392 &IgnoreRules::empty(),
3393 freshness,
3394 now,
3395 );
3396
3397 assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3398 assert!(
3399 matches!(
3400 plan.items[0].outcome,
3401 Outcome::Skipped {
3402 reason: SkipReason::NotSafelyEditable(
3403 deps_core::edit::UnplannableReason::LatestUnverified
3404 ),
3405 ..
3406 }
3407 ),
3408 "got: {:?}",
3409 plan.items[0].outcome
3410 );
3411 assert_eq!(
3412 plan.items[0].target(),
3413 Some(&ConcreteVersion::from("1.1.0"))
3414 );
3415 assert_eq!(
3416 crate::exit::update_exit_code(&plan),
3417 crate::exit::EXIT_POLICY_VIOLATION
3418 );
3419 }
3420
3421 /// Tester Gap A / decision-table row 8: both `latest` AND the fallback are OSV-blocked
3422 /// simultaneously — defers entirely to `latest`'s own attribution (via `resolve_from_latest`),
3423 /// exit 1, never demoted, regardless of why the fallback also failed.
3424 #[test]
3425 fn test_plan_updates_both_latest_and_fallback_osv_blocked_defers_to_latest() {
3426 use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
3427
3428 let content = "pkg = \"=1.0.0\"\n";
3429 let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
3430 let flagged = |version: &str| {
3431 let mut status = LatestStatusMap::new();
3432 status.insert(
3433 deps_core::test_util::vuln_key("pkg"),
3434 UpgradeStatus::CandidateVulnerable {
3435 version: deps_core::ConcreteVersion::new(version),
3436 advisory_ids: Capped::new(vec!["GHSA-x".to_string()], 1),
3437 worst_severity: Some(VulnSeverity::High),
3438 },
3439 );
3440 status
3441 };
3442 analysis.latest_status = Some(flagged("2.0.0"));
3443 analysis.fallback_status = Some(flagged("1.1.0"));
3444
3445 let plan = plan_updates(
3446 &analysis,
3447 content,
3448 &FALLBACK_FORMATTER,
3449 &never_reparse,
3450 &[],
3451 &IgnoreRules::empty(),
3452 freshness,
3453 now,
3454 );
3455
3456 assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3457 assert!(
3458 matches!(
3459 plan.items[0].outcome,
3460 Outcome::Skipped {
3461 reason: SkipReason::NotSafelyEditable(
3462 deps_core::edit::UnplannableReason::LatestFlaggedByOsv
3463 ),
3464 ..
3465 }
3466 ),
3467 "got: {:?}",
3468 plan.items[0].outcome
3469 );
3470 assert!(
3471 plan.items[0].cooldown_fallback.is_none(),
3472 "row 8 defers entirely to latest's own attribution, no fallback note"
3473 );
3474 assert_eq!(
3475 crate::exit::update_exit_code(&plan),
3476 crate::exit::EXIT_POLICY_VIOLATION
3477 );
3478 }
3479
3480 /// Tester Gap B / decision-table row 11: the fallback is blocked by a non-OSV structural
3481 /// reason (an unsafe version string) — a routine cooldown skip, exit clean, never
3482 /// `NotSafelyEditable`.
3483 #[test]
3484 fn test_plan_updates_fallback_blocked_by_non_osv_reason_is_cooldown_skip() {
3485 let content = "pkg = \"=1.0.0\"\n";
3486 // A space is outside `is_safe_version_string`'s allowlist, so the fallback view
3487 // resolves this occurrence to `Unplannable(UnsafeLatestVersion)`.
3488 let (analysis, freshness, now) = fallback_scenario_analysis("1.1.0 unsafe");
3489
3490 let plan = plan_updates(
3491 &analysis,
3492 content,
3493 &FALLBACK_FORMATTER,
3494 &never_reparse,
3495 &[],
3496 &IgnoreRules::empty(),
3497 freshness,
3498 now,
3499 );
3500
3501 assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3502 assert!(
3503 matches!(
3504 plan.items[0].outcome,
3505 Outcome::Skipped {
3506 reason: SkipReason::WithinFreshnessCooldown,
3507 ..
3508 }
3509 ),
3510 "a fallback blocked by a non-OSV structural reason is a routine cooldown skip, not \
3511 an exit-1 safety refusal: {:?}",
3512 plan.items[0].outcome
3513 );
3514 assert_eq!(
3515 crate::exit::update_exit_code(&plan),
3516 crate::exit::EXIT_CLEAN
3517 );
3518 }
3519
3520 /// Fix-cycle item 3/S3 (impl-critic repro E): row 7 (a flagged latest with a clean
3521 /// fallback) must still honor `[update].ignore` — previously it wrote `Applied(fb.edit)`
3522 /// unconditionally, bypassing the rule the identical row-9 case already respected.
3523 ///
3524 /// Fix-cycle S1 (impl-critic, significant): this MUST reach `requirement_ok = true` (the
3525 /// guard's `Writable` verdict) so the ignore-rule check inside that branch is the thing
3526 /// actually under test — `RealSemverFormatter` + a working `reparse_quoted_deps` and a
3527 /// `content` literal matching the exact-pin requirement text are required for that; using
3528 /// `FALLBACK_FORMATTER`/`never_reparse` (a0-uncompilable) made this test pass through
3529 /// `never_demoted`'s OWN, unrelated ignore-rule check instead, leaving the actual row-7
3530 /// branch with zero coverage (proven by mutation: removing the ignore-rule check inside
3531 /// `requirement_ok` still passed 318/318 deps-cli tests before this fix).
3532 #[tokio::test]
3533 async fn test_plan_updates_row7_honors_ignore_rule_instead_of_applying() {
3534 use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
3535
3536 let content = "pkg = \"=1.0.0\"\n";
3537 let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
3538 let mut latest_status = LatestStatusMap::new();
3539 latest_status.insert(
3540 deps_core::test_util::vuln_key("pkg"),
3541 UpgradeStatus::CandidateVulnerable {
3542 version: ConcreteVersion::new("2.0.0"),
3543 advisory_ids: Capped::new(vec!["GHSA-x".to_string()], 1),
3544 worst_severity: Some(VulnSeverity::High),
3545 },
3546 );
3547 analysis.latest_status = Some(latest_status);
3548 let ignore_pkg = IgnoreRules::new(
3549 vec![crate::config::IgnoreRule {
3550 name: "pkg".to_string(),
3551 update_types: None,
3552 }],
3553 &RealSemverFormatter,
3554 );
3555 let reparse = reparse_quoted_deps(vec![("pkg", Position::new(0, 7))]);
3556
3557 let plan = plan_updates(
3558 &analysis,
3559 content,
3560 &RealSemverFormatter,
3561 &reparse,
3562 &[],
3563 &ignore_pkg,
3564 freshness,
3565 now,
3566 );
3567
3568 assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3569 assert!(
3570 matches!(
3571 plan.items[0].outcome,
3572 Outcome::Skipped {
3573 reason: SkipReason::IgnoreRule,
3574 ..
3575 }
3576 ),
3577 "an ignored package's row-7 fallback must not be applied: {:?}",
3578 plan.items[0].outcome
3579 );
3580 assert_eq!(
3581 crate::exit::update_exit_code(&plan),
3582 crate::exit::EXIT_CLEAN
3583 );
3584 }
3585
3586 /// Fix-cycle S1 follow-up (row-9 counterpart, impl-critic recommendation): the identical
3587 /// ignore-rule check inside `requirement_ok`, but for an UNFLAGGED latest (row 9's ordinary
3588 /// cooldown-fallback substitution, not row 7's flagged-latest variant) — proves the
3589 /// ignore-rule branch is covered regardless of which row reaches it.
3590 #[tokio::test]
3591 async fn test_plan_updates_row9_honors_ignore_rule_instead_of_applying() {
3592 let content = "pkg = \"=1.0.0\"\n";
3593 let (analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
3594 let ignore_pkg = IgnoreRules::new(
3595 vec![crate::config::IgnoreRule {
3596 name: "pkg".to_string(),
3597 update_types: None,
3598 }],
3599 &RealSemverFormatter,
3600 );
3601 let reparse = reparse_quoted_deps(vec![("pkg", Position::new(0, 7))]);
3602
3603 let plan = plan_updates(
3604 &analysis,
3605 content,
3606 &RealSemverFormatter,
3607 &reparse,
3608 &[],
3609 &ignore_pkg,
3610 freshness,
3611 now,
3612 );
3613
3614 assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3615 assert!(
3616 matches!(
3617 plan.items[0].outcome,
3618 Outcome::Skipped {
3619 reason: SkipReason::IgnoreRule,
3620 ..
3621 }
3622 ),
3623 "an ignored package's row-9 fallback must not be applied: {:?}",
3624 plan.items[0].outcome
3625 );
3626 assert_eq!(
3627 crate::exit::update_exit_code(&plan),
3628 crate::exit::EXIT_CLEAN
3629 );
3630 }
3631
3632 /// Fix-cycle item 4/S4 (impl-critic repro F): row 10 (an OSV-blocked fallback) must still
3633 /// honor `[update].ignore` — previously an ignored package's blocked fallback exited 1
3634 /// regardless of the rule, unlike row 8's identical `resolve_from_latest` handling.
3635 #[test]
3636 fn test_plan_updates_row10_honors_ignore_rule_instead_of_exiting_nonzero() {
3637 use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
3638
3639 let content = "pkg = \"=1.0.0\"\n";
3640 let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
3641 let mut fallback_status = LatestStatusMap::new();
3642 fallback_status.insert(
3643 deps_core::test_util::vuln_key("pkg"),
3644 UpgradeStatus::CandidateVulnerable {
3645 version: ConcreteVersion::new("1.1.0"),
3646 advisory_ids: Capped::new(vec!["GHSA-x".to_string()], 1),
3647 worst_severity: Some(VulnSeverity::High),
3648 },
3649 );
3650 analysis.fallback_status = Some(fallback_status);
3651 let ignore_pkg = IgnoreRules::new(
3652 vec![crate::config::IgnoreRule {
3653 name: "pkg".to_string(),
3654 update_types: None,
3655 }],
3656 &FALLBACK_FORMATTER,
3657 );
3658
3659 let plan = plan_updates(
3660 &analysis,
3661 content,
3662 &FALLBACK_FORMATTER,
3663 &never_reparse,
3664 &[],
3665 &ignore_pkg,
3666 freshness,
3667 now,
3668 );
3669
3670 assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3671 assert!(
3672 matches!(
3673 plan.items[0].outcome,
3674 Outcome::Skipped {
3675 reason: SkipReason::IgnoreRule,
3676 ..
3677 }
3678 ),
3679 "an ignored package's row-10 blocked fallback must exit clean, not policy-violation: {:?}",
3680 plan.items[0].outcome
3681 );
3682 assert_eq!(
3683 plan.items[0].target(),
3684 None,
3685 "the ignore-rule skip of an OSV-blocked fallback has no concrete target"
3686 );
3687 assert_eq!(
3688 crate::exit::update_exit_code(&plan),
3689 crate::exit::EXIT_CLEAN
3690 );
3691 }
3692
3693 /// Fix-cycle item 5/security M1: the GOSSIP lookup in `resolve_occurrence` must use the
3694 /// RAW package name — `analysis.gossip_findings` (from `fetch_gossip_findings_batch`) is
3695 /// keyed by the raw name, matching `diagnostics::apply_outdated_rule`/hover. Using the
3696 /// *normalized* name instead made the planner blind to GOSSIP data for any formatter whose
3697 /// normalization changes case, reopening the check/update divergence #1529 closed.
3698 #[test]
3699 fn test_plan_updates_gossip_lookup_uses_raw_name_not_normalized() {
3700 let lowercase_formatter: deps_core::test_util::StubFormatter =
3701 deps_core::test_util::StubFormatter::new().with_lowercase_names();
3702 let content = "Django = \"1.0.0\"\n";
3703 let now = deps_core::PublishTime::from_unix_secs(10_000);
3704 // Well outside any realistic local cooldown window — if the GOSSIP-Active verdict is
3705 // missed (the bug), this dependency falls through to the local heuristic and clears.
3706 let old_published_at = deps_core::PublishTime::from_unix_secs(10_000 - 30 * 24 * 60 * 60);
3707 let mut versions = HashMap::new();
3708 versions.insert(
3709 PackageName::new("Django"),
3710 PackageVersions::latest_only("2.0.0").with_published_at(old_published_at),
3711 );
3712 let mut analysis = test_analysis(
3713 vec![test_dep(
3714 "Django",
3715 "1.0.0",
3716 Range::new(Position::new(0, 10), Position::new(0, 15)),
3717 )],
3718 versions,
3719 );
3720 let mut gossip = HashMap::new();
3721 gossip.insert(
3722 PackageName::new("Django"), // raw name — must NOT be looked up as "django"
3723 deps_core::test_util::stub_gossip_findings(
3724 "2.0.0",
3725 Some(deps_core::GossipCooldown::new(
3726 deps_core::PublishTime::from_unix_secs(10_000 + 1_000),
3727 deps_core::GossipRiskLevel::High,
3728 )),
3729 ),
3730 );
3731 analysis.gossip_findings = gossip;
3732
3733 let plan = plan_updates(
3734 &analysis,
3735 content,
3736 &lowercase_formatter,
3737 &never_reparse,
3738 &[],
3739 &IgnoreRules::empty(),
3740 deps_core::FreshnessSettings::Enabled {
3741 cooldown: deps_core::CooldownWindow::from_secs(1_000),
3742 },
3743 now,
3744 );
3745
3746 assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3747 assert!(
3748 matches!(
3749 plan.items[0].outcome,
3750 Outcome::Skipped {
3751 reason: SkipReason::WithinFreshnessCooldown,
3752 ..
3753 }
3754 ),
3755 "the GOSSIP-Active cooldown for the raw name 'Django' must be detected even though \
3756 normalize_package_name lowercases it to 'django': {:?}",
3757 plan.items[0].outcome
3758 );
3759 }
3760
3761 /// Code review (M2 minor, strengthened `OccurrenceKey`): two different packages whose
3762 /// `name_range` collides (`TestDep::name_range` always returns `Range::default()`, modeling
3763 /// Gradle/Composer's degraded-parsing paths) must never swap fallback edits or requirement
3764 /// checks — each occurrence's distinct `version_range` disambiguates them.
3765 #[test]
3766 fn test_plan_updates_name_range_collision_does_not_swap_occurrences() {
3767 let content = "alpha = \"=1.0.0\"\nbeta = \"=9.0.0\"\n";
3768 let now = deps_core::PublishTime::from_unix_secs(10_000);
3769 let published_at = deps_core::PublishTime::from_unix_secs(9_900); // within cooldown
3770
3771 // Spec 076: exact pins, with `latest` a major-version bump OUTSIDE the caret range a
3772 // bare-rendered fallback edit compiles to (`^1.1.0` excludes `2.0.0`; `^9.1.0` excludes
3773 // `10.0.0`) — otherwise the two-phase guard's d1 check would correctly fail closed
3774 // (spec 076 §1's documented auto-following-ecosystem case), which is not what this
3775 // test's own point (no cross-occurrence swap) is about.
3776 let mut versions = HashMap::new();
3777 versions.insert(
3778 PackageName::new("alpha"),
3779 // `available` must list both the exact-pin R0's own floor ("1.0.0") and the
3780 // fallback itself ("1.1.0"), or the guard's fail-closed `FallbackUnlisted`/floor
3781 // checks reject before this test's own cross-occurrence scenario is exercised.
3782 PackageVersions::new(
3783 deps_core::ConcreteVersion::new("2.0.0"),
3784 std::sync::Arc::from(vec![
3785 deps_core::ConcreteVersion::new("2.0.0"),
3786 deps_core::ConcreteVersion::new("1.1.0"),
3787 deps_core::ConcreteVersion::new("1.0.0"),
3788 ]),
3789 )
3790 .with_published_at(published_at)
3791 .with_cooldown_fallback(deps_core::lsp_helpers::CooldownFallback::new(
3792 "1.1.0".into(),
3793 deps_core::PublishTime::from_unix_secs(1_000),
3794 )),
3795 );
3796 versions.insert(
3797 PackageName::new("beta"),
3798 PackageVersions::new(
3799 deps_core::ConcreteVersion::new("10.0.0"),
3800 std::sync::Arc::from(vec![
3801 deps_core::ConcreteVersion::new("10.0.0"),
3802 deps_core::ConcreteVersion::new("9.1.0"),
3803 deps_core::ConcreteVersion::new("9.0.0"),
3804 ]),
3805 )
3806 .with_published_at(published_at)
3807 .with_cooldown_fallback(deps_core::lsp_helpers::CooldownFallback::new(
3808 "9.1.0".into(),
3809 deps_core::PublishTime::from_unix_secs(1_000),
3810 )),
3811 );
3812
3813 let analysis = test_analysis(
3814 vec![
3815 test_dep(
3816 "alpha",
3817 "=1.0.0",
3818 Range::new(Position::new(0, 9), Position::new(0, 15)),
3819 ),
3820 test_dep(
3821 "beta",
3822 "=9.0.0",
3823 Range::new(Position::new(1, 8), Position::new(1, 14)),
3824 ),
3825 ],
3826 versions,
3827 );
3828 let reparse = reparse_quoted_deps(vec![
3829 ("alpha", Position::new(0, 9)),
3830 ("beta", Position::new(1, 8)),
3831 ]);
3832
3833 let plan = plan_updates(
3834 &analysis,
3835 content,
3836 &RealSemverFormatter,
3837 &reparse,
3838 &[],
3839 &IgnoreRules::empty(),
3840 deps_core::FreshnessSettings::Enabled {
3841 cooldown: deps_core::CooldownWindow::from_secs(1_000),
3842 },
3843 now,
3844 );
3845
3846 assert_eq!(plan.items.len(), 2, "{:?}", plan.items);
3847 let alpha = plan
3848 .items
3849 .iter()
3850 .find(|i| i.name == "alpha")
3851 .expect("alpha item present");
3852 let beta = plan
3853 .items
3854 .iter()
3855 .find(|i| i.name == "beta")
3856 .expect("beta item present");
3857 assert_eq!(
3858 alpha.target(),
3859 Some(&ConcreteVersion::from("1.1.0")),
3860 "alpha must get its own fallback, not beta's: {alpha:?}"
3861 );
3862 assert_eq!(
3863 beta.target(),
3864 Some(&ConcreteVersion::from("9.1.0")),
3865 "beta must get its own fallback, not alpha's: {beta:?}"
3866 );
3867 assert!(matches!(alpha.outcome, Outcome::Applied { .. }));
3868 assert!(matches!(beta.outcome, Outcome::Applied { .. }));
3869 }
3870
3871 /// Code review (severity upgrade over the earlier "attribution only, never a wrong write"
3872 /// signoff): the SAME package declared twice, both occurrences degrading to the identical
3873 /// `name_range` (`TestDep::name_range` always returns `Range::default()`, modeling
3874 /// Gradle/Composer's degraded-position parsing) but with distinct `version_range`s, is a
3875 /// genuine collision the planner cannot disambiguate from `UpdateCandidate` alone. Proves the
3876 /// fail-closed fix: neither occurrence's FR-003 downgrade guard runs against the other's
3877 /// declared requirement, and neither gets a swapped/incorrect fallback write — both
3878 /// conservatively skip as `WithinFreshnessCooldown` with their own unmodified `latest`.
3879 #[test]
3880 fn test_plan_updates_true_name_range_collision_fails_closed_on_both_occurrences() {
3881 let content = "pkg = \"1.0.0\"\npkg = \"5.0.0\"\n";
3882 let now = deps_core::PublishTime::from_unix_secs(10_000);
3883 let published_at = deps_core::PublishTime::from_unix_secs(9_900); // within cooldown
3884
3885 let mut versions = HashMap::new();
3886 versions.insert(
3887 PackageName::new("pkg"),
3888 PackageVersions::latest_only("1.2.0")
3889 .with_published_at(published_at)
3890 .with_cooldown_fallback(deps_core::lsp_helpers::CooldownFallback::new(
3891 "1.1.0".into(),
3892 deps_core::PublishTime::from_unix_secs(1_000),
3893 )),
3894 );
3895
3896 let analysis = test_analysis(
3897 vec![
3898 test_dep(
3899 "pkg",
3900 "1.0.0",
3901 Range::new(Position::new(0, 7), Position::new(0, 12)),
3902 ),
3903 test_dep(
3904 "pkg",
3905 "5.0.0",
3906 Range::new(Position::new(1, 7), Position::new(1, 12)),
3907 ),
3908 ],
3909 versions,
3910 );
3911
3912 let plan = plan_updates(
3913 &analysis,
3914 content,
3915 &FALLBACK_FORMATTER,
3916 &never_reparse,
3917 &[],
3918 &IgnoreRules::empty(),
3919 deps_core::FreshnessSettings::Enabled {
3920 cooldown: deps_core::CooldownWindow::from_secs(1_000),
3921 },
3922 now,
3923 );
3924
3925 assert_eq!(plan.items.len(), 2, "{:?}", plan.items);
3926 for item in &plan.items {
3927 assert!(
3928 matches!(
3929 item.outcome,
3930 Outcome::Skipped {
3931 reason: SkipReason::WithinFreshnessCooldown,
3932 ..
3933 }
3934 ),
3935 "an unresolvable collision must fail closed, never approve a downgrade or a \
3936 swapped write: {item:?}"
3937 );
3938 assert_eq!(
3939 item.target(),
3940 Some(&ConcreteVersion::from("1.2.0")),
3941 "target must stay the real (unmodified) latest, never a fallback picked via a \
3942 collided lookup: {item:?}"
3943 );
3944 }
3945 }
3946
3947 /// Team-lead/impl-critic follow-up: `fallback_by_key` used to build its map the same
3948 /// "collapse-then-key" way `dep_by_key` did, so on a collision `.collect()` kept only the
3949 /// last occurrence's fallback candidate — the other occurrence could then be resolved
3950 /// against a stolen `UpdateCandidate` in the `Unplannable` branch (rows 8/10/11), a
3951 /// mismatched `SkipReason`/attribution even though (per impl-critic's trace) it never
3952 /// produced a wrong write. `pkg`'s two occurrences collide on `(name, name_range)`: one has
3953 /// a perfectly valid, independently-Applicable fallback; the other's fallback is
3954 /// structurally broken (`NonLiteralSpan`, its declared requirement doesn't match the
3955 /// manifest text at its own span). Excluding collision keys from `fallback_by_key` (mirroring
3956 /// `dep_by_key`) means NEITHER occurrence gets a fallback candidate at all — both fail closed
3957 /// through the "fallback absent" (row 6) branch uniformly, never `Applied`, never a nonzero
3958 /// exit, and never one silently wearing the other's specific `UnplannableReason`.
3959 #[test]
3960 fn test_plan_updates_mixed_planned_unplannable_collision_fails_closed_uniformly() {
3961 let content = "pkg = \"1.0.0\"\npkg = \"1.0.0\"\n";
3962 let now = deps_core::PublishTime::from_unix_secs(10_000);
3963 let published_at = deps_core::PublishTime::from_unix_secs(9_900); // within cooldown
3964
3965 let mut versions = HashMap::new();
3966 versions.insert(
3967 PackageName::new("pkg"),
3968 PackageVersions::latest_only("1.2.0")
3969 .with_published_at(published_at)
3970 .with_cooldown_fallback(deps_core::lsp_helpers::CooldownFallback::new(
3971 "1.1.0".into(),
3972 deps_core::PublishTime::from_unix_secs(1_000),
3973 )),
3974 );
3975
3976 let analysis = test_analysis(
3977 vec![
3978 // occ1: requirement matches the manifest text — a genuinely valid,
3979 // independently-Applicable fallback candidate in isolation.
3980 test_dep(
3981 "pkg",
3982 "1.0.0",
3983 Range::new(Position::new(0, 7), Position::new(0, 12)),
3984 ),
3985 // occ2: declared requirement ("9.9.9") does not match the manifest text
3986 // ("1.0.0") at its own span — always `Unplannable(NonLiteralSpan)`,
3987 // independent of which registry view (latest/fallback) classifies it.
3988 test_dep(
3989 "pkg",
3990 "9.9.9",
3991 Range::new(Position::new(1, 7), Position::new(1, 12)),
3992 ),
3993 ],
3994 versions,
3995 );
3996
3997 let plan = plan_updates(
3998 &analysis,
3999 content,
4000 &FALLBACK_FORMATTER,
4001 &never_reparse,
4002 &[],
4003 &IgnoreRules::empty(),
4004 deps_core::FreshnessSettings::Enabled {
4005 cooldown: deps_core::CooldownWindow::from_secs(1_000),
4006 },
4007 now,
4008 );
4009
4010 assert_eq!(plan.items.len(), 2, "{:?}", plan.items);
4011 for item in &plan.items {
4012 assert!(
4013 matches!(
4014 item.outcome,
4015 Outcome::Skipped {
4016 reason: SkipReason::WithinFreshnessCooldown,
4017 ..
4018 }
4019 ),
4020 "a collision must fail closed uniformly for both occurrences, never `Applied` \
4021 and never attributing one occurrence's structural defect to the other: {item:?}"
4022 );
4023 }
4024 assert_eq!(
4025 crate::exit::update_exit_code(&plan),
4026 crate::exit::EXIT_CLEAN
4027 );
4028 }
4029}