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