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