Skip to main content

big_code_analysis/
metric_catalog.rs

1//! Single source of truth for the metric catalog.
2//!
3//! Before this module existed, the same set of offender metric ids was
4//! hand-maintained in three places — [`output::sarif`]'s rule
5//! descriptions, the CLI's threshold extractor table, and a third copy
6//! inside a "does every extractor have a description" test — plus a
7//! fourth, differently-shaped table powering `bca list-metrics`. Those
8//! tables drifted: ten of twenty-one rule-description keys once failed
9//! to match any real offender id and went unnoticed for two model
10//! versions.
11//!
12//! [`METRICS`](crate::metric_catalog::METRICS) is now the canonical
13//! list of offender sub-metric ids (`halstead.volume`, `mi.original`,
14//! …) together with their long-form sentences and
15//! [`Direction`](crate::metric_catalog::Direction).
16//! [`FAMILIES`](crate::metric_catalog::FAMILIES) is the canonical view
17//! that `bca list-metrics` renders. The library's offender formatters
18//! ([`output::sarif`], [`output::code_climate`]) read `METRICS`; the
19//! CLI's threshold engine keys its extractor table off the same ids and
20//! a parity test pins the two id-sets together, so a new metric cannot
21//! ship with a half-updated catalog.
22//!
23//! [`output::sarif`]: crate::output
24//! [`output::code_climate`]: crate::output
25
26#![allow(clippy::doc_markdown)]
27
28use crate::spaces::SpaceKind;
29
30/// The space kind a metric's threshold is meaningful on (issue #969).
31///
32/// A threshold gate (`bca check`, the Python `to_sarif` binding) walks
33/// every [`crate::FuncSpace`] — the file-level [`SpaceKind::Unit`] root,
34/// every container (class / impl / ...), and every individual function.
35/// For the subtree-summed accessors a metric's value at any space that
36/// owns children is a *sum across many functions*, so a per-function
37/// limit would fire on every non-trivial file and multi-method `impl`.
38/// Scope records the kind each metric actually measures so the front-ends
39/// gate it there and nowhere else — keeping the CLI gate and the binding
40/// in lockstep, the same way [`Direction`] keeps their breach direction
41/// aligned.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub enum MetricScope {
44    /// Gate only the whole-file [`SpaceKind::Unit`] root — the `loc.*`
45    /// size family, whose limit is a per-file ceiling.
46    File,
47    /// Gate only individual function spaces ([`SpaceKind::Function`] —
48    /// free functions, methods, closures). The per-function complexity
49    /// metrics (cognitive, cyclomatic, abc, mi.*) and the subtree sums
50    /// that describe one function (halstead.*, nargs, nexits, tokens)
51    /// live here.
52    ///
53    /// Whether "one function" includes its nested closures is per
54    /// metric, not per scope. `halstead.*`, `nexits` and `tokens` read
55    /// subtree sums, because a closure's tokens and exits really are
56    /// part of the enclosing body a reader must follow. `nargs` reads
57    /// the space's own parameters instead (#1196): a closure that opens
58    /// its own space is gated on its own row, and summing its arguments
59    /// into the enclosing signature made the offender's number describe
60    /// something its remediation could not change.
61    Function,
62    /// Gate only container spaces that own methods (class / struct /
63    /// trait / impl / namespace / interface) — the object-oriented size
64    /// metrics `nom`, `wmc`, `npm`, `npa`.
65    Container,
66}
67
68impl MetricScope {
69    /// Whether a threshold with this scope is evaluated against `kind`.
70    ///
71    /// The single source of truth for the kind-filtering both the CLI
72    /// gate and the Python binding apply, so the two cannot drift on
73    /// which space kinds a metric gates.
74    #[must_use]
75    pub fn admits(self, kind: SpaceKind) -> bool {
76        match self {
77            Self::File => matches!(kind, SpaceKind::Unit),
78            Self::Function => matches!(kind, SpaceKind::Function),
79            Self::Container => matches!(
80                kind,
81                SpaceKind::Class
82                    | SpaceKind::Struct
83                    | SpaceKind::Trait
84                    | SpaceKind::Impl
85                    | SpaceKind::Namespace
86                    | SpaceKind::Interface
87            ),
88        }
89    }
90}
91
92/// Which direction of a metric's value is unhealthy.
93///
94/// Most metrics grow worse as they grow larger; the Maintainability
95/// Index family is the inverse — a *lower* value is worse. Code Climate
96/// uses this to invert the threshold-breach ratio, and the rule
97/// sentences use it to pick "exceeds" vs "falls below" phrasing.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
99pub enum Direction {
100    /// A higher value is worse (cyclomatic, halstead.*, loc.*, …).
101    HigherIsWorse,
102    /// A lower value is worse (the `mi.*` Maintainability Index family).
103    LowerIsWorse,
104}
105
106/// Catalog entry for one offender-emitting sub-metric id.
107///
108/// The `id` is the dotted key the threshold engine emits for an
109/// offender (`halstead.volume`); `family` groups ids under a top-level
110/// metric (`halstead`) and must match a [`MetricFamily::name`].
111///
112/// `#[non_exhaustive]`: these are read-only records the library
113/// constructs (downstream consumers read fields, never build them), so
114/// a new field can be added in a future minor without a SemVer break.
115#[derive(Debug, Clone, Copy)]
116#[non_exhaustive]
117pub struct MetricInfo {
118    /// Dotted offender id, e.g. `"halstead.volume"` or `"cognitive"`.
119    pub id: &'static str,
120    /// Top-level family this id belongs to, e.g. `"halstead"`.
121    pub family: &'static str,
122    /// Long-form sentence for SARIF `rule.shortDescription.text` and
123    /// the Code Climate `description` prefix.
124    pub long_description: &'static str,
125    /// Whether a higher or lower value is the unhealthy direction.
126    pub direction: Direction,
127    /// Whether the metric's JSON headline is an aggregate across
128    /// descendant spaces (a `sum`/`*_sum` field) that does **not** match
129    /// the CLI threshold accessor's per-space scalar at any interior
130    /// space.
131    ///
132    /// `true` for the four metrics whose serialized JSON value diverges
133    /// from the per-space accessor — `cognitive`, `cyclomatic`,
134    /// `cyclomatic.modified`, and `abc` (#441). The aggregate equals the
135    /// per-space scalar only at a leaf space (no descendant
136    /// function/closure spaces); at any interior space — the file-level
137    /// `unit` or a container with descendants — it is larger.
138    ///
139    /// This flag describes the `sum`/`*_sum` *aggregate* field, which
140    /// still diverges. As of #958 the wire shape **also** serializes each
141    /// of these four metrics' per-space own value (`cyclomatic.value`,
142    /// `cyclomatic.modified.value`, `cognitive.value`, `abc.value`), so a
143    /// JSON-walking front-end no longer needs this flag to stay correct:
144    /// it reads the own value directly and emits at every space exactly
145    /// like the CLI. The Python `to_sarif` binding was switched to that
146    /// path in #958; before it, the binding emitted these only at leaf
147    /// spaces to avoid subtree-wide values masquerading as per-space
148    /// findings the CLI never produces (#855). The flag name retains its
149    /// original unit-only framing.
150    ///
151    /// The flag is **not** derivable from the JSON path string: `nexits`
152    /// also serialises a `sum` field, but its CLI accessor (`nexits_sum()`)
153    /// reads that same aggregate, so it does not diverge and is `false`.
154    /// The divergence is between the JSON field and the CLI accessor,
155    /// which only this registry now records once for both front-ends to
156    /// share (#442).
157    pub skip_at_unit: bool,
158    /// The space kind this metric's threshold gates (issue #969). Both
159    /// the CLI threshold engine and the Python `to_sarif` binding read
160    /// this to skip spaces a metric does not measure, so a metric's
161    /// file-wide or `impl`-wide aggregate never fires as a per-function
162    /// limit. See [`MetricScope`].
163    pub scope: MetricScope,
164}
165
166/// A `bca list-metrics` row: the bare name printed in `names` mode and
167/// the one-line summary printed in `descriptions` mode.
168///
169/// `#[non_exhaustive]` for the same forward-compat reason as
170/// [`MetricInfo`].
171#[derive(Debug, Clone, Copy)]
172#[non_exhaustive]
173pub struct MetricRow {
174    /// Bare name printed one-per-line by `list-metrics`, e.g.
175    /// `"halstead"` or `"sloc"`. Downstream tooling (`bca diff`, which
176    /// buckets per-file metric deltas by these names) relies on them, so
177    /// they are an external contract.
178    pub name: &'static str,
179    /// One-line description printed in `list-metrics descriptions` mode.
180    pub summary: &'static str,
181}
182
183/// A top-level metric family as surfaced by `bca list-metrics`.
184///
185/// Most families render as a single [`MetricRow`] whose name equals
186/// [`name`](Self::name). `loc` is the exception: it renders one row per
187/// sub-measurement (`sloc`, `ploc`, …) because those bare names are an
188/// external grep contract.
189///
190/// `#[non_exhaustive]` for the same forward-compat reason as
191/// [`MetricInfo`].
192#[derive(Debug, Clone, Copy)]
193#[non_exhaustive]
194pub struct MetricFamily {
195    /// Family key, e.g. `"halstead"`, `"loc"`. Matches
196    /// [`MetricInfo::family`].
197    pub name: &'static str,
198    /// `list-metrics` rows for this family, in display order.
199    pub rows: &'static [MetricRow],
200}
201
202/// Canonical offender sub-metric catalog. Long-form sentences and the
203/// `mi.*` lower-is-worse direction moved here verbatim from the former
204/// `output::rule_descriptions` table.
205///
206/// `#[rustfmt::skip]`: the one-row-per-entry layout keeps the table
207/// scannable; rustfmt would otherwise wrap each struct over many lines.
208#[rustfmt::skip]
209pub const METRICS: &[MetricInfo] = &[
210    MetricInfo { id: "cognitive",           family: "cognitive",  long_description: "Cognitive Complexity exceeds the configured threshold.",          direction: Direction::HigherIsWorse, skip_at_unit: true,  scope: MetricScope::Function  },
211    MetricInfo { id: "cyclomatic",          family: "cyclomatic", long_description: "Cyclomatic Complexity exceeds the configured threshold.",         direction: Direction::HigherIsWorse, skip_at_unit: true,  scope: MetricScope::Function  },
212    MetricInfo { id: "cyclomatic.modified", family: "cyclomatic", long_description: "Modified Cyclomatic Complexity exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: true,  scope: MetricScope::Function  },
213    MetricInfo { id: "halstead.volume",     family: "halstead",   long_description: "Halstead volume exceeds the configured threshold.",               direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function  },
214    MetricInfo { id: "halstead.difficulty", family: "halstead",   long_description: "Halstead difficulty exceeds the configured threshold.",           direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function  },
215    MetricInfo { id: "halstead.effort",     family: "halstead",   long_description: "Halstead effort exceeds the configured threshold.",               direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function  },
216    MetricInfo { id: "halstead.time",       family: "halstead",   long_description: "Halstead time-to-program exceeds the configured threshold.",      direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function  },
217    MetricInfo { id: "halstead.bugs",       family: "halstead",   long_description: "Estimated Halstead bugs exceed the configured threshold.",         direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function  },
218    MetricInfo { id: "loc.sloc",            family: "loc",        long_description: "Source lines of code exceed the configured threshold.",            direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::File      },
219    MetricInfo { id: "loc.ploc",            family: "loc",        long_description: "Physical lines of code exceed the configured threshold.",          direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::File      },
220    MetricInfo { id: "loc.lloc",            family: "loc",        long_description: "Logical lines of code exceed the configured threshold.",           direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::File      },
221    MetricInfo { id: "loc.cloc",            family: "loc",        long_description: "Comment lines of code exceed the configured threshold.",           direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::File      },
222    MetricInfo { id: "loc.blank",           family: "loc",        long_description: "Blank lines of code exceed the configured threshold.",             direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::File      },
223    MetricInfo { id: "nom",                 family: "nom",        long_description: "Number of methods/functions exceeds the configured threshold.",    direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Container },
224    MetricInfo { id: "tokens",              family: "tokens",     long_description: "Number of tokens exceeds the configured threshold.",               direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function  },
225    MetricInfo { id: "nexits",              family: "nexits",     long_description: "Number of exit points exceeds the configured threshold.",          direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function  },
226    MetricInfo { id: "nargs",               family: "nargs",      long_description: "Number of function arguments exceeds the configured threshold.",   direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function  },
227    MetricInfo { id: "mi.original",         family: "mi",         long_description: "Maintainability Index falls below the configured threshold.",      direction: Direction::LowerIsWorse,  skip_at_unit: false, scope: MetricScope::Function  },
228    MetricInfo { id: "mi.sei",              family: "mi",         long_description: "Maintainability Index (SEI) falls below the configured threshold.", direction: Direction::LowerIsWorse,  skip_at_unit: false, scope: MetricScope::Function  },
229    MetricInfo { id: "mi.visual_studio",    family: "mi",         long_description: "Maintainability Index (Visual Studio) falls below the configured threshold.", direction: Direction::LowerIsWorse,  skip_at_unit: false, scope: MetricScope::Function  },
230    MetricInfo { id: "abc",                 family: "abc",        long_description: "ABC magnitude exceeds the configured threshold.",                  direction: Direction::HigherIsWorse, skip_at_unit: true,  scope: MetricScope::Function  },
231    MetricInfo { id: "wmc",                 family: "wmc",        long_description: "Weighted Methods per Class exceeds the configured threshold.",     direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Container },
232    MetricInfo { id: "npm",                 family: "npm",        long_description: "Number of public methods exceeds the configured threshold.",       direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Container },
233    MetricInfo { id: "npa",                 family: "npa",        long_description: "Number of public attributes exceeds the configured threshold.",    direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Container },
234];
235
236/// Canonical `bca list-metrics` view. Family summaries moved here
237/// verbatim from the CLI's former hand-maintained catalog. Declaration
238/// order is the `list-metrics` print order.
239///
240/// Only `loc` expands to multiple rows; every other family is a single
241/// row whose name equals the family name.
242pub const FAMILIES: &[MetricFamily] = &[
243    MetricFamily {
244        name: "cognitive",
245        rows: &[MetricRow {
246            name: "cognitive",
247            summary: "Cognitive Complexity: how difficult code is to understand.",
248        }],
249    },
250    MetricFamily {
251        name: "cyclomatic",
252        rows: &[MetricRow {
253            name: "cyclomatic",
254            summary: "Cyclomatic Complexity: linearly independent paths through the code; the modified variant collapses switch/match/when arms in a single switch statement into one decision point.",
255        }],
256    },
257    MetricFamily {
258        name: "halstead",
259        rows: &[MetricRow {
260            name: "halstead",
261            summary: "Halstead suite: vocabulary, length, volume, difficulty, effort, time, bugs.",
262        }],
263    },
264    MetricFamily {
265        name: "loc",
266        rows: &[
267            MetricRow {
268                name: "sloc",
269                summary: "Source lines of code: total lines in a source file.",
270            },
271            MetricRow {
272                name: "ploc",
273                summary: "Physical lines of code: instruction lines.",
274            },
275            MetricRow {
276                name: "lloc",
277                summary: "Logical lines of code: statement count.",
278            },
279            MetricRow {
280                name: "cloc",
281                summary: "Comment lines of code.",
282            },
283            MetricRow {
284                name: "blank",
285                summary: "Blank lines.",
286            },
287        ],
288    },
289    MetricFamily {
290        name: "nom",
291        rows: &[MetricRow {
292            name: "nom",
293            summary: "Number of methods and closures.",
294        }],
295    },
296    MetricFamily {
297        name: "tokens",
298        rows: &[MetricRow {
299            name: "tokens",
300            summary: "Per-function token count: AST leaves excluding comments.",
301        }],
302    },
303    MetricFamily {
304        name: "nexits",
305        rows: &[MetricRow {
306            name: "nexits",
307            summary: "Number of exit points from a function or method.",
308        }],
309    },
310    MetricFamily {
311        name: "nargs",
312        rows: &[MetricRow {
313            name: "nargs",
314            summary: "Number of arguments to a function or method.",
315        }],
316    },
317    MetricFamily {
318        name: "mi",
319        rows: &[MetricRow {
320            name: "mi",
321            summary: "Maintainability Index suite.",
322        }],
323    },
324    MetricFamily {
325        name: "abc",
326        rows: &[MetricRow {
327            name: "abc",
328            summary: "ABC: assignments, branches, and conditions.",
329        }],
330    },
331    MetricFamily {
332        name: "wmc",
333        rows: &[MetricRow {
334            name: "wmc",
335            summary: "Weighted Methods per Class.",
336        }],
337    },
338    MetricFamily {
339        name: "npm",
340        rows: &[MetricRow {
341            name: "npm",
342            summary: "Number of public methods of a class.",
343        }],
344    },
345    MetricFamily {
346        name: "npa",
347        rows: &[MetricRow {
348            name: "npa",
349            summary: "Number of public attributes of a class.",
350        }],
351    },
352];
353
354/// Catalog entry for a known offender id, or `None`. Callers pick their
355/// own fallback for unknown ids (SARIF emits the raw id; Code Climate
356/// falls through to its default message).
357///
358/// Public so out-of-crate consumers (the CLI threshold engine) can read
359/// a metric's [`Direction`] — the `mi.*` family is lower-is-worse, so
360/// the gate and the offender wording must consult it rather than
361/// assuming a higher value is always the violation (#698).
362#[must_use]
363pub fn lookup(id: &str) -> Option<&'static MetricInfo> {
364    METRICS.iter().find(|m| m.id == id)
365}
366
367/// Whether a lower value of the metric `id` is the unhealthy direction
368/// (the `mi.*` Maintainability Index family). The threshold gate, the
369/// Code Climate severity-ratio inversion, and the SARIF/offender wording
370/// all consult this so they never drift from one another. An id the
371/// catalog does not know defaults to higher-is-worse — the same fallback
372/// every offender formatter already uses (#698).
373#[must_use]
374pub fn lower_is_worse(id: &str) -> bool {
375    lookup(id).is_some_and(|m| matches!(m.direction, Direction::LowerIsWorse))
376}
377
378/// The [`MetricScope`] of metric `id` — the space kind its threshold
379/// gates (issue #969). `None` for an id the catalog does not know; both
380/// front-ends treat an unknown id as a usage error before reaching here,
381/// so the `None` arm is only a defensive fallback.
382#[must_use]
383pub fn scope(id: &str) -> Option<MetricScope> {
384    lookup(id).map(|m| m.scope)
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use std::collections::HashSet;
391
392    #[test]
393    fn metric_ids_are_unique() {
394        let mut seen = HashSet::new();
395        for m in METRICS {
396            assert!(seen.insert(m.id), "duplicate metric id {:?}", m.id);
397        }
398    }
399
400    #[test]
401    fn family_names_are_unique() {
402        let mut seen = HashSet::new();
403        for f in FAMILIES {
404            assert!(seen.insert(f.name), "duplicate family name {:?}", f.name);
405        }
406    }
407
408    #[test]
409    fn every_metric_family_is_declared() {
410        let families: HashSet<&str> = FAMILIES.iter().map(|f| f.name).collect();
411        for m in METRICS {
412            assert!(
413                families.contains(m.family),
414                "metric {:?} references undeclared family {:?}",
415                m.id,
416                m.family,
417            );
418        }
419    }
420
421    #[test]
422    fn every_family_has_a_metric() {
423        let metric_families: HashSet<&str> = METRICS.iter().map(|m| m.family).collect();
424        for f in FAMILIES {
425            assert!(
426                metric_families.contains(f.name),
427                "family {:?} has no METRICS entry",
428                f.name,
429            );
430        }
431    }
432
433    #[test]
434    fn lookup_round_trips_and_rejects_unknown() {
435        for m in METRICS {
436            assert_eq!(lookup(m.id).map(|i| i.id), Some(m.id));
437        }
438        assert!(lookup("not.a.metric").is_none());
439    }
440
441    /// `mi.*` is the only lower-is-worse family. This pins the data that
442    /// replaced the former `is_lower_is_worse` prefix predicate; if the
443    /// `Direction` of an `mi.*` row is flipped (or a non-`mi` row is
444    /// marked `LowerIsWorse`), Code Climate's breach-ratio inversion
445    /// silently flips with it.
446    #[test]
447    fn lower_is_worse_iff_mi_family() {
448        for m in METRICS {
449            let expect_lower = m.family == "mi";
450            assert_eq!(
451                matches!(m.direction, Direction::LowerIsWorse),
452                expect_lower,
453                "metric {:?} has the wrong direction",
454                m.id,
455            );
456        }
457    }
458
459    #[test]
460    fn lower_is_worse_helper_matches_catalog_and_defaults_false() {
461        assert!(lower_is_worse("mi.original"), "mi.* is lower-is-worse");
462        assert!(
463            !lower_is_worse("cyclomatic"),
464            "cyclomatic is higher-is-worse"
465        );
466        // An id the catalog does not know defaults to higher-is-worse, so
467        // the shared gate never flags an unknown metric on the wrong side.
468        assert!(!lower_is_worse("not_a_metric"));
469    }
470
471    /// `mi.*` sentences phrase the breach as "falls below"; every other
472    /// metric phrases it as "exceeds"/"exceed". This pins the wording to
473    /// the direction so a copy-paste sentence with the wrong verb is
474    /// caught.
475    #[test]
476    fn sentence_phrasing_matches_direction() {
477        for m in METRICS {
478            match m.direction {
479                Direction::LowerIsWorse => assert!(
480                    m.long_description.contains("falls below"),
481                    "{:?} should use `falls below`: {:?}",
482                    m.id,
483                    m.long_description,
484                ),
485                Direction::HigherIsWorse => assert!(
486                    m.long_description.contains("exceed"),
487                    "{:?} should use `exceed(s)`: {:?}",
488                    m.id,
489                    m.long_description,
490                ),
491            }
492        }
493    }
494
495    /// `skip_at_unit` is `true` for exactly the four metrics whose
496    /// serialized JSON headline at the file-level `unit` space is an
497    /// aggregate over descendant spaces that does not match the CLI
498    /// threshold accessor's per-space scalar (#441). The Python
499    /// `to_sarif` binding mirrors this registry; a cross-crate test in
500    /// `big-code-analysis-py/src/sarif.rs` pins its `METRIC_FIELDS`
501    /// table's flags to these values, so this set is the single source
502    /// of truth both front-ends derive from (#442).
503    ///
504    /// The property is deliberately enumerated rather than derived from
505    /// the id string: `nexits` also serialises a `sum` field but reads
506    /// that same aggregate via its CLI accessor, so it does not diverge.
507    #[test]
508    fn skip_at_unit_is_the_sum_vs_per_space_divergence_set() {
509        let mut skip: Vec<&str> = METRICS
510            .iter()
511            .filter(|m| m.skip_at_unit)
512            .map(|m| m.id)
513            .collect();
514        skip.sort_unstable();
515        assert_eq!(
516            skip,
517            ["abc", "cognitive", "cyclomatic", "cyclomatic.modified"],
518            "skip_at_unit set drifted from the JSON-aggregate-vs-CLI-accessor \
519             property; review against the CLI EXTRACTORS accessors before editing",
520        );
521    }
522
523    /// The per-metric [`MetricScope`] partition (#969): `loc.*` gates the
524    /// file root, the OO size metrics gate containers, everything else
525    /// gates leaf functions. Enumerated so a new metric must be placed
526    /// deliberately rather than defaulting silently — both the CLI gate
527    /// and the Python binding derive their kind-filtering from this.
528    #[test]
529    fn scope_partitions_metrics_by_measured_kind() {
530        let by_scope = |want: MetricScope| {
531            let mut ids: Vec<&str> = METRICS
532                .iter()
533                .filter(|m| m.scope == want)
534                .map(|m| m.id)
535                .collect();
536            ids.sort_unstable();
537            ids
538        };
539        assert_eq!(
540            by_scope(MetricScope::File),
541            ["loc.blank", "loc.cloc", "loc.lloc", "loc.ploc", "loc.sloc"],
542            "only the loc.* size family is File-scoped",
543        );
544        assert_eq!(
545            by_scope(MetricScope::Container),
546            ["nom", "npa", "npm", "wmc"],
547            "only the OO size metrics are Container-scoped",
548        );
549        // Everything else is per-function; spot-check the representatives
550        // and confirm the partition is total (no metric left unscoped).
551        for id in [
552            "cognitive",
553            "cyclomatic",
554            "halstead.effort",
555            "nargs",
556            "nexits",
557            "abc",
558            "mi.original",
559        ] {
560            assert_eq!(
561                scope(id),
562                Some(MetricScope::Function),
563                "{id} should be Function-scoped"
564            );
565        }
566        let counted = by_scope(MetricScope::File).len()
567            + by_scope(MetricScope::Function).len()
568            + by_scope(MetricScope::Container).len();
569        assert_eq!(
570            counted,
571            METRICS.len(),
572            "every metric must have exactly one scope"
573        );
574    }
575
576    /// [`MetricScope::admits`] gates exactly the intended kinds: File only
577    /// the `Unit` root, Function only `Function`, Container the
578    /// method-owning kinds — and nothing admits `Unknown`.
579    #[test]
580    fn scope_admits_only_its_kinds() {
581        assert!(MetricScope::File.admits(SpaceKind::Unit));
582        assert!(!MetricScope::File.admits(SpaceKind::Function));
583        assert!(!MetricScope::File.admits(SpaceKind::Class));
584
585        assert!(MetricScope::Function.admits(SpaceKind::Function));
586        assert!(!MetricScope::Function.admits(SpaceKind::Unit));
587        assert!(!MetricScope::Function.admits(SpaceKind::Impl));
588
589        for kind in [
590            SpaceKind::Class,
591            SpaceKind::Struct,
592            SpaceKind::Trait,
593            SpaceKind::Impl,
594            SpaceKind::Namespace,
595            SpaceKind::Interface,
596        ] {
597            assert!(
598                MetricScope::Container.admits(kind),
599                "{kind:?} is a container"
600            );
601        }
602        assert!(!MetricScope::Container.admits(SpaceKind::Unit));
603        assert!(!MetricScope::Container.admits(SpaceKind::Function));
604
605        for scope in [
606            MetricScope::File,
607            MetricScope::Function,
608            MetricScope::Container,
609        ] {
610            assert!(
611                !scope.admits(SpaceKind::Unknown),
612                "{scope:?} must not admit Unknown"
613            );
614        }
615    }
616}