Skip to main content

fallow_output/
audit_branching.rs

1//! Branching conservation for the review brief.
2//!
3//! A per-function complexity ceiling constrains a partition, not a quantity.
4//! Splitting a function moves its branches into new functions instead of
5//! removing them, so every per-unit metric improves while the total branching
6//! is unchanged. This section reports the total against the number of functions
7//! now holding it, so a reader can tell the two apart.
8//!
9//! Reviewer-private; never gates.
10
11use fallow_types::extract::FileBranching;
12use rustc_hash::FxHashMap;
13use serde::Serialize;
14
15/// Branching totals per root-relative path, for one revision.
16pub type BranchingSnapshot = FxHashMap<String, FileBranching>;
17
18/// How many branch points a total may move before the change is treated as
19/// flat. Absolute rather than proportional: a small changeset is where a
20/// two-branch move matters most, and a proportional band would blind exactly
21/// that case.
22pub const DEFAULT_BRANCHING_TOLERANCE: u32 = 2;
23
24/// How many files the payload names before it starts counting instead.
25const MAX_BY_FILE: usize = 5;
26
27/// One metric across the two revisions.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30pub struct BranchingMetric {
31    /// Value on the base revision.
32    pub previous: u32,
33    /// Value on the head revision.
34    pub current: u32,
35    /// `current - previous`. Signed, so a consumer never has to infer direction
36    /// from a separate field.
37    pub delta: i64,
38}
39
40impl BranchingMetric {
41    fn new(previous: u32, current: u32) -> Self {
42        Self {
43            previous,
44            current,
45            delta: i64::from(current) - i64::from(previous),
46        }
47    }
48}
49
50/// Where a cognitive-complexity improvement came from.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
52#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
53#[serde(rename_all = "kebab-case")]
54pub enum CognitiveAttribution {
55    /// Nesting depth was reset, which extraction does for free. No branching
56    /// left.
57    NestingReset,
58    /// The branch-point count fell. Still a statement about the count and not
59    /// about the program: decision points are counted per file, including at
60    /// module scope, but a branch can leave one file for another that the
61    /// changeset did not touch, so there is no "branching removed" verdict.
62    FewerBranchPoints,
63    /// Both moved, or neither did. The second case is cognitive falling while
64    /// branching and nesting both held, where naming either cause would assert
65    /// something the numbers do not show.
66    Mixed,
67}
68
69/// The cognitive figure and what drove it.
70///
71/// `previous` and `current` exclude prop-count and hook-density increments.
72/// Both are cognitive-only, and prop count records an excess over a floor, so
73/// it is superlinear in a split and would move this number with branching and
74/// nesting both flat. This therefore does not match the cognitive score the
75/// complexity findings report.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
77#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
78pub struct BranchingCognitive {
79    /// Cognitive weight on the base revision.
80    pub previous: u32,
81    /// Cognitive weight on the head revision.
82    pub current: u32,
83    /// `current - previous`.
84    pub delta: i64,
85    /// Change in the summed nesting depth behind those increments.
86    pub nesting_weight_delta: i64,
87    /// What the improvement is attributable to, absent when cognitive did not
88    /// fall. There is nothing to attribute when the number rose or held.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub attributed_to: Option<CognitiveAttribution>,
91}
92
93/// Size and composition of the compared set.
94#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
95#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
96pub struct BranchingScope {
97    /// Files carrying units on both revisions.
98    pub files_both: u32,
99    /// Files carrying units on the head revision only.
100    pub files_added: u32,
101    /// Files that carried units on the base revision only, whether they were
102    /// deleted or merely lost every accounted unit. Reported, and excluded from
103    /// every headline number: such a file contributes its whole base-side total
104    /// as a fall with no head counterpart.
105    pub files_only_in_base: u32,
106    /// Branch points on test-shaped paths within the head totals. Test code
107    /// routinely dominates both terms, so a reader needs to see its share
108    /// before reading the headline.
109    pub test_branch_points: u32,
110    /// Functions on test-shaped paths within the head totals.
111    pub test_functions: u32,
112    /// Share of head branch points owned by the single largest file, so a
113    /// reader can see when one vendored or generated file owns the number.
114    pub largest_file_share_of_branch_points: f64,
115}
116
117/// One file present on both revisions whose branching held while it gained
118/// functions and its largest function shrank.
119///
120/// Local by construction: nothing here depends on any other file, so unrelated
121/// work in the changeset cannot make it more or less true. A set-level
122/// classifier cannot make this claim, because a changeset contains arbitrary
123/// other work and an aggregate cannot attribute.
124///
125/// It is a description, not an inference. The three conditions are the
126/// signature a split leaves, and they are also satisfiable without one: the
127/// peak is a file-level maximum (`FileBranching::peak_cyclomatic`), so it can
128/// fall because the largest function left the file while arriving helpers
129/// happen to carry the branching it took with it. Both numbers are reported so
130/// a reader can see that for themselves, and the rendered text states what was
131/// measured rather than concluding a refactor happened.
132///
133/// Files carrying synthetic template units are excluded, because those units
134/// are outside every count here, so the numbers would not describe the file a
135/// reader opens. Test paths are excluded too: their totals are reported in
136/// `BranchingScope` instead.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
138#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
139pub struct SplitInPlace {
140    /// Root-relative path.
141    pub path: String,
142    /// Branch points on the base revision.
143    pub branch_points_before: u32,
144    /// And on head. Within `tolerance` of `branch_points_before`, which is what
145    /// "held" means here. Both are reported because one number alone cannot be
146    /// checked.
147    pub branch_points_after: u32,
148    /// Accounted functions before the split.
149    pub functions_before: u32,
150    /// Accounted functions after it.
151    pub functions_after: u32,
152    /// Highest single-function cyclomatic score before.
153    pub peak_before: u16,
154    /// And after. It falls by construction when a function is split, which is
155    /// why it is evidence here and never a metric to celebrate.
156    pub peak_after: u16,
157}
158
159/// One file's contribution to the change.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
161#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
162pub struct BranchingFileDelta {
163    /// Root-relative path.
164    pub path: String,
165    /// Change in branch points for this file.
166    pub branch_points_delta: i64,
167    /// Change in accounted functions for this file.
168    pub functions_delta: i64,
169}
170
171/// The brief's branching section.
172#[derive(Debug, Clone, PartialEq, Serialize)]
173#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
174pub struct BranchingReport {
175    /// Files carrying the split signature: branching within `tolerance` of
176    /// where it was, more functions, a smaller largest function. Empty when
177    /// none do, which is the common case and is not itself a finding. See
178    /// `SplitInPlace` for why this describes a shape rather than asserting a
179    /// refactor.
180    pub split_in_place: Vec<SplitInPlace>,
181    /// The band inside which a file's branching counts as held. Published
182    /// because a claim against an unpublished threshold is not reproducible by
183    /// a consumer.
184    pub tolerance: u32,
185    /// Size and composition of the compared set.
186    pub scope: BranchingScope,
187    /// Branch points over the accounting set. The two sides cover different
188    /// populations by construction: `current` includes files the changeset
189    /// added, `previous` cannot. Files present only on the base revision are in
190    /// neither, and are reported in `branch_points_only_in_base`.
191    pub branch_points: BranchingMetric,
192    /// Functions over the same two populations, with the same asymmetry.
193    pub functions: BranchingMetric,
194    /// Highest single-function score across the set. Reported as context, never
195    /// as evidence on its own: a split lowers it by construction, which is the
196    /// whole reason this section exists.
197    pub peak_unit_cyclomatic: BranchingMetric,
198    /// Base-side branch points of the files that have no head entry.
199    pub branch_points_only_in_base: u32,
200    /// The cognitive figure and what drove it.
201    pub cognitive: BranchingCognitive,
202    /// The files that moved the numbers most, largest absolute branch-point
203    /// change first.
204    pub by_file: Vec<BranchingFileDelta>,
205    /// Files with a change that the list did not name.
206    pub by_file_omitted: u32,
207}
208
209/// Totals over one partition of one revision.
210#[derive(Default, Clone, Copy)]
211struct Totals {
212    branch_points: u32,
213    functions: u32,
214    peak: u16,
215    cognitive: u32,
216    nesting: u32,
217}
218
219impl Totals {
220    fn add(&mut self, file: FileBranching) {
221        self.branch_points += file.branch_points;
222        self.functions += file.functions;
223        self.peak = self.peak.max(file.peak_cyclomatic);
224        self.cognitive += file.cognitive;
225        self.nesting += file.cognitive_nesting_weight;
226    }
227}
228
229/// The accounting set split by file disposition, with the per-file deltas.
230///
231/// Disposition comes from which side carries units for a path, not from git:
232/// both revisions are analyzed in full, so presence in each map already answers
233/// it, and no second status query can disagree with the totals being compared.
234struct Partition {
235    surviving_base: Totals,
236    surviving_head: Totals,
237    deleted: Totals,
238    scope: BranchingScope,
239    deltas: Vec<BranchingFileDelta>,
240    split_in_place: Vec<SplitInPlace>,
241}
242
243fn partition(
244    base: &BranchingSnapshot,
245    head: &BranchingSnapshot,
246    tolerance: u32,
247    is_test_path: &dyn Fn(&str) -> bool,
248) -> Partition {
249    let mut out = Partition {
250        surviving_base: Totals::default(),
251        surviving_head: Totals::default(),
252        deleted: Totals::default(),
253        scope: BranchingScope {
254            files_both: 0,
255            files_added: 0,
256            files_only_in_base: 0,
257            test_branch_points: 0,
258            test_functions: 0,
259            largest_file_share_of_branch_points: 0.0,
260        },
261        deltas: Vec::new(),
262        split_in_place: Vec::new(),
263    };
264    let mut test_totals = Totals::default();
265
266    for (path, head_file) in head {
267        out.surviving_head.add(*head_file);
268        if is_test_path(path) {
269            test_totals.add(*head_file);
270        }
271        let Some(base_file) = base.get(path) else {
272            out.scope.files_added += 1;
273            out.deltas.push(BranchingFileDelta {
274                path: path.clone(),
275                branch_points_delta: i64::from(head_file.branch_points),
276                functions_delta: i64::from(head_file.functions),
277            });
278            continue;
279        };
280        out.scope.files_both += 1;
281        out.surviving_base.add(*base_file);
282        let branch_delta = i64::from(head_file.branch_points) - i64::from(base_file.branch_points);
283        let function_delta = i64::from(head_file.functions) - i64::from(base_file.functions);
284        // The module unit is counted in `functions` because it holds real
285        // branching, but nobody split a function into it. Judging a split on
286        // the raw count would read a branch hoisted out of a function to module
287        // scope as an in-place split: the branching holds, the count rises by
288        // one, and the worst function shrinks.
289        let authored_function_delta = function_delta - i64::from(head_file.has_module_unit)
290            + i64::from(base_file.has_module_unit);
291        // Branching held while the file grew functions and its worst one got
292        // smaller: the branches were repartitioned inside this file.
293        if branch_delta.unsigned_abs() <= u64::from(tolerance)
294            && authored_function_delta > 0
295            && head_file.peak_cyclomatic < base_file.peak_cyclomatic
296            && !is_test_path(path)
297            && !is_excluded_from_the_claim(path)
298            && !head_file.has_synthetic_units
299            && !base_file.has_synthetic_units
300        {
301            out.split_in_place.push(SplitInPlace {
302                path: path.clone(),
303                branch_points_before: base_file.branch_points,
304                branch_points_after: head_file.branch_points,
305                functions_before: base_file.functions,
306                functions_after: head_file.functions,
307                peak_before: base_file.peak_cyclomatic,
308                peak_after: head_file.peak_cyclomatic,
309            });
310        }
311        if branch_delta != 0 || function_delta != 0 {
312            out.deltas.push(BranchingFileDelta {
313                path: path.clone(),
314                branch_points_delta: branch_delta,
315                functions_delta: function_delta,
316            });
317        }
318    }
319
320    for (path, base_file) in base {
321        if !head.contains_key(path) {
322            out.scope.files_only_in_base += 1;
323            out.deleted.add(*base_file);
324        }
325    }
326
327    out.scope.test_branch_points = test_totals.branch_points;
328    out.scope.test_functions = test_totals.functions;
329    out
330}
331
332/// Paths whose numbers describe machinery rather than authored code.
333///
334/// Deliberately a short, unambiguous list rather than a general heuristic: a
335/// wrong exclusion here silently drops a real finding, and the rendered list
336/// only has room for two files, so a vendored bundle taking a slot costs a
337/// reader the production file they needed.
338fn is_excluded_from_the_claim(path: &str) -> bool {
339    [
340        "/generated/",
341        "/vendor/",
342        "/dist/",
343        "/node_modules/",
344        "/__generated__/",
345    ]
346    .iter()
347    .any(|marker| path.contains(marker))
348        || path.starts_with("generated/")
349        || path.starts_with("vendor/")
350        || path.starts_with("dist/")
351}
352
353fn scope_largest(head: &BranchingSnapshot) -> u32 {
354    head.values()
355        .map(|file| file.branch_points)
356        .max()
357        .unwrap_or(0)
358}
359
360impl BranchingReport {
361    /// Compare two revisions over the accounting set.
362    ///
363    /// `is_test_path` classifies a root-relative path; the caller owns that
364    /// heuristic. Both maps must already be restricted to the accounting set
365    /// and keyed in the same space, with base-side renames remapped onto head
366    /// paths.
367    #[must_use]
368    pub fn compare(
369        base: &BranchingSnapshot,
370        head: &BranchingSnapshot,
371        tolerance: u32,
372        is_test_path: &dyn Fn(&str) -> bool,
373    ) -> Self {
374        let Partition {
375            surviving_base,
376            surviving_head,
377            deleted,
378            mut scope,
379            mut deltas,
380            mut split_in_place,
381        } = partition(base, head, tolerance, is_test_path);
382
383        scope.largest_file_share_of_branch_points = if surviving_head.branch_points == 0 {
384            0.0
385        } else {
386            f64::from(scope_largest(head)) / f64::from(surviving_head.branch_points)
387        };
388
389        let branch_points =
390            BranchingMetric::new(surviving_base.branch_points, surviving_head.branch_points);
391        let functions = BranchingMetric::new(surviving_base.functions, surviving_head.functions);
392        let peak_unit_cyclomatic = BranchingMetric::new(
393            u32::from(surviving_base.peak),
394            u32::from(surviving_head.peak),
395        );
396        let cognitive_delta =
397            i64::from(surviving_head.cognitive) - i64::from(surviving_base.cognitive);
398        let nesting_weight_delta =
399            i64::from(surviving_head.nesting) - i64::from(surviving_base.nesting);
400
401        split_in_place.sort_by(|a, b| {
402            (b.functions_after - b.functions_before)
403                .cmp(&(a.functions_after - a.functions_before))
404                .then_with(|| a.path.cmp(&b.path))
405        });
406
407        deltas.sort_by(|a, b| {
408            b.branch_points_delta
409                .abs()
410                .cmp(&a.branch_points_delta.abs())
411                .then_with(|| b.functions_delta.abs().cmp(&a.functions_delta.abs()))
412                .then_with(|| a.path.cmp(&b.path))
413        });
414        let by_file_omitted = u32::try_from(deltas.len().saturating_sub(MAX_BY_FILE)).unwrap_or(0);
415        deltas.truncate(MAX_BY_FILE);
416
417        Self {
418            split_in_place,
419            tolerance,
420            scope,
421            branch_points,
422            functions,
423            peak_unit_cyclomatic,
424            branch_points_only_in_base: deleted.branch_points,
425            cognitive: BranchingCognitive {
426                previous: surviving_base.cognitive,
427                current: surviving_head.cognitive,
428                delta: cognitive_delta,
429                nesting_weight_delta,
430                attributed_to: attribute_cognitive(
431                    cognitive_delta,
432                    branch_points.delta,
433                    nesting_weight_delta,
434                    tolerance,
435                ),
436            },
437            by_file: deltas,
438            by_file_omitted,
439        }
440    }
441
442    /// Whether the section says anything a reader needs.
443    ///
444    /// Only a file that demonstrably split in place qualifies. The set totals
445    /// alone are context, not news, and the human brief stays silent on them,
446    /// matching every sibling section.
447    #[must_use]
448    pub fn is_reportable(&self) -> bool {
449        !self.split_in_place.is_empty()
450    }
451}
452
453/// Split a cognitive improvement between nesting and branching.
454///
455/// Extraction rebases nesting to zero on every new frame, so it lowers cognitive
456/// without removing a single branch. Flattening an else ladder into guard
457/// clauses also leaves branch points flat, because an `else if` carries the same
458/// increment a plain `if` does, so `nesting-reset` covers both. The function
459/// count is what separates them, and it is reported beside this.
460///
461/// Returns `None` unless cognitive actually fell. Attributing a rise to
462/// "branches removed" reads as the opposite of what happened, which is what a
463/// real split through a nullish-coalescing chain produces: branching up,
464/// cognitive up, and nothing to attribute.
465fn attribute_cognitive(
466    cognitive_delta: i64,
467    branch_delta: i64,
468    nesting_weight_delta: i64,
469    tolerance: u32,
470) -> Option<CognitiveAttribution> {
471    if cognitive_delta >= 0 {
472        return None;
473    }
474    let branches_removed = branch_delta < -i64::from(tolerance);
475    let nesting_reset = nesting_weight_delta < 0;
476    Some(match (branches_removed, nesting_reset) {
477        (true, false) => CognitiveAttribution::FewerBranchPoints,
478        (false, true) => CognitiveAttribution::NestingReset,
479        // Both moved, or neither did. The second case is cognitive falling with
480        // branching and nesting both held, where naming either cause would
481        // assert something the numbers do not show.
482        (true, true) | (false, false) => CognitiveAttribution::Mixed,
483    })
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    fn file(branch_points: u32, functions: u32, peak: u16) -> FileBranching {
491        FileBranching {
492            branch_points,
493            functions,
494            peak_cyclomatic: peak,
495            cognitive: branch_points,
496            cognitive_nesting_weight: 0,
497            has_module_unit: false,
498            has_synthetic_units: false,
499        }
500    }
501
502    fn snapshot(entries: &[(&str, FileBranching)]) -> BranchingSnapshot {
503        entries
504            .iter()
505            .map(|(path, totals)| ((*path).to_string(), *totals))
506            .collect()
507    }
508
509    fn compare(base: &BranchingSnapshot, head: &BranchingSnapshot) -> BranchingReport {
510        BranchingReport::compare(base, head, DEFAULT_BRANCHING_TOLERANCE, &|path| {
511            path.contains(".test.")
512        })
513    }
514
515    #[test]
516    fn a_file_that_splits_in_place_is_named() {
517        let base = snapshot(&[("src/a.ts", file(39, 1, 40))]);
518        let head = snapshot(&[("src/a.ts", file(39, 8, 6))]);
519
520        let report = compare(&base, &head);
521
522        assert_eq!(report.split_in_place.len(), 1);
523        let split = &report.split_in_place[0];
524        assert_eq!(split.path, "src/a.ts");
525        assert_eq!(
526            (split.branch_points_before, split.branch_points_after),
527            (39, 39)
528        );
529        assert_eq!((split.functions_before, split.functions_after), (1, 8));
530        assert_eq!((split.peak_before, split.peak_after), (40, 6));
531        assert!(report.is_reportable());
532    }
533
534    #[test]
535    fn the_claim_is_local_so_unrelated_work_cannot_change_it() {
536        // The routes that defeated a changeset-level classifier: an unrelated
537        // pair of moves that cancel in the sum, a large deleted file, an empty
538        // added file, and branching arriving elsewhere. None of them can touch
539        // a statement about one file.
540        let base = snapshot(&[
541            ("src/a.ts", file(39, 1, 40)),
542            ("src/b.ts", file(12, 3, 5)),
543            ("src/d.ts", file(0, 1, 1)),
544            ("src/gone.ts", file(300, 9, 40)),
545        ]);
546        let head = snapshot(&[
547            ("src/a.ts", file(39, 8, 6)),
548            ("src/b.ts", file(4, 3, 5)),
549            ("src/d.ts", file(208, 1, 90)),
550            ("src/added.ts", file(0, 1, 1)),
551        ]);
552
553        let report = compare(&base, &head);
554
555        assert_eq!(
556            report.split_in_place.len(),
557            1,
558            "only src/a.ts split; nothing else in the changeset makes that more or less true"
559        );
560        assert_eq!(report.split_in_place[0].path, "src/a.ts");
561    }
562
563    #[test]
564    fn a_split_that_lands_in_a_test_file_is_not_an_in_place_split() {
565        // The source file did not hold its branching, it lost it.
566        let base = snapshot(&[("src/pricing.ts", file(48, 4, 20))]);
567        let head = snapshot(&[
568            ("src/pricing.ts", file(0, 1, 1)),
569            ("src/pricing.test.ts", file(48, 16, 4)),
570        ]);
571
572        let report = compare(&base, &head);
573
574        assert!(report.split_in_place.is_empty());
575        assert!(!report.is_reportable());
576        assert_eq!(
577            report.scope.test_branch_points, 48,
578            "still reported as scope"
579        );
580    }
581
582    #[test]
583    fn a_file_whose_branching_fell_reports_both_numbers() {
584        // Rendering only the base value would read as "2 branch points held"
585        // for a file that now has none.
586        let base = snapshot(&[("src/a.ts", file(2, 1, 3))]);
587        let head = snapshot(&[("src/a.ts", file(0, 2, 1))]);
588
589        let report = compare(&base, &head);
590
591        let split = &report.split_in_place[0];
592        assert_eq!(
593            (split.branch_points_before, split.branch_points_after),
594            (2, 0)
595        );
596    }
597
598    #[test]
599    fn a_test_file_never_carries_the_claim() {
600        // Its totals are reported in scope instead, so a test file cannot take
601        // a slot from production code in the rendered list.
602        let base = snapshot(&[("src/a.test.ts", file(30, 1, 31))]);
603        let head = snapshot(&[("src/a.test.ts", file(30, 8, 6))]);
604
605        let report = compare(&base, &head);
606
607        assert!(report.split_in_place.is_empty());
608        assert_eq!(report.scope.test_branch_points, 30);
609    }
610
611    #[test]
612    fn hoisting_branching_to_module_scope_is_not_an_in_place_split() {
613        // The module unit holds real branching and is counted in `functions`,
614        // but nobody split a function into it. On the raw count this reads as a
615        // split: branching held, the count rose by one, the worst function
616        // shrank.
617        let base = snapshot(&[("src/a.ts", file(5, 1, 6))]);
618        let head = snapshot(&[(
619            "src/a.ts",
620            FileBranching {
621                branch_points: 5,
622                functions: 2,
623                peak_cyclomatic: 3,
624                cognitive: 5,
625                cognitive_nesting_weight: 0,
626                has_module_unit: true,
627                has_synthetic_units: false,
628            },
629        )]);
630
631        let report = compare(&base, &head);
632
633        assert_eq!(
634            report.branch_points.delta, 0,
635            "the branching survives the hoist, which is why it is counted"
636        );
637        assert!(
638            report.split_in_place.is_empty(),
639            "no authored function was added, so this is not a split"
640        );
641    }
642
643    #[test]
644    fn a_split_in_a_file_that_also_has_module_branching_still_counts() {
645        // The module unit must not mask a real split in the same file.
646        let base = snapshot(&[(
647            "src/a.ts",
648            FileBranching {
649                branch_points: 40,
650                functions: 2,
651                peak_cyclomatic: 39,
652                cognitive: 40,
653                cognitive_nesting_weight: 0,
654                has_module_unit: true,
655                has_synthetic_units: false,
656            },
657        )]);
658        let head = snapshot(&[(
659            "src/a.ts",
660            FileBranching {
661                branch_points: 40,
662                functions: 9,
663                peak_cyclomatic: 6,
664                cognitive: 40,
665                cognitive_nesting_weight: 0,
666                has_module_unit: true,
667                has_synthetic_units: false,
668            },
669        )]);
670
671        let report = compare(&base, &head);
672
673        assert_eq!(report.split_in_place.len(), 1);
674    }
675
676    #[test]
677    fn a_file_with_synthetic_template_units_never_carries_the_claim() {
678        // Template units sit outside every count here, so the numbers would not
679        // describe the file a reader opens.
680        let with_template = |branch_points: u32, functions: u32, peak: u16| FileBranching {
681            branch_points,
682            functions,
683            peak_cyclomatic: peak,
684            cognitive: branch_points,
685            cognitive_nesting_weight: 0,
686            has_module_unit: false,
687            has_synthetic_units: true,
688        };
689        let base = snapshot(&[("src/App.vue", with_template(6, 1, 7))]);
690        let head = snapshot(&[("src/App.vue", with_template(6, 4, 3))]);
691
692        let report = compare(&base, &head);
693
694        assert!(report.split_in_place.is_empty());
695    }
696
697    #[test]
698    fn generated_and_vendored_paths_never_carry_the_claim() {
699        // The rendered list holds two files, so machinery taking a slot costs a
700        // reader the production file they needed.
701        for path in [
702            "src/__generated__/schema.ts",
703            "vendor/bundle.js",
704            "dist/main.js",
705            "packages/app/generated/api.ts",
706        ] {
707            let base = snapshot(&[(path, file(30, 1, 31))]);
708            let head = snapshot(&[(path, file(30, 8, 6))]);
709            assert!(
710                compare(&base, &head).split_in_place.is_empty(),
711                "{path} should not carry the claim"
712            );
713        }
714    }
715
716    #[test]
717    fn a_split_with_a_little_glue_branching_still_counts() {
718        let base = snapshot(&[("src/a.ts", file(30, 1, 31))]);
719        let head = snapshot(&[("src/a.ts", file(32, 6, 8))]);
720
721        let report = compare(&base, &head);
722
723        assert_eq!(report.split_in_place.len(), 1);
724    }
725
726    #[test]
727    fn branching_arriving_in_a_file_is_not_a_split() {
728        let base = snapshot(&[("src/a.ts", file(10, 1, 11))]);
729        let head = snapshot(&[("src/a.ts", file(40, 5, 12))]);
730
731        let report = compare(&base, &head);
732
733        assert!(report.split_in_place.is_empty());
734    }
735
736    #[test]
737    fn a_file_whose_peak_held_is_not_a_split() {
738        let base = snapshot(&[("src/a.ts", file(30, 2, 20))]);
739        let head = snapshot(&[("src/a.ts", file(30, 6, 20))]);
740
741        let report = compare(&base, &head);
742
743        assert!(
744            report.split_in_place.is_empty(),
745            "functions rose and branching held, but the worst function is untouched"
746        );
747    }
748
749    #[test]
750    fn splits_are_ordered_by_how_far_the_file_was_partitioned() {
751        let base = snapshot(&[("src/a.ts", file(9, 1, 10)), ("src/b.ts", file(20, 1, 21))]);
752        let head = snapshot(&[("src/a.ts", file(9, 3, 4)), ("src/b.ts", file(20, 9, 5))]);
753
754        let report = compare(&base, &head);
755
756        assert_eq!(
757            report
758                .split_in_place
759                .iter()
760                .map(|s| s.path.as_str())
761                .collect::<Vec<_>>(),
762            vec!["src/b.ts", "src/a.ts"]
763        );
764    }
765
766    #[test]
767    fn the_set_totals_still_describe_the_changeset() {
768        let base = snapshot(&[
769            ("src/a.ts", file(30, 5, 8)),
770            ("src/gone.ts", file(300, 9, 40)),
771        ]);
772        let head = snapshot(&[("src/a.ts", file(30, 5, 8)), ("src/new.ts", file(9, 6, 4))]);
773
774        let report = compare(&base, &head);
775
776        assert_eq!(report.branch_points.previous, 30);
777        assert_eq!(report.branch_points.current, 39);
778        assert_eq!(report.functions.delta, 6);
779        assert_eq!(report.scope.files_added, 1);
780        assert_eq!(report.scope.files_only_in_base, 1);
781        assert_eq!(report.branch_points_only_in_base, 300);
782        assert!(
783            !report.is_reportable(),
784            "totals alone are context, not news"
785        );
786    }
787
788    #[test]
789    fn an_empty_accounting_set_reports_nothing() {
790        let report = compare(&snapshot(&[]), &snapshot(&[]));
791
792        assert!(report.split_in_place.is_empty());
793        assert_eq!(report.branch_points.current, 0);
794        assert!(!report.is_reportable());
795    }
796
797    #[test]
798    fn a_cognitive_win_with_branching_held_is_a_nesting_reset() {
799        let base = snapshot(&[(
800            "src/a.ts",
801            FileBranching {
802                branch_points: 10,
803                functions: 1,
804                peak_cyclomatic: 11,
805                cognitive: 40,
806                cognitive_nesting_weight: 30,
807                has_module_unit: false,
808                has_synthetic_units: false,
809            },
810        )]);
811        let head = snapshot(&[(
812            "src/a.ts",
813            FileBranching {
814                branch_points: 10,
815                functions: 6,
816                peak_cyclomatic: 4,
817                cognitive: 12,
818                cognitive_nesting_weight: 2,
819                has_module_unit: false,
820                has_synthetic_units: false,
821            },
822        )]);
823
824        let report = compare(&base, &head);
825
826        assert_eq!(report.cognitive.delta, -28);
827        assert_eq!(
828            report.cognitive.attributed_to,
829            Some(CognitiveAttribution::NestingReset)
830        );
831    }
832
833    #[test]
834    fn a_cognitive_rise_is_attributed_to_nothing() {
835        let base = snapshot(&[("src/a.ts", file(7, 1, 8))]);
836        let head = snapshot(&[("src/a.ts", file(11, 5, 5))]);
837
838        let report = compare(&base, &head);
839
840        assert!(report.cognitive.delta > 0);
841        assert_eq!(report.cognitive.attributed_to, None);
842    }
843
844    #[test]
845    fn test_paths_are_reported_separately() {
846        let base = snapshot(&[("src/a.ts", file(10, 2, 6))]);
847        let head = snapshot(&[
848            ("src/a.ts", file(10, 2, 6)),
849            ("src/a.test.ts", file(40, 30, 3)),
850        ]);
851
852        let report = compare(&base, &head);
853
854        assert_eq!(report.scope.test_branch_points, 40);
855        assert_eq!(report.scope.test_functions, 30);
856    }
857
858    #[test]
859    fn one_dominant_file_is_visible_in_the_share() {
860        let base = snapshot(&[("src/a.ts", file(1, 1, 2))]);
861        let head = snapshot(&[
862            ("src/a.ts", file(1, 1, 2)),
863            ("src/vendor/bundle.js", file(99, 5, 40)),
864        ]);
865
866        let report = compare(&base, &head);
867
868        assert!(
869            (report.scope.largest_file_share_of_branch_points - 0.99).abs() < 1e-9,
870            "{}",
871            report.scope.largest_file_share_of_branch_points
872        );
873    }
874
875    #[test]
876    fn the_file_list_is_capped_and_the_remainder_counted() {
877        let base = snapshot(&[]);
878        let head = snapshot(&[
879            ("src/a.ts", file(9, 1, 3)),
880            ("src/b.ts", file(8, 1, 3)),
881            ("src/c.ts", file(7, 1, 3)),
882            ("src/d.ts", file(6, 1, 3)),
883            ("src/e.ts", file(5, 1, 3)),
884            ("src/f.ts", file(4, 1, 3)),
885            ("src/g.ts", file(3, 1, 3)),
886        ]);
887
888        let report = compare(&base, &head);
889
890        assert_eq!(report.by_file.len(), 5);
891        assert_eq!(report.by_file_omitted, 2);
892        assert_eq!(report.by_file[0].path, "src/a.ts");
893    }
894}