Skip to main content

fallow_engine/
baseline_growth.rs

1//! The shrink-only rule for a committed baseline.
2//!
3//! A baseline records debt that a repository still must pay. A change that adds
4//! a finding can also re-save the baseline, and then the new finding is
5//! suppressed and no stale entry exists. This module compares the baseline file
6//! with the same file at a base ref and names each key that the base file does
7//! not have. The comparison reads two file versions only. It needs no analysis
8//! run, so it gives the same answer on a whole-project run and on a narrowed run.
9//!
10//! A key is compared as the file writes it. A renamed file gives a new key, so
11//! the rule counts it as growth. The rule is strict on purpose: a reviewer
12//! approves each new key, or the change removes it. A dead-code baseline with
13//! an `identity` stores line-free keys once for each occurrence, so a moved
14//! line is not growth and one more occurrence of a key is.
15
16use std::collections::{BTreeMap, BTreeSet};
17use std::path::Path;
18
19use serde_json::{Map, Value};
20
21use fallow_types::identity::{IdentityPaths, dead_code_canonical_key};
22
23use crate::baseline::BaselineKind;
24
25/// The keys that a baseline has and its version at the base ref does not have.
26#[derive(Debug, Clone, Default, PartialEq, Eq)]
27pub struct BaselineGrowth {
28    /// One item per category that grew, sorted by category name.
29    pub categories: Vec<GrownCategory>,
30}
31
32/// The new keys of one baseline category.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct GrownCategory {
35    /// The category as the baseline file names it, for example `unused_files`.
36    pub category: String,
37    /// The new keys, sorted.
38    pub keys: Vec<String>,
39}
40
41impl BaselineGrowth {
42    /// The number of new keys in all categories.
43    #[must_use]
44    pub fn added_entries(&self) -> usize {
45        self.categories.iter().map(|grown| grown.keys.len()).sum()
46    }
47
48    /// True when the baseline has no key that the base does not have.
49    #[must_use]
50    pub fn is_empty(&self) -> bool {
51        self.categories.is_empty()
52    }
53}
54
55/// Compare a baseline (`head`) with its version at the base ref (`base`).
56///
57/// Both values are the parsed JSON files. `kind` selects the categories that
58/// the format of the command writes:
59///
60/// - `dead-code`: every top-level array of keys. When both files carry the
61///   same `identity`, each extra occurrence of a key is growth. When the change
62///   rewrote a legacy baseline, each legacy key is translated to its canonical
63///   key and compared the same way. A category that holds a legacy key with no
64///   unambiguous translation (a key with a line, a bare package name, a key
65///   that lacks a part of the canonical key) is compared by its entry count.
66///   Two legacy files compare as key sets.
67/// - `dupes`: the content fingerprints of the clone groups. A base file saved
68///   before fingerprints existed is compared by its `clone_groups` keys.
69/// - `health`: the legacy `findings` keys, the finding counts per file and
70///   category (a higher count is growth), the runtime-coverage finding IDs and
71///   the refactoring target keys. When both files carry per-function counts,
72///   those counts replace the per-file counts.
73#[must_use]
74pub fn baseline_growth(kind: BaselineKind, base: &Value, head: &Value) -> BaselineGrowth {
75    let empty = Map::new();
76    let base = base.as_object().unwrap_or(&empty);
77    let head = head.as_object().unwrap_or(&empty);
78    let mut grown: BTreeMap<String, Vec<String>> = BTreeMap::new();
79    match kind {
80        BaselineKind::DeadCode => {
81            let base_scheme = base.get(DEAD_CODE_IDENTITY);
82            let head_scheme = head.get(DEAD_CODE_IDENTITY);
83            for (category, value) in head {
84                if !value.is_array() {
85                    continue;
86                }
87                if base_scheme.is_none() && head_scheme.is_some() {
88                    add_grown_after_upgrade(&mut grown, category, base, head);
89                } else if base_scheme != head_scheme {
90                    add_grown_entry_count(&mut grown, category, base, head);
91                } else if head_scheme.is_some() {
92                    add_new_occurrences(&mut grown, category, base, head);
93                } else {
94                    add_new_keys(&mut grown, category, base, head);
95                }
96            }
97        }
98        BaselineKind::Dupes => {
99            let category = if base.contains_key(DUPES_FINGERPRINTS) {
100                DUPES_FINGERPRINTS
101            } else {
102                DUPES_LEGACY_GROUPS
103            };
104            add_new_keys(&mut grown, category, base, head);
105        }
106        BaselineKind::Health => {
107            for category in HEALTH_KEY_CATEGORIES {
108                add_new_keys(&mut grown, category, base, head);
109            }
110            let counts = if has_entries(base, HEALTH_IDENTITY_COUNTS)
111                && has_entries(head, HEALTH_IDENTITY_COUNTS)
112            {
113                HEALTH_IDENTITY_COUNTS
114            } else {
115                HEALTH_FILE_COUNTS
116            };
117            add_grown_counts(&mut grown, counts, base, head);
118        }
119    }
120    BaselineGrowth {
121        categories: grown
122            .into_iter()
123            .filter(|(_, keys)| !keys.is_empty())
124            .map(|(category, keys)| GrownCategory { category, keys })
125            .collect(),
126    }
127}
128
129const DEAD_CODE_IDENTITY: &str = "identity";
130const DUPES_FINGERPRINTS: &str = "normalized_clone_fingerprints";
131const DUPES_LEGACY_GROUPS: &str = "clone_groups";
132const HEALTH_KEY_CATEGORIES: [&str; 3] = ["findings", "runtime_coverage_findings", "target_keys"];
133const HEALTH_FILE_COUNTS: &str = "finding_counts";
134const HEALTH_IDENTITY_COUNTS: &str = "identity_finding_counts";
135
136/// The string members of the array `category`, as a set.
137fn key_set<'a>(object: &'a Map<String, Value>, category: &str) -> BTreeSet<&'a str> {
138    object
139        .get(category)
140        .and_then(Value::as_array)
141        .map(|keys| keys.iter().filter_map(Value::as_str).collect())
142        .unwrap_or_default()
143}
144
145fn has_entries(object: &Map<String, Value>, category: &str) -> bool {
146    object
147        .get(category)
148        .and_then(Value::as_object)
149        .is_some_and(|buckets| !buckets.is_empty())
150}
151
152fn add_new_keys(
153    grown: &mut BTreeMap<String, Vec<String>>,
154    category: &str,
155    base: &Map<String, Value>,
156    head: &Map<String, Value>,
157) {
158    let known = key_set(base, category);
159    let added: Vec<String> = key_set(head, category)
160        .into_iter()
161        .filter(|key| !known.contains(key))
162        .map(display_key)
163        .collect();
164    if !added.is_empty() {
165        grown.entry(category.to_owned()).or_default().extend(added);
166    }
167}
168
169/// The string members of the array `category`, with the number of times each
170/// occurs.
171fn key_counts<'a>(object: &'a Map<String, Value>, category: &str) -> BTreeMap<&'a str, usize> {
172    let mut counts = BTreeMap::new();
173    for key in object
174        .get(category)
175        .and_then(Value::as_array)
176        .into_iter()
177        .flatten()
178        .filter_map(Value::as_str)
179    {
180        *counts.entry(key).or_insert(0) += 1;
181    }
182    counts
183}
184
185/// One entry for each occurrence of a key beyond its count at the base.
186fn add_new_occurrences(
187    grown: &mut BTreeMap<String, Vec<String>>,
188    category: &str,
189    base: &Map<String, Value>,
190    head: &Map<String, Value>,
191) {
192    let known: BTreeMap<String, usize> = key_counts(base, category)
193        .into_iter()
194        .map(|(key, count)| (key.to_owned(), count))
195        .collect();
196    push_new_occurrences(grown, category, &known, head);
197}
198
199/// Push one entry for each occurrence of a head key beyond its `known` count.
200fn push_new_occurrences(
201    grown: &mut BTreeMap<String, Vec<String>>,
202    category: &str,
203    known: &BTreeMap<String, usize>,
204    head: &Map<String, Value>,
205) {
206    let mut added = Vec::new();
207    for (key, now) in key_counts(head, category) {
208        let before = known.get(key).copied().unwrap_or(0);
209        added.extend(std::iter::repeat_n(
210            display_key(key),
211            now.saturating_sub(before),
212        ));
213    }
214    if !added.is_empty() {
215        grown.entry(category.to_owned()).or_default().extend(added);
216    }
217}
218
219/// Compare a legacy base category with a canonical head category. Every legacy
220/// key must translate, or the category falls back to its entry count.
221fn add_grown_after_upgrade(
222    grown: &mut BTreeMap<String, Vec<String>>,
223    category: &str,
224    base: &Map<String, Value>,
225    head: &Map<String, Value>,
226) {
227    let mut known: BTreeMap<String, usize> = BTreeMap::new();
228    for (key, count) in key_counts(base, category) {
229        let Some(canonical) = canonical_from_legacy(category, key) else {
230            add_grown_entry_count(grown, category, base, head);
231            return;
232        };
233        *known.entry(canonical).or_insert(0) += count;
234    }
235    push_new_occurrences(grown, category, &known, head);
236}
237
238/// The canonical key of a legacy dead-code baseline key, when the legacy form
239/// holds every part of the canonical key and no line.
240fn canonical_from_legacy(category: &str, key: &str) -> Option<String> {
241    let paths = IdentityPaths::new(Path::new(""));
242    let path = |value: &str| paths.key(Path::new(value));
243    let set = |values: &[&str]| paths.set(values.iter().map(Path::new));
244    let canonical = |rule: &str, parts: &[String]| {
245        let parts: Vec<&str> = parts.iter().map(String::as_str).collect();
246        Some(dead_code_canonical_key(rule, &parts))
247    };
248    let path_name = |rule: &str| {
249        let (file, name) = key.split_once(':')?;
250        canonical(rule, &[path(file), name.to_owned()])
251    };
252    let manifest_package = |rule: &str| {
253        let (manifest, package) = key.rsplit_once(':')?;
254        if !manifest.ends_with("package.json") {
255            return None;
256        }
257        canonical(rule, &[path(manifest), package.to_owned()])
258    };
259    let member = |rule: &str| {
260        let (file, member) = key.split_once(':')?;
261        let (parent, name) = member.split_once('.')?;
262        canonical(rule, &[path(file), parent.to_owned(), name.to_owned()])
263    };
264    match category {
265        "unused_files" => canonical("unused-file", &[path(key)]),
266        "unused_exports" => path_name("unused-export"),
267        "unused_types" => path_name("unused-type"),
268        "deprecated_exports_in_use" => path_name("deprecated-export-in-use"),
269        "invalid_client_exports" => path_name("invalid-client-export"),
270        "unresolved_imports" => path_name("unresolved-import"),
271        "unprovided_injects" => path_name("unprovided-inject"),
272        "unrendered_components" => path_name("unrendered-component"),
273        "unused_server_actions" => path_name("unused-server-action"),
274        "unused_load_data_keys" => path_name("unused-load-data-key"),
275        "route_collisions" => path_name("route-collision"),
276        "dynamic_segment_name_conflicts" => path_name("dynamic-segment-name-conflict"),
277        "boundary_call_violations" => path_name("boundary-call-violation"),
278        "boundary_coverage_violations" => canonical("boundary-coverage", &[path(key)]),
279        "unused_dependencies" => manifest_package("unused-dependency"),
280        "unused_dev_dependencies" => manifest_package("unused-dev-dependency"),
281        "unused_optional_dependencies" => manifest_package("unused-optional-dependency"),
282        "type_only_dependencies" => manifest_package("type-only-dependency"),
283        "test_only_dependencies" => manifest_package("test-only-dependency"),
284        "dev_dependencies_in_production" => manifest_package("dev-dependency-in-production"),
285        "unused_enum_members" => member("unused-enum-member"),
286        "unused_class_members" => member("unused-class-member"),
287        "unused_store_members" => member("unused-store-member"),
288        "unlisted_dependencies" => canonical("unlisted-dependency", &[key.to_owned()]),
289        "private_type_leaks" => {
290            let (file, rest) = key.split_once(':')?;
291            let (export, leaked) = rest.split_once("->")?;
292            canonical(
293                "private-type-leak",
294                &[path(file), export.to_owned(), leaked.to_owned()],
295            )
296        }
297        "duplicate_exports" => {
298            let mut parts = key.split('|');
299            let name = parts.next()?.to_owned();
300            let files: Vec<&str> = parts.collect();
301            if files.is_empty() {
302                return None;
303            }
304            canonical("duplicate-export", &[name, set(&files)])
305        }
306        "circular_dependencies" => {
307            let files: Vec<&str> = key.split("->").collect();
308            canonical("circular-dependency", &[set(&files)])
309        }
310        "re_export_cycles" => {
311            let (kind, rest) = key.split_once(':')?;
312            let files: Vec<&str> = rest.split("<->").collect();
313            canonical("re-export-cycle", &[kind.to_owned(), set(&files)])
314        }
315        "boundary_violations" => {
316            let (from, to) = key.split_once("->")?;
317            canonical("boundary-violation", &[path(from), path(to)])
318        }
319        "unused_dependency_overrides" => {
320            let (source, raw_key) = key.split_once(':')?;
321            canonical(
322                "unused-dependency-override",
323                &[source.to_owned(), raw_key.to_owned()],
324            )
325        }
326        "misconfigured_dependency_overrides" => {
327            let (source, raw_key) = key.split_once(':')?;
328            canonical(
329                "misconfigured-dependency-override",
330                &[source.to_owned(), raw_key.to_owned()],
331            )
332        }
333        // Keys with a line (stale suppressions, misplaced directives, catalog
334        // references), keys without the path or component name of the
335        // canonical key (catalog entries and groups, component contracts),
336        // keys whose parts cannot be split safely (policy violations) and
337        // keys whose canonical key has fewer parts (client and server barrels)
338        // have no translation.
339        _ => None,
340    }
341}
342
343/// One entry for each entry of `category` beyond its count at the base. Used
344/// when the two files write keys in different forms.
345fn add_grown_entry_count(
346    grown: &mut BTreeMap<String, Vec<String>>,
347    category: &str,
348    base: &Map<String, Value>,
349    head: &Map<String, Value>,
350) {
351    let entries = |object: &Map<String, Value>| {
352        object
353            .get(category)
354            .and_then(Value::as_array)
355            .map_or(0, Vec::len)
356    };
357    let (before, now) = (entries(base), entries(head));
358    let extra = now.saturating_sub(before);
359    let added = (1..=extra).map(|index| {
360        format!(
361            "new entry {index} of {extra}: the key format changed, so the gate compares entry counts ({before} -> {now})"
362        )
363    });
364    if extra > 0 {
365        grown.entry(category.to_owned()).or_default().extend(added);
366    }
367}
368
369/// Health count buckets: `path -> finding category -> { count }`. A bucket
370/// whose count is higher than at the base is growth, also when the base has no
371/// such bucket.
372fn add_grown_counts(
373    grown: &mut BTreeMap<String, Vec<String>>,
374    category: &str,
375    base: &Map<String, Value>,
376    head: &Map<String, Value>,
377) {
378    let Some(head_buckets) = head.get(category).and_then(Value::as_object) else {
379        return;
380    };
381    let base_buckets = base.get(category).and_then(Value::as_object);
382    let mut added = Vec::new();
383    for (path, finding_counts) in head_buckets {
384        let Some(finding_counts) = finding_counts.as_object() else {
385            continue;
386        };
387        for (finding, count) in finding_counts {
388            let now = bucket_count(Some(count));
389            let before = bucket_count(
390                base_buckets
391                    .and_then(|buckets| buckets.get(path))
392                    .and_then(|counts| counts.get(finding)),
393            );
394            if now > before {
395                let key = display_key(path);
396                added.push(if before == 0 {
397                    format!("{key} {finding} (count {now})")
398                } else {
399                    format!("{key} {finding} (count {before} -> {now})")
400                });
401            }
402        }
403    }
404    if !added.is_empty() {
405        grown.entry(category.to_owned()).or_default().extend(added);
406    }
407}
408
409fn bucket_count(bucket: Option<&Value>) -> u64 {
410    bucket
411        .and_then(|bucket| bucket.get("count"))
412        .and_then(Value::as_u64)
413        .unwrap_or(0)
414}
415
416/// Some formats join key parts with a NUL byte. Show it as `:`, so the
417/// message stays on one line and prints in every terminal.
418fn display_key(key: &str) -> String {
419    key.replace('\0', ":")
420}
421
422/// Why the baseline at the base ref could not be read.
423#[derive(Debug, Clone, PartialEq, Eq)]
424pub enum BaseBaselineError {
425    /// `git` could not be started.
426    GitMissing(String),
427    /// The baseline is not in a git work tree.
428    NotARepository,
429    /// Git cannot resolve the ref to a commit, typically because a shallow
430    /// clone did not fetch it.
431    RefUnavailable,
432    /// Git failed for another reason.
433    GitFailed(String),
434}
435
436/// Read the version of the file at `path` that the commit `git_ref` has.
437///
438/// `Ok(None)` when the commit has no file at that path, which is a baseline
439/// that the change adds. The path is resolved relative to the directory of the
440/// file, so a baseline in a nested directory or under `--root` reads correctly.
441pub fn read_baseline_at_ref(
442    path: &Path,
443    git_ref: &str,
444) -> Result<Option<String>, BaseBaselineError> {
445    let dir = match path.parent() {
446        Some(parent) if !parent.as_os_str().is_empty() => parent,
447        _ => Path::new("."),
448    };
449    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
450        return Err(BaseBaselineError::GitFailed(format!(
451            "the baseline path {} has no file name",
452            path.display()
453        )));
454    };
455    let inside = run_git(dir, &["rev-parse", "--is-inside-work-tree"])?;
456    if !inside.status.success() {
457        return Err(BaseBaselineError::NotARepository);
458    }
459    let resolved = run_git(
460        dir,
461        &["rev-parse", "--verify", "--quiet", &peel_to_commit(git_ref)],
462    )?;
463    if !resolved.status.success() {
464        return Err(BaseBaselineError::RefUnavailable);
465    }
466    let object = format!("{git_ref}:./{name}");
467    let exists = run_git(dir, &["cat-file", "-e", &object])?;
468    if !exists.status.success() {
469        return Ok(None);
470    }
471    let shown = run_git(dir, &["show", &object])?;
472    if !shown.status.success() {
473        return Err(BaseBaselineError::GitFailed(
474            String::from_utf8_lossy(&shown.stderr).trim().to_owned(),
475        ));
476    }
477    Ok(Some(String::from_utf8_lossy(&shown.stdout).into_owned()))
478}
479
480/// True when `git_ref` names the commit that `HEAD` names in the repository
481/// at `dir`. A base that resolves to `HEAD` compares a committed baseline with
482/// itself, so only an uncommitted change can grow it.
483#[must_use]
484pub fn ref_is_head(dir: &Path, git_ref: &str) -> bool {
485    let resolve = |rev: &str| {
486        run_git(
487            dir,
488            &["rev-parse", "--verify", "--quiet", &peel_to_commit(rev)],
489        )
490        .ok()
491        .filter(|output| output.status.success())
492        .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned())
493    };
494    match (resolve(git_ref), resolve("HEAD")) {
495        (Some(base), Some(head)) => base == head,
496        _ => false,
497    }
498}
499
500/// `rev^{commit}`: git resolves it only when `rev` names a commit.
501fn peel_to_commit(rev: &str) -> String {
502    format!("{rev}^{{commit}}")
503}
504
505fn run_git(dir: &Path, args: &[&str]) -> Result<std::process::Output, BaseBaselineError> {
506    crate::git_env::git_command()
507        .args(args)
508        .current_dir(dir)
509        .output()
510        .map_err(|error| BaseBaselineError::GitMissing(error.to_string()))
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use serde_json::json;
517
518    fn keys(growth: &BaselineGrowth) -> Vec<(String, Vec<String>)> {
519        growth
520            .categories
521            .iter()
522            .map(|grown| (grown.category.clone(), grown.keys.clone()))
523            .collect()
524    }
525
526    #[test]
527    fn dead_code_growth_is_each_new_key_per_category() {
528        let base = json!({
529            "kind": "dead-code",
530            "unused_files": ["src/a.ts"],
531            "boundary_violations": ["src/ui/a.ts->src/core/x.ts"],
532        });
533        let head = json!({
534            "kind": "dead-code",
535            "unused_files": ["src/a.ts", "src/b.ts"],
536            "boundary_violations": [
537                "src/ui/a.ts->src/core/x.ts",
538                "src/ui/c.ts->src/core/z.ts"
539            ],
540            "unused_exports": ["src/c.ts:x"],
541        });
542        let growth = baseline_growth(BaselineKind::DeadCode, &base, &head);
543        assert_eq!(
544            keys(&growth),
545            vec![
546                (
547                    "boundary_violations".to_owned(),
548                    vec!["src/ui/c.ts->src/core/z.ts".to_owned()]
549                ),
550                ("unused_exports".to_owned(), vec!["src/c.ts:x".to_owned()]),
551                ("unused_files".to_owned(), vec!["src/b.ts".to_owned()]),
552            ]
553        );
554        assert_eq!(growth.added_entries(), 3);
555    }
556
557    #[test]
558    fn dead_code_growth_with_canonical_keys_counts_occurrences() {
559        let base = json!({
560            "identity": "dc1",
561            "unused_class_members": ["unused-class-member:src/a.ts:A:value"],
562        });
563        let head = json!({
564            "identity": "dc1",
565            "unused_class_members": [
566                "unused-class-member:src/a.ts:A:value",
567                "unused-class-member:src/a.ts:A:value"
568            ],
569        });
570
571        let growth = baseline_growth(BaselineKind::DeadCode, &base, &head);
572
573        assert_eq!(
574            keys(&growth),
575            vec![(
576                "unused_class_members".to_owned(),
577                vec!["unused-class-member:src/a.ts:A:value".to_owned()]
578            )]
579        );
580        assert!(baseline_growth(BaselineKind::DeadCode, &head, &base).is_empty());
581    }
582
583    #[test]
584    fn an_upgrade_that_swaps_a_finding_is_growth() {
585        let base = json!({
586            "unused_exports": ["src/a.ts:helperA", "src/b.ts:helperB"],
587        });
588        let head = json!({
589            "identity": "dc1",
590            "unused_exports": [
591                "unused-export:src/b.ts:helperB",
592                "unused-export:src/c.ts:helperC"
593            ],
594        });
595
596        let growth = baseline_growth(BaselineKind::DeadCode, &base, &head);
597
598        assert_eq!(
599            keys(&growth),
600            vec![(
601                "unused_exports".to_owned(),
602                vec!["unused-export:src/c.ts:helperC".to_owned()]
603            )]
604        );
605    }
606
607    #[test]
608    fn a_clean_upgrade_is_not_growth() {
609        let base = json!({
610            "unused_files": ["src/old.ts"],
611            "unused_exports": ["src/a.ts:helperA"],
612            "unused_dependencies": ["packages/app/package.json:lodash"],
613            "unused_class_members": ["src/s.ts:Service.run"],
614            "unlisted_dependencies": ["chalk"],
615            "duplicate_exports": ["Config|src/b.ts|src/a.ts"],
616            "circular_dependencies": ["src/a.ts->src/b.ts"],
617            "boundary_violations": ["src/ui/a.ts->src/db/q.ts"],
618            "stale_suppressions": ["stale-suppression:src/f.ts:3"],
619        });
620        let head = json!({
621            "identity": "dc1",
622            "unused_files": ["unused-file:src/old.ts"],
623            "unused_exports": ["unused-export:src/a.ts:helperA"],
624            "unused_dependencies": ["unused-dependency:packages/app/package.json:lodash"],
625            "unused_class_members": ["unused-class-member:src/s.ts:Service:run"],
626            "unlisted_dependencies": ["unlisted-dependency:chalk"],
627            "duplicate_exports": ["duplicate-export:Config:src/a.ts|src/b.ts"],
628            "circular_dependencies": ["circular-dependency:src/a.ts|src/b.ts"],
629            "boundary_violations": ["boundary-violation:src/ui/a.ts:src/db/q.ts"],
630            "stale_suppressions": ["stale-suppression:src/f.ts:comment:unused-export:line"],
631        });
632
633        assert!(baseline_growth(BaselineKind::DeadCode, &base, &head).is_empty());
634    }
635
636    #[test]
637    fn a_legacy_key_without_a_translation_falls_back_to_the_entry_count() {
638        let base = json!({ "unused_dependencies": ["lodash"] });
639        let head = json!({
640            "identity": "dc1",
641            "unused_dependencies": [
642                "unused-dependency:package.json:lodash",
643                "unused-dependency:package.json:chalk"
644            ],
645        });
646
647        assert_eq!(
648            baseline_growth(BaselineKind::DeadCode, &base, &head).added_entries(),
649            1
650        );
651    }
652
653    #[test]
654    fn a_rewritten_legacy_baseline_is_compared_by_entry_counts() {
655        let base = json!({ "unused_files": ["src/a.ts", "src/b.ts"] });
656        let same = json!({
657            "identity": "dc1",
658            "unused_files": ["unused-file:src/a.ts", "unused-file:src/b.ts"],
659        });
660        let grown = json!({
661            "identity": "dc1",
662            "unused_files": [
663                "unused-file:src/a.ts",
664                "unused-file:src/b.ts",
665                "unused-file:src/c.ts"
666            ],
667        });
668
669        assert!(baseline_growth(BaselineKind::DeadCode, &base, &same).is_empty());
670        assert_eq!(
671            baseline_growth(BaselineKind::DeadCode, &base, &grown).added_entries(),
672            1
673        );
674    }
675
676    #[test]
677    fn a_removed_or_kept_key_is_not_growth() {
678        let base = json!({ "unused_files": ["src/a.ts", "src/b.ts"] });
679        let head = json!({ "unused_files": ["src/b.ts"] });
680        assert!(baseline_growth(BaselineKind::DeadCode, &base, &head).is_empty());
681    }
682
683    #[test]
684    fn dupes_growth_counts_one_key_per_clone_group() {
685        let base = json!({
686            "clone_groups": ["a.ts:1-9|b.ts:1-9"],
687            "clone_fingerprints": ["f1"],
688            "normalized_clone_fingerprints": ["n1"],
689        });
690        let head = json!({
691            "clone_groups": ["a.ts:1-9|b.ts:1-9", "c.ts:1-9|d.ts:1-9"],
692            "clone_fingerprints": ["f1", "f2"],
693            "normalized_clone_fingerprints": ["n1", "n2"],
694        });
695        let growth = baseline_growth(BaselineKind::Dupes, &base, &head);
696        assert_eq!(
697            keys(&growth),
698            vec![(DUPES_FINGERPRINTS.to_owned(), vec!["n2".to_owned()])]
699        );
700    }
701
702    #[test]
703    fn a_legacy_dupes_base_is_compared_by_clone_group_keys() {
704        let base = json!({ "clone_groups": ["a.ts:1-9|b.ts:1-9"] });
705        let head = json!({
706            "clone_groups": ["a.ts:1-9|b.ts:1-9"],
707            "normalized_clone_fingerprints": ["n1"],
708        });
709        assert!(baseline_growth(BaselineKind::Dupes, &base, &head).is_empty());
710    }
711
712    #[test]
713    fn a_higher_health_count_is_growth() {
714        let base = json!({
715            "finding_counts": { "src/a.ts": { "cyclomatic": { "count": 1 } } },
716        });
717        let head = json!({
718            "finding_counts": {
719                "src/a.ts": { "cyclomatic": { "count": 2 } },
720                "src/b.ts": { "cognitive": { "count": 1 } },
721            },
722            "target_keys": ["src/b.ts:split"],
723        });
724        let growth = baseline_growth(BaselineKind::Health, &base, &head);
725        assert_eq!(
726            keys(&growth),
727            vec![
728                (
729                    HEALTH_FILE_COUNTS.to_owned(),
730                    vec![
731                        "src/a.ts cyclomatic (count 1 -> 2)".to_owned(),
732                        "src/b.ts cognitive (count 1)".to_owned(),
733                    ]
734                ),
735                ("target_keys".to_owned(), vec!["src/b.ts:split".to_owned()]),
736            ]
737        );
738        assert_eq!(growth.added_entries(), 3);
739    }
740
741    #[test]
742    fn identity_counts_replace_file_counts_when_both_files_have_them() {
743        let base = json!({
744            "finding_counts": { "src/a.ts": { "cyclomatic": { "count": 1 } } },
745            "identity_finding_counts": { "src/a.ts\u{0}old": { "cyclomatic": { "count": 1 } } },
746        });
747        let head = json!({
748            "finding_counts": { "src/a.ts": { "cyclomatic": { "count": 1 } } },
749            "identity_finding_counts": { "src/a.ts\u{0}new": { "cyclomatic": { "count": 1 } } },
750        });
751        let growth = baseline_growth(BaselineKind::Health, &base, &head);
752        assert_eq!(
753            keys(&growth),
754            vec![(
755                HEALTH_IDENTITY_COUNTS.to_owned(),
756                vec!["src/a.ts:new cyclomatic (count 1)".to_owned()]
757            )]
758        );
759    }
760}