Skip to main content

deps_cli/
report.rs

1//! `CheckReport`/`CheckFinding`/`Category`/`FailOnPolicy` and the classification
2//! orchestrator that assembles one manifest's [`deps_core::VersionData`].
3//!
4//! It then calls its ecosystem's [`deps_core::Ecosystem::generate_diagnostics`] — the
5//! identical call `deps-lsp`'s `handlers/diagnostics.rs` makes.
6//!
7//! Every outdated/yanked/vulnerable/unsatisfiable/deprecated verdict is decided by
8//! [`deps_engine::classify`] or by `generate_diagnostics` itself; nothing in this module
9//! re-derives a verdict from raw registry/lockfile/OSV data (spec 062 FR-005). `Category`
10//! stays in this crate rather than `deps_core` per `specs/062-cli-check-mode/plan.md`'s
11//! `[NEEDS CLARIFICATION: O-4]` marker — see that doc before moving it.
12
13use deps_core::diagnostic::{Diagnostic, Severity};
14use deps_core::lsp_helpers::{
15    DEPRECATED_DIAGNOSTIC_CODE, DependencyOutcomes, LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE,
16    UNSATISFIABLE_DIAGNOSTIC_CODE,
17};
18use deps_core::osv::{OsvClient, ScanOutcome, VulnSeverity, VulnerabilityMap};
19use deps_core::policy_config::PolicyConfig;
20use deps_core::position::Range;
21use deps_core::{Dependency, Ecosystem, EcosystemId, HttpCache, PackageName, VersionData};
22use deps_engine::classify::diff::{
23    merge_deprecations_after_fetch, merge_no_comparable_versions_after_fetch,
24};
25use deps_engine::classify::fetch::{
26    apply_fetch_outcomes, composer_minimum_stability, dedup_dependencies_by_source,
27    fetch_latest_versions_parallel,
28};
29use deps_engine::classify::osv::build_scan_targets;
30use deps_engine::classify::resolved::{collect_in_use_versions, load_resolved_versions};
31use std::collections::{BTreeMap, HashMap};
32use std::path::{Path, PathBuf};
33use std::sync::Arc;
34use std::time::Duration;
35
36/// Every non-`deps-core` diagnostic code constant in the workspace, mirrored here as
37/// literals rather than importing `deps-github-actions`/`deps-gitlab-ci` directly (both are
38/// optional, feature-gated dependencies of `deps-engine`; importing their constants
39/// unconditionally would break a `--no-default-features --features cargo`-style build, and
40/// `#[cfg]`-gating each match arm was judged not worth the complexity for three stable
41/// strings). **This list must stay exhaustive** — issue C3 (spec 062 review) was exactly one
42/// ecosystem-owned code (`UNRESOLVED_HOST_DIAGNOSTIC_CODE`) missing from it, which silently
43/// misclassified an informational notice as `Category::Vulnerable`. Before trusting
44/// [`classify`]'s fallback again, re-run
45/// `grep -rn 'pub const.*_DIAGNOSTIC_CODE.*: &str' crates/*/src/lib.rs crates/*/src/ecosystem.rs`
46/// across the workspace and add anything new here.
47const GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE: &str = "mutable-ref-pin";
48/// See [`GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE`]'s doc.
49const GITLAB_CI_MUTABLE_REF_PIN_CODE: &str = "gitlab-ci-mutable-ref-pin";
50/// `deps_gitlab_ci::UNRESOLVED_HOST_DIAGNOSTIC_CODE` — an informational notice (INFORMATION
51/// severity, never a vulnerability), emitted unconditionally whenever GitLab CI's
52/// `registries.gitlab_instance_host` is unset/invalid. See
53/// [`GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE`]'s doc for why this is a literal.
54const GITLAB_CI_UNRESOLVED_HOST_CODE: &str = "unresolved-gitlab-host";
55
56/// Ceiling on the OSV scan timeout, independent of the configured `fetch_timeout_secs` —
57/// mirrors `deps-lsp`'s `document::osv_scan::OSV_SCAN_TIMEOUT_CEILING_SECS`: the shared
58/// `reqwest` client behind [`HttpCache`] already imposes its own client-wide 30s timeout, so
59/// a longer per-phase timeout would never actually bind.
60const OSV_SCAN_TIMEOUT_CEILING_SECS: u64 = 30;
61
62/// A category a [`CheckFinding`] can be classified into — the seven `--fail-on` tokens FR-009
63/// defines, plus [`Category::Other`].
64///
65/// `Other` covers a `generate_diagnostics` finding that matches none of the seven (an
66/// "Unknown package", a collapsed registry-lookup failure, or a workspace-registry/
67/// offline/dependency-count notice). It is never selectable via `--fail-on`
68/// (`#[value(skip)]`) and never matched by [`FailOnPolicy`] — it exists purely so
69/// [`CheckFinding`] stays a 1:1 mapping of every diagnostic `generate_diagnostics` produced,
70/// per spec 062 `tasks.md` T020, rather than silently dropping findings this crate cannot
71/// classify.
72///
73/// # Examples
74///
75/// ```
76/// use deps_cli::report::Category;
77///
78/// assert_eq!(Category::MutableRefPin.as_str(), "mutable-ref");
79/// assert_eq!(Category::License.as_str(), "license");
80/// ```
81#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, clap::ValueEnum)]
82pub enum Category {
83    /// A newer version is published for a dependency's declared requirement.
84    Outdated,
85    /// The in-use (or only-satisfying) version has been yanked/retracted.
86    Yanked,
87    /// A known OSV advisory affects the in-use version.
88    Vulnerable,
89    /// No published version satisfies the declared requirement.
90    Unsatisfiable,
91    /// Pinned to a mutable ref (tag/branch) instead of a commit SHA (GitHub Actions/GitLab
92    /// CI).
93    #[value(name = "mutable-ref")]
94    MutableRefPin,
95    /// The resolved license violates the configured allow/deny policy.
96    License,
97    /// The registry reports the package itself as deprecated/abandoned.
98    Deprecated,
99    /// A `generate_diagnostics` finding that does not map to any category above.
100    #[value(skip)]
101    Other,
102}
103
104impl Category {
105    /// The FR-009 wire token for this category (`table`/`json` output and `--fail-on`).
106    #[must_use]
107    pub const fn as_str(self) -> &'static str {
108        match self {
109            Self::Outdated => "outdated",
110            Self::Yanked => "yanked",
111            Self::Vulnerable => "vulnerable",
112            Self::Unsatisfiable => "unsatisfiable",
113            Self::MutableRefPin => "mutable-ref",
114            Self::License => "license",
115            Self::Deprecated => "deprecated",
116            Self::Other => "other",
117        }
118    }
119
120    /// A one-line human-readable description, longer than [`Self::as_str`]'s wire token —
121    /// used as SARIF rule metadata (`crate::format::sarif`) for a rule that only has
122    /// category-level granularity (no finer diagnostic code). Deliberately close in wording
123    /// to each variant's own doc comment above (both describe the same category, and SARIF's
124    /// `shortDescription` is meant to read like ordinary documentation prose) rather than a
125    /// genuinely distinct text written just to avoid the overlap.
126    ///
127    /// # Examples
128    ///
129    /// ```
130    /// use deps_cli::report::Category;
131    ///
132    /// assert_eq!(
133    ///     Category::Vulnerable.description(),
134    ///     "A known security advisory affects the in-use version."
135    /// );
136    /// assert_eq!(
137    ///     Category::License.description(),
138    ///     "The resolved license violates the configured allow/deny policy."
139    /// );
140    /// ```
141    #[must_use]
142    pub const fn description(self) -> &'static str {
143        match self {
144            Self::Outdated => {
145                "A newer version is published for the dependency's declared requirement."
146            }
147            Self::Yanked => "The in-use version has been yanked or retracted from the registry.",
148            Self::Vulnerable => "A known security advisory affects the in-use version.",
149            Self::Unsatisfiable => "No published version satisfies the declared requirement.",
150            Self::MutableRefPin => {
151                "Pinned to a mutable ref (tag or branch) instead of a commit SHA."
152            }
153            Self::License => "The resolved license violates the configured allow/deny policy.",
154            Self::Deprecated => {
155                "The registry reports the package itself as deprecated or abandoned."
156            }
157            Self::Other => "A finding that does not map to any of the other categories.",
158        }
159    }
160}
161
162impl std::fmt::Display for Category {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        f.write_str(self.as_str())
165    }
166}
167
168/// One reported issue, derived 1:1 from a [`Diagnostic`] `generate_diagnostics` produced.
169#[derive(Debug, Clone)]
170pub struct CheckFinding {
171    /// Which ecosystem's manifest this finding came from.
172    pub ecosystem: EcosystemId,
173    /// Path to the manifest, relative to the walked root when discovered by [`crate::walk`].
174    pub manifest_path: PathBuf,
175    /// The dependency's declared name, when a manifest occurrence's range matched the
176    /// diagnostic's own range. `None` for a document-level finding not anchored to one
177    /// dependency (e.g. an offline/dependency-count notice).
178    pub dependency_name: Option<String>,
179    /// The dependency's declared version requirement, when known.
180    pub requirement: Option<String>,
181    /// The classified category (see [`Category`]).
182    pub category: Category,
183    /// The diagnostic's own `code`, when it carried a string one (spec 062 review S3,
184    /// issue #1075): a vulnerability diagnostic's code is an OSV advisory id
185    /// (`RUSTSEC-...`/`GHSA-...`), finer-grained than [`Self::category`]; the workspace's
186    /// other stable diagnostic-code constants (`UNSATISFIABLE_DIAGNOSTIC_CODE`, etc.) are
187    /// 1:1 with a category, so carrying them here changes nothing beyond echoing
188    /// `category`. `None` when `classify` fell back to matching the diagnostic's message
189    /// text instead of its code (`Outdated`/`Yanked`/`Other`).
190    pub code: Option<String>,
191    /// The advisory's own `https://osv.dev/vulnerability/{id}` page, when [`Self::code`] is
192    /// an OSV advisory id — sourced from the diagnostic's `code_description.href` (already
193    /// `Uri`-parsed and validated by `deps_core::lsp_helpers::diagnostics::
194    /// push_vulnerability_diagnostics` before it ever reaches a `Diagnostic`), not
195    /// re-derived from `code` — the authoritative URL OSV itself gave us, rather than a
196    /// second, redundant formula that could drift from it. `None` whenever `code_description`
197    /// is absent (every non-advisory finding, and the rare case where OSV's own `url` failed
198    /// `Uri` parsing upstream).
199    pub advisory_url: Option<String>,
200    /// The OSV-derived severity bucket for [`Self::code`], when it names an advisory this
201    /// manifest's OSV scan actually fetched (issue #1077 C2) — looked up by advisory id from
202    /// the same scan results `generate_diagnostics` consumed, not re-derived from
203    /// [`Self::severity`] (the three-bucket [`Severity`] `code` already collapsed into is too
204    /// coarse to recover a CVSS-style grade from). `None` for every non-advisory finding, and
205    /// for an advisory `code` this run's scan did not itself fetch (e.g. a stale `code` from a
206    /// formatter that does not source it from a live scan).
207    pub advisory_severity: Option<VulnSeverity>,
208    /// The diagnostic's severity.
209    pub severity: Severity,
210    /// The diagnostic's range within the manifest.
211    pub range: Range,
212    /// The diagnostic's human-readable message.
213    pub message: String,
214}
215
216/// The full result of one `check` invocation.
217#[derive(Debug, Clone, Default)]
218pub struct CheckReport {
219    /// Every finding produced across every walked manifest.
220    pub findings: Vec<CheckFinding>,
221}
222
223impl CheckReport {
224    /// Per-category finding counts, derived from [`Self::findings`] rather than kept as
225    /// separately mutated state (spec 062 tasks.md T020).
226    ///
227    /// # Examples
228    ///
229    /// ```
230    /// use deps_cli::report::{Category, CheckFinding, CheckReport};
231    /// use deps_core::EcosystemId;
232    /// use deps_core::diagnostic::Severity;
233    /// use deps_core::position::Range;
234    /// use std::path::PathBuf;
235    ///
236    /// let report = CheckReport {
237    ///     findings: vec![CheckFinding {
238    ///         ecosystem: EcosystemId::Cargo,
239    ///         manifest_path: PathBuf::from("Cargo.toml"),
240    ///         dependency_name: Some("serde".to_string()),
241    ///         requirement: Some("1.0".to_string()),
242    ///         category: Category::Outdated,
243    ///         code: None,
244    ///         advisory_url: None,
245    ///         advisory_severity: None,
246    ///         severity: Severity::Hint,
247    ///         range: Range::default(),
248    ///         message: "Newer version available: 1.1".to_string(),
249    ///     }],
250    /// };
251    /// assert_eq!(report.summary().get(&Category::Outdated), Some(&1));
252    /// ```
253    #[must_use]
254    pub fn summary(&self) -> BTreeMap<Category, usize> {
255        let mut counts = BTreeMap::new();
256        for finding in &self.findings {
257            *counts.entry(finding.category).or_insert(0_usize) += 1;
258        }
259        counts
260    }
261}
262
263/// The set of categories that turn at least one matching [`CheckFinding`] into a
264/// process-exit-1 policy violation (FR-009/FR-010).
265#[derive(Debug, Clone)]
266pub struct FailOnPolicy {
267    categories: Vec<Category>,
268}
269
270impl FailOnPolicy {
271    /// Builds a policy from an explicit category list.
272    #[must_use]
273    pub const fn new(categories: Vec<Category>) -> Self {
274        Self { categories }
275    }
276
277    /// The default policy (FR-010): `vulnerable,yanked,unsatisfiable` — the categories that
278    /// represent a broken or unsafe build, as opposed to advisory-only categories.
279    ///
280    /// # Examples
281    ///
282    /// ```
283    /// use deps_cli::report::{Category, FailOnPolicy};
284    ///
285    /// let policy = FailOnPolicy::default_categories();
286    /// assert!(policy.categories().contains(&Category::Vulnerable));
287    /// assert!(!policy.categories().contains(&Category::Outdated));
288    /// ```
289    #[must_use]
290    pub fn default_categories() -> Self {
291        Self::new(vec![
292            Category::Vulnerable,
293            Category::Yanked,
294            Category::Unsatisfiable,
295        ])
296    }
297
298    /// The categories this policy fails on.
299    #[must_use]
300    pub fn categories(&self) -> &[Category] {
301        &self.categories
302    }
303
304    /// Whether any finding in `findings` matches this policy.
305    #[must_use]
306    pub fn matches(&self, findings: &[CheckFinding]) -> bool {
307        findings
308            .iter()
309            .any(|finding| self.categories.contains(&finding.category))
310    }
311}
312
313impl Default for FailOnPolicy {
314    fn default() -> Self {
315        Self::default_categories()
316    }
317}
318
319/// Runtime handles and resolved policy shared across every manifest a `check` run classifies.
320///
321/// Built once in `main.rs` (or by an integration test) and passed by reference to
322/// [`check_manifest`] for every discovered manifest.
323#[derive(Clone)]
324pub struct CheckContext {
325    /// Shared HTTP cache for registry requests.
326    pub cache: Arc<HttpCache>,
327    /// Shared OSV.dev vulnerability scan client.
328    pub osv: Arc<OsvClient>,
329    /// Shared lock-file cache, keyed by resolved lockfile path.
330    pub lockfile_cache: Arc<deps_core::lockfile::LockFileCache>,
331    /// The resolved policy configuration for this run.
332    pub policy: PolicyConfig,
333}
334
335/// Error running [`check_manifest`] for one manifest.
336#[derive(Debug, thiserror::Error)]
337pub enum CheckError {
338    /// The manifest content could not be parsed by its ecosystem's parser.
339    #[error("failed to parse {path}: {source}")]
340    Parse {
341        /// The manifest path that failed to parse.
342        path: PathBuf,
343        /// The underlying parse error.
344        #[source]
345        source: deps_core::DepsError,
346    },
347    /// `manifest_path` could not be represented as a file URI (e.g. a malformed or
348    /// non-representable path on this platform).
349    #[error("could not build a file URI for {path}")]
350    InvalidPath {
351        /// The manifest path that could not be converted.
352        path: PathBuf,
353    },
354}
355
356/// The result of classifying one manifest.
357#[derive(Debug, Clone)]
358pub struct ManifestCheckResult {
359    /// Every finding produced for this manifest.
360    pub findings: Vec<CheckFinding>,
361    /// Whether at least one dependency's registry fetch failed while not offline (FR-012) —
362    /// the caller uses this to decide between exit 1 (policy violation over an otherwise
363    /// complete report) and exit 2 (an incomplete report because a registry was
364    /// unreachable).
365    pub registry_unreachable: bool,
366}
367
368/// Classifies one already-routed manifest.
369///
370/// Parses it, resolves in-use/lockfile versions, fetches latest registry versions, runs an
371/// OSV scan (when enabled and not offline), and calls the ecosystem's own
372/// `generate_diagnostics` — then converts each returned [`Diagnostic`] into a
373/// [`CheckFinding`].
374///
375/// Every verdict decision (outdated/yanked/vulnerable/unsatisfiable/deprecated/license) is
376/// made by [`deps_engine::classify`] or by `generate_diagnostics` itself; this function only
377/// assembles their inputs and converts their output (spec 062 FR-005).
378///
379/// # Errors
380///
381/// Returns [`CheckError::InvalidPath`] if `manifest_path` cannot be represented as a file
382/// URI, or [`CheckError::Parse`] if `content` fails to parse as this ecosystem's manifest
383/// format.
384pub async fn check_manifest(
385    ecosystem: &Arc<dyn Ecosystem>,
386    manifest_path: &Path,
387    display_path: &Path,
388    content: &str,
389    ctx: &CheckContext,
390) -> Result<ManifestCheckResult, CheckError> {
391    let uri = 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    let (resolved_versions, resolved_version_candidates) =
404        load_resolved_versions(&uri, &ctx.lockfile_cache, ecosystem.as_ref()).await;
405
406    let (dep_sources, collided_names) =
407        dedup_dependencies_by_source(parse_result.as_ref(), formatter);
408    let in_use = collect_in_use_versions(
409        parse_result.as_ref(),
410        &resolved_versions,
411        &resolved_version_candidates,
412        formatter,
413        ecosystem_id,
414    );
415    let minimum_stability = composer_minimum_stability(parse_result.as_ref());
416    let attempted_names: Vec<PackageName> = dep_sources.keys().cloned().collect();
417
418    let fetch_result = fetch_latest_versions_parallel(
419        ecosystem.registry(),
420        dep_sources.into_iter().collect(),
421        &in_use,
422        None,
423        ctx.policy.freshness.to_settings(),
424        ctx.policy.cache.fetch_timeout_secs,
425        ctx.policy.cache.max_concurrent_fetches,
426        minimum_stability.as_deref(),
427    )
428    .await;
429    // `failed_count` also counts not-found lookups, which aren't evidence of an unreachable
430    // registry — using it here made any repo with one typo'd dependency exit 2 every run.
431    let registry_unreachable = !ctx.policy.network.offline && !fetch_result.fetch_failed.is_empty();
432
433    let mut outcomes = DependencyOutcomes::new();
434    let fetched_names: Vec<PackageName> = fetch_result.versions.keys().cloned().collect();
435    apply_fetch_outcomes(
436        &mut outcomes,
437        fetch_result.yanked_versions,
438        fetch_result.fetch_failed,
439        collided_names,
440        formatter,
441    );
442    merge_deprecations_after_fetch(
443        &mut outcomes,
444        &fetched_names,
445        fetch_result.deprecations,
446        formatter,
447    );
448    merge_no_comparable_versions_after_fetch(
449        &mut outcomes,
450        &attempted_names,
451        fetch_result.no_comparable_versions,
452        formatter,
453    );
454    let cached_versions = fetch_result.versions;
455    // Tier-1 license backfill (issue #660/#661 precedent): populated for native-list
456    // ecosystems (PyPI, Composer) whose registry response carries a license field.
457    let licenses = fetch_result.licenses;
458
459    let vulnerabilities: Option<VulnerabilityMap> =
460        if ctx.policy.diagnostics.vulnerabilities_enabled && !ctx.policy.network.offline {
461            let (targets, skipped) = build_scan_targets(
462                parse_result.as_ref(),
463                &resolved_versions,
464                &resolved_version_candidates,
465                formatter,
466                ecosystem_id,
467            );
468            let mut vulns = skipped;
469            if !targets.is_empty() {
470                let timeout = Duration::from_secs(
471                    ctx.policy
472                        .cache
473                        .fetch_timeout_secs
474                        .min(OSV_SCAN_TIMEOUT_CEILING_SECS),
475                );
476                let scanned = ctx.osv.scan(ecosystem_id, &targets, timeout).await;
477                vulns.extend(scanned);
478            }
479            Some(vulns)
480        } else {
481            None
482        };
483
484    // TODO(critic): tier-3 license prefetch (spec 062 deviation #2) — Dart/Swift/Gradle/Deno's
485    // dedicated `Ecosystem::fetch_license` is still not called from this crate.
486    let license_policy = ctx.policy.license_policy.to_policy();
487    let mut version_data = VersionData::new(&cached_versions, &resolved_versions)
488        .with_resolved_version_candidates(&resolved_version_candidates)
489        .with_outcomes(&outcomes)
490        .with_ecosystem(ecosystem_id)
491        .with_offline(ctx.policy.network.offline)
492        .with_license_source(ecosystem.license_source())
493        .with_license_policy(&license_policy)
494        .with_license_prefetch(&licenses);
495    if let Some(vulnerabilities) = vulnerabilities.as_ref() {
496        version_data = version_data.with_vulnerabilities(vulnerabilities);
497    }
498
499    let severities = ctx.policy.diagnostics.to_severities();
500    let diagnostics = ecosystem
501        .generate_diagnostics(
502            parse_result.as_ref(),
503            version_data,
504            &uri,
505            ctx.policy.freshness.to_settings(),
506            severities,
507        )
508        .await;
509
510    let dep_index = DependencyIndex::build(parse_result.as_ref());
511    let advisory_severities = advisory_severity_index(vulnerabilities.as_ref());
512    // Same per-occurrence key `vulnerabilities` was built under, so a shared advisory id
513    // across two dependencies can never resolve to the wrong one's severity (issue #1077 review #4).
514    let vuln_keys = deps_core::osv::vulnerability_keys(
515        parse_result.as_ref(),
516        &resolved_versions,
517        Some(&resolved_version_candidates),
518        formatter,
519        ecosystem_id,
520    );
521    let findings = diagnostics
522        .into_iter()
523        .map(|diagnostic| {
524            to_finding(
525                ecosystem_id,
526                display_path,
527                &dep_index,
528                formatter,
529                diagnostic,
530                &advisory_severities,
531                &vuln_keys,
532            )
533        })
534        .collect();
535    Ok(ManifestCheckResult {
536        findings,
537        registry_unreachable,
538    })
539}
540
541/// Indexes every dependency occurrence by its name range and (separately) its version
542/// range, so a diagnostic anchored at either can be traced back to the declaration that
543/// produced it (see [`to_finding`]).
544///
545/// Two separate maps (M6, spec 062 review) rather than one shared `HashMap<Range, _>`: a
546/// single map risks one dependency's name-range entry silently overwriting a *different*
547/// dependency's version-range entry if the two ranges ever coincide, misattributing
548/// `dependency_name`. Keeping the two lookups apart makes that impossible regardless of
549/// range values, not just unlikely in practice.
550struct DependencyIndex<'a> {
551    by_name_range: HashMap<Range, &'a dyn Dependency>,
552    by_version_range: HashMap<Range, &'a dyn Dependency>,
553}
554
555impl<'a> DependencyIndex<'a> {
556    fn build(parse_result: &'a dyn deps_core::ParseResult) -> Self {
557        let mut by_name_range = HashMap::new();
558        let mut by_version_range = HashMap::new();
559        for dep in parse_result.dependencies() {
560            if !dep.name_range_is_synthetic() {
561                by_name_range.insert(dep.name_range(), dep);
562            }
563            if let Some(version_range) = dep.version_range() {
564                by_version_range.insert(version_range, dep);
565            }
566        }
567        Self {
568            by_name_range,
569            by_version_range,
570        }
571    }
572
573    /// Looks up the dependency a diagnostic's `range` is anchored to, preferring a
574    /// name-range match (diagnostics anchored at `resolved.version_range` still resolve via
575    /// the version-range map when no name-range entry matches the same coordinates).
576    fn lookup(&self, range: Range) -> Option<&'a dyn Dependency> {
577        self.by_name_range
578            .get(&range)
579            .or_else(|| self.by_version_range.get(&range))
580            .copied()
581    }
582}
583
584/// Indexes every advisory this manifest's OSV scan fetched, keyed by (the [`VulnerabilityMap`]
585/// key identifying the specific dependency occurrence, advisory id) rather than by advisory id
586/// alone (issue #1077 review #4), so [`to_finding`] can attach a
587/// [`CheckFinding::advisory_severity`] without re-deriving a grade from the coarser
588/// [`Severity`] a `Diagnostic` carries.
589///
590/// A bare `HashMap<String, VulnSeverity>` keyed only by advisory id would let one dependency's
591/// severity silently overwrite another's (unspecified `HashMap` iteration order) whenever two
592/// dependencies in this manifest shared an advisory id — OSV can legitimately report one id
593/// against several different packages in the same record. [`to_finding`] looks its own
594/// dependency occurrence's key up via [`deps_core::osv::vulnerability_keys`] — the same
595/// function `vulnerabilities` itself was keyed under — so this can never drift out of sync
596/// with how the scan actually mapped occurrences to results.
597///
598/// `None` `vulnerabilities` (scan disabled, or offline) yields an empty index, same as a
599/// (key, id) pair this index has no entry for — both resolve to
600/// `CheckFinding::advisory_severity: None`.
601fn advisory_severity_index(
602    vulnerabilities: Option<&VulnerabilityMap>,
603) -> HashMap<(String, String), VulnSeverity> {
604    let mut index = HashMap::new();
605    let Some(vulnerabilities) = vulnerabilities else {
606        return index;
607    };
608    for (dependency_key, outcome) in vulnerabilities {
609        if let ScanOutcome::Vulnerable(dv) = outcome {
610            for advisory in dv.advisories.items() {
611                index.insert(
612                    (dependency_key.clone(), advisory.id.clone()),
613                    advisory.severity,
614                );
615            }
616        }
617    }
618    index
619}
620
621/// Converts one `generate_diagnostics` [`Diagnostic`] into a [`CheckFinding`], classifying
622/// its [`Category`] (see [`classify`]) and, when its range matches a manifest occurrence
623/// (from [`DependencyIndex`]), its `dependency_name`/`requirement`. `advisory_severities` and
624/// `vuln_keys` together resolve [`CheckFinding::advisory_severity`] — see
625/// [`advisory_severity_index`].
626fn to_finding(
627    ecosystem: EcosystemId,
628    display_path: &Path,
629    dep_index: &DependencyIndex<'_>,
630    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
631    diagnostic: Diagnostic,
632    advisory_severities: &HashMap<(String, String), VulnSeverity>,
633    vuln_keys: &HashMap<Range, String>,
634) -> CheckFinding {
635    let category = classify(&diagnostic, formatter);
636    let dep = dep_index.lookup(diagnostic.range);
637    let code = diagnostic.code.clone();
638    let advisory_url = diagnostic
639        .code_description
640        .as_ref()
641        .map(|code_description| code_description.href.as_str().to_string());
642    let advisory_severity = code.as_deref().zip(dep).and_then(|(code, dep)| {
643        let dependency_key = vuln_keys.get(&dep.name_range())?;
644        advisory_severities
645            .get(&(dependency_key.clone(), code.to_string()))
646            .copied()
647    });
648    CheckFinding {
649        ecosystem,
650        manifest_path: display_path.to_path_buf(),
651        dependency_name: dep.map(|d| d.name().to_string()),
652        requirement: dep
653            .and_then(Dependency::version_requirement)
654            .map(ToString::to_string),
655        category,
656        code,
657        advisory_url,
658        advisory_severity,
659        severity: diagnostic.severity.unwrap_or(Severity::Warning),
660        range: diagnostic.range,
661        message: diagnostic.message,
662    }
663}
664
665/// Classifies a `generate_diagnostics` [`Diagnostic`] into a [`Category`].
666///
667/// Diagnostics that carry one of the workspace's known non-advisory codes (the three
668/// `deps-core` sentinels, the two mutable-ref-pin codes, or GitLab CI's
669/// [`GITLAB_CI_UNRESOLVED_HOST_CODE`] notice) classify directly from `code`. A vulnerability
670/// advisory id (an OSV id such as `RUSTSEC-...`/`GHSA-...`) is the only other free-form `code`
671/// value `generate_diagnostics_from_cache` ever sets, so any `Some(code)` that matches none of
672/// the known non-advisory codes classifies as [`Category::Vulnerable`] — this fallback is
673/// sound only as long as the known-code list above stays exhaustive (see that list's own doc
674/// for the regression this already caused once). An outdated diagnostic carries no code but
675/// always starts with the fixed prefix `apply_outdated_rule` uses; a yanked diagnostic carries
676/// no code either, but always contains `formatter.yanked_message()` verbatim — the same text
677/// it was built from. The advisory-overflow summary line ("+N more advisories") also carries
678/// no code but always ends with that fixed suffix, and is still [`Category::Vulnerable`].
679/// Anything else (unknown-package, collapsed fetch-failure, blocked-registry,
680/// offline/dependency-count notices) classifies as [`Category::Other`].
681fn classify(
682    diagnostic: &Diagnostic,
683    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
684) -> Category {
685    if let Some(code) = &diagnostic.code {
686        return match code.as_str() {
687            UNSATISFIABLE_DIAGNOSTIC_CODE => Category::Unsatisfiable,
688            LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE => Category::License,
689            DEPRECATED_DIAGNOSTIC_CODE => Category::Deprecated,
690            GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE | GITLAB_CI_MUTABLE_REF_PIN_CODE => {
691                Category::MutableRefPin
692            }
693            GITLAB_CI_UNRESOLVED_HOST_CODE => Category::Other,
694            _ => Category::Vulnerable,
695        };
696    }
697    if diagnostic.message.starts_with("Newer version available") {
698        return Category::Outdated;
699    }
700    if diagnostic.message.contains(formatter.yanked_message()) {
701        return Category::Yanked;
702    }
703    // The advisory-overflow summary line carries no code but is still a vulnerability finding
704    // (M1, spec 062 review) — else a manifest over `ADVISORY_DISPLAY_CAP` reports it as `Other`.
705    if diagnostic.message.ends_with("more advisories") {
706        return Category::Vulnerable;
707    }
708    Category::Other
709}
710
711/// Builds a file URI from a filesystem path, without any path-existence check. Returns
712/// `None` when `path` cannot be represented as a file URI at all — `Url::from_file_path`
713/// requires an absolute path (this function already joins a relative one onto the current
714/// directory first) and, on Windows, a disk (`C:`) or UNC (`\\`) prefix; UNC paths
715/// themselves are supported, just not any other Windows path prefix shape. The caller
716/// surfaces a `None` here as [`CheckError::InvalidPath`] rather than fabricating a
717/// synthetic, unusable URI.
718fn path_to_uri(path: &Path) -> Option<url::Url> {
719    let absolute = if path.is_absolute() {
720        path.to_path_buf()
721    } else {
722        std::env::current_dir()
723            .map(|cwd| cwd.join(path))
724            .unwrap_or_else(|_| path.to_path_buf())
725    };
726    url::Url::from_file_path(&absolute).ok()
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    fn finding(category: Category) -> CheckFinding {
734        CheckFinding {
735            ecosystem: EcosystemId::Cargo,
736            manifest_path: PathBuf::from("Cargo.toml"),
737            dependency_name: Some("serde".to_string()),
738            requirement: Some("1.0".to_string()),
739            category,
740            code: None,
741            advisory_url: None,
742            advisory_severity: None,
743            severity: Severity::Warning,
744            range: Range::default(),
745            message: "test".to_string(),
746        }
747    }
748
749    #[test]
750    fn test_category_as_str_matches_fr009_tokens() {
751        assert_eq!(Category::Outdated.as_str(), "outdated");
752        assert_eq!(Category::Yanked.as_str(), "yanked");
753        assert_eq!(Category::Vulnerable.as_str(), "vulnerable");
754        assert_eq!(Category::Unsatisfiable.as_str(), "unsatisfiable");
755        assert_eq!(Category::MutableRefPin.as_str(), "mutable-ref");
756        assert_eq!(Category::License.as_str(), "license");
757        assert_eq!(Category::Deprecated.as_str(), "deprecated");
758        assert_eq!(Category::Other.as_str(), "other");
759    }
760
761    #[test]
762    fn test_fail_on_policy_default_categories() {
763        let policy = FailOnPolicy::default_categories();
764        assert!(policy.matches(&[finding(Category::Vulnerable)]));
765        assert!(policy.matches(&[finding(Category::Yanked)]));
766        assert!(policy.matches(&[finding(Category::Unsatisfiable)]));
767        assert!(!policy.matches(&[finding(Category::Outdated)]));
768        assert!(!policy.matches(&[finding(Category::License)]));
769        assert!(!policy.matches(&[finding(Category::Deprecated)]));
770        assert!(!policy.matches(&[finding(Category::MutableRefPin)]));
771        assert!(!policy.matches(&[finding(Category::Other)]));
772    }
773
774    #[test]
775    fn test_fail_on_policy_custom_categories() {
776        let policy = FailOnPolicy::new(vec![Category::License]);
777        assert!(policy.matches(&[finding(Category::License)]));
778        assert!(!policy.matches(&[finding(Category::Vulnerable)]));
779    }
780
781    #[test]
782    fn test_fail_on_policy_empty_findings_never_matches() {
783        assert!(!FailOnPolicy::default_categories().matches(&[]));
784    }
785
786    #[test]
787    fn test_check_report_summary_counts_per_category() {
788        let report = CheckReport {
789            findings: vec![
790                finding(Category::Outdated),
791                finding(Category::Outdated),
792                finding(Category::Vulnerable),
793            ],
794        };
795        let summary = report.summary();
796        assert_eq!(summary.get(&Category::Outdated), Some(&2));
797        assert_eq!(summary.get(&Category::Vulnerable), Some(&1));
798        assert_eq!(summary.get(&Category::License), None);
799    }
800
801    #[test]
802    fn test_check_report_summary_empty_for_no_findings() {
803        let report = CheckReport::default();
804        assert!(report.summary().is_empty());
805    }
806
807    struct StubFormatter;
808    impl deps_core::lsp_helpers::PackageNaming for StubFormatter {}
809    impl deps_core::lsp_helpers::PackageRendering for StubFormatter {
810        fn format_version_for_text_edit(&self, version: &deps_core::ConcreteVersion) -> String {
811            version.to_string()
812        }
813        fn package_url(&self, name: &PackageName) -> String {
814            name.to_string()
815        }
816    }
817    impl deps_core::lsp_helpers::RequirementResolution for StubFormatter {}
818    impl deps_core::lsp_helpers::DiagnosticMessages for StubFormatter {}
819    impl deps_core::lsp_helpers::DiagnosticPolicy for StubFormatter {}
820    impl deps_core::lsp_helpers::SourcePolicy for StubFormatter {}
821    impl deps_core::lsp_helpers::OsvNaming for StubFormatter {}
822
823    fn diagnostic_with(code: Option<&str>, message: &str) -> Diagnostic {
824        let diagnostic =
825            Diagnostic::new(Range::default(), message).with_severity(Severity::Warning);
826        match code {
827            Some(code) => diagnostic.with_code(code),
828            None => diagnostic,
829        }
830    }
831
832    #[test]
833    fn test_classify_unsatisfiable_by_code() {
834        let d = diagnostic_with(Some(UNSATISFIABLE_DIAGNOSTIC_CODE), "no matching version");
835        assert_eq!(classify(&d, &StubFormatter), Category::Unsatisfiable);
836    }
837
838    #[test]
839    fn test_classify_license_by_code() {
840        let d = diagnostic_with(
841            Some(LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE),
842            "GPL-3.0 denied",
843        );
844        assert_eq!(classify(&d, &StubFormatter), Category::License);
845    }
846
847    #[test]
848    fn test_classify_deprecated_by_code() {
849        let d = diagnostic_with(Some(DEPRECATED_DIAGNOSTIC_CODE), "package deprecated");
850        assert_eq!(classify(&d, &StubFormatter), Category::Deprecated);
851    }
852
853    #[test]
854    fn test_classify_mutable_ref_pin_by_code() {
855        let d = diagnostic_with(Some(GITHUB_ACTIONS_MUTABLE_REF_PIN_CODE), "pinned to a tag");
856        assert_eq!(classify(&d, &StubFormatter), Category::MutableRefPin);
857        let d = diagnostic_with(Some(GITLAB_CI_MUTABLE_REF_PIN_CODE), "pinned to a tag");
858        assert_eq!(classify(&d, &StubFormatter), Category::MutableRefPin);
859    }
860
861    #[test]
862    fn test_classify_advisory_code_is_vulnerable() {
863        let d = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
864        assert_eq!(classify(&d, &StubFormatter), Category::Vulnerable);
865    }
866
867    #[test]
868    fn test_classify_outdated_by_message_prefix() {
869        let d = diagnostic_with(None, "Newer version available: 2.0.0");
870        assert_eq!(classify(&d, &StubFormatter), Category::Outdated);
871    }
872
873    #[test]
874    fn test_classify_yanked_by_formatter_message() {
875        let d = diagnostic_with(None, "This version has been yanked (1.0.0)");
876        assert_eq!(classify(&d, &StubFormatter), Category::Yanked);
877    }
878
879    #[test]
880    fn test_classify_unknown_package_is_other() {
881        let d = diagnostic_with(None, "Unknown package 'left-pad'");
882        assert_eq!(classify(&d, &StubFormatter), Category::Other);
883    }
884
885    /// Regression test for M1 (spec 062 review): the trailing "+N more advisories" overflow
886    /// summary carries no code but is still a vulnerability finding, not `Other`.
887    #[test]
888    fn test_classify_advisory_overflow_summary_is_vulnerable() {
889        let d = diagnostic_with(None, "+5 more advisories");
890        assert_eq!(classify(&d, &StubFormatter), Category::Vulnerable);
891    }
892
893    /// Regression test for C3 (spec 062 review): GitLab CI's `unresolved-gitlab-host` notice
894    /// is informational (INFORMATION severity, never a vulnerability) and must not fall
895    /// through to the advisory-id fallback.
896    #[test]
897    fn test_classify_gitlab_unresolved_host_is_other_not_vulnerable() {
898        let d = diagnostic_with(
899            Some(GITLAB_CI_UNRESOLVED_HOST_CODE),
900            "registries.gitlab_instance_host is unset; skipping component/project host resolution",
901        );
902        assert_eq!(classify(&d, &StubFormatter), Category::Other);
903    }
904
905    /// A single-dependency parse result whose one dependency ("dep-0") sits at
906    /// `Range::default()` (`deps_core::test_util::StubDependency::name_range` always
907    /// returns it) — matching `diagnostic_with`'s own hardcoded `range: Range::default()`
908    /// (both the same `deps_core::position::Range` `DependencyIndex` is keyed on), so
909    /// `DependencyIndex::lookup` resolves it for tests that need a real, non-`None`
910    /// dependency occurrence.
911    fn dep_index_with_one_dependency() -> Box<dyn deps_core::ParseResult> {
912        deps_core::test_util::stub_parse_result_with_dependencies(1)
913    }
914
915    fn empty_dep_index() -> Box<dyn deps_core::ParseResult> {
916        deps_core::test_util::stub_parse_result_with_dependencies(0)
917    }
918
919    /// Regression test (issue #1077 tester must-fix #2): every other SARIF test hand-builds a
920    /// `CheckFinding` with `code` pre-set, bypassing the real `Diagnostic.code ->
921    /// CheckFinding.code` extraction this covers.
922    #[test]
923    fn test_to_finding_extracts_string_code_from_diagnostic() {
924        let parse_result = empty_dep_index();
925        let dep_index = DependencyIndex::build(parse_result.as_ref());
926        let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
927        let finding = to_finding(
928            EcosystemId::Cargo,
929            Path::new("Cargo.toml"),
930            &dep_index,
931            &StubFormatter,
932            diagnostic,
933            &HashMap::new(),
934            &HashMap::new(),
935        );
936        assert_eq!(finding.code.as_deref(), Some("RUSTSEC-2024-0001"));
937    }
938
939    #[test]
940    fn test_to_finding_code_is_none_without_a_diagnostic_code() {
941        let parse_result = empty_dep_index();
942        let dep_index = DependencyIndex::build(parse_result.as_ref());
943        let diagnostic = diagnostic_with(None, "Newer version available: 2.0.0");
944        let finding = to_finding(
945            EcosystemId::Cargo,
946            Path::new("Cargo.toml"),
947            &dep_index,
948            &StubFormatter,
949            diagnostic,
950            &HashMap::new(),
951            &HashMap::new(),
952        );
953        assert!(finding.code.is_none());
954    }
955
956    #[test]
957    fn test_to_finding_extracts_advisory_url_from_code_description() {
958        let parse_result = empty_dep_index();
959        let dep_index = DependencyIndex::build(parse_result.as_ref());
960        let href: url::Url = "https://osv.dev/vulnerability/RUSTSEC-2024-0001"
961            .parse()
962            .expect("valid URL");
963        let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary")
964            .with_code_description(deps_core::diagnostic::CodeDescription::new(href));
965        let finding = to_finding(
966            EcosystemId::Cargo,
967            Path::new("Cargo.toml"),
968            &dep_index,
969            &StubFormatter,
970            diagnostic,
971            &HashMap::new(),
972            &HashMap::new(),
973        );
974        assert_eq!(
975            finding.advisory_url.as_deref(),
976            Some("https://osv.dev/vulnerability/RUSTSEC-2024-0001")
977        );
978    }
979
980    #[test]
981    fn test_to_finding_advisory_url_is_none_without_code_description() {
982        let parse_result = empty_dep_index();
983        let dep_index = DependencyIndex::build(parse_result.as_ref());
984        let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
985        let finding = to_finding(
986            EcosystemId::Cargo,
987            Path::new("Cargo.toml"),
988            &dep_index,
989            &StubFormatter,
990            diagnostic,
991            &HashMap::new(),
992            &HashMap::new(),
993        );
994        assert!(finding.advisory_url.is_none());
995    }
996
997    #[test]
998    fn test_to_finding_resolves_advisory_severity_from_index() {
999        let parse_result = dep_index_with_one_dependency();
1000        let dep_index = DependencyIndex::build(parse_result.as_ref());
1001        let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1002
1003        let mut vuln_keys = HashMap::new();
1004        vuln_keys.insert(Range::default(), "dep-0".to_string());
1005        let mut severities = HashMap::new();
1006        severities.insert(
1007            ("dep-0".to_string(), "RUSTSEC-2024-0001".to_string()),
1008            VulnSeverity::Critical,
1009        );
1010
1011        let finding = to_finding(
1012            EcosystemId::Cargo,
1013            Path::new("Cargo.toml"),
1014            &dep_index,
1015            &StubFormatter,
1016            diagnostic,
1017            &severities,
1018            &vuln_keys,
1019        );
1020        assert_eq!(finding.advisory_severity, Some(VulnSeverity::Critical));
1021    }
1022
1023    #[test]
1024    fn test_to_finding_advisory_severity_is_none_for_an_unindexed_code() {
1025        let parse_result = dep_index_with_one_dependency();
1026        let dep_index = DependencyIndex::build(parse_result.as_ref());
1027        let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1028        let mut vuln_keys = HashMap::new();
1029        vuln_keys.insert(Range::default(), "dep-0".to_string());
1030
1031        let finding = to_finding(
1032            EcosystemId::Cargo,
1033            Path::new("Cargo.toml"),
1034            &dep_index,
1035            &StubFormatter,
1036            diagnostic,
1037            &HashMap::new(),
1038            &vuln_keys,
1039        );
1040        assert!(finding.advisory_severity.is_none());
1041    }
1042
1043    /// Regression test for issue #1077 review #4: no matching dependency occurrence at all
1044    /// (an empty `dep_index`/`vuln_keys`, e.g. a document-level diagnostic) must resolve to
1045    /// `None`, not panic.
1046    #[test]
1047    fn test_to_finding_advisory_severity_is_none_without_a_matched_dependency() {
1048        let parse_result = empty_dep_index();
1049        let dep_index = DependencyIndex::build(parse_result.as_ref());
1050        let diagnostic = diagnostic_with(Some("RUSTSEC-2024-0001"), "advisory summary");
1051        let mut severities = HashMap::new();
1052        severities.insert(
1053            ("dep-0".to_string(), "RUSTSEC-2024-0001".to_string()),
1054            VulnSeverity::Critical,
1055        );
1056
1057        let finding = to_finding(
1058            EcosystemId::Cargo,
1059            Path::new("Cargo.toml"),
1060            &dep_index,
1061            &StubFormatter,
1062            diagnostic,
1063            &severities,
1064            &HashMap::new(),
1065        );
1066        assert!(finding.advisory_severity.is_none());
1067    }
1068
1069    #[test]
1070    fn test_advisory_severity_index_collects_from_vulnerable_outcomes() {
1071        use deps_core::osv::{Advisory, Capped, DependencyVulnerabilities};
1072        use std::sync::Arc;
1073
1074        let advisory = Advisory::new(
1075            "RUSTSEC-2024-0001".to_string(),
1076            "2024-01-01T00:00:00Z".to_string(),
1077            VulnSeverity::High,
1078            "https://osv.dev/vulnerability/RUSTSEC-2024-0001".to_string(),
1079        );
1080        let dv = DependencyVulnerabilities::new(Capped::new(vec![Arc::new(advisory)], 1));
1081        let mut map: VulnerabilityMap = HashMap::new();
1082        map.insert("serde".to_string(), ScanOutcome::Vulnerable(dv));
1083
1084        let index = advisory_severity_index(Some(&map));
1085        assert_eq!(
1086            index.get(&("serde".to_string(), "RUSTSEC-2024-0001".to_string())),
1087            Some(&VulnSeverity::High)
1088        );
1089    }
1090
1091    /// Regression test for issue #1077 review #4: two different dependencies whose OSV
1092    /// records legitimately share one advisory id must not let one silently overwrite the
1093    /// other's severity bucket.
1094    #[test]
1095    fn test_advisory_severity_index_does_not_collide_across_dependencies_sharing_an_advisory_id() {
1096        use deps_core::osv::{Advisory, Capped, DependencyVulnerabilities};
1097        use std::sync::Arc;
1098
1099        let advisory_for = |severity: VulnSeverity| {
1100            Arc::new(Advisory::new(
1101                "GHSA-shared-id".to_string(),
1102                "2024-01-01T00:00:00Z".to_string(),
1103                severity,
1104                "https://osv.dev/vulnerability/GHSA-shared-id".to_string(),
1105            ))
1106        };
1107        let mut map: VulnerabilityMap = HashMap::new();
1108        map.insert(
1109            "package-a".to_string(),
1110            ScanOutcome::Vulnerable(DependencyVulnerabilities::new(Capped::new(
1111                vec![advisory_for(VulnSeverity::Critical)],
1112                1,
1113            ))),
1114        );
1115        map.insert(
1116            "package-b".to_string(),
1117            ScanOutcome::Vulnerable(DependencyVulnerabilities::new(Capped::new(
1118                vec![advisory_for(VulnSeverity::Low)],
1119                1,
1120            ))),
1121        );
1122
1123        let index = advisory_severity_index(Some(&map));
1124        assert_eq!(
1125            index.get(&("package-a".to_string(), "GHSA-shared-id".to_string())),
1126            Some(&VulnSeverity::Critical)
1127        );
1128        assert_eq!(
1129            index.get(&("package-b".to_string(), "GHSA-shared-id".to_string())),
1130            Some(&VulnSeverity::Low)
1131        );
1132    }
1133
1134    #[test]
1135    fn test_advisory_severity_index_empty_without_vulnerabilities() {
1136        assert!(advisory_severity_index(None).is_empty());
1137    }
1138}