Skip to main content

sbom_tools/quality/
scorer.rs

1//! SBOM Quality Scorer.
2//!
3//! Main scoring engine that combines metrics and compliance checking
4//! into an overall quality assessment.
5
6use crate::model::{CompletenessDeclaration, NormalizedSbom, SbomFormat};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use super::compliance::{ComplianceChecker, ComplianceLevel, ComplianceResult};
11use super::metrics::{
12    AuditabilityMetrics, CompletenessMetrics, CompletenessWeights, CryptographyMetrics,
13    DependencyMetrics, HashQualityMetrics, IdentifierMetrics, LicenseMetrics, LifecycleMetrics,
14    ProvenanceMetrics, VulnerabilityMetrics,
15};
16
17/// Quality scoring engine version
18pub const SCORING_ENGINE_VERSION: &str = "2.1";
19
20/// Returns true if any of the JSON pointers resolves to a non-empty value in `raw`.
21/// Used by the AI-readiness profile to inspect model-card fields preserved in
22/// `Component.extensions.raw` that are not surfaced into the typed model.
23fn has_non_empty_pointer(raw: Option<&Value>, pointers: &[&str]) -> bool {
24    pointers
25        .iter()
26        .filter_map(|pointer| raw.and_then(|value| value.pointer(pointer)))
27        .any(|value| match value {
28            Value::Null => false,
29            Value::Array(items) => !items.is_empty(),
30            Value::Object(entries) => !entries.is_empty(),
31            Value::String(text) => !text.trim().is_empty(),
32            _ => true,
33        })
34}
35
36/// Returns true if a component is connected to the vulnerability/exploitability
37/// tooling stack: it carries at least one vulnerability reference (which
38/// OSV/KEV/EPSS/VEX enrichment acts on) OR a security/advisory external
39/// reference an analyst can pivot on. This realizes the BSI thesis that an AI
40/// SBOM is only useful when linked to cybersecurity tooling.
41fn ml_has_exploitability_reference(component: &crate::model::Component) -> bool {
42    use crate::model::ExternalRefType;
43    if !component.vulnerabilities.is_empty() {
44        return true;
45    }
46    component.external_refs.iter().any(|r| {
47        matches!(
48            r.ref_type,
49            ExternalRefType::Advisories
50                | ExternalRefType::SecurityContact
51                | ExternalRefType::VulnerabilityAssertion
52                | ExternalRefType::ExploitabilityStatement
53        )
54    })
55}
56
57/// Scoring profile determines weights and thresholds.
58///
59/// The `#[value(...)]` attributes are the single source of truth for the
60/// CLI spellings of `quality --profile` (help text, parse errors, shell
61/// completions) and for the config file's `compliance.profile` key, which
62/// parses through [`std::str::FromStr`] over the same table.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
64#[non_exhaustive]
65pub enum ScoringProfile {
66    /// Minimal requirements - basic identification
67    #[value(name = "minimal")]
68    Minimal,
69    /// Standard requirements - recommended for most use cases
70    #[value(name = "standard")]
71    Standard,
72    /// Security-focused - emphasizes vulnerability info and supply chain
73    #[value(name = "security")]
74    Security,
75    /// License-focused - emphasizes license compliance
76    #[value(name = "license-compliance", alias = "license")]
77    LicenseCompliance,
78    /// EU Cyber Resilience Act - emphasizes supply chain transparency and security disclosure
79    #[value(name = "cra", alias = "cyber-resilience")]
80    Cra,
81    /// BSI TR-03183-2 v2.1.0 (German national CRA-aligned SBOM technical
82    /// guideline). Stricter than CRA on formats and hashes (SHA-512);
83    /// uses CRA-style weights.
84    #[value(
85        name = "bsi",
86        alias = "tr-03183",
87        alias = "tr03183",
88        alias = "bsi-tr-03183-2"
89    )]
90    BsiTr03183_2,
91    /// Comprehensive - all aspects equally weighted
92    #[value(name = "comprehensive", alias = "full")]
93    Comprehensive,
94    /// CBOM - cryptographic BOM focus (algorithm strength, PQC readiness, key/cert lifecycle)
95    #[value(name = "cbom", alias = "cryptographic")]
96    Cbom,
97    /// AI/ML readiness - evaluates model-card completeness for machine-learning components
98    #[value(name = "ai-readiness", alias = "ai_readiness")]
99    AiReadiness,
100}
101
102impl std::fmt::Display for ScoringProfile {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.write_str(self.canonical_name())
105    }
106}
107
108impl std::str::FromStr for ScoringProfile {
109    type Err = String;
110
111    /// Case-insensitive parse through the same name/alias table clap uses.
112    fn from_str(s: &str) -> Result<Self, Self::Err> {
113        let s = s.trim();
114        for variant in <Self as clap::ValueEnum>::value_variants() {
115            if clap::ValueEnum::to_possible_value(variant).is_some_and(|pv| pv.matches(s, true)) {
116                return Ok(*variant);
117            }
118        }
119        Err(format!(
120            "unknown scoring profile '{s}'. Valid values: {}",
121            Self::valid_values()
122        ))
123    }
124}
125
126impl ScoringProfile {
127    /// The canonical CLI spelling (the `#[value(name = ...)]`).
128    #[must_use]
129    pub const fn canonical_name(self) -> &'static str {
130        match self {
131            Self::Minimal => "minimal",
132            Self::Standard => "standard",
133            Self::Security => "security",
134            Self::LicenseCompliance => "license-compliance",
135            Self::Cra => "cra",
136            Self::BsiTr03183_2 => "bsi",
137            Self::Comprehensive => "comprehensive",
138            Self::Cbom => "cbom",
139            Self::AiReadiness => "ai-readiness",
140        }
141    }
142
143    /// Comma-separated list of every canonical value, for error messages.
144    #[must_use]
145    pub fn valid_values() -> String {
146        <Self as clap::ValueEnum>::value_variants()
147            .iter()
148            .map(|v| v.canonical_name())
149            .collect::<Vec<_>>()
150            .join(", ")
151    }
152
153    /// Get the compliance level associated with this profile
154    #[must_use]
155    pub const fn compliance_level(&self) -> ComplianceLevel {
156        match self {
157            Self::Minimal => ComplianceLevel::Minimum,
158            Self::Standard | Self::LicenseCompliance => ComplianceLevel::Standard,
159            Self::Security => ComplianceLevel::NtiaMinimum,
160            Self::Cra => ComplianceLevel::CraPhase2,
161            Self::BsiTr03183_2 => ComplianceLevel::BsiTr03183_2,
162            Self::Comprehensive => ComplianceLevel::Comprehensive,
163            Self::Cbom => ComplianceLevel::Comprehensive,
164            Self::AiReadiness => ComplianceLevel::Comprehensive,
165        }
166    }
167
168    /// Get weights for this profile
169    ///
170    /// All weights sum to 1.0. The lifecycle weight is applied only when
171    /// enrichment data is available; otherwise it is redistributed.
172    const fn weights(self) -> ScoringWeights {
173        match self {
174            Self::Minimal => ScoringWeights {
175                completeness: 0.35,
176                identifiers: 0.20,
177                licenses: 0.10,
178                vulnerabilities: 0.05,
179                dependencies: 0.10,
180                integrity: 0.05,
181                provenance: 0.10,
182                lifecycle: 0.05,
183            },
184            Self::Standard => ScoringWeights {
185                completeness: 0.25,
186                identifiers: 0.20,
187                licenses: 0.12,
188                vulnerabilities: 0.08,
189                dependencies: 0.10,
190                integrity: 0.08,
191                provenance: 0.10,
192                lifecycle: 0.07,
193            },
194            Self::Security => ScoringWeights {
195                completeness: 0.12,
196                identifiers: 0.18,
197                licenses: 0.05,
198                vulnerabilities: 0.20,
199                dependencies: 0.10,
200                integrity: 0.15,
201                provenance: 0.10,
202                lifecycle: 0.10,
203            },
204            Self::LicenseCompliance => ScoringWeights {
205                completeness: 0.15,
206                identifiers: 0.12,
207                licenses: 0.35,
208                vulnerabilities: 0.05,
209                dependencies: 0.10,
210                integrity: 0.05,
211                provenance: 0.10,
212                lifecycle: 0.08,
213            },
214            Self::Cra => ScoringWeights {
215                completeness: 0.12,
216                identifiers: 0.18,
217                licenses: 0.08,
218                vulnerabilities: 0.15,
219                dependencies: 0.12,
220                integrity: 0.12,
221                provenance: 0.15,
222                lifecycle: 0.08,
223            },
224            // BSI TR-03183-2 emphasises identifiers (§5.2.4 additional tier)
225            // and integrity (§5.2.2 mandatory SHA-512 hashes) even more than
226            // CRA, while still tracking provenance/dependencies.
227            Self::BsiTr03183_2 => ScoringWeights {
228                completeness: 0.10,
229                identifiers: 0.22,
230                licenses: 0.08,
231                vulnerabilities: 0.10,
232                dependencies: 0.12,
233                integrity: 0.18,
234                provenance: 0.12,
235                lifecycle: 0.08,
236            },
237            Self::Comprehensive => ScoringWeights {
238                completeness: 0.15,
239                identifiers: 0.13,
240                licenses: 0.13,
241                vulnerabilities: 0.10,
242                dependencies: 0.12,
243                integrity: 0.12,
244                provenance: 0.13,
245                lifecycle: 0.12,
246            },
247            // CBOM slots are reinterpreted:
248            // completeness->CryptoCompl, identifiers->OIDs, licenses->AlgoStrength,
249            // vulnerabilities->CryptoRefs, dependencies->CryptoLifecycle,
250            // integrity->PQCReadiness, provenance->Provenance(std), lifecycle->Licenses(std)
251            Self::Cbom => ScoringWeights {
252                completeness: 0.15,
253                identifiers: 0.15,
254                licenses: 0.22,
255                vulnerabilities: 0.10,
256                dependencies: 0.13,
257                integrity: 0.15,
258                provenance: 0.08,
259                lifecycle: 0.02,
260            },
261            // AiReadiness uses a dedicated scoring path; these weights are only a
262            // structural fallback and are never reached in normal execution.
263            Self::AiReadiness => ScoringWeights {
264                completeness: 0.25,
265                identifiers: 0.15,
266                licenses: 0.15,
267                vulnerabilities: 0.10,
268                dependencies: 0.10,
269                integrity: 0.08,
270                provenance: 0.10,
271                lifecycle: 0.07,
272            },
273        }
274    }
275}
276
277/// Weights for overall score calculation (sum to 1.0)
278#[derive(Debug, Clone)]
279struct ScoringWeights {
280    completeness: f32,
281    identifiers: f32,
282    licenses: f32,
283    vulnerabilities: f32,
284    dependencies: f32,
285    integrity: f32,
286    provenance: f32,
287    lifecycle: f32,
288}
289
290impl ScoringWeights {
291    /// Return weights as an array for iteration
292    fn as_array(&self) -> [f32; 8] {
293        [
294            self.completeness,
295            self.identifiers,
296            self.licenses,
297            self.vulnerabilities,
298            self.dependencies,
299            self.integrity,
300            self.provenance,
301            self.lifecycle,
302        ]
303    }
304
305    /// Renormalize weights, excluding categories marked as N/A.
306    ///
307    /// When a category has no applicable data (e.g., lifecycle without
308    /// enrichment), its weight is proportionally redistributed.
309    fn renormalize(&self, available: &[bool; 8]) -> [f32; 8] {
310        let raw = self.as_array();
311        let total_available: f32 = raw
312            .iter()
313            .zip(available)
314            .filter(|&(_, a)| *a)
315            .map(|(w, _)| w)
316            .sum();
317
318        if total_available <= 0.0 {
319            return [0.0; 8];
320        }
321
322        let scale = 1.0 / total_available;
323        let mut result = [0.0_f32; 8];
324        for (i, (&w, &avail)) in raw.iter().zip(available).enumerate() {
325            result[i] = if avail { w * scale } else { 0.0 };
326        }
327        result
328    }
329}
330
331/// Quality grade based on score
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
333#[non_exhaustive]
334pub enum QualityGrade {
335    /// Excellent: 90-100
336    A,
337    /// Good: 80-89
338    B,
339    /// Fair: 70-79
340    C,
341    /// Poor: 60-69
342    D,
343    /// Failing: <60
344    F,
345}
346
347impl QualityGrade {
348    /// Create grade from score
349    #[must_use]
350    pub const fn from_score(score: f32) -> Self {
351        // Guard against NaN (all comparisons return false) and out-of-range values
352        let clamped = if score > 100.0 {
353            100
354        } else if score >= 0.0 {
355            score as u32
356        } else {
357            0
358        };
359        match clamped {
360            90..=100 => Self::A,
361            80..=89 => Self::B,
362            70..=79 => Self::C,
363            60..=69 => Self::D,
364            _ => Self::F,
365        }
366    }
367
368    /// Get grade letter
369    #[must_use]
370    pub const fn letter(&self) -> &'static str {
371        match self {
372            Self::A => "A",
373            Self::B => "B",
374            Self::C => "C",
375            Self::D => "D",
376            Self::F => "F",
377        }
378    }
379
380    /// Get grade description
381    #[must_use]
382    pub const fn description(&self) -> &'static str {
383        match self {
384            Self::A => "Excellent",
385            Self::B => "Good",
386            Self::C => "Fair",
387            Self::D => "Poor",
388            Self::F => "Failing",
389        }
390    }
391}
392
393/// Recommendation for improving quality
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct Recommendation {
396    /// Priority (1 = highest, 5 = lowest)
397    pub priority: u8,
398    /// Category of the recommendation
399    pub category: RecommendationCategory,
400    /// Human-readable message
401    pub message: String,
402    /// Estimated impact on score (0-100)
403    pub impact: f32,
404    /// Affected components (if applicable)
405    pub affected_count: usize,
406}
407
408/// Single AI-readiness check result
409#[derive(Debug, Clone, Serialize, Deserialize)]
410#[non_exhaustive]
411pub struct AiCheck {
412    /// Machine-readable ID, e.g. "AI-001"
413    pub id: String,
414    /// Human-readable name
415    pub name: String,
416    /// Whether the check passed for every ML component
417    pub passed: bool,
418    /// Optional detail message (per-component pass/fail)
419    pub detail: Option<String>,
420    /// Relative weight of this check (0.0–1.0)
421    pub weight: f32,
422}
423
424/// AI/ML model-card completeness metrics (populated only for the `AiReadiness` profile)
425#[derive(Debug, Clone, Serialize, Deserialize)]
426#[non_exhaustive]
427pub struct AiReadinessMetrics {
428    /// Number of ML model components found
429    pub ml_component_count: usize,
430    /// True when no ML components were found — the score is N/A
431    pub not_applicable: bool,
432    /// Human-readable reason for N/A (when `not_applicable` is true)
433    pub na_reason: Option<String>,
434    /// Per-check results
435    pub checks: Vec<AiCheck>,
436    /// Number of ML components that passed every check
437    pub components_fully_documented: usize,
438}
439
440impl AiReadinessMetrics {
441    /// Whether AI readiness is not applicable to this SBOM (no ML components).
442    #[must_use]
443    pub const fn is_not_applicable(&self) -> bool {
444        self.not_applicable
445    }
446}
447
448/// Category for recommendations
449#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
450#[non_exhaustive]
451pub enum RecommendationCategory {
452    Completeness,
453    Identifiers,
454    Licenses,
455    Vulnerabilities,
456    Dependencies,
457    Compliance,
458    Integrity,
459    Provenance,
460    Lifecycle,
461}
462
463impl RecommendationCategory {
464    #[must_use]
465    pub const fn name(&self) -> &'static str {
466        match self {
467            Self::Completeness => "Completeness",
468            Self::Identifiers => "Identifiers",
469            Self::Licenses => "Licenses",
470            Self::Vulnerabilities => "Vulnerabilities",
471            Self::Dependencies => "Dependencies",
472            Self::Compliance => "Compliance",
473            Self::Integrity => "Integrity",
474            Self::Provenance => "Provenance",
475            Self::Lifecycle => "Lifecycle",
476        }
477    }
478}
479
480/// Complete quality report for an SBOM
481#[derive(Debug, Clone, Serialize, Deserialize)]
482#[must_use]
483#[non_exhaustive]
484pub struct QualityReport {
485    /// Scoring engine version
486    pub scoring_engine_version: String,
487    /// Overall score (0-100)
488    pub overall_score: f32,
489    /// Overall grade
490    pub grade: QualityGrade,
491    /// Scoring profile used
492    pub profile: ScoringProfile,
493
494    // Individual category scores (0-100)
495    /// Completeness score
496    pub completeness_score: f32,
497    /// Identifier quality score
498    pub identifier_score: f32,
499    /// License quality score
500    pub license_score: f32,
501    /// Vulnerability documentation score (`None` if no vulnerability data)
502    pub vulnerability_score: Option<f32>,
503    /// Dependency graph quality score
504    pub dependency_score: f32,
505    /// Hash/integrity quality score
506    pub integrity_score: f32,
507    /// Provenance quality score (combined provenance + auditability)
508    pub provenance_score: f32,
509    /// Lifecycle quality score (`None` if no enrichment data)
510    pub lifecycle_score: Option<f32>,
511
512    // Detailed metrics
513    /// Detailed completeness metrics
514    pub completeness_metrics: CompletenessMetrics,
515    /// Detailed identifier metrics
516    pub identifier_metrics: IdentifierMetrics,
517    /// Detailed license metrics
518    pub license_metrics: LicenseMetrics,
519    /// Detailed vulnerability metrics
520    pub vulnerability_metrics: VulnerabilityMetrics,
521    /// Detailed dependency metrics
522    pub dependency_metrics: DependencyMetrics,
523    /// Hash/integrity metrics
524    pub hash_quality_metrics: HashQualityMetrics,
525    /// Provenance metrics
526    pub provenance_metrics: ProvenanceMetrics,
527    /// Auditability metrics
528    pub auditability_metrics: AuditabilityMetrics,
529    /// Lifecycle metrics (enrichment-dependent)
530    pub lifecycle_metrics: LifecycleMetrics,
531    /// Cryptography quality score (`None` if no crypto components)
532    pub cryptography_score: Option<f32>,
533    /// Cryptography metrics (CBOM)
534    pub cryptography_metrics: CryptographyMetrics,
535
536    /// Compliance check result
537    pub compliance: ComplianceResult,
538    /// Prioritized recommendations
539    pub recommendations: Vec<Recommendation>,
540    /// AI/ML readiness metrics (`Some` only when profile is `AiReadiness`)
541    pub ai_readiness_metrics: Option<AiReadinessMetrics>,
542}
543
544/// Quality scorer for SBOMs
545#[derive(Debug, Clone)]
546pub struct QualityScorer {
547    /// Scoring profile
548    profile: ScoringProfile,
549    /// Completeness weights
550    completeness_weights: CompletenessWeights,
551    /// Optional CRA sidecar metadata; when set, the embedded compliance check
552    /// (used to drive recommendations under `ScoringProfile::Cra`) consults
553    /// the sidecar for fields the SBOM doesn't carry.
554    cra_sidecar: Option<crate::model::CraSidecarMetadata>,
555    /// Optional CRA Annex III/IV product class. Sidecar `productClass` (when
556    /// present on `cra_sidecar`) wins over this value at check time.
557    cra_product_class: Option<crate::model::CraProductClass>,
558    /// Optional pinned evaluation clock for the embedded compliance check.
559    /// `None` means wall clock; set it (CLI `--as-of`, tests) so deadline-
560    /// sensitive checks (CRA Art. 14 readiness, SBOM age, EUCC certificate
561    /// expiry) are reproducible across runs.
562    as_of: Option<chrono::DateTime<chrono::Utc>>,
563}
564
565impl QualityScorer {
566    /// Create a new quality scorer with the given profile
567    #[must_use]
568    pub fn new(profile: ScoringProfile) -> Self {
569        Self {
570            profile,
571            completeness_weights: CompletenessWeights::default(),
572            cra_sidecar: None,
573            cra_product_class: None,
574            as_of: None,
575        }
576    }
577
578    /// Set custom completeness weights
579    #[must_use]
580    pub const fn with_completeness_weights(mut self, weights: CompletenessWeights) -> Self {
581        self.completeness_weights = weights;
582        self
583    }
584
585    /// Attach CRA sidecar metadata for the embedded compliance check.
586    #[must_use]
587    pub fn with_cra_sidecar(mut self, sidecar: crate::model::CraSidecarMetadata) -> Self {
588        self.cra_sidecar = Some(sidecar);
589        self
590    }
591
592    /// Set the CRA Annex III/IV product class explicitly (for severity
593    /// calibration when the embedded compliance check runs under
594    /// `ScoringProfile::Cra`). Sidecar `productClass` overrides this.
595    #[must_use]
596    pub const fn with_cra_product_class(mut self, class: crate::model::CraProductClass) -> Self {
597        self.cra_product_class = Some(class);
598        self
599    }
600
601    /// Pin the evaluation clock of the embedded compliance check (mirrors
602    /// [`ComplianceChecker::with_as_of`]). Deadline-sensitive checks (CRA
603    /// Art. 14 readiness, SBOM age, EUCC certificate expiry) evaluate against
604    /// this instant instead of the wall clock — reproducible CI gates.
605    #[must_use]
606    pub const fn with_as_of(mut self, as_of: chrono::DateTime<chrono::Utc>) -> Self {
607        self.as_of = Some(as_of);
608        self
609    }
610
611    /// Score an SBOM
612    pub fn score(&self, sbom: &NormalizedSbom) -> QualityReport {
613        // AI readiness uses a dedicated scoring path that is incompatible with the
614        // standard 8-category pipeline.
615        if self.profile == ScoringProfile::AiReadiness {
616            return self.score_ai_readiness(sbom);
617        }
618
619        let total_components = sbom.components.len();
620        let is_cyclonedx = sbom.document.format == SbomFormat::CycloneDx;
621
622        // Calculate all metrics
623        let completeness_metrics = CompletenessMetrics::from_sbom(sbom);
624        let identifier_metrics = IdentifierMetrics::from_sbom(sbom);
625        let license_metrics = LicenseMetrics::from_sbom(sbom);
626        let vulnerability_metrics = VulnerabilityMetrics::from_sbom(sbom);
627        let dependency_metrics = DependencyMetrics::from_sbom(sbom);
628        let hash_quality_metrics = HashQualityMetrics::from_sbom(sbom);
629        let provenance_metrics = ProvenanceMetrics::from_sbom(sbom);
630        let auditability_metrics = AuditabilityMetrics::from_sbom(sbom);
631        let lifecycle_metrics = LifecycleMetrics::from_sbom(sbom);
632        let cryptography_metrics = CryptographyMetrics::from_sbom(sbom);
633
634        // Calculate individual category scores
635        let completeness_score = completeness_metrics.overall_score(&self.completeness_weights);
636        let identifier_score = identifier_metrics.quality_score(total_components);
637        let license_score = license_metrics.quality_score(total_components);
638        let vulnerability_score = vulnerability_metrics.documentation_score();
639        let dependency_score = dependency_metrics.quality_score(total_components);
640        let integrity_score = hash_quality_metrics.quality_score(total_components);
641        let provenance_raw = provenance_metrics.quality_score(is_cyclonedx);
642        let auditability_raw = auditability_metrics.quality_score(total_components);
643        // Combine provenance and auditability (60/40 split)
644        let provenance_score = provenance_raw * 0.6 + auditability_raw * 0.4;
645        let lifecycle_score = lifecycle_metrics.quality_score();
646        let cryptography_score = cryptography_metrics.quality_score();
647
648        // For CBOM profile, substitute crypto-specific scores into the 8 slots
649        let is_cbom = self.profile == ScoringProfile::Cbom;
650        let (available, scores) = if is_cbom && cryptography_metrics.has_data() {
651            let cm = &cryptography_metrics;
652            // PQC readiness is `None` when no algorithms exist; its weight is
653            // proportionally redistributed (same treatment as
654            // `vulnerability_score` below) so a zero-algorithm CBOM neither
655            // earns a free 100-weighted category nor a punitive 0.
656            let pqc_score = cm.pqc_readiness_score();
657            (
658                [
659                    true,                // Crpt
660                    true,                // OIDs
661                    true,                // Algo
662                    true,                // Refs
663                    true,                // Life
664                    pqc_score.is_some(), // PQC
665                    true,                // Prov
666                    true,                // Lic
667                ],
668                [
669                    cm.crypto_completeness_score(), // slot 1: Crpt
670                    cm.crypto_identifier_score(),   // slot 2: OIDs
671                    cm.algorithm_strength_score(),  // slot 3: Algo
672                    cm.crypto_dependency_score(),   // slot 4: Refs
673                    cm.crypto_lifecycle_score(),    // slot 5: Life
674                    pqc_score.unwrap_or(0.0),       // slot 6: PQC (N/A reweighted away)
675                    provenance_score,               // slot 7: Prov (standard)
676                    license_score,                  // slot 8: Lic  (standard)
677                ],
678            )
679        } else {
680            // Standard SBOM scoring
681            let vuln_available = vulnerability_score.is_some();
682            let lifecycle_available = lifecycle_score.is_some();
683            (
684                [
685                    true,                // completeness
686                    true,                // identifiers
687                    true,                // licenses
688                    vuln_available,      // vulnerabilities
689                    true,                // dependencies
690                    true,                // integrity
691                    true,                // provenance
692                    lifecycle_available, // lifecycle
693                ],
694                [
695                    completeness_score,
696                    identifier_score,
697                    license_score,
698                    vulnerability_score.unwrap_or(0.0),
699                    dependency_score,
700                    integrity_score,
701                    provenance_score,
702                    lifecycle_score.unwrap_or(0.0),
703                ],
704            )
705        };
706
707        // Calculate weighted overall score with N/A renormalization
708        let weights = self.profile.weights();
709        let norm = weights.renormalize(&available);
710
711        let mut overall_score: f32 = scores.iter().zip(norm.iter()).map(|(s, w)| s * w).sum();
712        overall_score = overall_score.min(100.0);
713
714        // Apply hard penalty caps for critical issues
715        overall_score = self.apply_score_caps(
716            overall_score,
717            &lifecycle_metrics,
718            &dependency_metrics,
719            &hash_quality_metrics,
720            &cryptography_metrics,
721            total_components,
722        );
723
724        // Run compliance check (with sidecar + product class + pinned clock
725        // if configured)
726        let mut compliance_checker = ComplianceChecker::new(self.profile.compliance_level());
727        if let Some(sc) = self.cra_sidecar.clone() {
728            compliance_checker = compliance_checker.with_sidecar(sc);
729        }
730        if let Some(c) = self.cra_product_class {
731            compliance_checker = compliance_checker.with_product_class(c);
732        }
733        if let Some(t) = self.as_of {
734            compliance_checker = compliance_checker.with_as_of(t);
735        }
736        let compliance = compliance_checker.check(sbom);
737
738        // Generate recommendations
739        let recommendations = self.generate_recommendations(
740            &completeness_metrics,
741            &identifier_metrics,
742            &license_metrics,
743            &dependency_metrics,
744            &hash_quality_metrics,
745            &provenance_metrics,
746            &lifecycle_metrics,
747            &auditability_metrics,
748            &compliance,
749            total_components,
750        );
751
752        QualityReport {
753            scoring_engine_version: SCORING_ENGINE_VERSION.to_string(),
754            overall_score,
755            grade: QualityGrade::from_score(overall_score),
756            profile: self.profile,
757            completeness_score,
758            identifier_score,
759            license_score,
760            vulnerability_score,
761            dependency_score,
762            integrity_score,
763            provenance_score,
764            lifecycle_score,
765            completeness_metrics,
766            identifier_metrics,
767            license_metrics,
768            vulnerability_metrics,
769            dependency_metrics,
770            hash_quality_metrics,
771            provenance_metrics,
772            auditability_metrics,
773            lifecycle_metrics,
774            cryptography_score,
775            cryptography_metrics,
776            compliance,
777            recommendations,
778            ai_readiness_metrics: None,
779        }
780    }
781
782    /// Score ML model-card completeness for the AI-readiness profile.
783    ///
784    /// Selects ML components with the same applicability semantics as the AI
785    /// compliance profiles ([`super::compliance::ai_shared::ai_bom_scope`]):
786    /// components typed `machine-learning-model` OR carrying parsed ML-model
787    /// metadata (CycloneDX modelCard / SPDX 3.0 AI profile), plus ML-looking
788    /// suspects (a `pkg:huggingface` PURL or `model-card` reference without
789    /// metadata), so "untype your models" cannot turn the profile N/A and
790    /// bypass `--min-score`. Each selected component is evaluated against
791    /// eleven checks (AI-001..AI-011): AI-001..AI-009 cover model-card
792    /// transparency, AI-010 is a weight-hash integrity check, and AI-011
793    /// verifies the component is connected to the vulnerability/exploitability
794    /// tooling stack. The returned `QualityReport` has all standard category
795    /// scores zeroed/`None`; the rich data lives in `ai_readiness_metrics`.
796    /// When the SBOM has no ML components (by type, metadata, or ML content
797    /// signals) the report is marked not-applicable.
798    fn score_ai_readiness(&self, sbom: &NormalizedSbom) -> QualityReport {
799        // Standard metrics are still computed so the report is structurally valid.
800        let completeness_metrics = CompletenessMetrics::from_sbom(sbom);
801        let identifier_metrics = IdentifierMetrics::from_sbom(sbom);
802        let license_metrics = LicenseMetrics::from_sbom(sbom);
803        let vulnerability_metrics = VulnerabilityMetrics::from_sbom(sbom);
804        let dependency_metrics = DependencyMetrics::from_sbom(sbom);
805        let hash_quality_metrics = HashQualityMetrics::from_sbom(sbom);
806        let provenance_metrics = ProvenanceMetrics::from_sbom(sbom);
807        let auditability_metrics = AuditabilityMetrics::from_sbom(sbom);
808        let lifecycle_metrics = LifecycleMetrics::from_sbom(sbom);
809
810        let compliance = {
811            let mut checker = ComplianceChecker::new(self.profile.compliance_level());
812            if let Some(t) = self.as_of {
813                checker = checker.with_as_of(t);
814            }
815            checker.check(sbom)
816        };
817
818        let make_report = |overall_score: f32,
819                           grade: QualityGrade,
820                           recommendations: Vec<Recommendation>,
821                           metrics: AiReadinessMetrics| QualityReport {
822            scoring_engine_version: SCORING_ENGINE_VERSION.to_string(),
823            overall_score,
824            grade,
825            profile: self.profile,
826            completeness_score: 0.0,
827            identifier_score: 0.0,
828            license_score: 0.0,
829            vulnerability_score: None,
830            dependency_score: 0.0,
831            integrity_score: 0.0,
832            provenance_score: 0.0,
833            lifecycle_score: None,
834            completeness_metrics: completeness_metrics.clone(),
835            identifier_metrics: identifier_metrics.clone(),
836            license_metrics: license_metrics.clone(),
837            vulnerability_metrics: vulnerability_metrics.clone(),
838            dependency_metrics: dependency_metrics.clone(),
839            hash_quality_metrics: hash_quality_metrics.clone(),
840            provenance_metrics: provenance_metrics.clone(),
841            auditability_metrics: auditability_metrics.clone(),
842            lifecycle_metrics: lifecycle_metrics.clone(),
843            cryptography_score: None,
844            cryptography_metrics: CryptographyMetrics::default(),
845            compliance: compliance.clone(),
846            recommendations,
847            ai_readiness_metrics: Some(metrics),
848        };
849
850        // Shared AI-BOM scope (same classification as `validate --standard
851        // ai-act/bsi-ai`): ML components by type OR parsed ML-model metadata,
852        // and untyped ML suspects. Suspects are scored rather than exempted —
853        // they carry no model card, so they score what they document — which
854        // keeps the profile applicable exactly when the compliance profiles
855        // consider the SBOM to contain ML content. Dataset-evidenced
856        // components are neither models nor suspects and are never scored.
857        let scope = super::compliance::ai_shared::ai_bom_scope(sbom);
858        let ml_components: Vec<_> = scope
859            .ml_components
860            .iter()
861            .chain(scope.untyped_ml_components.iter())
862            .copied()
863            .collect();
864
865        if ml_components.is_empty() {
866            let metrics = AiReadinessMetrics {
867                ml_component_count: 0,
868                not_applicable: true,
869                na_reason: Some(
870                    "No machine-learning-model components found in this SBOM (by declared \
871                     type, parsed ML-model metadata, or ML content signals)"
872                        .to_string(),
873                ),
874                checks: Vec::new(),
875                components_fully_documented: 0,
876            };
877            return make_report(0.0, QualityGrade::F, Vec::new(), metrics);
878        }
879
880        // Per-check (id, name, weight). AI-010 adds the integrity dimension —
881        // model-weight tampering is the canonical AI supply-chain attack — so it
882        // carries weight comparable to the transparency checks. The literals are
883        // chosen for readability; they no longer sum to exactly 1.0 once AI-010 is
884        // added, so they are renormalized below to keep the total at 1.0.
885        const CHECK_DEFS: [(&str, &str, f32); 11] = [
886            ("AI-001", "Model card URL present", 0.15),
887            ("AI-002", "Architecture family declared", 0.12),
888            ("AI-003", "Training datasets referenced", 0.12),
889            ("AI-004", "Quantitative analysis present", 0.12),
890            ("AI-005", "Fairness assessments included", 0.11),
891            ("AI-006", "Energy consumption disclosed", 0.10),
892            ("AI-007", "Use-cases documented", 0.10),
893            ("AI-008", "Known limitations stated", 0.09),
894            ("AI-009", "Ethical considerations present", 0.09),
895            ("AI-010", "Model weight hashes present", 0.12),
896            // AI-011 closes the BSI "vulnerability/exploitability referencing"
897            // gap for AI clusters: a model is only connected to the security
898            // tooling stack if it carries a CVE/advisory reference that OSV/KEV
899            // /EPSS/VEX can act on. Weighted like the integrity check (AI-010).
900            ("AI-011", "Exploitability/advisory reference present", 0.12),
901        ];
902
903        // Renormalize the per-check weights so they sum to exactly 1.0. Without
904        // this the literals above total 1.12 and a fully documented model would
905        // score >100 (before the .min(100.0) clamp), distorting partial scores.
906        let weight_sum: f32 = CHECK_DEFS.iter().map(|(_, _, w)| *w).sum();
907
908        let n = ml_components.len();
909        let mut total_weighted_score = 0.0_f32;
910        let mut components_fully_documented = 0_usize;
911        let mut component_details: Vec<Vec<String>> = vec![Vec::new(); CHECK_DEFS.len()];
912        let mut failing_components = vec![0_usize; CHECK_DEFS.len()];
913
914        for component in &ml_components {
915            let ml = component.ml_model.as_ref();
916            let raw = component.extensions.raw.as_ref();
917
918            let results: [bool; 11] = [
919                // AI-001: model card URL
920                ml.and_then(|m| m.model_card_url.as_ref()).is_some(),
921                // AI-002: architecture family
922                ml.and_then(|m| m.architecture_family.as_ref()).is_some(),
923                // AI-003: training datasets
924                ml.is_some_and(|m| !m.training_datasets.is_empty()),
925                // AI-004: quantitative analysis — typed performance metrics, with
926                // a raw-pointer fallback for SBOMs parsed before typed extraction.
927                ml.is_some_and(|m| !m.performance_metrics.is_empty())
928                    || has_non_empty_pointer(
929                        raw,
930                        &[
931                            "/modelCard/quantitativeAnalysis",
932                            "/mlModel/modelCard/quantitativeAnalysis",
933                        ],
934                    ),
935                // AI-005: fairness assessments. Fallback pointer corrected to the
936                // spec path `fairnessAssessments` (was the non-spec ...Considerations).
937                ml.is_some_and(|m| !m.fairness.is_empty())
938                    || has_non_empty_pointer(
939                        raw,
940                        &[
941                            "/modelCard/considerations/fairnessAssessments",
942                            "/mlModel/modelCard/considerations/fairnessAssessments",
943                            "/mlModel/considerations/fairnessAssessments",
944                            // Legacy non-spec key, retained for back-compat.
945                            "/modelCard/considerations/fairnessConsiderations",
946                            "/mlModel/modelCard/considerations/fairnessConsiderations",
947                            "/mlModel/considerations/fairnessConsiderations",
948                        ],
949                    ),
950                // AI-006: energy consumption
951                ml.and_then(|m| m.energy_kwh_training).is_some(),
952                // AI-007: use-cases
953                ml.is_some_and(|m| !m.use_cases.is_empty())
954                    || has_non_empty_pointer(
955                        raw,
956                        &[
957                            "/modelCard/considerations/useCases",
958                            "/mlModel/modelCard/considerations/useCases",
959                            "/mlModel/considerations/useCases",
960                        ],
961                    ),
962                // AI-008: limitations
963                ml.and_then(|m| m.limitations.as_ref()).is_some(),
964                // AI-009: ethical considerations
965                ml.is_some_and(|m| !m.ethical_considerations.is_empty())
966                    || has_non_empty_pointer(
967                        raw,
968                        &[
969                            "/modelCard/considerations/ethicalConsiderations",
970                            "/mlModel/modelCard/considerations/ethicalConsiderations",
971                            "/mlModel/considerations/ethicalConsiderations",
972                        ],
973                    ),
974                // AI-010: model weight hashes present. Integrity check — a
975                // MachineLearningModel component must carry at least one hash so
976                // its weights can be verified against tampering. Hashes typically
977                // arrive via HuggingFace enrichment (siblings[].lfs.sha256).
978                !component.hashes.is_empty(),
979                // AI-011: exploitability/advisory reference present. The model is
980                // only connected to the cybersecurity tooling stack (OSV/KEV/EPSS
981                // /VEX) when it carries at least one vulnerability reference OR a
982                // security/advisory external reference an analyst can pivot on.
983                ml_has_exploitability_reference(component),
984            ];
985
986            if results.iter().all(|&p| p) {
987                components_fully_documented += 1;
988            }
989
990            total_weighted_score += results
991                .iter()
992                .zip(CHECK_DEFS.iter())
993                .map(|(&passed, (_, _, w))| if passed { *w / weight_sum } else { 0.0 })
994                .sum::<f32>();
995
996            for (i, &passed) in results.iter().enumerate() {
997                component_details[i].push(format!(
998                    "{}: {}",
999                    component.name,
1000                    if passed { "pass" } else { "fail" }
1001                ));
1002                if !passed {
1003                    failing_components[i] += 1;
1004                }
1005            }
1006        }
1007
1008        let checks: Vec<AiCheck> = CHECK_DEFS
1009            .iter()
1010            .enumerate()
1011            .map(|(i, (id, name, weight))| {
1012                let failures = failing_components[i];
1013                let detail = if component_details[i].is_empty() {
1014                    None
1015                } else {
1016                    Some(format!(
1017                        "{}/{} components passed; {}",
1018                        n - failures,
1019                        n,
1020                        component_details[i].join("; ")
1021                    ))
1022                };
1023                AiCheck {
1024                    id: (*id).to_string(),
1025                    name: (*name).to_string(),
1026                    passed: failures == 0,
1027                    detail,
1028                    // Expose the renormalized weight so reported weights sum to 1.0.
1029                    weight: *weight / weight_sum,
1030                }
1031            })
1032            .collect();
1033
1034        // Average across all ML components, scaled to 0-100.
1035        let overall_score = ((total_weighted_score / n as f32) * 100.0).min(100.0);
1036
1037        let mut recommendations: Vec<Recommendation> = checks
1038            .iter()
1039            .zip(failing_components.iter())
1040            .filter(|(c, _)| !c.passed)
1041            .enumerate()
1042            .map(|(i, (chk, &affected_count))| Recommendation {
1043                priority: (i as u8 / 3) + 1,
1044                category: RecommendationCategory::Completeness,
1045                message: format!("[{}] {}", chk.id, chk.name),
1046                impact: chk.weight * 100.0,
1047                affected_count,
1048            })
1049            .collect();
1050
1051        recommendations.sort_by(|a, b| {
1052            a.priority.cmp(&b.priority).then_with(|| {
1053                b.impact
1054                    .partial_cmp(&a.impact)
1055                    .unwrap_or(std::cmp::Ordering::Equal)
1056            })
1057        });
1058
1059        let metrics = AiReadinessMetrics {
1060            ml_component_count: n,
1061            not_applicable: false,
1062            na_reason: None,
1063            checks,
1064            components_fully_documented,
1065        };
1066
1067        make_report(
1068            overall_score,
1069            QualityGrade::from_score(overall_score),
1070            recommendations,
1071            metrics,
1072        )
1073    }
1074
1075    /// Apply hard score caps for critical issues
1076    fn apply_score_caps(
1077        &self,
1078        mut score: f32,
1079        lifecycle: &LifecycleMetrics,
1080        deps: &DependencyMetrics,
1081        hashes: &HashQualityMetrics,
1082        crypto: &CryptographyMetrics,
1083        total_components: usize,
1084    ) -> f32 {
1085        let is_security_profile =
1086            matches!(self.profile, ScoringProfile::Security | ScoringProfile::Cra);
1087
1088        // EOL components: cap at D grade for security-focused profiles
1089        if is_security_profile && lifecycle.eol_components > 0 {
1090            score = score.min(69.0);
1091        }
1092
1093        // Dependency cycles: cap at B grade
1094        if deps.cycle_count > 0
1095            && matches!(
1096                self.profile,
1097                ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
1098            )
1099        {
1100            score = score.min(89.0);
1101        }
1102
1103        // No hashes at all: cap at C grade for Security profile
1104        if matches!(self.profile, ScoringProfile::Security)
1105            && total_components > 0
1106            && hashes.components_with_any_hash == 0
1107        {
1108            score = score.min(79.0);
1109        }
1110
1111        // Weak-only hashes: cap at B grade for Security profile
1112        if matches!(self.profile, ScoringProfile::Security)
1113            && hashes.components_with_weak_only > 0
1114            && hashes.components_with_strong_hash == 0
1115        {
1116            score = score.min(89.0);
1117        }
1118
1119        // CBOM-specific hard caps
1120        if self.profile == ScoringProfile::Cbom && crypto.has_data() {
1121            if crypto.weak_algorithm_count > 0 {
1122                score = score.min(69.0);
1123            }
1124            if crypto.compromised_keys > 0 {
1125                score = score.min(79.0);
1126            }
1127            if crypto.quantum_safe_count == 0 && crypto.algorithms_count > 0 {
1128                score = score.min(79.0);
1129            }
1130        }
1131
1132        score
1133    }
1134
1135    #[allow(clippy::too_many_arguments)]
1136    fn generate_recommendations(
1137        &self,
1138        completeness: &CompletenessMetrics,
1139        identifiers: &IdentifierMetrics,
1140        licenses: &LicenseMetrics,
1141        dependencies: &DependencyMetrics,
1142        hashes: &HashQualityMetrics,
1143        provenance: &ProvenanceMetrics,
1144        lifecycle: &LifecycleMetrics,
1145        auditability: &AuditabilityMetrics,
1146        compliance: &ComplianceResult,
1147        total_components: usize,
1148    ) -> Vec<Recommendation> {
1149        let mut recommendations = Vec::new();
1150
1151        // Priority 1: Compliance errors
1152        if compliance.error_count > 0 {
1153            recommendations.push(Recommendation {
1154                priority: 1,
1155                category: RecommendationCategory::Compliance,
1156                message: format!(
1157                    "Fix {} compliance error(s) to meet {} requirements",
1158                    compliance.error_count,
1159                    compliance.level.name()
1160                ),
1161                impact: 20.0,
1162                affected_count: compliance.error_count,
1163            });
1164        }
1165
1166        // Priority 1: EOL components
1167        if lifecycle.eol_components > 0 {
1168            recommendations.push(Recommendation {
1169                priority: 1,
1170                category: RecommendationCategory::Lifecycle,
1171                message: format!(
1172                    "{} component(s) have reached end-of-life — upgrade or replace",
1173                    lifecycle.eol_components
1174                ),
1175                impact: 15.0,
1176                affected_count: lifecycle.eol_components,
1177            });
1178        }
1179
1180        // Priority 1: Missing versions (critical for identification)
1181        let missing_versions = total_components
1182            - ((completeness.components_with_version / 100.0) * total_components as f32) as usize;
1183        if missing_versions > 0 {
1184            recommendations.push(Recommendation {
1185                priority: 1,
1186                category: RecommendationCategory::Completeness,
1187                message: "Add version information to all components".to_string(),
1188                impact: (missing_versions as f32 / total_components.max(1) as f32) * 15.0,
1189                affected_count: missing_versions,
1190            });
1191        }
1192
1193        // Priority 2: Weak-only hashes
1194        if hashes.components_with_weak_only > 0 {
1195            recommendations.push(Recommendation {
1196                priority: 2,
1197                category: RecommendationCategory::Integrity,
1198                message: "Upgrade weak hashes (MD5/SHA-1) to SHA-256 or stronger".to_string(),
1199                impact: 10.0,
1200                affected_count: hashes.components_with_weak_only,
1201            });
1202        }
1203
1204        // Priority 2: Missing PURLs (important for identification)
1205        if identifiers.missing_all_identifiers > 0 {
1206            recommendations.push(Recommendation {
1207                priority: 2,
1208                category: RecommendationCategory::Identifiers,
1209                message: "Add PURL or CPE identifiers to components".to_string(),
1210                impact: (identifiers.missing_all_identifiers as f32
1211                    / total_components.max(1) as f32)
1212                    * 20.0,
1213                affected_count: identifiers.missing_all_identifiers,
1214            });
1215        }
1216
1217        // Priority 2: Invalid identifiers
1218        let invalid_ids = identifiers.invalid_purls + identifiers.invalid_cpes;
1219        if invalid_ids > 0 {
1220            recommendations.push(Recommendation {
1221                priority: 2,
1222                category: RecommendationCategory::Identifiers,
1223                message: "Fix malformed PURL/CPE identifiers".to_string(),
1224                impact: 10.0,
1225                affected_count: invalid_ids,
1226            });
1227        }
1228
1229        // Priority 2: Missing tool creator info
1230        if !provenance.has_tool_creator {
1231            recommendations.push(Recommendation {
1232                priority: 2,
1233                category: RecommendationCategory::Provenance,
1234                message: "Add SBOM creation tool information".to_string(),
1235                impact: 8.0,
1236                affected_count: 0,
1237            });
1238        }
1239
1240        // Priority 3: Dependency cycles
1241        if dependencies.cycle_count > 0 {
1242            recommendations.push(Recommendation {
1243                priority: 3,
1244                category: RecommendationCategory::Dependencies,
1245                message: format!(
1246                    "{} dependency cycle(s) detected — review dependency graph",
1247                    dependencies.cycle_count
1248                ),
1249                impact: 10.0,
1250                affected_count: dependencies.cycle_count,
1251            });
1252        }
1253
1254        // Priority 2-3: Software complexity
1255        if let Some(level) = &dependencies.complexity_level {
1256            match level {
1257                super::metrics::ComplexityLevel::VeryHigh => {
1258                    recommendations.push(Recommendation {
1259                        priority: 2,
1260                        category: RecommendationCategory::Dependencies,
1261                        message:
1262                            "Dependency structure is very complex — review for unnecessary transitive dependencies"
1263                                .to_string(),
1264                        impact: 8.0,
1265                        affected_count: dependencies.total_dependencies,
1266                    });
1267                }
1268                super::metrics::ComplexityLevel::High => {
1269                    recommendations.push(Recommendation {
1270                        priority: 3,
1271                        category: RecommendationCategory::Dependencies,
1272                        message:
1273                            "Dependency structure is complex — consider reducing hub dependencies or flattening deep chains"
1274                                .to_string(),
1275                        impact: 5.0,
1276                        affected_count: dependencies.total_dependencies,
1277                    });
1278                }
1279                _ => {}
1280            }
1281        }
1282
1283        // Priority 3: Missing licenses
1284        let missing_licenses = total_components - licenses.with_declared;
1285        if missing_licenses > 0 && (missing_licenses as f32 / total_components.max(1) as f32) > 0.2
1286        {
1287            recommendations.push(Recommendation {
1288                priority: 3,
1289                category: RecommendationCategory::Licenses,
1290                message: "Add license information to components".to_string(),
1291                impact: (missing_licenses as f32 / total_components.max(1) as f32) * 12.0,
1292                affected_count: missing_licenses,
1293            });
1294        }
1295
1296        // Priority 3: NOASSERTION licenses
1297        if licenses.noassertion_count > 0 {
1298            recommendations.push(Recommendation {
1299                priority: 3,
1300                category: RecommendationCategory::Licenses,
1301                message: "Replace NOASSERTION with actual license information".to_string(),
1302                impact: 5.0,
1303                affected_count: licenses.noassertion_count,
1304            });
1305        }
1306
1307        // Priority 3: VCS URL coverage — derived from the actual VCS
1308        // reference count, not hash coverage (an unrelated field).
1309        if total_components > 0 {
1310            let missing_vcs = total_components.saturating_sub(auditability.components_with_vcs);
1311            if missing_vcs > total_components / 2 {
1312                recommendations.push(Recommendation {
1313                    priority: 3,
1314                    category: RecommendationCategory::Provenance,
1315                    message: "Add VCS (source repository) URLs to components".to_string(),
1316                    impact: 5.0,
1317                    affected_count: missing_vcs,
1318                });
1319            }
1320        }
1321
1322        // Priority 4: Non-standard licenses
1323        if licenses.non_standard_licenses > 0 {
1324            recommendations.push(Recommendation {
1325                priority: 4,
1326                category: RecommendationCategory::Licenses,
1327                message: "Use SPDX license identifiers for better interoperability".to_string(),
1328                impact: 3.0,
1329                affected_count: licenses.non_standard_licenses,
1330            });
1331        }
1332
1333        // Priority 4: Outdated components
1334        if lifecycle.outdated_components > 0 {
1335            recommendations.push(Recommendation {
1336                priority: 4,
1337                category: RecommendationCategory::Lifecycle,
1338                message: format!(
1339                    "{} component(s) are outdated — newer versions available",
1340                    lifecycle.outdated_components
1341                ),
1342                impact: 5.0,
1343                affected_count: lifecycle.outdated_components,
1344            });
1345        }
1346
1347        // Priority 4: Missing completeness declaration
1348        if provenance.completeness_declaration == CompletenessDeclaration::Unknown
1349            && matches!(
1350                self.profile,
1351                ScoringProfile::Cra | ScoringProfile::Comprehensive
1352            )
1353        {
1354            recommendations.push(Recommendation {
1355                priority: 4,
1356                category: RecommendationCategory::Provenance,
1357                message: "Add compositions section with aggregate completeness declaration"
1358                    .to_string(),
1359                impact: 5.0,
1360                affected_count: 0,
1361            });
1362        }
1363
1364        // Priority 4: Missing dependency information
1365        if total_components > 1 && dependencies.total_dependencies == 0 {
1366            recommendations.push(Recommendation {
1367                priority: 4,
1368                category: RecommendationCategory::Dependencies,
1369                message: "Add dependency relationships between components".to_string(),
1370                impact: 10.0,
1371                affected_count: total_components,
1372            });
1373        }
1374
1375        // Priority 4: Many orphan components
1376        if dependencies.orphan_components > 1
1377            && (dependencies.orphan_components as f32 / total_components.max(1) as f32) > 0.3
1378        {
1379            recommendations.push(Recommendation {
1380                priority: 4,
1381                category: RecommendationCategory::Dependencies,
1382                message: "Review orphan components that have no dependency relationships"
1383                    .to_string(),
1384                impact: 5.0,
1385                affected_count: dependencies.orphan_components,
1386            });
1387        }
1388
1389        // Priority 5: Missing supplier information
1390        let missing_suppliers = total_components
1391            - ((completeness.components_with_supplier / 100.0) * total_components as f32) as usize;
1392        if missing_suppliers > 0
1393            && (missing_suppliers as f32 / total_components.max(1) as f32) > 0.5
1394        {
1395            recommendations.push(Recommendation {
1396                priority: 5,
1397                category: RecommendationCategory::Completeness,
1398                message: "Add supplier information to components".to_string(),
1399                impact: (missing_suppliers as f32 / total_components.max(1) as f32) * 8.0,
1400                affected_count: missing_suppliers,
1401            });
1402        }
1403
1404        // Priority 5: Missing hashes
1405        let missing_hashes = total_components
1406            - ((completeness.components_with_hashes / 100.0) * total_components as f32) as usize;
1407        if missing_hashes > 0
1408            && matches!(
1409                self.profile,
1410                ScoringProfile::Security | ScoringProfile::Comprehensive
1411            )
1412        {
1413            recommendations.push(Recommendation {
1414                priority: 5,
1415                category: RecommendationCategory::Integrity,
1416                message: "Add cryptographic hashes for integrity verification".to_string(),
1417                impact: (missing_hashes as f32 / total_components.max(1) as f32) * 5.0,
1418                affected_count: missing_hashes,
1419            });
1420        }
1421
1422        // Priority 5: Consider SBOM signing (only if not already signed)
1423        if !provenance.has_signature
1424            && matches!(
1425                self.profile,
1426                ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
1427            )
1428        {
1429            recommendations.push(Recommendation {
1430                priority: 5,
1431                category: RecommendationCategory::Integrity,
1432                message: "Consider adding a digital signature to the SBOM".to_string(),
1433                impact: 3.0,
1434                affected_count: 0,
1435            });
1436        }
1437
1438        // Sort by priority, then by impact
1439        recommendations.sort_by(|a, b| {
1440            a.priority.cmp(&b.priority).then_with(|| {
1441                b.impact
1442                    .partial_cmp(&a.impact)
1443                    .unwrap_or(std::cmp::Ordering::Equal)
1444            })
1445        });
1446
1447        recommendations
1448    }
1449}
1450
1451impl Default for QualityScorer {
1452    fn default() -> Self {
1453        Self::new(ScoringProfile::Standard)
1454    }
1455}
1456
1457#[cfg(test)]
1458mod tests {
1459    use super::*;
1460    use crate::model::{Component, ComponentType, DocumentMetadata, MlModelInfo};
1461    use serde_json::json;
1462
1463    #[test]
1464    fn test_grade_from_score() {
1465        assert_eq!(QualityGrade::from_score(95.0), QualityGrade::A);
1466        assert_eq!(QualityGrade::from_score(85.0), QualityGrade::B);
1467        assert_eq!(QualityGrade::from_score(75.0), QualityGrade::C);
1468        assert_eq!(QualityGrade::from_score(65.0), QualityGrade::D);
1469        assert_eq!(QualityGrade::from_score(55.0), QualityGrade::F);
1470    }
1471
1472    #[test]
1473    fn with_as_of_pins_the_embedded_compliance_clock() {
1474        // The CRA Art. 14 readiness checks escalate Warning→Error after the
1475        // 2026-09-11 deadline for Important Class II products. Pinning the
1476        // scorer's clock on either side of the boundary must therefore change
1477        // the embedded compliance verdict — proving `--as-of` reaches the
1478        // checker instead of it silently using the wall clock.
1479        use chrono::{TimeZone, Utc};
1480        let sbom = NormalizedSbom::default();
1481        let score_at = |ts: chrono::DateTime<chrono::Utc>| {
1482            QualityScorer::new(ScoringProfile::Cra)
1483                .with_cra_product_class(crate::model::CraProductClass::ImportantClass2)
1484                .with_as_of(ts)
1485                .score(&sbom)
1486        };
1487        let pre = score_at(Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap());
1488        let post = score_at(Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap());
1489        assert!(
1490            post.compliance.error_count > pre.compliance.error_count,
1491            "post-deadline run must escalate Art. 14 findings: pre={} post={}",
1492            pre.compliance.error_count,
1493            post.compliance.error_count
1494        );
1495        // Determinism: the same pinned clock yields the same verdict.
1496        let pre2 = score_at(Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap());
1497        assert_eq!(pre.compliance.error_count, pre2.compliance.error_count);
1498        assert_eq!(pre.compliance.is_compliant, pre2.compliance.is_compliant);
1499    }
1500
1501    #[test]
1502    fn test_scoring_profile_compliance_level() {
1503        assert_eq!(
1504            ScoringProfile::Minimal.compliance_level(),
1505            ComplianceLevel::Minimum
1506        );
1507        assert_eq!(
1508            ScoringProfile::Security.compliance_level(),
1509            ComplianceLevel::NtiaMinimum
1510        );
1511        assert_eq!(
1512            ScoringProfile::Comprehensive.compliance_level(),
1513            ComplianceLevel::Comprehensive
1514        );
1515        assert_eq!(
1516            ScoringProfile::AiReadiness.compliance_level(),
1517            ComplianceLevel::Comprehensive
1518        );
1519    }
1520
1521    #[test]
1522    fn test_scoring_weights_sum_to_one() {
1523        let profiles = [
1524            ScoringProfile::Minimal,
1525            ScoringProfile::Standard,
1526            ScoringProfile::Security,
1527            ScoringProfile::LicenseCompliance,
1528            ScoringProfile::Cra,
1529            ScoringProfile::Comprehensive,
1530            ScoringProfile::Cbom,
1531            ScoringProfile::AiReadiness,
1532        ];
1533        for profile in &profiles {
1534            let w = profile.weights();
1535            let sum: f32 = w.as_array().iter().sum();
1536            assert!(
1537                (sum - 1.0).abs() < 0.01,
1538                "{profile:?} weights sum to {sum}, expected 1.0"
1539            );
1540        }
1541    }
1542
1543    #[test]
1544    fn test_renormalize_all_available() {
1545        let w = ScoringProfile::Standard.weights();
1546        let available = [true; 8];
1547        let norm = w.renormalize(&available);
1548        let sum: f32 = norm.iter().sum();
1549        assert!((sum - 1.0).abs() < 0.001);
1550    }
1551
1552    #[test]
1553    fn test_renormalize_lifecycle_unavailable() {
1554        let w = ScoringProfile::Standard.weights();
1555        let mut available = [true; 8];
1556        available[7] = false; // lifecycle
1557        let norm = w.renormalize(&available);
1558        let sum: f32 = norm.iter().sum();
1559        assert!((sum - 1.0).abs() < 0.001);
1560        assert_eq!(norm[7], 0.0);
1561    }
1562
1563    #[test]
1564    fn test_scoring_engine_version() {
1565        assert_eq!(SCORING_ENGINE_VERSION, "2.1");
1566    }
1567
1568    #[test]
1569    fn cbom_hard_cap_weak_algorithms() {
1570        use crate::model::{
1571            AlgorithmProperties, CanonicalId, Component, ComponentType, CryptoAssetType,
1572            CryptoPrimitive, CryptoProperties, NormalizedSbom,
1573        };
1574
1575        let mut sbom = NormalizedSbom::default();
1576        // Add a weak crypto component (MD5 algorithm)
1577        let mut comp = Component::new("MD5".to_string(), "md5-ref".to_string());
1578        comp.component_type = ComponentType::Cryptographic;
1579        comp.crypto_properties = Some(
1580            CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
1581                AlgorithmProperties::new(CryptoPrimitive::Hash)
1582                    .with_algorithm_family("MD5".to_string())
1583                    .with_nist_quantum_security_level(0),
1584            ),
1585        );
1586        sbom.components
1587            .insert(CanonicalId::from_name_version("md5", None), comp);
1588
1589        let scorer = QualityScorer::new(ScoringProfile::Cbom);
1590        let report = scorer.score(&sbom);
1591        // Weak algorithm → D max (69)
1592        assert!(
1593            report.overall_score <= 69.0,
1594            "weak algo should cap at D, got {}",
1595            report.overall_score
1596        );
1597    }
1598
1599    /// A CBOM with crypto assets but zero algorithms must not earn a free
1600    /// 100-weighted PQC category (nor a punitive 0): `pqc_readiness_score()`
1601    /// is `None` and its weight is proportionally redistributed across the
1602    /// remaining categories, mirroring `vulnerability_score`.
1603    #[test]
1604    fn cbom_zero_algorithms_reweights_pqc_slot() {
1605        use crate::model::{
1606            CanonicalId, Component, ComponentType, CryptoAssetType, CryptoProperties,
1607            NormalizedSbom,
1608        };
1609
1610        let mut sbom = NormalizedSbom::default();
1611        let mut comp = Component::new("tls-cert".to_string(), "cert-ref".to_string());
1612        comp.component_type = ComponentType::Cryptographic;
1613        comp.crypto_properties = Some(CryptoProperties::new(CryptoAssetType::Certificate));
1614        sbom.components
1615            .insert(CanonicalId::from_name_version("tls-cert", None), comp);
1616
1617        let report = QualityScorer::new(ScoringProfile::Cbom).score(&sbom);
1618        let cm = &report.cryptography_metrics;
1619        assert!(cm.has_data());
1620        assert_eq!(cm.algorithms_count, 0);
1621        assert!(
1622            cm.pqc_readiness_score().is_none(),
1623            "no algorithms → PQC N/A"
1624        );
1625
1626        // Recompute the reweighted aggregate: PQC (index 5) excluded, its
1627        // weight redistributed proportionally across the other seven slots.
1628        let w = ScoringProfile::Cbom.weights().as_array();
1629        let scores = [
1630            cm.crypto_completeness_score(),
1631            cm.crypto_identifier_score(),
1632            cm.algorithm_strength_score(),
1633            cm.crypto_dependency_score(),
1634            cm.crypto_lifecycle_score(),
1635            0.0, // PQC: N/A, excluded
1636            report.provenance_score,
1637            report.license_score,
1638        ];
1639        let available_weight: f32 = w
1640            .iter()
1641            .enumerate()
1642            .filter(|&(i, _)| i != 5)
1643            .map(|(_, wt)| wt)
1644            .sum();
1645        let expected: f32 = scores
1646            .iter()
1647            .zip(w.iter())
1648            .enumerate()
1649            .filter(|&(i, _)| i != 5)
1650            .map(|(_, (s, wt))| s * (wt / available_weight))
1651            .sum();
1652        assert!(
1653            (report.overall_score - expected.min(100.0)).abs() < 0.01,
1654            "overall {} != reweighted aggregate {}",
1655            report.overall_score,
1656            expected
1657        );
1658
1659        // Regression guard: not the pre-fix inflated value (a vacuous 100
1660        // occupying the full PQC weight).
1661        let inflated: f32 = scores
1662            .iter()
1663            .zip(w.iter())
1664            .enumerate()
1665            .map(|(i, (s, wt))| if i == 5 { 100.0 * wt } else { s * wt })
1666            .sum();
1667        assert!(
1668            (report.overall_score - inflated).abs() > 0.5,
1669            "overall {} still matches the vacuous-100 aggregate {}",
1670            report.overall_score,
1671            inflated
1672        );
1673    }
1674
1675    fn ml_component(bom_ref: &str, name: &str, ml: MlModelInfo, raw: Value) -> Component {
1676        let mut component =
1677            Component::new(name.to_string(), bom_ref.to_string()).with_version("1.0.0".to_string());
1678        component.component_type = ComponentType::MachineLearningModel;
1679        component.ml_model = Some(ml);
1680        component.extensions.raw = Some(raw);
1681        component
1682    }
1683
1684    #[test]
1685    fn test_ai_readiness_not_applicable_without_ml_components() {
1686        let sbom = NormalizedSbom::new(DocumentMetadata::default());
1687        let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1688        let metrics = report
1689            .ai_readiness_metrics
1690            .expect("AI readiness metrics should be present");
1691        assert!(metrics.is_not_applicable());
1692        assert_eq!(metrics.ml_component_count, 0);
1693        assert!(metrics.checks.is_empty());
1694    }
1695
1696    /// Mistyped models (application/library-typed, but carrying parsed
1697    /// ML-model metadata) must be scored, not declared N/A. Regression: the
1698    /// type-only filter let `quality --profile ai-readiness` report N/A —
1699    /// bypassing `--min-score` — on the very SBOMs `validate --standard
1700    /// ai-act` assesses as applicable AI-BOMs.
1701    #[test]
1702    fn test_ai_readiness_scores_mistyped_model_with_ml_metadata() {
1703        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1704        let mut component = Component::new("sentiment-model".to_string(), "ml-1".to_string())
1705            .with_version("1.0.0".to_string());
1706        component.component_type = ComponentType::Application;
1707        component.ml_model = Some(MlModelInfo::default());
1708        sbom.add_component(component);
1709
1710        let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1711        let metrics = report
1712            .ai_readiness_metrics
1713            .expect("AI readiness metrics should be present");
1714        assert!(
1715            !metrics.is_not_applicable(),
1716            "ML-model metadata must make the profile applicable regardless of the declared type"
1717        );
1718        assert_eq!(metrics.ml_component_count, 1);
1719        // An empty model card documents nothing: the transparency checks fail
1720        // and the score gates instead of vanishing into N/A.
1721        assert_eq!(metrics.checks.len(), 11);
1722        assert!(metrics.checks.iter().all(|c| !c.passed));
1723        assert_eq!(report.grade, QualityGrade::F);
1724    }
1725
1726    /// Untyped ML suspects (pkg:huggingface PURL, no metadata) keep the
1727    /// profile applicable and are scored — mirroring SBOM-AIACT-UNTYPED-ML /
1728    /// SBOM-BSIAI-UNTYPED-ML keeping `validate` applicable — so untyping a
1729    /// model cannot dodge the score gate either.
1730    #[test]
1731    fn test_ai_readiness_scores_untyped_huggingface_suspect() {
1732        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1733        let hf = Component::new("bert-base-uncased".to_string(), "hf-1".to_string())
1734            .with_version("1.0.0".to_string())
1735            .with_purl("pkg:huggingface/google-bert/bert-base-uncased@1.0.0".to_string());
1736        sbom.add_component(hf);
1737
1738        let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1739        let metrics = report
1740            .ai_readiness_metrics
1741            .expect("AI readiness metrics should be present");
1742        assert!(
1743            !metrics.is_not_applicable(),
1744            "an ML-looking suspect must keep the AI-readiness profile applicable"
1745        );
1746        assert_eq!(metrics.ml_component_count, 1);
1747        assert_eq!(report.grade, QualityGrade::F);
1748    }
1749
1750    /// A HuggingFace-hosted DATASET (dataset evidence present) is neither an
1751    /// ML model nor an evasion suspect: it must not be scored against the
1752    /// model-card checks, and a dataset-only SBOM stays N/A for this
1753    /// model-card-centric profile.
1754    #[test]
1755    fn test_ai_readiness_does_not_score_hf_dataset_with_evidence() {
1756        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1757        let mut dataset = Component::new("imdb".to_string(), "ds-1".to_string())
1758            .with_version("1.0.0".to_string())
1759            .with_purl("pkg:huggingface/datasets/imdb@1.0.0".to_string());
1760        dataset.component_type = ComponentType::Data;
1761        dataset.dataset = Some(crate::model::DatasetInfo::default());
1762        sbom.add_component(dataset);
1763        // A plain library must not count either.
1764        sbom.add_component(
1765            Component::new("express".to_string(), "lib-1".to_string())
1766                .with_purl("pkg:npm/express@4.19.2".to_string()),
1767        );
1768
1769        let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1770        let metrics = report
1771            .ai_readiness_metrics
1772            .expect("AI readiness metrics should be present");
1773        assert!(
1774            metrics.is_not_applicable(),
1775            "a documented dataset must not be scored as an ML model"
1776        );
1777        assert_eq!(metrics.ml_component_count, 0);
1778    }
1779
1780    #[test]
1781    fn test_ai_readiness_reads_nested_model_card_extensions() {
1782        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1783        let ml = MlModelInfo {
1784            architecture_family: Some("transformer".to_string()),
1785            training_datasets: vec![crate::model::DatasetRef {
1786                reference: None,
1787                name: Some("wikipedia-2.5B".to_string()),
1788                purl: None,
1789            }],
1790            energy_kwh_training: Some(1500.0),
1791            model_card_url: Some("https://example.test/model-card".to_string()),
1792            limitations: Some("Only validated for English text".to_string()),
1793            ..MlModelInfo::default()
1794        };
1795        let raw = json!({
1796            "mlModel": {
1797                "modelCard": {
1798                    "quantitativeAnalysis": {
1799                        "performanceMetrics": [{ "type": "accuracy", "value": 0.97 }]
1800                    },
1801                    "considerations": {
1802                        "fairnessConsiderations": ["Assessed on demographic parity"],
1803                        "useCases": ["Document classification"],
1804                        "ethicalConsiderations": ["Human review required for sensitive domains"]
1805                    }
1806                }
1807            }
1808        });
1809        let mut component = ml_component("ml-1", "bert-base", ml, raw);
1810        // A weight hash makes the AI-010 integrity check pass.
1811        component.hashes.push(crate::model::Hash::new(
1812            crate::model::HashAlgorithm::Sha256,
1813            "a".repeat(64),
1814        ));
1815        // A vulnerability reference makes the AI-011 exploitability check pass.
1816        component
1817            .vulnerabilities
1818            .push(crate::model::VulnerabilityRef::new(
1819                "CVE-2024-0001".to_string(),
1820                crate::model::VulnerabilitySource::Cve,
1821            ));
1822        sbom.add_component(component);
1823
1824        let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1825        let metrics = report
1826            .ai_readiness_metrics
1827            .expect("AI readiness metrics should be present");
1828        assert!(!metrics.is_not_applicable());
1829        // All eleven checks should pass → fully documented, perfect score.
1830        for check in &metrics.checks {
1831            assert!(check.passed, "expected {} to pass", check.id);
1832        }
1833        assert_eq!(metrics.checks.len(), 11, "AI-001..AI-011 are all reported");
1834        // The renormalized per-check weights must still sum to 1.0.
1835        let weight_total: f32 = metrics.checks.iter().map(|c| c.weight).sum();
1836        assert!(
1837            (weight_total - 1.0).abs() < 0.001,
1838            "renormalized weights must sum to 1.0, got {weight_total}"
1839        );
1840        assert_eq!(metrics.components_fully_documented, 1);
1841        assert!((report.overall_score - 100.0).abs() < 0.01);
1842        assert_eq!(report.grade, QualityGrade::A);
1843    }
1844
1845    #[test]
1846    fn test_ai_readiness_fails_check_when_any_model_is_missing_it() {
1847        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1848        let complete_ml = MlModelInfo {
1849            architecture_family: Some("transformer".to_string()),
1850            training_datasets: vec![crate::model::DatasetRef {
1851                reference: None,
1852                name: Some("dataset".to_string()),
1853                purl: None,
1854            }],
1855            energy_kwh_training: Some(10.0),
1856            model_card_url: Some("https://example.test/model-card".to_string()),
1857            limitations: Some("Only validated for English text".to_string()),
1858            ..MlModelInfo::default()
1859        };
1860        let complete_raw = json!({
1861            "mlModel": { "modelCard": {
1862                "quantitativeAnalysis": { "performanceMetrics": [{ "type": "accuracy", "value": 0.98 }] },
1863                "considerations": {
1864                    "fairnessConsiderations": ["Reviewed"],
1865                    "useCases": ["Classification"],
1866                    "ethicalConsiderations": ["Human review required"]
1867                }
1868            }}
1869        });
1870        sbom.add_component(ml_component(
1871            "ml-1",
1872            "complete-model",
1873            complete_ml.clone(),
1874            complete_raw,
1875        ));
1876
1877        // Second model is missing fairness assessments.
1878        let incomplete_raw = json!({
1879            "mlModel": { "modelCard": {
1880                "quantitativeAnalysis": { "performanceMetrics": [{ "type": "accuracy", "value": 0.94 }] },
1881                "considerations": {
1882                    "useCases": ["Classification"],
1883                    "ethicalConsiderations": ["Human review required"]
1884                }
1885            }}
1886        });
1887        sbom.add_component(ml_component(
1888            "ml-2",
1889            "incomplete-model",
1890            complete_ml,
1891            incomplete_raw,
1892        ));
1893
1894        let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1895        let metrics = report
1896            .ai_readiness_metrics
1897            .expect("AI readiness metrics should be present");
1898        let fairness = metrics
1899            .checks
1900            .iter()
1901            .find(|c| c.id == "AI-005")
1902            .expect("AI-005 should be present");
1903        assert!(
1904            !fairness.passed,
1905            "AI-005 should fail when any model is missing fairness data"
1906        );
1907        assert!(
1908            fairness
1909                .detail
1910                .as_deref()
1911                .unwrap_or_default()
1912                .contains("1/2 components passed")
1913        );
1914        let rec = report
1915            .recommendations
1916            .iter()
1917            .find(|r| r.message.contains("AI-005"))
1918            .expect("missing fairness recommendation");
1919        assert_eq!(rec.affected_count, 1);
1920    }
1921
1922    #[test]
1923    fn test_ai_010_weight_hash_integrity_check() {
1924        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1925
1926        // A model with no hashes fails AI-010; one with a hash passes it.
1927        let bare = ml_component("ml-1", "no-hash", MlModelInfo::default(), json!({}));
1928        sbom.add_component(bare);
1929
1930        let mut hashed = ml_component("ml-2", "with-hash", MlModelInfo::default(), json!({}));
1931        hashed.hashes.push(crate::model::Hash::new(
1932            crate::model::HashAlgorithm::Sha256,
1933            "b".repeat(64),
1934        ));
1935        sbom.add_component(hashed);
1936
1937        let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1938        let metrics = report
1939            .ai_readiness_metrics
1940            .expect("AI readiness metrics should be present");
1941
1942        let ai010 = metrics
1943            .checks
1944            .iter()
1945            .find(|c| c.id == "AI-010")
1946            .expect("AI-010 should be present");
1947        // One of two models lacks a hash, so the aggregate check fails.
1948        assert!(
1949            !ai010.passed,
1950            "AI-010 should fail when any model is missing weight hashes"
1951        );
1952        assert!(
1953            ai010
1954                .detail
1955                .as_deref()
1956                .unwrap_or_default()
1957                .contains("1/2 components passed"),
1958            "AI-010 detail should report 1/2 models passing"
1959        );
1960    }
1961
1962    #[test]
1963    fn test_ai_011_exploitability_reference_check() {
1964        use crate::model::{
1965            ExternalRefType, ExternalReference, VulnerabilityRef, VulnerabilitySource,
1966        };
1967
1968        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1969
1970        // Model 1: carries a vulnerability reference → AI-011 passes.
1971        let mut with_vuln = ml_component("ml-1", "with-vuln", MlModelInfo::default(), json!({}));
1972        with_vuln.vulnerabilities.push(VulnerabilityRef::new(
1973            "CVE-2024-1234".to_string(),
1974            VulnerabilitySource::Cve,
1975        ));
1976        sbom.add_component(with_vuln);
1977
1978        // Model 2: carries a security advisory external reference → AI-011 passes.
1979        let mut with_advisory =
1980            ml_component("ml-2", "with-advisory", MlModelInfo::default(), json!({}));
1981        with_advisory.external_refs.push(ExternalReference {
1982            ref_type: ExternalRefType::Advisories,
1983            url: "https://example.test/advisory".to_string(),
1984            comment: None,
1985            hashes: Vec::new(),
1986        });
1987        sbom.add_component(with_advisory);
1988
1989        let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1990        let metrics = report
1991            .ai_readiness_metrics
1992            .expect("AI readiness metrics should be present");
1993        let ai011 = metrics
1994            .checks
1995            .iter()
1996            .find(|c| c.id == "AI-011")
1997            .expect("AI-011 should be present");
1998        assert!(
1999            ai011.passed,
2000            "AI-011 should pass when every model carries a vuln or advisory reference"
2001        );
2002    }
2003
2004    #[test]
2005    fn test_ai_011_fails_without_exploitability_reference() {
2006        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
2007        // A model with neither a vuln ref nor an advisory external reference.
2008        sbom.add_component(ml_component(
2009            "ml-1",
2010            "no-refs",
2011            MlModelInfo::default(),
2012            json!({}),
2013        ));
2014
2015        let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
2016        let metrics = report
2017            .ai_readiness_metrics
2018            .expect("AI readiness metrics should be present");
2019        let ai011 = metrics
2020            .checks
2021            .iter()
2022            .find(|c| c.id == "AI-011")
2023            .expect("AI-011 should be present");
2024        assert!(
2025            !ai011.passed,
2026            "AI-011 should fail when a model has no exploitability/advisory reference"
2027        );
2028    }
2029}