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}