use fallow_types::extract::FileBranching;
use rustc_hash::FxHashMap;
use serde::Serialize;
pub type BranchingSnapshot = FxHashMap<String, FileBranching>;
pub const DEFAULT_BRANCHING_TOLERANCE: u32 = 2;
const MAX_BY_FILE: usize = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct BranchingMetric {
pub previous: u32,
pub current: u32,
pub delta: i64,
}
impl BranchingMetric {
fn new(previous: u32, current: u32) -> Self {
Self {
previous,
current,
delta: i64::from(current) - i64::from(previous),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum CognitiveAttribution {
NestingReset,
FewerBranchPoints,
Mixed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct BranchingCognitive {
pub previous: u32,
pub current: u32,
pub delta: i64,
pub nesting_weight_delta: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attributed_to: Option<CognitiveAttribution>,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct BranchingScope {
pub files_both: u32,
pub files_added: u32,
pub files_only_in_base: u32,
pub test_branch_points: u32,
pub test_functions: u32,
pub largest_file_share_of_branch_points: f64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SplitInPlace {
pub path: String,
pub branch_points_before: u32,
pub branch_points_after: u32,
pub functions_before: u32,
pub functions_after: u32,
pub peak_before: u16,
pub peak_after: u16,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct BranchingFileDelta {
pub path: String,
pub branch_points_delta: i64,
pub functions_delta: i64,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct BranchingReport {
pub split_in_place: Vec<SplitInPlace>,
pub tolerance: u32,
pub scope: BranchingScope,
pub branch_points: BranchingMetric,
pub functions: BranchingMetric,
pub peak_unit_cyclomatic: BranchingMetric,
pub branch_points_only_in_base: u32,
pub cognitive: BranchingCognitive,
pub by_file: Vec<BranchingFileDelta>,
pub by_file_omitted: u32,
}
#[derive(Default, Clone, Copy)]
struct Totals {
branch_points: u32,
functions: u32,
peak: u16,
cognitive: u32,
nesting: u32,
}
impl Totals {
fn add(&mut self, file: FileBranching) {
self.branch_points += file.branch_points;
self.functions += file.functions;
self.peak = self.peak.max(file.peak_cyclomatic);
self.cognitive += file.cognitive;
self.nesting += file.cognitive_nesting_weight;
}
}
struct Partition {
surviving_base: Totals,
surviving_head: Totals,
deleted: Totals,
scope: BranchingScope,
deltas: Vec<BranchingFileDelta>,
split_in_place: Vec<SplitInPlace>,
}
fn partition(
base: &BranchingSnapshot,
head: &BranchingSnapshot,
tolerance: u32,
is_test_path: &dyn Fn(&str) -> bool,
) -> Partition {
let mut out = Partition {
surviving_base: Totals::default(),
surviving_head: Totals::default(),
deleted: Totals::default(),
scope: BranchingScope {
files_both: 0,
files_added: 0,
files_only_in_base: 0,
test_branch_points: 0,
test_functions: 0,
largest_file_share_of_branch_points: 0.0,
},
deltas: Vec::new(),
split_in_place: Vec::new(),
};
let mut test_totals = Totals::default();
for (path, head_file) in head {
out.surviving_head.add(*head_file);
if is_test_path(path) {
test_totals.add(*head_file);
}
let Some(base_file) = base.get(path) else {
out.scope.files_added += 1;
out.deltas.push(BranchingFileDelta {
path: path.clone(),
branch_points_delta: i64::from(head_file.branch_points),
functions_delta: i64::from(head_file.functions),
});
continue;
};
out.scope.files_both += 1;
out.surviving_base.add(*base_file);
let branch_delta = i64::from(head_file.branch_points) - i64::from(base_file.branch_points);
let function_delta = i64::from(head_file.functions) - i64::from(base_file.functions);
let authored_function_delta = function_delta - i64::from(head_file.has_module_unit)
+ i64::from(base_file.has_module_unit);
if branch_delta.unsigned_abs() <= u64::from(tolerance)
&& authored_function_delta > 0
&& head_file.peak_cyclomatic < base_file.peak_cyclomatic
&& !is_test_path(path)
&& !is_excluded_from_the_claim(path)
&& !head_file.has_synthetic_units
&& !base_file.has_synthetic_units
{
out.split_in_place.push(SplitInPlace {
path: path.clone(),
branch_points_before: base_file.branch_points,
branch_points_after: head_file.branch_points,
functions_before: base_file.functions,
functions_after: head_file.functions,
peak_before: base_file.peak_cyclomatic,
peak_after: head_file.peak_cyclomatic,
});
}
if branch_delta != 0 || function_delta != 0 {
out.deltas.push(BranchingFileDelta {
path: path.clone(),
branch_points_delta: branch_delta,
functions_delta: function_delta,
});
}
}
for (path, base_file) in base {
if !head.contains_key(path) {
out.scope.files_only_in_base += 1;
out.deleted.add(*base_file);
}
}
out.scope.test_branch_points = test_totals.branch_points;
out.scope.test_functions = test_totals.functions;
out
}
fn is_excluded_from_the_claim(path: &str) -> bool {
[
"/generated/",
"/vendor/",
"/dist/",
"/node_modules/",
"/__generated__/",
]
.iter()
.any(|marker| path.contains(marker))
|| path.starts_with("generated/")
|| path.starts_with("vendor/")
|| path.starts_with("dist/")
}
fn scope_largest(head: &BranchingSnapshot) -> u32 {
head.values()
.map(|file| file.branch_points)
.max()
.unwrap_or(0)
}
impl BranchingReport {
#[must_use]
pub fn compare(
base: &BranchingSnapshot,
head: &BranchingSnapshot,
tolerance: u32,
is_test_path: &dyn Fn(&str) -> bool,
) -> Self {
let Partition {
surviving_base,
surviving_head,
deleted,
mut scope,
mut deltas,
mut split_in_place,
} = partition(base, head, tolerance, is_test_path);
scope.largest_file_share_of_branch_points = if surviving_head.branch_points == 0 {
0.0
} else {
f64::from(scope_largest(head)) / f64::from(surviving_head.branch_points)
};
let branch_points =
BranchingMetric::new(surviving_base.branch_points, surviving_head.branch_points);
let functions = BranchingMetric::new(surviving_base.functions, surviving_head.functions);
let peak_unit_cyclomatic = BranchingMetric::new(
u32::from(surviving_base.peak),
u32::from(surviving_head.peak),
);
let cognitive_delta =
i64::from(surviving_head.cognitive) - i64::from(surviving_base.cognitive);
let nesting_weight_delta =
i64::from(surviving_head.nesting) - i64::from(surviving_base.nesting);
split_in_place.sort_by(|a, b| {
(b.functions_after - b.functions_before)
.cmp(&(a.functions_after - a.functions_before))
.then_with(|| a.path.cmp(&b.path))
});
deltas.sort_by(|a, b| {
b.branch_points_delta
.abs()
.cmp(&a.branch_points_delta.abs())
.then_with(|| b.functions_delta.abs().cmp(&a.functions_delta.abs()))
.then_with(|| a.path.cmp(&b.path))
});
let by_file_omitted = u32::try_from(deltas.len().saturating_sub(MAX_BY_FILE)).unwrap_or(0);
deltas.truncate(MAX_BY_FILE);
Self {
split_in_place,
tolerance,
scope,
branch_points,
functions,
peak_unit_cyclomatic,
branch_points_only_in_base: deleted.branch_points,
cognitive: BranchingCognitive {
previous: surviving_base.cognitive,
current: surviving_head.cognitive,
delta: cognitive_delta,
nesting_weight_delta,
attributed_to: attribute_cognitive(
cognitive_delta,
branch_points.delta,
nesting_weight_delta,
tolerance,
),
},
by_file: deltas,
by_file_omitted,
}
}
#[must_use]
pub fn is_reportable(&self) -> bool {
!self.split_in_place.is_empty()
}
}
fn attribute_cognitive(
cognitive_delta: i64,
branch_delta: i64,
nesting_weight_delta: i64,
tolerance: u32,
) -> Option<CognitiveAttribution> {
if cognitive_delta >= 0 {
return None;
}
let branches_removed = branch_delta < -i64::from(tolerance);
let nesting_reset = nesting_weight_delta < 0;
Some(match (branches_removed, nesting_reset) {
(true, false) => CognitiveAttribution::FewerBranchPoints,
(false, true) => CognitiveAttribution::NestingReset,
(true, true) | (false, false) => CognitiveAttribution::Mixed,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn file(branch_points: u32, functions: u32, peak: u16) -> FileBranching {
FileBranching {
branch_points,
functions,
peak_cyclomatic: peak,
cognitive: branch_points,
cognitive_nesting_weight: 0,
has_module_unit: false,
has_synthetic_units: false,
}
}
fn snapshot(entries: &[(&str, FileBranching)]) -> BranchingSnapshot {
entries
.iter()
.map(|(path, totals)| ((*path).to_string(), *totals))
.collect()
}
fn compare(base: &BranchingSnapshot, head: &BranchingSnapshot) -> BranchingReport {
BranchingReport::compare(base, head, DEFAULT_BRANCHING_TOLERANCE, &|path| {
path.contains(".test.")
})
}
#[test]
fn a_file_that_splits_in_place_is_named() {
let base = snapshot(&[("src/a.ts", file(39, 1, 40))]);
let head = snapshot(&[("src/a.ts", file(39, 8, 6))]);
let report = compare(&base, &head);
assert_eq!(report.split_in_place.len(), 1);
let split = &report.split_in_place[0];
assert_eq!(split.path, "src/a.ts");
assert_eq!(
(split.branch_points_before, split.branch_points_after),
(39, 39)
);
assert_eq!((split.functions_before, split.functions_after), (1, 8));
assert_eq!((split.peak_before, split.peak_after), (40, 6));
assert!(report.is_reportable());
}
#[test]
fn the_claim_is_local_so_unrelated_work_cannot_change_it() {
let base = snapshot(&[
("src/a.ts", file(39, 1, 40)),
("src/b.ts", file(12, 3, 5)),
("src/d.ts", file(0, 1, 1)),
("src/gone.ts", file(300, 9, 40)),
]);
let head = snapshot(&[
("src/a.ts", file(39, 8, 6)),
("src/b.ts", file(4, 3, 5)),
("src/d.ts", file(208, 1, 90)),
("src/added.ts", file(0, 1, 1)),
]);
let report = compare(&base, &head);
assert_eq!(
report.split_in_place.len(),
1,
"only src/a.ts split; nothing else in the changeset makes that more or less true"
);
assert_eq!(report.split_in_place[0].path, "src/a.ts");
}
#[test]
fn a_split_that_lands_in_a_test_file_is_not_an_in_place_split() {
let base = snapshot(&[("src/pricing.ts", file(48, 4, 20))]);
let head = snapshot(&[
("src/pricing.ts", file(0, 1, 1)),
("src/pricing.test.ts", file(48, 16, 4)),
]);
let report = compare(&base, &head);
assert!(report.split_in_place.is_empty());
assert!(!report.is_reportable());
assert_eq!(
report.scope.test_branch_points, 48,
"still reported as scope"
);
}
#[test]
fn a_file_whose_branching_fell_reports_both_numbers() {
let base = snapshot(&[("src/a.ts", file(2, 1, 3))]);
let head = snapshot(&[("src/a.ts", file(0, 2, 1))]);
let report = compare(&base, &head);
let split = &report.split_in_place[0];
assert_eq!(
(split.branch_points_before, split.branch_points_after),
(2, 0)
);
}
#[test]
fn a_test_file_never_carries_the_claim() {
let base = snapshot(&[("src/a.test.ts", file(30, 1, 31))]);
let head = snapshot(&[("src/a.test.ts", file(30, 8, 6))]);
let report = compare(&base, &head);
assert!(report.split_in_place.is_empty());
assert_eq!(report.scope.test_branch_points, 30);
}
#[test]
fn hoisting_branching_to_module_scope_is_not_an_in_place_split() {
let base = snapshot(&[("src/a.ts", file(5, 1, 6))]);
let head = snapshot(&[(
"src/a.ts",
FileBranching {
branch_points: 5,
functions: 2,
peak_cyclomatic: 3,
cognitive: 5,
cognitive_nesting_weight: 0,
has_module_unit: true,
has_synthetic_units: false,
},
)]);
let report = compare(&base, &head);
assert_eq!(
report.branch_points.delta, 0,
"the branching survives the hoist, which is why it is counted"
);
assert!(
report.split_in_place.is_empty(),
"no authored function was added, so this is not a split"
);
}
#[test]
fn a_split_in_a_file_that_also_has_module_branching_still_counts() {
let base = snapshot(&[(
"src/a.ts",
FileBranching {
branch_points: 40,
functions: 2,
peak_cyclomatic: 39,
cognitive: 40,
cognitive_nesting_weight: 0,
has_module_unit: true,
has_synthetic_units: false,
},
)]);
let head = snapshot(&[(
"src/a.ts",
FileBranching {
branch_points: 40,
functions: 9,
peak_cyclomatic: 6,
cognitive: 40,
cognitive_nesting_weight: 0,
has_module_unit: true,
has_synthetic_units: false,
},
)]);
let report = compare(&base, &head);
assert_eq!(report.split_in_place.len(), 1);
}
#[test]
fn a_file_with_synthetic_template_units_never_carries_the_claim() {
let with_template = |branch_points: u32, functions: u32, peak: u16| FileBranching {
branch_points,
functions,
peak_cyclomatic: peak,
cognitive: branch_points,
cognitive_nesting_weight: 0,
has_module_unit: false,
has_synthetic_units: true,
};
let base = snapshot(&[("src/App.vue", with_template(6, 1, 7))]);
let head = snapshot(&[("src/App.vue", with_template(6, 4, 3))]);
let report = compare(&base, &head);
assert!(report.split_in_place.is_empty());
}
#[test]
fn generated_and_vendored_paths_never_carry_the_claim() {
for path in [
"src/__generated__/schema.ts",
"vendor/bundle.js",
"dist/main.js",
"packages/app/generated/api.ts",
] {
let base = snapshot(&[(path, file(30, 1, 31))]);
let head = snapshot(&[(path, file(30, 8, 6))]);
assert!(
compare(&base, &head).split_in_place.is_empty(),
"{path} should not carry the claim"
);
}
}
#[test]
fn a_split_with_a_little_glue_branching_still_counts() {
let base = snapshot(&[("src/a.ts", file(30, 1, 31))]);
let head = snapshot(&[("src/a.ts", file(32, 6, 8))]);
let report = compare(&base, &head);
assert_eq!(report.split_in_place.len(), 1);
}
#[test]
fn branching_arriving_in_a_file_is_not_a_split() {
let base = snapshot(&[("src/a.ts", file(10, 1, 11))]);
let head = snapshot(&[("src/a.ts", file(40, 5, 12))]);
let report = compare(&base, &head);
assert!(report.split_in_place.is_empty());
}
#[test]
fn a_file_whose_peak_held_is_not_a_split() {
let base = snapshot(&[("src/a.ts", file(30, 2, 20))]);
let head = snapshot(&[("src/a.ts", file(30, 6, 20))]);
let report = compare(&base, &head);
assert!(
report.split_in_place.is_empty(),
"functions rose and branching held, but the worst function is untouched"
);
}
#[test]
fn splits_are_ordered_by_how_far_the_file_was_partitioned() {
let base = snapshot(&[("src/a.ts", file(9, 1, 10)), ("src/b.ts", file(20, 1, 21))]);
let head = snapshot(&[("src/a.ts", file(9, 3, 4)), ("src/b.ts", file(20, 9, 5))]);
let report = compare(&base, &head);
assert_eq!(
report
.split_in_place
.iter()
.map(|s| s.path.as_str())
.collect::<Vec<_>>(),
vec!["src/b.ts", "src/a.ts"]
);
}
#[test]
fn the_set_totals_still_describe_the_changeset() {
let base = snapshot(&[
("src/a.ts", file(30, 5, 8)),
("src/gone.ts", file(300, 9, 40)),
]);
let head = snapshot(&[("src/a.ts", file(30, 5, 8)), ("src/new.ts", file(9, 6, 4))]);
let report = compare(&base, &head);
assert_eq!(report.branch_points.previous, 30);
assert_eq!(report.branch_points.current, 39);
assert_eq!(report.functions.delta, 6);
assert_eq!(report.scope.files_added, 1);
assert_eq!(report.scope.files_only_in_base, 1);
assert_eq!(report.branch_points_only_in_base, 300);
assert!(
!report.is_reportable(),
"totals alone are context, not news"
);
}
#[test]
fn an_empty_accounting_set_reports_nothing() {
let report = compare(&snapshot(&[]), &snapshot(&[]));
assert!(report.split_in_place.is_empty());
assert_eq!(report.branch_points.current, 0);
assert!(!report.is_reportable());
}
#[test]
fn a_cognitive_win_with_branching_held_is_a_nesting_reset() {
let base = snapshot(&[(
"src/a.ts",
FileBranching {
branch_points: 10,
functions: 1,
peak_cyclomatic: 11,
cognitive: 40,
cognitive_nesting_weight: 30,
has_module_unit: false,
has_synthetic_units: false,
},
)]);
let head = snapshot(&[(
"src/a.ts",
FileBranching {
branch_points: 10,
functions: 6,
peak_cyclomatic: 4,
cognitive: 12,
cognitive_nesting_weight: 2,
has_module_unit: false,
has_synthetic_units: false,
},
)]);
let report = compare(&base, &head);
assert_eq!(report.cognitive.delta, -28);
assert_eq!(
report.cognitive.attributed_to,
Some(CognitiveAttribution::NestingReset)
);
}
#[test]
fn a_cognitive_rise_is_attributed_to_nothing() {
let base = snapshot(&[("src/a.ts", file(7, 1, 8))]);
let head = snapshot(&[("src/a.ts", file(11, 5, 5))]);
let report = compare(&base, &head);
assert!(report.cognitive.delta > 0);
assert_eq!(report.cognitive.attributed_to, None);
}
#[test]
fn test_paths_are_reported_separately() {
let base = snapshot(&[("src/a.ts", file(10, 2, 6))]);
let head = snapshot(&[
("src/a.ts", file(10, 2, 6)),
("src/a.test.ts", file(40, 30, 3)),
]);
let report = compare(&base, &head);
assert_eq!(report.scope.test_branch_points, 40);
assert_eq!(report.scope.test_functions, 30);
}
#[test]
fn one_dominant_file_is_visible_in_the_share() {
let base = snapshot(&[("src/a.ts", file(1, 1, 2))]);
let head = snapshot(&[
("src/a.ts", file(1, 1, 2)),
("src/vendor/bundle.js", file(99, 5, 40)),
]);
let report = compare(&base, &head);
assert!(
(report.scope.largest_file_share_of_branch_points - 0.99).abs() < 1e-9,
"{}",
report.scope.largest_file_share_of_branch_points
);
}
#[test]
fn the_file_list_is_capped_and_the_remainder_counted() {
let base = snapshot(&[]);
let head = snapshot(&[
("src/a.ts", file(9, 1, 3)),
("src/b.ts", file(8, 1, 3)),
("src/c.ts", file(7, 1, 3)),
("src/d.ts", file(6, 1, 3)),
("src/e.ts", file(5, 1, 3)),
("src/f.ts", file(4, 1, 3)),
("src/g.ts", file(3, 1, 3)),
]);
let report = compare(&base, &head);
assert_eq!(report.by_file.len(), 5);
assert_eq!(report.by_file_omitted, 2);
assert_eq!(report.by_file[0].path, "src/a.ts");
}
}