Skip to main content

deps_cli/
analyze.rs

1//! Shared parse -> lockfile -> fetch -> OSV pipeline.
2//!
3//! Extracted out of [`crate::report::check_manifest`] (spec 062) so `deps-cli update` (spec
4//! 068, #1329) can reuse the identical classification inputs `check` builds, without
5//! duplicating any of it.
6//!
7//! [`check_manifest`](crate::report::check_manifest) is now [`analyze_manifest`] followed by
8//! `generate_diagnostics`/`to_finding` — no behavior change to `check` itself.
9
10use deps_core::licenses::LicensePolicy;
11use deps_core::lsp_helpers::{
12    CooldownDisposition, DependencyOutcomes, LatestVerdict, PackageVersions, RequirementGate,
13    cooldown_disposition, latest_verdict,
14};
15use deps_core::osv::{LatestStatusMap, VulnerabilityMap};
16use deps_core::{
17    ConcreteVersion, Ecosystem, EcosystemId, FreshnessSettings, GossipFindings, LicenseSource,
18    PackageName, PublishTime, VersionData,
19};
20use deps_engine::classify::diff::{
21    merge_deprecations_after_fetch, merge_no_comparable_versions_after_fetch,
22};
23use deps_engine::classify::fetch::{
24    apply_fetch_outcomes, fetch_latest_versions_parallel, prepare_fetch,
25};
26use deps_engine::classify::license::prefetch_tier3_licenses;
27use deps_engine::classify::osv::{build_latest_check_targets, build_scan_targets};
28use deps_engine::classify::resolved::load_resolved_versions;
29use std::collections::{HashMap, HashSet};
30use std::path::Path;
31use std::sync::Arc;
32use std::time::Duration;
33
34use crate::report::{CheckContext, CheckError};
35
36/// Ceiling on the OSV scan timeout, independent of the configured `fetch_timeout_secs` —
37/// mirrors `deps-lsp`'s `document::osv_scan::OSV_SCAN_TIMEOUT_CEILING_SECS` and
38/// `report.rs`'s own (now-removed) copy of the same constant: the shared `reqwest` client
39/// behind [`deps_core::HttpCache`] already imposes its own client-wide 30s timeout, so a
40/// longer per-phase timeout would never actually bind.
41const OSV_SCAN_TIMEOUT_CEILING_SECS: u64 = 30;
42
43/// Which of [`analyze_manifest`]'s independent, network-touching phases (beyond the mandatory
44/// registry version fetch) actually run.
45///
46/// Code review finding 6: before this type existed, `analyze_manifest` always ran both the
47/// tier-3 license prefetch and the OSV scan, even for `deps-cli update`'s default mode — which
48/// reads neither `ManifestAnalysis::licenses` nor `ManifestAnalysis::vulnerabilities` at all —
49/// wasting a network round trip per dependency and burning shared rate-limit budgets (e.g.
50/// Swift's 60 req/h unauthenticated GitHub budget) on every plain `deps-cli update` run.
51///
52/// # Examples
53///
54/// ```
55/// use deps_cli::analyze::AnalysisScope;
56///
57/// let update_default_mode = AnalysisScope::update_default();
58/// assert!(!update_default_mode.licenses);
59/// assert!(!update_default_mode.vulnerabilities);
60/// assert!(update_default_mode.gossip);
61/// assert!(update_default_mode.cooldown_fallback);
62///
63/// let update_security_only = AnalysisScope::vulnerabilities_only();
64/// assert!(!update_security_only.licenses);
65/// assert!(update_security_only.vulnerabilities);
66/// assert!(!update_security_only.gossip);
67/// assert!(!update_security_only.cooldown_fallback);
68/// ```
69#[expect(
70    clippy::struct_excessive_bools,
71    reason = "each field is an independent phase-gate knob (license/vulnerability/gossip/\
72              cooldown-fallback), not overlapping state a two-variant enum could express \
73              more clearly — mirrors StubFormatter's identical rationale"
74)]
75#[derive(Debug, Clone, Copy)]
76pub struct AnalysisScope {
77    /// Whether to run the tier-3 license prefetch (Dart/Swift/Gradle/Deno — a no-op for every
78    /// other ecosystem regardless of this flag). Only [`crate::report::check_manifest`] reads
79    /// license data; no `update` mode does.
80    pub licenses: bool,
81    /// Whether to run the OSV vulnerability scan, subject to the existing
82    /// `diagnostics.vulnerabilities_enabled`/`network.offline` policy gates either way. `check`
83    /// and `update --security-only` both need this; `update`'s default mode does not.
84    pub vulnerabilities: bool,
85    /// Whether to run the spec 074 GOSSIP prefetch, subject to the existing `[gossip].enabled`
86    /// policy gate either way (issue #1521 item 4). **Deliberately independent of
87    /// `[freshness].enabled`** — spec 074 FR-002/FR-009/NFR-004 enumerate GOSSIP's gates
88    /// exhaustively (`[gossip].enabled`, not offline, a `deps_dev_system`-covered ecosystem, a
89    /// public-registry-content source) and never include freshness; GOSSIP and the local
90    /// `freshness.cooldown_secs` heuristic are deliberately independent signals (NFR-004),
91    /// mirroring `deps-lsp`'s own `run_gossip_prefetch`, which likewise gates only on
92    /// `is_gossip_enabled()`/offline. `false` under `update --security-only`: that mode's fix
93    /// target comes from the advisory's `recommended_fix()`, never the freshness/GOSSIP-filtered
94    /// registry pick (FR-014), so the prefetch's result would never be read — an avoidable
95    /// network call.
96    pub gossip: bool,
97    /// Whether to run spec 075 FR-010's extra, uncapped OSV round verifying every occurrence's
98    /// cooldown-fallback candidate. Only `true` for `update`'s default mode: `check` never
99    /// writes a fallback candidate (spec 075 §1 Out of Scope) and `--security-only`'s fix
100    /// target always comes from the advisory, never a freshness/GOSSIP-filtered pick — so
101    /// neither reads [`ManifestAnalysis::fallback_status`], and running this round for them
102    /// would be a wasted network call (NFR-004).
103    pub cooldown_fallback: bool,
104}
105
106impl AnalysisScope {
107    /// Both phases run — [`crate::report::check_manifest`]'s scope, and the default for any
108    /// caller that reads everything `ManifestAnalysis` can carry.
109    #[must_use]
110    pub const fn all() -> Self {
111        Self {
112            licenses: true,
113            vulnerabilities: true,
114            gossip: true,
115            cooldown_fallback: false,
116        }
117    }
118
119    /// Only the OSV scan runs — `deps-cli update --security-only`'s scope. `gossip` is `false`
120    /// (issue #1521 item 4): see [`Self::gossip`]'s doc.
121    #[must_use]
122    pub const fn vulnerabilities_only() -> Self {
123        Self {
124            licenses: false,
125            vulnerabilities: true,
126            gossip: false,
127            cooldown_fallback: false,
128        }
129    }
130
131    /// Neither the license nor the vulnerability-classification phase runs —
132    /// `deps-cli update`'s default-mode scope. Named for that one caller (not `none()`,
133    /// its pre-#1517 name) since [`analyze_manifest`] still unconditionally runs the OSV
134    /// **latest-check** (issue #1517) regardless of this scope: `update`'s default mode
135    /// must never write a flagged/unverified `latest` into the manifest, so that check is
136    /// not one of the two phases this scope can opt out of. `gossip` is `true`: default mode
137    /// is exactly the consumer spec 074's cooldown filter exists for. `cooldown_fallback` is
138    /// `true`: spec 075's fallback-candidate OSV round.
139    #[must_use]
140    pub const fn update_default() -> Self {
141        Self {
142            licenses: false,
143            vulnerabilities: false,
144            gossip: true,
145            cooldown_fallback: true,
146        }
147    }
148}
149
150/// One manifest's fully-assembled classification inputs.
151///
152/// The parsed dependencies, every lock-file/registry/OSV-derived map [`VersionData`]
153/// borrows, and the two registry/license-fetch incompleteness signals `check`/`update` both
154/// need for their own exit-code decisions.
155///
156/// Built by [`analyze_manifest`]; call [`Self::version_data`] to get the borrowed
157/// [`VersionData`] view [`deps_core::Ecosystem::generate_diagnostics`] and
158/// [`deps_core::edit::collect_update_edits`]/[`deps_core::edit::plan_vulnerability_fix`] all
159/// take.
160pub struct ManifestAnalysis {
161    /// The parsed manifest.
162    pub parse_result: Box<dyn deps_core::ParseResult>,
163    /// The manifest's file URI (derived from the path `analyze_manifest` was given).
164    pub uri: url::Url,
165    /// The instant this analysis ran, captured once (fix-cycle item 9/security L3).
166    ///
167    /// `analyze_manifest`'s own fallback-candidate OSV round (FR-010) and `deps-cli update`'s
168    /// planner (`plan_updates`) must evaluate `cooldown_disposition` against the SAME `now` —
169    /// two independent `PublishTime::now()` calls straddling the OSV round could, under
170    /// backward clock skew, let the planner see a dependency as `Blocked` that the OSV-gating
171    /// pass never verified a fallback for (`fallback_status` would then read `None`, degrading
172    /// to `NotApplicable` and writing an unverified fallback). Callers building `plan_updates`'s
173    /// `now` argument should use this field rather than calling `PublishTime::now()` again.
174    pub now: PublishTime,
175    /// The manifest's ecosystem.
176    pub ecosystem_id: EcosystemId,
177    /// Latest known versions and full version lists from the registry.
178    pub cached_versions: HashMap<PackageName, deps_core::lsp_helpers::PackageVersions>,
179    /// Versions actually resolved in the lock file.
180    pub resolved_versions: HashMap<PackageName, ConcreteVersion>,
181    /// Every lock-file-resolved version for a package name, when more than one is retained
182    /// (issue #649).
183    pub resolved_version_candidates: HashMap<PackageName, Vec<ConcreteVersion>>,
184    /// Yanked/deprecation/fetch-failure/no-comparable-versions findings from the fetch.
185    pub outcomes: DependencyOutcomes,
186    /// OSV scan results, when the scan ran (enabled and not offline).
187    pub vulnerabilities: Option<VulnerabilityMap>,
188    /// Phase B's per-key "latest" check result (issue #1517), when the check ran (enabled and
189    /// not offline) — populated regardless of `AnalysisScope`, since `update`'s default mode
190    /// needs this even though it opts out of both `licenses` and `vulnerabilities`. See
191    /// [`deps_core::osv::LatestStatusMap`] for the map's fail-closed-on-absence contract.
192    pub latest_status: Option<LatestStatusMap>,
193    /// Spec 075 FR-010: a separate OSV verdict map for every occurrence's cooldown-fallback
194    /// candidate, keyed identically to [`Self::latest_status`] but populated from a
195    /// fallback-substituted version view — NEVER merged into [`Self::latest_status`], since
196    /// [`LatestStatusMap`] has no version key and a merge would silently overwrite `latest`'s
197    /// own verdict. `None` when [`AnalysisScope::cooldown_fallback`] is `false`, the OSV
198    /// check is disabled/offline, or no dependency has a stored fallback candidate to verify
199    /// (NFR-004: this round costs zero extra network calls in that case).
200    pub fallback_status: Option<LatestStatusMap>,
201    /// Spec 075 FR-010's fallback-substituted view of [`Self::cached_versions`] (only
202    /// `latest` swapped for each dependency's stored cooldown-fallback candidate, when one
203    /// exists) — the exact view this OSV round already built to verify a fallback candidate
204    /// against, retained here so `deps-cli update`'s planner (`plan_updates`) can reuse it
205    /// instead of recomputing an identical `cooldown_fallback_view` from scratch (issue
206    /// #1551 finding 3). Same gate as [`Self::fallback_status`]: `None` when
207    /// [`AnalysisScope::cooldown_fallback`] is `false`, or no dependency's [`cooldown_disposition`]
208    /// actually differs from [`Self::cached_versions`] (NFR-004: nothing to substitute).
209    pub cooldown_fallback_view: Option<HashMap<PackageName, PackageVersions>>,
210    /// Already-computed GOSSIP findings (spec 074/075 FR-006), retained here instead of being
211    /// discarded after the registry fetch — fixes `check`'s dead `with_gossip_prefetch` branch
212    /// (`ManifestAnalysis::version_data` never called it before this field existed), so `check`
213    /// and `update` consult the same [`deps_core::lsp_helpers::cooldown_disposition`] precedence
214    /// end to end. Empty when GOSSIP is disabled/offline/unsupported, never absent.
215    pub gossip_findings: HashMap<PackageName, deps_core::GossipFindings>,
216    /// License data backfilled from the registry fetch (tier 1) and the tier-3 prefetch,
217    /// keyed by raw package name.
218    pub licenses: HashMap<PackageName, Vec<String>>,
219    /// The resolved SPDX allow/deny license policy for this run.
220    pub license_policy: LicensePolicy,
221    /// How license strings were sourced (registry-declared SPDX vs. free text).
222    pub license_source: LicenseSource,
223    /// The [`deps_core::NetworkMode`] set for this run.
224    pub network: deps_core::NetworkMode,
225    /// Raw package names whose registry fetch errored or timed out (`FetchResult::fetch_failed`,
226    /// captured before [`apply_fetch_outcomes`] consumes it) — the two-signal input
227    /// `deps-cli update --security-only`'s `Unfixable` classification needs (FR-011): a
228    /// dependency is `Unfixable` when it appears here **or** has no [`Self::cached_versions`]
229    /// entry.
230    pub fetch_failed: HashSet<PackageName>,
231    /// Whether at least one dependency's *version* registry fetch failed while not offline.
232    pub registry_unreachable: bool,
233    /// Whether at least one dependency's tier-3 license fetch timed out while not offline.
234    pub license_fetch_incomplete: bool,
235}
236
237impl ManifestAnalysis {
238    /// The borrowed [`VersionData`] view over this analysis's maps.
239    #[must_use]
240    pub fn version_data(&self) -> VersionData<'_> {
241        let mut version_data = VersionData::new(&self.cached_versions, &self.resolved_versions)
242            .with_resolved_version_candidates(&self.resolved_version_candidates)
243            .with_outcomes(&self.outcomes)
244            .with_ecosystem(self.ecosystem_id)
245            .with_network(self.network)
246            .with_license_source(self.license_source)
247            .with_license_policy(&self.license_policy)
248            .with_license_prefetch(&self.licenses)
249            .with_gossip_prefetch(&self.gossip_findings);
250        if let Some(vulnerabilities) = self.vulnerabilities.as_ref() {
251            version_data = version_data.with_vulnerabilities(vulnerabilities);
252        }
253        if let Some(latest_status) = self.latest_status.as_ref() {
254            version_data = version_data.with_latest_status(latest_status);
255        }
256        version_data
257    }
258
259    /// Whether any [`deps_core::lsp_helpers::RequirementStatus::Outdated`] dependency actually
260    /// in scope for this run's plan (per `package_filter`/`ignore_rules`, issue #1517 critique
261    /// S4) came back [`LatestVerdict::Unverified`] (issue #1517) — a transient OSV
262    /// failure/timeout, or the check never having run at all despite being enabled. Callers
263    /// (`deps-cli update`'s default mode) treat this the same as
264    /// [`Self::registry_unreachable`]: abort the whole run rather than silently omitting just
265    /// the affected dependency from the plan, since an unverifiable check must never be
266    /// mistaken for a clean one.
267    ///
268    /// `package_filter` is matched via `crate::update::is_requested`, and `ignore_rules` via
269    /// [`crate::update::ignore::IgnoreRules::matches_name`] (a name-only match, deliberately not
270    /// [`crate::update::ignore::IgnoreRules::skip_reason`]'s kind-scoped one: this abort-check
271    /// runs before any concrete `current`/target pair exists to classify an [`UpdateKind`] from).
272    /// A dependency this widens past scope (a kind-scoped ignore rule that would not actually
273    /// have matched this update) is not a regression: `deps_core::edit::collect_update_candidates`
274    /// applies the exact same [`latest_verdict`] gate per-candidate independently, so an
275    /// unverified `latest` for it still surfaces as `Skipped(NotSafelyEditable(LatestUnverified))`
276    /// in the final plan (a nonzero exit) rather than silently vanishing — this check only
277    /// controls whether the *whole run* aborts early with a clearer message, not whether the
278    /// unverified dependency itself is ever caught.
279    ///
280    /// [`UpdateKind`]: deps_core::edit::UpdateKind
281    #[must_use]
282    pub fn has_unverified_latest_check(
283        &self,
284        formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
285        package_filter: &[String],
286        ignore_rules: &crate::update::ignore::IgnoreRules,
287    ) -> bool {
288        let vuln_keys = deps_core::osv::vulnerability_keys(
289            self.parse_result.as_ref(),
290            &self.resolved_versions,
291            Some(&self.resolved_version_candidates),
292            formatter,
293            self.ecosystem_id,
294        );
295        self.parse_result.dependencies().into_iter().any(|dep| {
296            let normalized_name = formatter.normalize_package_name(dep.name());
297            if !crate::update::is_requested(package_filter, &normalized_name, formatter)
298                || ignore_rules.matches_name(&normalized_name)
299            {
300                return false;
301            }
302            let Some(latest) = self
303                .cached_versions
304                .get(normalized_name.as_str())
305                .or_else(|| self.cached_versions.get(dep.name()))
306                .map(|v| &v.latest)
307            else {
308                return false;
309            };
310            let Some(version_req) = dep.version_requirement() else {
311                return false;
312            };
313            if formatter.requirement_status_for(dep, version_req, latest)
314                != deps_core::lsp_helpers::RequirementStatus::Outdated
315            {
316                return false;
317            }
318            matches!(
319                latest_verdict(
320                    self.latest_status.as_ref(),
321                    dep,
322                    Some(&vuln_keys),
323                    &normalized_name,
324                    latest.as_str(),
325                    formatter,
326                ),
327                LatestVerdict::Unverified
328            )
329        })
330    }
331}
332
333/// Builds a fallback view of `cached_versions`: every package whose [`cooldown_disposition`]
334/// is `Blocked { fallback: Some(_), .. }` has its `latest` swapped for the stored
335/// [`deps_core::lsp_helpers::CooldownFallback`] candidate, everything else left unchanged.
336///
337/// Feeds both spec 075 FR-010's OSV verification round (this module) and FR-007's unified
338/// planner pipeline (`crate::update`) the exact same substituted view, via
339/// [`deps_core::edit::collect_update_candidates`]/[`build_latest_check_targets`] reading
340/// `PackageVersions::latest` as they always do — so a fallback candidate is verified and
341/// planned against identically, with no separate code path to drift out of sync.
342pub(crate) fn cooldown_fallback_view(
343    cached_versions: &HashMap<PackageName, PackageVersions>,
344    gossip_prefetch: Option<&HashMap<PackageName, GossipFindings>>,
345    freshness: FreshnessSettings,
346    now: PublishTime,
347) -> HashMap<PackageName, PackageVersions> {
348    cached_versions
349        .iter()
350        .map(|(name, versions)| {
351            let mut substituted = versions.clone();
352            if let CooldownDisposition::Blocked {
353                fallback: Some(fallback),
354                ..
355            } = cooldown_disposition(versions, name, freshness, gossip_prefetch, now)
356            {
357                substituted.latest = fallback.version.clone();
358            }
359            (name.clone(), substituted)
360        })
361        .collect()
362}
363
364/// Parses `content`, resolves in-use/lock-file versions, and fetches latest registry versions.
365///
366/// Per `scope`, also runs the tier-3 license prefetch and/or an OSV scan (each additionally
367/// subject to its own existing policy/offline gates either way).
368///
369/// The shared prefix [`crate::report::check_manifest`] and `deps-cli update` both need,
370/// extracted verbatim from `check_manifest`'s former body (spec 068 T007). `scope` exists so
371/// `update`'s default mode (which reads neither `licenses` nor `vulnerabilities`) does not pay
372/// for phases nothing in its plan consumes — see [`AnalysisScope`]'s doc (code review finding
373/// 6).
374///
375/// # Errors
376///
377/// Returns [`CheckError::InvalidPath`] if `manifest_path` cannot be represented as a file
378/// URI, or [`CheckError::Parse`] if `content` fails to parse as this ecosystem's manifest
379/// format.
380pub async fn analyze_manifest(
381    ecosystem: &Arc<dyn Ecosystem>,
382    manifest_path: &Path,
383    content: &str,
384    ctx: &CheckContext,
385    scope: AnalysisScope,
386) -> Result<ManifestAnalysis, CheckError> {
387    // Fix-cycle item 9/security L3: captured once, threaded through this function's own
388    // fallback-OSV-round gate below and stored on the returned `ManifestAnalysis` for
389    // `plan_updates` to reuse — never a second independent `PublishTime::now()` call.
390    let now = PublishTime::now();
391    let uri = crate::report::path_to_uri(manifest_path).ok_or_else(|| CheckError::InvalidPath {
392        path: manifest_path.to_path_buf(),
393    })?;
394    let parse_result = deps_core::parse_manifest_blocking(ecosystem, content, &uri)
395        .await
396        .map_err(|source| CheckError::Parse {
397            path: manifest_path.to_path_buf(),
398            source,
399        })?;
400    let formatter = ecosystem.formatter();
401    let ecosystem_id = ecosystem.ecosystem_id();
402
403    // A one-shot check has no prior in-memory resolved-version state to protect from a
404    // transient parse failure the way `deps-lsp` does, so the reload-ok signal (issue
405    // #1407) isn't needed here.
406    let (resolved_versions, resolved_version_candidates) =
407        load_resolved_versions(&uri, &ctx.lockfile_cache, ecosystem.as_ref())
408            .await
409            .into_maps();
410
411    let prep = prepare_fetch(
412        parse_result.as_ref(),
413        formatter,
414        ecosystem_id,
415        &resolved_versions,
416        &resolved_version_candidates,
417    );
418    let collided_names = prep.collided_names;
419    let attempted_names: Vec<PackageName> = prep
420        .dep_sources
421        .iter()
422        .map(|(name, _)| name.clone())
423        .collect();
424
425    // Spec 074 FR-002: one batch prefetch per manifest, mirroring `prefetch_tier3_licenses`'s
426    // own "prefetch once, thread the result through" shape below. `None` when
427    // `!ctx.policy.gossip.enabled` short-circuits `fetch_gossip_findings_batch` before any
428    // HTTP call (FR-009) — construction of `ctx.deps_dev` itself is unconditional (FR-001).
429    // Issue #1521 item 4: also `None` under `scope.gossip == false` (`update --security-only`,
430    // whose fix target never reads a GOSSIP-filtered `latest` at all) — see `AnalysisScope::gossip`'s
431    // doc for why this is *not* additionally gated on `ctx.policy.freshness.enabled`.
432    let network = ctx.policy.network.mode();
433    let gossip_client = (scope.gossip && ctx.policy.gossip.enabled).then_some(&ctx.deps_dev);
434    let gossip_findings = deps_core::lsp_helpers::fetch_gossip_findings_batch(
435        ecosystem_id,
436        parse_result.as_ref(),
437        formatter,
438        network,
439        gossip_client,
440    )
441    .await;
442
443    let fetch_result = fetch_latest_versions_parallel(
444        ecosystem.registry(),
445        prep.dep_sources,
446        &prep.in_use,
447        None,
448        ctx.policy.freshness.to_freshness(),
449        ctx.policy.cache.fetch_timeout_secs,
450        ctx.policy.cache.max_concurrent_fetches,
451        &prep.selection_context,
452        Some(&gossip_findings),
453    )
454    .await;
455    // `fetch_failed` (genuine failures only), not `failure_summary`'s count, which also
456    // includes not-found lookups — using that here made a typo'd dependency exit 2 every run.
457    let registry_unreachable = network.is_online() && !fetch_result.fetch_failed.is_empty();
458    // Captured before `apply_fetch_outcomes` consumes `fetch_result.fetch_failed` below —
459    // `deps-cli update --security-only`'s FR-011 two-signal `Unfixable` rule needs the raw
460    // set independently of the yanked/deprecation-merged `DependencyOutcomes`.
461    let fetch_failed: HashSet<PackageName> = fetch_result.fetch_failed.keys().cloned().collect();
462
463    let mut outcomes = DependencyOutcomes::new();
464    let fetched_names: Vec<PackageName> = fetch_result.versions.keys().cloned().collect();
465    apply_fetch_outcomes(
466        &mut outcomes,
467        fetch_result.yanked_versions,
468        fetch_result.fetch_failed,
469        collided_names,
470        formatter,
471    );
472    merge_deprecations_after_fetch(
473        &mut outcomes,
474        &fetched_names,
475        fetch_result.deprecations,
476        formatter,
477    );
478    merge_no_comparable_versions_after_fetch(
479        &mut outcomes,
480        &attempted_names,
481        fetch_result.no_comparable_versions,
482        formatter,
483    );
484    let cached_versions = fetch_result.versions;
485    // Tier-1 license backfill (issue #660/#661 precedent): populated for native-list
486    // ecosystems (PyPI, Composer) whose registry response carries a license field.
487    let mut licenses = fetch_result.licenses;
488
489    // Hoisted so both the tier-3 gate below and the returned `ManifestAnalysis` share one
490    // computed policy (issue #1133 critic M1).
491    let license_policy = ctx.policy.license_policy.to_policy();
492
493    // Tier-3 license prefetch (issue #1133, populated for Dart/Swift/Gradle/Deno, a no-op
494    // for every other ecosystem — see `deps_engine::classify::license`'s doc) and the OSV
495    // scan are independent (OSV never reads licenses) and both make network round trips, so
496    // they run concurrently via `tokio::join!` rather than sequentially (critic M2) —
497    // mirrors `deps-lsp`, which spawns both as separate concurrent tasks.
498    //
499    // Only `!offline` is gated here — the non-empty-`license_policy` check (critic M1) is
500    // enforced *inside* `prefetch_tier3_licenses` itself (code-review finding #3), not
501    // re-derived at this call site, so it can't be silently forgotten by a future caller:
502    // `licenses`' only consumer in this crate is `apply_license_policy_rule`, a no-op when
503    // no policy is configured, so with the default (empty) policy this call would otherwise
504    // issue N network round trips for a result nothing reads — burning Swift's
505    // unauthenticated 60 req/h GitHub budget among others for nothing. `scope.licenses` adds a
506    // second, caller-declared reason to skip this entirely (code review finding 6) — no
507    // `update` mode ever reads `ManifestAnalysis::licenses`.
508    let run_tier3_prefetch = scope.licenses && network.is_online();
509    let tier3_license_fetch = async {
510        if run_tier3_prefetch {
511            prefetch_tier3_licenses(
512                ecosystem.as_ref(),
513                parse_result.as_ref(),
514                &resolved_versions,
515                &resolved_version_candidates,
516                &license_policy,
517                ctx.policy.cache.fetch_timeout_secs,
518                ctx.policy.cache.max_concurrent_fetches,
519            )
520            .await
521        } else {
522            deps_engine::classify::license::TierThreeLicenseFetch::default()
523        }
524    };
525
526    // `scope.vulnerabilities` (code review finding 6): `update`'s default mode never reads
527    // `ManifestAnalysis::vulnerabilities`, so it declares this scope out entirely rather than
528    // paying for a scan nothing consumes.
529    let run_osv_scan = scope.vulnerabilities
530        && ctx.policy.diagnostics.vulnerabilities_enabled
531        && network.is_online();
532    let osv_scan = async {
533        if run_osv_scan {
534            let (targets, skipped) = build_scan_targets(
535                parse_result.as_ref(),
536                &resolved_versions,
537                &resolved_version_candidates,
538                formatter,
539                ecosystem_id,
540            );
541            let mut vulns = skipped;
542            if !targets.is_empty() {
543                let timeout = Duration::from_secs(
544                    ctx.policy
545                        .cache
546                        .fetch_timeout_secs
547                        .min(OSV_SCAN_TIMEOUT_CEILING_SECS),
548                );
549                let scanned = ctx.osv.scan(ecosystem_id, &targets, timeout).await;
550                vulns.extend(scanned);
551            }
552            Some(vulns)
553        } else {
554            None
555        }
556    };
557
558    // Issue #1517: unconditional on `scope` (unlike the OSV vulnerability scan above) —
559    // `update`'s default mode never reads `ManifestAnalysis::vulnerabilities`, but it always
560    // needs to know whether the `latest` it's about to write is itself safe. Still gated on
561    // the same `vulnerabilities_enabled`/`!offline` policy every other OSV call respects.
562    let run_latest_check = ctx.policy.diagnostics.vulnerabilities_enabled && network.is_online();
563
564    // Spec 075 FR-010: the fallback-candidate OSV round only fires when
565    // `scope.cooldown_fallback` is set AND at least one dependency's `cooldown_disposition`
566    // actually found `Blocked { fallback: Some(_) }` — NFR-004: zero extra network calls
567    // otherwise (a view identical to `cached_versions` has nothing new to verify).
568    let cooldown_fallback_view_map = scope
569        .cooldown_fallback
570        .then(|| {
571            cooldown_fallback_view(
572                &cached_versions,
573                Some(&gossip_findings),
574                ctx.policy.freshness.to_freshness(),
575                now,
576            )
577        })
578        .filter(|view| {
579            view.iter().any(|(name, v)| {
580                cached_versions
581                    .get(name)
582                    .is_none_or(|c| c.latest != v.latest)
583            })
584        });
585    let run_fallback_check = run_latest_check && cooldown_fallback_view_map.is_some();
586
587    // FR-010: computed once and shared between the latest-status and fallback-status futures
588    // below, instead of each independently re-deriving the same map.
589    let vuln_keys = (run_latest_check || run_fallback_check).then(|| {
590        deps_core::osv::vulnerability_keys(
591            parse_result.as_ref(),
592            &resolved_versions,
593            Some(&resolved_version_candidates),
594            formatter,
595            ecosystem_id,
596        )
597    });
598
599    let latest_check = async {
600        let (true, Some(vuln_keys)) = (run_latest_check, vuln_keys.as_ref()) else {
601            return None;
602        };
603        let (targets, mut latest_status) = build_latest_check_targets(
604            parse_result.as_ref(),
605            &cached_versions,
606            vuln_keys,
607            formatter,
608        );
609        if !targets.is_empty() {
610            let timeout = Duration::from_secs(
611                ctx.policy
612                    .cache
613                    .fetch_timeout_secs
614                    .min(OSV_SCAN_TIMEOUT_CEILING_SECS),
615            );
616            let checked = ctx
617                .osv
618                .check_candidates(ecosystem_id, &targets, timeout)
619                .await;
620            latest_status.extend(checked);
621        }
622        Some(latest_status)
623    };
624
625    // Spec 075 FR-010: reuses `build_latest_check_targets` (never a new OSV-request builder)
626    // over the fallback-substituted view, so the fallback candidate is verified exactly the
627    // way `latest` itself is. Result is a wholly separate map — never merged into
628    // `latest_status` (see `ManifestAnalysis::fallback_status`'s doc for why).
629    let fallback_check = async {
630        let (true, Some(vuln_keys), Some(view)) = (
631            run_fallback_check,
632            vuln_keys.as_ref(),
633            cooldown_fallback_view_map.as_ref(),
634        ) else {
635            return None;
636        };
637        let (targets, mut fallback_status) =
638            build_latest_check_targets(parse_result.as_ref(), view, vuln_keys, formatter);
639        if !targets.is_empty() {
640            let timeout = Duration::from_secs(
641                ctx.policy
642                    .cache
643                    .fetch_timeout_secs
644                    .min(OSV_SCAN_TIMEOUT_CEILING_SECS),
645            );
646            let checked = ctx
647                .osv
648                .check_candidates(ecosystem_id, &targets, timeout)
649                .await;
650            fallback_status.extend(checked);
651        }
652        Some(fallback_status)
653    };
654
655    let (tier3_result, vulnerabilities, latest_status, fallback_status): (
656        _,
657        Option<VulnerabilityMap>,
658        _,
659        _,
660    ) = tokio::join!(tier3_license_fetch, osv_scan, latest_check, fallback_check);
661
662    // Merged (not replaced) alongside the tier-1 backfill above via `entry().or_insert()`,
663    // not `extend` (critic nit): the two sources are disjoint today (only Composer
664    // populates `FetchResult::licenses`, and it's `RegistryDeclaredSpdx`, never a tier-3
665    // ecosystem), but `or_insert` makes the intended precedence explicit — an
666    // author-declared tier-1 license must win over a heuristic tier-3 one if that ever
667    // stops holding, rather than whichever call happened to run last.
668    for (name, license) in tier3_result.licenses {
669        licenses.entry(name).or_insert(license);
670    }
671    // A confirmed tier-3 timeout feeds the same exit-2 "incomplete report" signal a
672    // registry-unreachable manifest does, but through its own field (issue #1133 code-review
673    // finding #1) — not `registry_unreachable` itself: a Maven Central outage while the
674    // version registry is fully reachable is a different failure than an unreachable
675    // registry, and a caller inspecting `registry_unreachable` must not be misled into
676    // diagnosing the wrong system.
677    let license_fetch_incomplete = tier3_result.timed_out > 0;
678
679    Ok(ManifestAnalysis {
680        parse_result,
681        uri,
682        now,
683        ecosystem_id,
684        cached_versions,
685        resolved_versions,
686        resolved_version_candidates,
687        outcomes,
688        vulnerabilities,
689        latest_status,
690        fallback_status,
691        cooldown_fallback_view: cooldown_fallback_view_map,
692        gossip_findings,
693        licenses,
694        license_policy,
695        license_source: ecosystem.license_source(),
696        network,
697        fetch_failed,
698        registry_unreachable,
699        license_fetch_incomplete,
700    })
701}
702
703#[cfg(test)]
704mod has_unverified_latest_check_tests {
705    use super::ManifestAnalysis;
706    use crate::update::ignore::IgnoreRules;
707    use deps_core::licenses::LicensePolicy;
708    use deps_core::lsp_helpers::{DependencyOutcomes, PackageVersions};
709    use deps_core::osv::LatestStatusMap;
710    use deps_core::parser::DependencySource;
711    use deps_core::position::{Position, Range};
712    use deps_core::test_util::StubFormatter;
713    use deps_core::{Dependency, EcosystemId, PackageName, ParseResult, VersionReq};
714    use std::any::Any;
715    use std::collections::{HashMap, HashSet};
716
717    struct MockDep {
718        name: PackageName,
719        version_req: VersionReq,
720        version_range: Range,
721    }
722    impl Dependency for MockDep {
723        fn name(&self) -> &PackageName {
724            &self.name
725        }
726        fn name_range(&self) -> Range {
727            Range::default()
728        }
729        fn version_requirement(&self) -> Option<&VersionReq> {
730            Some(&self.version_req)
731        }
732        fn version_range(&self) -> Option<Range> {
733            Some(self.version_range)
734        }
735        fn source(&self) -> DependencySource {
736            DependencySource::Registry
737        }
738        fn as_any(&self) -> &dyn Any {
739            self
740        }
741    }
742
743    struct MockParseResult {
744        deps: Vec<MockDep>,
745        uri: url::Url,
746    }
747    impl ParseResult for MockParseResult {
748        fn dependencies(&self) -> Vec<&dyn Dependency> {
749            self.deps.iter().map(|d| d as &dyn Dependency).collect()
750        }
751        fn workspace_root(&self) -> Option<&std::path::Path> {
752            None
753        }
754        fn uri(&self) -> &url::Url {
755            &self.uri
756        }
757        fn as_any(&self) -> &dyn Any {
758            self
759        }
760    }
761
762    /// One outdated `serde` dependency ("1.0.0" -> cached latest "1.2.0"), with `latest_status`
763    /// set to `Some(&empty map)` — the exact pre-phase-B/never-checked state `latest_verdict`
764    /// treats as `Unverified` (fail closed).
765    fn analysis_with_one_outdated_unverified_dep() -> ManifestAnalysis {
766        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
767        let mut cached_versions = HashMap::new();
768        cached_versions.insert(
769            PackageName::new("serde"),
770            PackageVersions::latest_only("1.2.0"),
771        );
772
773        ManifestAnalysis {
774            parse_result: Box::new(MockParseResult {
775                deps: vec![MockDep {
776                    name: PackageName::new("serde"),
777                    version_req: VersionReq::new("1.0.0"),
778                    version_range: Range::new(Position::new(0, 9), Position::new(0, 14)),
779                }],
780                uri: uri.clone(),
781            }),
782            uri,
783            now: deps_core::PublishTime::now(),
784            ecosystem_id: EcosystemId::Cargo,
785            cached_versions,
786            resolved_versions: HashMap::new(),
787            resolved_version_candidates: HashMap::new(),
788            outcomes: DependencyOutcomes::new(),
789            vulnerabilities: None,
790            latest_status: Some(LatestStatusMap::new()),
791            fallback_status: None,
792            cooldown_fallback_view: None,
793            gossip_findings: HashMap::new(),
794            licenses: HashMap::new(),
795            license_policy: LicensePolicy::default(),
796            license_source: deps_core::LicenseSource::default(),
797            network: deps_core::NetworkMode::Online,
798            fetch_failed: HashSet::new(),
799            registry_unreachable: false,
800            license_fetch_incomplete: false,
801        }
802    }
803
804    /// Baseline: an unfiltered, unignored outdated dependency with no OSV verdict for its
805    /// cached latest is reported as unverified.
806    #[test]
807    fn true_for_outdated_unverified_dependency_in_scope() {
808        let analysis = analysis_with_one_outdated_unverified_dep();
809        assert!(analysis.has_unverified_latest_check(
810            &StubFormatter::DEFAULT,
811            &[],
812            &IgnoreRules::empty(),
813        ));
814    }
815
816    /// Issue #1517 critique S4: a `--package` filter that does not name the unverified
817    /// dependency must exclude it from this abort-check, the same scoping
818    /// `deps_cli::update::is_requested` applies to the actual plan.
819    #[test]
820    fn false_when_package_filter_excludes_the_dependency() {
821        let analysis = analysis_with_one_outdated_unverified_dep();
822        assert!(!analysis.has_unverified_latest_check(
823            &StubFormatter::DEFAULT,
824            &["some-other-package".to_string()],
825            &IgnoreRules::empty(),
826        ));
827    }
828
829    /// A `--package` filter that does name the dependency still reports it.
830    #[test]
831    fn true_when_package_filter_names_the_dependency() {
832        let analysis = analysis_with_one_outdated_unverified_dep();
833        assert!(analysis.has_unverified_latest_check(
834            &StubFormatter::DEFAULT,
835            &["serde".to_string()],
836            &IgnoreRules::empty(),
837        ));
838    }
839
840    /// Issue #1517 critique S4: an `[update].ignore` rule naming the dependency must exclude
841    /// it from this abort-check too, regardless of the rule's `update_types` scope (a name-only
842    /// match — see `has_unverified_latest_check`'s own doc for why it does not attempt
843    /// `IgnoreRules::skip_reason`'s kind-scoped match here).
844    #[test]
845    fn false_when_an_ignore_rule_names_the_dependency() {
846        let analysis = analysis_with_one_outdated_unverified_dep();
847        let rules = IgnoreRules::new(
848            vec![crate::config::IgnoreRule {
849                name: "serde".to_string(),
850                update_types: None,
851            }],
852            &StubFormatter::DEFAULT,
853        );
854        assert!(!analysis.has_unverified_latest_check(&StubFormatter::DEFAULT, &[], &rules));
855    }
856}