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}