1use 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
27pub struct BaselineGrowth {
28 pub categories: Vec<GrownCategory>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct GrownCategory {
35 pub category: String,
37 pub keys: Vec<String>,
39}
40
41impl BaselineGrowth {
42 #[must_use]
44 pub fn added_entries(&self) -> usize {
45 self.categories.iter().map(|grown| grown.keys.len()).sum()
46 }
47
48 #[must_use]
50 pub fn is_empty(&self) -> bool {
51 self.categories.is_empty()
52 }
53}
54
55#[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
136fn 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
169fn 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
185fn 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
199fn 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
219fn 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
238fn 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 _ => None,
340 }
341}
342
343fn 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
369fn 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
416fn display_key(key: &str) -> String {
419 key.replace('\0', ":")
420}
421
422#[derive(Debug, Clone, PartialEq, Eq)]
424pub enum BaseBaselineError {
425 GitMissing(String),
427 NotARepository,
429 RefUnavailable,
432 GitFailed(String),
434}
435
436pub 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#[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
500fn 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}