Skip to main content

rust_doctor/
audit.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3use std::fmt::Write as _;
4
5use serde::ser::{Error as _, SerializeStruct};
6use serde::{Deserialize, Serialize, Serializer};
7
8use cargo_metadata::Metadata;
9
10use crate::execution::ScanExecution;
11use crate::policy::{CorpusMeasurement, RuleTier, UNMEASURED_NOISE_BASIS_POINTS};
12use crate::report::{Diagnostic, Severity, Status};
13use crate::source_kernel::SourceMeasurement;
14
15mod density;
16mod source_inventory;
17
18use density::{DensityScope, Scale, calculate_dimensions, contributions};
19
20/// The workspace the score is computed against: how much of it there is, and
21/// whether that is the workspace or a floor on it.
22#[derive(Debug, Clone, Copy, Default)]
23pub(crate) struct SourceFileInventory {
24    pub(crate) files: usize,
25    /// Lines of production Rust the walk counted. Test code is excluded, since
26    /// the score charges for what ships.
27    pub(crate) production_lines: usize,
28    pub(crate) complete: bool,
29}
30
31pub(crate) fn source_file_inventory(
32    metadata: &Metadata,
33    scan: Option<&ScanExecution>,
34    measurement: Option<&SourceMeasurement>,
35) -> SourceFileInventory {
36    source_inventory::collect(metadata, scan, measurement)
37}
38
39pub const SCORE_MODEL: &str = "core-v3";
40const SHARE_BASE_URL: &str = "https://rust-doctor.com/share";
41const MAX_SHARED_COUNT: usize = 1_000_000;
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Audit {
45    pub source_files: usize,
46    /// Lines of production Rust the scan counted, test code excluded. Zero when
47    /// the workspace holds none, and a floor on it whenever
48    /// `inventory_is_complete` is false.
49    pub production_lines: usize,
50    pub categories: Vec<AuditCategory>,
51    pub score: Option<AuditScore>,
52    /// Whether `source_files` counted the workspace or is a floor on it.
53    ///
54    /// Rebuilding the block for a narrower scope needs this fact and nothing else about the
55    /// original scan, and the block used to recover it from `score.authoritative`, which also
56    /// carries the status and whether every diagnostic was catalogued. One uncatalogued rule
57    /// anywhere therefore made every later scope non-authoritative, for a reason that had
58    /// nothing to do with the inventory. It stays private because it is not published: the
59    /// wire shape is the three members above.
60    inventory_is_complete: bool,
61}
62
63/// Per-severity count of a single quantity.
64///
65/// The report publishes two distinct quantities: the number of distinct
66/// diagnostics and the number of occurrences. Every surface exposes both under
67/// explicit names, and `total` is always the sum of the four severities.
68#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
69pub struct SeverityCounts {
70    pub errors: usize,
71    pub warnings: usize,
72    pub info: usize,
73    pub unknown: usize,
74    pub total: usize,
75}
76
77impl SeverityCounts {
78    pub(crate) fn add(&mut self, severity: Severity, count: usize) {
79        let bucket = match severity {
80            Severity::Error => &mut self.errors,
81            Severity::Warning => &mut self.warnings,
82            Severity::Info => &mut self.info,
83            Severity::Unknown => &mut self.unknown,
84        };
85        *bucket = bucket.saturating_add(count);
86        self.total = self.total.saturating_add(count);
87    }
88
89    /// Adds every bucket of another count into this one.
90    fn merge(&mut self, other: Self) {
91        self.errors = self.errors.saturating_add(other.errors);
92        self.warnings = self.warnings.saturating_add(other.warnings);
93        self.info = self.info.saturating_add(other.info);
94        self.unknown = self.unknown.saturating_add(other.unknown);
95        self.total = self.total.saturating_add(other.total);
96    }
97
98    const fn is_coherent(self) -> bool {
99        self.errors
100            .saturating_add(self.warnings)
101            .saturating_add(self.info)
102            .saturating_add(self.unknown)
103            == self.total
104    }
105
106    /// At least one occurrence per distinct diagnostic, never the reverse.
107    const fn covers(self, distinct: Self) -> bool {
108        self.errors >= distinct.errors
109            && self.warnings >= distinct.warnings
110            && self.info >= distinct.info
111            && self.unknown >= distinct.unknown
112            && self.total >= distinct.total
113    }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct AuditCategory {
118    pub name: AuditCategoryName,
119    pub distinct: SeverityCounts,
120    pub occurrences: SeverityCounts,
121}
122
123/// The categories the report publishes, and the order it publishes them in.
124///
125/// The declaration order is that order: `Ord` derives from it, the tally map is keyed by it, and
126/// `Audit::is_valid` checks it. A second list restating the same sequence is a second place for
127/// it to be wrong, which is why there is none.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
129pub enum AuditCategoryName {
130    Security,
131    Bugs,
132    Performance,
133    Dependencies,
134    Maintainability,
135    /// Diagnostic with no catalog category, a compilation error for instance.
136    /// The bucket exists so that no diagnostic disappears between `summary`
137    /// and `audit.categories`.
138    Other,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
142pub struct AuditScore {
143    pub model: String,
144    pub value: u8,
145    pub label: ScoreLabel,
146    pub authoritative: bool,
147    pub dimensions: ScoreDimensions,
148    /// Worst tier observed across all dimensions, or `null` when no scored
149    /// rule is catalogued.
150    pub worst_tier: Option<RuleTier>,
151    /// Global cap effectively applied to `value`, or `null` when the worst tier
152    /// imposes none. Published so that a score drop can be explained without
153    /// recomputation.
154    pub applied_ceiling: Option<u8>,
155    pub projected_after_top_three: Option<u8>,
156    pub projected_rule_ids: Vec<String>,
157    /// Rules that fired here and that the corpus adjudicated at no true
158    /// positive, in descending cost order, hence absent from
159    /// `projected_rule_ids` whatever their volume.
160    ///
161    /// Published so the omission reads as a measurement rather than a defect:
162    /// the rule with the most findings is often the one the corpus found most
163    /// often wrong, and a list that silently drops it is impossible to trust.
164    #[serde(skip_serializing_if = "Vec::is_empty")]
165    pub withheld_rule_ids: Vec<String>,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
169pub struct ScoreDimensions {
170    pub security: u8,
171    pub reliability: u8,
172    pub maintainability: u8,
173    pub performance: u8,
174    pub dependencies: u8,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
178pub enum ScoreLabel {
179    Great,
180    #[serde(rename = "Needs work")]
181    NeedsWork,
182    Critical,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum ShareError {
187    ScoreUnavailable,
188    NonAuthoritative,
189    InvalidPayload,
190}
191
192impl fmt::Display for ShareError {
193    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
194        formatter.write_str(match self {
195            Self::ScoreUnavailable => "the audit does not contain a score",
196            Self::NonAuthoritative => "the audit score is not authoritative",
197            Self::InvalidPayload => "the audit exceeds the public share bounds",
198        })
199    }
200}
201
202impl std::error::Error for ShareError {}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
205pub(crate) enum ScoreDimension {
206    Security,
207    Reliability,
208    Maintainability,
209    Performance,
210    Dependencies,
211}
212
213impl ScoreDimension {
214    /// Every dimension, in the order the report publishes them.
215    ///
216    /// This is the one list the scoring walks: `every_dimension_is_listed_once` stops compiling
217    /// when a dimension is declared and forgotten here.
218    const ALL: [Self; 5] = [
219        Self::Security,
220        Self::Reliability,
221        Self::Maintainability,
222        Self::Performance,
223        Self::Dependencies,
224    ];
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub(crate) struct RuleAggregate {
229    pub(crate) id: String,
230    pub(crate) effective_severity: Severity,
231    pub(crate) category: Option<AuditCategoryName>,
232    dimension: Option<ScoreDimension>,
233    /// What this rule's findings are counted against, read off the catalog's `producer` field.
234    scope: DensityScope,
235    tier: Option<RuleTier>,
236    /// Every occurrence the report publishes for this rule, which is what the reader is shown.
237    pub(crate) occurrences: usize,
238    /// What this rule adds to its dimension's numerator: its distinct scored sites, each
239    /// weighted by its severity. A site is one diagnostic, so a clone family naming K members
240    /// through `related` weighs one and not K, and the diagnostics outside production code are
241    /// counted in `occurrences` and weigh nothing here.
242    numerator: u64,
243    /// Smoothed false-positive rate the pinned corpus adjudicated, in basis
244    /// points, or `None` for a rule it never adjudicated.
245    /// It ranks what to repair first and enters no penalty: what a rule costs
246    /// the score is what it reported here, whatever it costs elsewhere.
247    noise: Option<u16>,
248    /// Millionths of a point of weighted score that removing this rule's sites recovers.
249    ///
250    /// It is stored rather than computed on demand because under core-v3 it is not a property of
251    /// the rule: the same numerator is worth different points depending on the whole set of rules
252    /// around it, the tier ceilings they impose and the scale they are divided by. So it is
253    /// computed once, where all three are known, and read from there by everything that ranks.
254    contribution: u64,
255}
256
257#[derive(Debug)]
258pub(crate) struct RuleAggregation {
259    pub(crate) rules: Vec<RuleAggregate>,
260    scale: Scale,
261    diagnostics_are_authoritative: bool,
262}
263
264#[derive(Debug)]
265struct PendingRule {
266    id: String,
267    severity: Severity,
268    category: Option<AuditCategoryName>,
269    dimension: Option<ScoreDimension>,
270    scope: DensityScope,
271    mapping_conflict: bool,
272    tier: Option<RuleTier>,
273    occurrences: usize,
274    numerator: u64,
275    noise: Option<u16>,
276}
277
278impl Audit {
279    pub fn build(
280        source_files: usize,
281        production_lines: usize,
282        status: Status,
283        diagnostics: &[Diagnostic],
284    ) -> Self {
285        Self::build_with_inventory(
286            SourceFileInventory {
287                files: source_files,
288                production_lines,
289                complete: true,
290            },
291            status,
292            diagnostics,
293        )
294    }
295
296    pub(crate) fn build_from_inventory(
297        inventory: SourceFileInventory,
298        status: Status,
299        diagnostics: &[Diagnostic],
300    ) -> Self {
301        Self::build_with_inventory(inventory, status, diagnostics)
302    }
303
304    fn build_with_inventory(
305        inventory: SourceFileInventory,
306        status: Status,
307        diagnostics: &[Diagnostic],
308    ) -> Self {
309        let SourceFileInventory {
310            files: source_files,
311            production_lines,
312            complete: inventory_is_complete,
313        } = inventory;
314        // Both quantities count everything the report publishes, FR-06 requires them to equal
315        // `summary`. What the score sets aside is decided inside `aggregate_rules`, so the two
316        // callers of it cannot disagree on the population.
317        let categories = category_tallies(diagnostics);
318        let aggregation = aggregate_rules(production_lines, diagnostics.iter());
319        let scan_is_complete = status == Status::Complete && inventory_is_complete;
320        // No source and no line are the same refusal: core-v3 scores a density, and a density
321        // over nothing is not a hundred out of a hundred, it is an answer the scan cannot give.
322        let score = is_scorable_workspace(source_files, production_lines)
323            .then(|| score(&aggregation, scan_is_complete));
324        Self {
325            source_files,
326            production_lines,
327            categories,
328            score,
329            inventory_is_complete,
330        }
331    }
332
333    pub(crate) fn rebuild_for_scope(&self, status: Status, diagnostics: &[Diagnostic]) -> Self {
334        Self::build_with_inventory(
335            SourceFileInventory {
336                files: self.source_files,
337                production_lines: self.production_lines,
338                complete: self.inventory_is_complete,
339            },
340            status,
341            diagnostics,
342        )
343    }
344
345    pub fn share_url(&self) -> Result<String, ShareError> {
346        let score = self.score.as_ref().ok_or(ShareError::ScoreUnavailable)?;
347        if !score.authoritative {
348            return Err(ShareError::NonAuthoritative);
349        }
350        if !self.is_valid() {
351            return Err(ShareError::InvalidPayload);
352        }
353
354        // The counts the block already publishes, not a third summation over the same
355        // categories that nothing kept in step with the first two.
356        let (_, occurrences) = self.totals();
357        build_share_url(
358            score.value,
359            occurrences.errors,
360            occurrences.warnings,
361            occurrences.info,
362            self.source_files,
363            self.production_lines,
364        )
365    }
366
367    pub fn is_valid(&self) -> bool {
368        let mut previous = None;
369        let categories_are_valid = self.categories.iter().all(|category| {
370            let ordered = previous.is_none_or(|previous| previous < category.name);
371            previous = Some(category.name);
372            ordered && category.is_valid()
373        });
374        categories_are_valid
375            && is_scorable_workspace(self.source_files, self.production_lines)
376                == self.score.is_some()
377            && self.score.as_ref().is_none_or(AuditScore::is_valid)
378    }
379
380    /// Both quantities aggregated over every category of the block.
381    pub fn totals(&self) -> (SeverityCounts, SeverityCounts) {
382        self.categories.iter().fold(
383            (SeverityCounts::default(), SeverityCounts::default()),
384            |(mut distinct, mut occurrences), category| {
385                distinct.merge(category.distinct);
386                occurrences.merge(category.occurrences);
387                (distinct, occurrences)
388            },
389        )
390    }
391}
392
393impl Serialize for Audit {
394    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
395    where
396        S: Serializer,
397    {
398        if !self.is_valid() {
399            return Err(S::Error::custom("invalid audit state"));
400        }
401        let mut state = serializer.serialize_struct("Audit", 4)?;
402        state.serialize_field("source_files", &self.source_files)?;
403        state.serialize_field("production_lines", &self.production_lines)?;
404        state.serialize_field("categories", &self.categories)?;
405        state.serialize_field("score", &self.score)?;
406        state.end()
407    }
408}
409
410impl AuditCategory {
411    fn empty(name: AuditCategoryName) -> Self {
412        Self {
413            name,
414            distinct: SeverityCounts::default(),
415            occurrences: SeverityCounts::default(),
416        }
417    }
418
419    fn is_valid(&self) -> bool {
420        self.distinct.total > 0
421            && self.distinct.is_coherent()
422            && self.occurrences.is_coherent()
423            && self.occurrences.covers(self.distinct)
424    }
425}
426
427impl Serialize for AuditCategory {
428    /// The four bare severity members are the schema-v7 spelling of `occurrences`, projected
429    /// from it rather than stored beside it.
430    ///
431    /// Held as fields, they were a second copy of one fact: a recopy pass wrote them, four
432    /// clauses of `is_valid` checked they still agreed, and `share_url` summed the copy while
433    /// `totals` summed the original. A projection cannot disagree with its source.
434    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
435    where
436        S: Serializer,
437    {
438        let mut state = serializer.serialize_struct("AuditCategory", 7)?;
439        state.serialize_field("name", &self.name)?;
440        state.serialize_field("errors", &self.occurrences.errors)?;
441        state.serialize_field("warnings", &self.occurrences.warnings)?;
442        state.serialize_field("info", &self.occurrences.info)?;
443        state.serialize_field("unknown", &self.occurrences.unknown)?;
444        state.serialize_field("distinct", &self.distinct)?;
445        state.serialize_field("occurrences", &self.occurrences)?;
446        state.end()
447    }
448}
449
450impl AuditScore {
451    /// Whether every published member still follows from the others.
452    ///
453    /// `Audit`'s `Serialize` refuses a block that fails this, so each clause is the one thing it
454    /// names rather than a term in one long conjunction: when it refuses, the caller can be told
455    /// which fact stopped holding.
456    pub fn is_valid(&self) -> bool {
457        if self.model != SCORE_MODEL || self.value > 100 {
458            return false;
459        }
460        if self.applied_ceiling != self.worst_tier.and_then(tier_overall_ceiling) {
461            return false;
462        }
463        if self.value != capped(weighted_score(self.dimensions), self.applied_ceiling) {
464            return false;
465        }
466        if self.label != score_label(self.value) {
467            return false;
468        }
469        if self.dimensions.values().into_iter().any(|value| value > 100) {
470            return false;
471        }
472        // Repairing the top three can only raise the score, never lower it.
473        if self
474            .projected_after_top_three
475            .is_some_and(|projected| projected > 100 || projected < self.value)
476        {
477            return false;
478        }
479        if !self.projected_rules_are_named_once() {
480            return false;
481        }
482        // An authoritative score names what to fix and what fixing it would be worth, or names
483        // neither. A non-authoritative one names nothing at all.
484        if self.authoritative {
485            matches!(
486                (
487                    self.projected_after_top_three,
488                    self.projected_rule_ids.is_empty()
489                ),
490                (None, true) | (Some(_), false)
491            )
492        } else {
493            self.projected_after_top_three.is_none() && self.projected_rule_ids.is_empty()
494        }
495    }
496
497    /// At most three rules, none of them empty and none of them named twice.
498    fn projected_rules_are_named_once(&self) -> bool {
499        let distinct: BTreeSet<_> = self.projected_rule_ids.iter().collect();
500        self.projected_rule_ids.len() <= 3
501            && distinct.len() == self.projected_rule_ids.len()
502            && self.projected_rule_ids.iter().all(|rule| !rule.is_empty())
503    }
504}
505
506impl ScoreDimensions {
507    /// The five dimensions, each scored by the same function.
508    ///
509    /// This and `value_for` are the two halves of the one mapping between a dimension and the
510    /// field the report publishes it under. Everything else walks `ScoreDimension::ALL`.
511    fn from_fn(mut score_for: impl FnMut(ScoreDimension) -> u8) -> Self {
512        Self {
513            security: score_for(ScoreDimension::Security),
514            reliability: score_for(ScoreDimension::Reliability),
515            maintainability: score_for(ScoreDimension::Maintainability),
516            performance: score_for(ScoreDimension::Performance),
517            dependencies: score_for(ScoreDimension::Dependencies),
518        }
519    }
520
521    fn values(self) -> [u8; 5] {
522        ScoreDimension::ALL.map(|dimension| self.value_for(dimension))
523    }
524
525    fn value_for(self, dimension: ScoreDimension) -> u8 {
526        match dimension {
527            ScoreDimension::Security => self.security,
528            ScoreDimension::Reliability => self.reliability,
529            ScoreDimension::Maintainability => self.maintainability,
530            ScoreDimension::Performance => self.performance,
531            ScoreDimension::Dependencies => self.dependencies,
532        }
533    }
534}
535
536impl ScoreLabel {
537    pub const fn as_str(self) -> &'static str {
538        match self {
539            Self::Great => "Great",
540            Self::NeedsWork => "Needs work",
541            Self::Critical => "Critical",
542        }
543    }
544}
545
546impl fmt::Display for ScoreLabel {
547    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
548        formatter.write_str(self.as_str())
549    }
550}
551
552impl AuditCategoryName {
553    pub const fn as_str(self) -> &'static str {
554        match self {
555            Self::Security => "Security",
556            Self::Bugs => "Bugs",
557            Self::Performance => "Performance",
558            Self::Dependencies => "Dependencies",
559            Self::Maintainability => "Maintainability",
560            Self::Other => "Other",
561        }
562    }
563}
564
565impl fmt::Display for AuditCategoryName {
566    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
567        formatter.write_str(self.as_str())
568    }
569}
570
571pub(crate) fn category_mapping(category: &str) -> Option<(AuditCategoryName, ScoreDimension)> {
572    match category {
573        "security" => Some((AuditCategoryName::Security, ScoreDimension::Security)),
574        "correctness" | "reliability" => {
575            Some((AuditCategoryName::Bugs, ScoreDimension::Reliability))
576        }
577        "performance" => Some((AuditCategoryName::Performance, ScoreDimension::Performance)),
578        "cargo" | "dependencies" => Some((
579            AuditCategoryName::Dependencies,
580            ScoreDimension::Dependencies,
581        )),
582        "maintainability" => Some((
583            AuditCategoryName::Maintainability,
584            ScoreDimension::Maintainability,
585        )),
586        _ => None,
587    }
588}
589
590/// Every diagnostic falls into exactly one bucket, including those no catalog
591/// category covers. Without that, `summary` and `audit.categories` would count
592/// different populations.
593fn category_bucket(category: Option<&str>) -> AuditCategoryName {
594    category
595        .and_then(category_mapping)
596        .map_or(AuditCategoryName::Other, |(name, _)| name)
597}
598
599/// Cap imposed on a dimension by the worst tier it contains.
600const fn tier_dimension_ceiling(tier: RuleTier) -> Option<u8> {
601    match tier {
602        RuleTier::P0 => Some(20),
603        RuleTier::P1 => Some(50),
604        RuleTier::P2 => Some(75),
605        RuleTier::P3 => None,
606    }
607}
608
609/// Cap imposed on the overall score by the worst tier across all dimensions.
610const fn tier_overall_ceiling(tier: RuleTier) -> Option<u8> {
611    match tier {
612        RuleTier::P0 => Some(40),
613        RuleTier::P1 => Some(65),
614        RuleTier::P2 | RuleTier::P3 => None,
615    }
616}
617
618/// Full scale of an adjudicated rate, matching the basis points the corpus
619/// publishes.
620const BASIS_POINTS: u64 = 10_000;
621
622/// Whether a workspace holds enough for a score to mean anything.
623///
624/// One condition rather than two places that have to agree: `build_with_inventory` gates the
625/// score on it and `is_valid` checks the gate held, so a block carrying a score over no source
626/// refuses to serialize instead of publishing a hundred over nothing.
627const fn is_scorable_workspace(source_files: usize, production_lines: usize) -> bool {
628    source_files > 0 && production_lines > 0
629}
630
631const fn capped(value: u8, ceiling: Option<u8>) -> u8 {
632    match ceiling {
633        Some(ceiling) if ceiling < value => ceiling,
634        _ => value,
635    }
636}
637
638/// The worst tier is the minimum, `P0` being declared first.
639fn worse_tier(current: Option<RuleTier>, candidate: Option<RuleTier>) -> Option<RuleTier> {
640    match (current, candidate) {
641        (Some(current), Some(candidate)) => Some(current.min(candidate)),
642        (current, candidate) => current.or(candidate),
643    }
644}
645
646const fn dimension_weight_twice(dimension: ScoreDimension) -> u64 {
647    match dimension {
648        ScoreDimension::Security => 4,
649        ScoreDimension::Reliability => 3,
650        ScoreDimension::Maintainability
651        | ScoreDimension::Performance
652        | ScoreDimension::Dependencies => 2,
653    }
654}
655
656impl RuleAggregate {
657    /// What removing this rule's sites recovers, in millionths of a point of weighted score.
658    ///
659    /// Under core-v2 this was the rule's own penalty, which is why it could be recomputed from
660    /// the rule alone. Under core-v3 the penalty is a density read through a curve, so what a
661    /// rule costs depends on everything sharing its dimension: the same site is worth more on a
662    /// clean dimension than on a saturated one. The number is therefore the answer to the
663    /// reader's actual question, the points the score gives back, and it is computed once by
664    /// `aggregate_rules` where the whole set and the scale are both known.
665    pub(crate) const fn contribution(&self) -> u64 {
666        self.contribution
667    }
668
669    /// What repairing this rule is expected to be worth, which is what it costs
670    /// the score discounted by how often the corpus found it wrong.
671    ///
672    /// A rule the corpus adjudicated wrong on nearly every site it showed is
673    /// expected to be worth little to repair, whatever its volume, and volume
674    /// is exactly what the noisiest rules have the most of. Ranking by
675    /// contribution alone told the user to fix the rule that fires most, which
676    /// is not the same question and, on a rule measured wrong almost
677    /// everywhere, is advice to change correct code.
678    ///
679    /// An unmeasured rule is discounted at `UNMEASURED_NOISE_BASIS_POINTS`, the
680    /// middle of the interval, rather than kept whole. No measurement is not
681    /// evidence of correctness either, and the corpus has adjudicated 24 of the
682    /// 62 catalogued rules: keeping the other 38 undiscounted put every one of
683    /// them ahead of every rule the corpus ever confirmed, so the ranking read
684    /// as a list of what nobody has checked. It is the same smoothing at no
685    /// observations rather than a threshold of its own, so the first site the
686    /// corpus adjudicates moves the rule off the middle in whichever direction
687    /// it was adjudicated.
688    pub(crate) fn expected_repair_value(&self) -> u64 {
689        let noise = self.noise.unwrap_or(UNMEASURED_NOISE_BASIS_POINTS);
690        let kept = BASIS_POINTS.saturating_sub(noise as u64);
691        self.contribution().saturating_mul(kept) / BASIS_POINTS
692    }
693
694    /// A non-scorable rule caps nothing: a tier cannot act without a retained
695    /// dimension and severity.
696    const fn scoring_tier(&self) -> Option<RuleTier> {
697        if self.is_scorable() { self.tier } else { None }
698    }
699
700    const fn is_scorable(&self) -> bool {
701        self.numerator > 0 && self.dimension.is_some()
702    }
703}
704
705/// One tally per category that fired, in the order the report publishes them.
706///
707/// The map is keyed by the category itself, whose `Ord` is its declaration order, so it already
708/// comes out ordered: sorting it a second time against a hand-written list is how the two orders
709/// used to be able to disagree.
710fn category_tallies(diagnostics: &[Diagnostic]) -> Vec<AuditCategory> {
711    let mut tallies = BTreeMap::<AuditCategoryName, AuditCategory>::new();
712    for diagnostic in diagnostics {
713        let name = category_bucket(diagnostic.category.as_deref());
714        let tally = tallies
715            .entry(name)
716            .or_insert_with(|| AuditCategory::empty(name));
717        tally.distinct.add(diagnostic.severity, 1);
718        tally
719            .occurrences
720            .add(diagnostic.severity, diagnostic.occurrences);
721    }
722
723    tallies.into_values().collect()
724}
725
726fn score(aggregation: &RuleAggregation, scan_complete: bool) -> AuditScore {
727    let scored = ScoredState::of(&aggregation.rules, &BTreeSet::new(), aggregation.scale);
728    let authoritative = scan_complete && aggregation.diagnostics_are_authoritative;
729    let (projected, withheld) = rank_repairs(&aggregation.rules);
730    let projected_rule_ids: Vec<String> = projected
731        .iter()
732        .take(3)
733        .map(|rule| rule.id.clone())
734        .collect();
735
736    // A ranking is only worth as much as the set of diagnostics it ranked, and that set is
737    // exactly what a scan that did not complete cannot vouch for. So the projection, the rules
738    // it names and the rules it withheld are published together or not at all.
739    let (projected_rule_ids, withheld_rule_ids, projected_after_top_three) = if authoritative {
740        let after = (!projected_rule_ids.is_empty()).then(|| {
741            let removed: BTreeSet<_> = projected_rule_ids.iter().cloned().collect();
742            ScoredState::of(&aggregation.rules, &removed, aggregation.scale).value
743        });
744        let withheld_rule_ids = withheld.iter().map(|rule| rule.id.clone()).collect();
745        (projected_rule_ids, withheld_rule_ids, after)
746    } else {
747        (Vec::new(), Vec::new(), None)
748    };
749
750    AuditScore {
751        model: SCORE_MODEL.to_owned(),
752        value: scored.value,
753        label: score_label(scored.value),
754        authoritative,
755        dimensions: scored.dimensions,
756        worst_tier: scored.worst_tier,
757        applied_ceiling: scored.applied_ceiling,
758        projected_after_top_three,
759        projected_rule_ids,
760        withheld_rule_ids,
761    }
762}
763
764/// The rules that cost the score anything, split by whether repairing them is worth something,
765/// each half ordered by what it is worth to the reader.
766///
767/// The two halves are one question asked once. A rule whose expected repair value rounds away is
768/// expected to be worth nothing to repair, whatever its volume, and volume is exactly what the
769/// noisiest rules have the most of: it is withheld rather than ranked last, because naming it
770/// would still be telling the reader to go and change correct code. It is published all the same,
771/// loudest first, so its absence from the projection reads as a measurement and not as a defect.
772///
773/// The threshold is unchanged by the smoothing and the partition it produces is not. No smoothed
774/// rate reaches ten thousand basis points, so a rule adjudicated wrong on every site it showed
775/// keeps a small share of its contribution and rejoins the projection unless that share rounds
776/// away. That is the intended reading: forty sites all adjudicated wrong is strong evidence and
777/// still not proof, and the sample it rests on is printed beside it wherever the report names it.
778fn rank_repairs(rules: &[RuleAggregate]) -> (Vec<&RuleAggregate>, Vec<&RuleAggregate>) {
779    let (mut projected, mut withheld): (Vec<_>, Vec<_>) = rules
780        .iter()
781        .filter(|rule| rule.is_scorable() && rule.contribution() > 0)
782        .partition(|rule| rule.expected_repair_value() > 0);
783    projected.sort_by(|left, right| {
784        right
785            .expected_repair_value()
786            .cmp(&left.expected_repair_value())
787            .then_with(|| right.contribution().cmp(&left.contribution()))
788            .then_with(|| left.id.cmp(&right.id))
789    });
790    withheld.sort_by(|left, right| {
791        right
792            .contribution()
793            .cmp(&left.contribution())
794            .then_with(|| left.id.cmp(&right.id))
795    });
796    (projected, withheld)
797}
798
799/// Capped score and its cause, for a given set of rules.
800struct ScoredState {
801    dimensions: ScoreDimensions,
802    worst_tier: Option<RuleTier>,
803    applied_ceiling: Option<u8>,
804    value: u8,
805}
806
807impl ScoredState {
808    fn of(rules: &[RuleAggregate], removed: &BTreeSet<String>, scale: Scale) -> Self {
809        let dimensions = calculate_dimensions(rules, removed, scale);
810        let worst_tier = rules
811            .iter()
812            .filter(|rule| !removed.contains(&rule.id))
813            .fold(None, |worst, rule| worse_tier(worst, rule.scoring_tier()));
814        let applied_ceiling = worst_tier.and_then(tier_overall_ceiling);
815        Self {
816            dimensions,
817            worst_tier,
818            applied_ceiling,
819            value: capped(weighted_score(dimensions), applied_ceiling),
820        }
821    }
822}
823
824/// One aggregate per rule that fired, over every diagnostic the report publishes.
825///
826/// The score and the report body read the same aggregates. What separates them is not the
827/// population but which figures they use: a diagnostic outside production code is counted in
828/// `occurrences` and weighs nothing in `numerator`, so it stays visible and costs no points,
829/// and it never decides whether the diagnostics are authoritative. That set-aside used to live
830/// at one of the two call sites, so the contribution the body ranked by was not the one the
831/// score charged, and a rule that only ever fired in a test was ranked as if it had.
832///
833/// The scale comes in rather than out because a density needs a denominator: the same rule
834/// firing at the same sites is worth different points in a two-kiloline crate and in a
835/// seven-hundred-kiloline one, and that is the whole reason this model exists.
836pub(crate) fn aggregate_rules<'a>(
837    production_lines: usize,
838    diagnostics: impl IntoIterator<Item = &'a Diagnostic>,
839) -> RuleAggregation {
840    let mut diagnostics_are_authoritative = true;
841    let mut rules = BTreeMap::<String, PendingRule>::new();
842    for diagnostic in diagnostics {
843        let weighs = crate::report::DiagnosticContext::weighs(diagnostic);
844        let Some(rule_id) = diagnostic.code.as_deref().filter(|code| !code.is_empty()) else {
845            diagnostics_are_authoritative &= !weighs;
846            continue;
847        };
848        let definition = crate::policy::find(rule_id);
849        let mapping = diagnostic.category.as_deref().and_then(category_mapping);
850        let weight = density::severity_weight(diagnostic.severity);
851        let scores = weighs && weight.is_some() && mapping.is_some();
852        if weighs && !scores {
853            diagnostics_are_authoritative = false;
854        }
855        // A rule the catalog cannot resolve has no producer to read a denominator off, so it is
856        // counted against the workspace and the flag says the score was computed over something
857        // the catalog does not describe. The scope has to be total: there is no third answer a
858        // density could be divided by.
859        if weighs && definition.is_none() {
860            diagnostics_are_authoritative = false;
861        }
862        let rule = rules.entry(rule_id.to_owned()).or_insert(PendingRule {
863            id: rule_id.to_owned(),
864            severity: diagnostic.severity,
865            category: None,
866            dimension: None,
867            scope: definition.map_or(DensityScope::Workspace, |definition| {
868                DensityScope::of(definition.producer)
869            }),
870            mapping_conflict: false,
871            tier: definition.map(|definition| definition.tier),
872            occurrences: 0,
873            numerator: 0,
874            noise: crate::policy::corpus_measurement(rule_id)
875                .map(CorpusMeasurement::noise_basis_points),
876        });
877        rule.occurrences = rule.occurrences.saturating_add(diagnostic.occurrences);
878        if diagnostic.severity.rank() < rule.severity.rank() {
879            rule.severity = diagnostic.severity;
880        }
881        if scores
882            && let Some((category, dimension)) = mapping
883            && let Some(weight) = weight
884        {
885            // One diagnostic is one distinct site, whatever it counts in `occurrences`: a clone
886            // family names its K members through `related` and is one place to go and look.
887            rule.numerator = rule.numerator.saturating_add(weight);
888            if rule.dimension.is_some_and(|current| current != dimension) {
889                rule.mapping_conflict = true;
890                diagnostics_are_authoritative = false;
891            } else if rule.dimension.is_none() {
892                rule.category = Some(category);
893                rule.dimension = Some(dimension);
894            }
895        } else if rule.category.is_none()
896            && let Some((category, _)) = mapping
897        {
898            rule.category = Some(category);
899        }
900    }
901    let scale = Scale::of(production_lines);
902    let mut rules: Vec<RuleAggregate> = rules
903        .into_values()
904        .map(|rule| RuleAggregate {
905            id: rule.id,
906            effective_severity: rule.severity,
907            category: (!rule.mapping_conflict).then_some(rule.category).flatten(),
908            dimension: (!rule.mapping_conflict && rule.numerator > 0)
909                .then_some(rule.dimension)
910                .flatten(),
911            scope: rule.scope,
912            tier: rule.tier,
913            occurrences: rule.occurrences,
914            numerator: rule.numerator,
915            noise: rule.noise,
916            contribution: 0,
917        })
918        .collect();
919    let recovered = contributions(&rules, scale);
920    for (rule, contribution) in rules.iter_mut().zip(recovered) {
921        rule.contribution = contribution;
922    }
923    RuleAggregation {
924        rules,
925        scale,
926        diagnostics_are_authoritative,
927    }
928}
929
930fn weighted_score(dimensions: ScoreDimensions) -> u8 {
931    let numerator = ScoreDimension::ALL.into_iter().fold(0u64, |sum, dimension| {
932        sum.saturating_add(
933            u64::from(dimensions.value_for(dimension)) * dimension_weight_twice(dimension),
934        )
935    });
936    u8::try_from((numerator.saturating_add(6) / 13).min(100)).unwrap_or(100)
937}
938
939const fn score_label(score: u8) -> ScoreLabel {
940    if score >= 75 {
941        ScoreLabel::Great
942    } else if score >= 50 {
943        ScoreLabel::NeedsWork
944    } else {
945        ScoreLabel::Critical
946    }
947}
948
949fn build_share_url(
950    score: u8,
951    errors: usize,
952    warnings: usize,
953    info: usize,
954    source_files: usize,
955    production_lines: usize,
956) -> Result<String, ShareError> {
957    if score > 100
958        || [errors, warnings, info, source_files, production_lines]
959            .into_iter()
960            .any(|count| count > MAX_SHARED_COUNT)
961    {
962        return Err(ShareError::InvalidPayload);
963    }
964
965    // The model comes first and is never omitted: every number after it is a
966    // reading of one scale, and a payload that names none is a score the page
967    // it opens has to guess the meaning of. `core-v2` and `core-v3` publish
968    // the same shape over the same rules for very different values.
969    let mut url = format!("{SHARE_BASE_URL}?s={score}&m={SCORE_MODEL}");
970    for (key, count) in [
971        ("e", errors),
972        ("w", warnings),
973        ("i", info),
974        ("f", source_files),
975        ("l", production_lines),
976    ] {
977        if count > 0 {
978            let _ = write!(url, "&{key}={count}");
979        }
980    }
981    Ok(url)
982}
983
984#[cfg(test)]
985mod tests;