1use std::fmt::Write as _;
22use std::path::Path;
23use std::process::ExitCode;
24
25use fallow_output::{markdown_code_span, markdown_table_code_span, markdown_table_text};
26use fallow_types::output_dead_code::caveat_labels_for_tokens;
27use serde_json::Value;
28
29use super::github::{PathRebase, arr, b, fmt_num, num, resolve_render_options, s, u};
30use super::github_annotations::EnvelopeKind;
31use crate::report::sink::outln;
32
33const DEAD_CODE_DOCS: &str = "https://fallow.tools/docs/explanations/dead-code/";
34const HEALTH_DOCS: &str = "https://fallow.tools/docs/explanations/health/";
35const DUPES_DOCS: &str = "https://fallow.tools/docs/explanations/duplication/";
36const SUPPRESSION_DOCS: &str = "https://fallow.tools/docs/configuration/suppression/";
37
38#[derive(Debug, Default, Clone)]
40pub struct LinkContext {
41 pub prefix: String,
44 pub repo: String,
46 pub sha: String,
48}
49
50impl LinkContext {
51 #[must_use]
53 pub(crate) fn from_env(rebase: &PathRebase) -> Self {
54 let env = |primary: &str, fallback: &str| {
55 std::env::var(primary)
56 .or_else(|_| std::env::var(fallback))
57 .unwrap_or_default()
58 };
59 let prefix = match rebase {
60 PathRebase::None => String::new(),
61 PathRebase::Prefix(prefix) => format!("{prefix}/"),
62 };
63 Self {
64 prefix,
65 repo: env("GH_REPO", "GITHUB_REPOSITORY"),
66 sha: env("PR_HEAD_SHA", "GITHUB_SHA"),
67 }
68 }
69}
70
71pub(crate) fn print_summary(kind: EnvelopeKind, envelope: &Value, root: &Path) -> ExitCode {
73 let options = resolve_render_options(root);
74 let links = LinkContext::from_env(&options.rebase);
75 outln!("{}", render_summary(kind, envelope, &links));
76 if let Some(section) = crate::report::config_pattern_text::markdown_section(
80 &crate::report::config_pattern_text::envelope_diagnostics(envelope),
81 ) {
82 outln!("{section}");
83 }
84 if let Some(line) = crate::report::gate_outcome_text::summary_line(envelope) {
88 outln!("");
89 outln!("{line}");
90 }
91 if let Some(line) = crate::report::request_outcome_text::summary_line(envelope) {
94 outln!("");
95 outln!("{line}");
96 }
97 ExitCode::SUCCESS
98}
99
100pub(crate) fn print_fix_summary(envelope: &Value) -> ExitCode {
105 outln!("{}", render_fix_summary(envelope));
106 ExitCode::SUCCESS
107}
108
109#[must_use]
111pub fn render_summary(kind: EnvelopeKind, envelope: &Value, links: &LinkContext) -> String {
112 match kind {
113 EnvelopeKind::DeadCode | EnvelopeKind::Architecture => render_check_summary(envelope),
114 EnvelopeKind::Dupes => render_dupes_summary(envelope),
115 EnvelopeKind::Health => render_health_summary(envelope),
116 EnvelopeKind::Audit => render_audit_summary(envelope),
117 EnvelopeKind::Security => render_security_summary(envelope),
118 EnvelopeKind::Combined => render_combined_summary(envelope, links),
119 EnvelopeKind::Fix => render_fix_summary(envelope),
120 }
121}
122
123fn pct(value: f64) -> String {
129 let rounded = (value * 10.0).round() / 10.0;
130 fmt_num(&serde_json::json!(rounded))
131}
132
133fn signed(value: f64) -> String {
135 if value > 0.0 {
136 format!("+{}", pct(value))
137 } else if value < 0.0 {
138 pct(value)
139 } else {
140 "0.0".to_owned()
141 }
142}
143
144fn opt_f(value: &Value, key: &str) -> Option<f64> {
145 value.get(key).and_then(Value::as_f64)
146}
147
148fn f_or_zero(value: &Value, key: &str) -> f64 {
149 opt_f(value, key).unwrap_or_default()
150}
151
152fn rel_path_absolute_only(path: &str) -> String {
155 if path.starts_with('/') {
156 last_three_segments(path)
157 } else {
158 path.to_owned()
159 }
160}
161
162fn last_three_segments(path: &str) -> String {
165 let segments: Vec<&str> = path.split('/').collect();
166 if segments.len() > 3 {
167 segments[segments.len() - 3..].join("/")
168 } else {
169 segments.join("/")
170 }
171}
172
173fn plural_n(n: usize, word: &str) -> String {
175 let suffix = if n == 1 { "" } else { "s" };
176 format!("{n} {word}{suffix}")
177}
178
179fn str_or<'v>(value: &'v Value, key: &str, default: &'v str) -> &'v str {
180 value.get(key).and_then(Value::as_str).unwrap_or(default)
181}
182
183fn path_line(item: &Value) -> String {
186 let path = rel_path_absolute_only(s(item, "path"));
187 match item.get("line").filter(|line| !line.is_null()) {
188 Some(line) => markdown_table_code_span(&format!("{path}:{}", fmt_num(line))),
189 None => markdown_table_code_span(&path),
190 }
191}
192
193fn code_cell(item: &Value, key: &str) -> String {
195 markdown_table_code_span(s(item, key))
196}
197
198fn caveat_cell_suffix(item: &Value) -> String {
208 caveat_labels_for_tokens(arr(item, "reachability_caveats").filter_map(Value::as_str))
209 .map_or_else(String::new, |labels| {
210 format!(" *(caveat: {})*", markdown_table_text(&labels))
211 })
212}
213
214fn rel_path_line_cell(item: &Value, path_key: &str) -> String {
216 markdown_table_code_span(&format!(
217 "{}:{}",
218 rel_path_absolute_only(s(item, path_key)),
219 num(item, "line")
220 ))
221}
222
223fn backtick_join(item: &Value, key: &str) -> String {
224 arr(item, key)
225 .filter_map(Value::as_str)
226 .map(markdown_table_code_span)
227 .collect::<Vec<_>>()
228 .join(", ")
229}
230
231const DEAD_CODE_CATEGORIES: &[(&str, &str, &str)] = &[
238 ("Unused files", "unused_files", "unused-files"),
239 ("Unused exports", "unused_exports", "unused-exports"),
240 ("Unused types", "unused_types", "unused-types"),
241 (
242 "Private type leaks",
243 "private_type_leaks",
244 "private-type-leaks",
245 ),
246 (
247 "Deprecated exports in use",
248 "deprecated_exports_in_use",
249 "deprecated-exports-in-use",
250 ),
251 (
252 "Unused dependencies",
253 "unused_dependencies",
254 "unused-dependencies",
255 ),
256 (
257 "Unused devDependencies",
258 "unused_dev_dependencies",
259 "unused-dependencies",
260 ),
261 (
262 "Unused optionalDependencies",
263 "unused_optional_dependencies",
264 "unused-dependencies",
265 ),
266 (
267 "Unused enum members",
268 "unused_enum_members",
269 "unused-enum-members",
270 ),
271 (
272 "Unused class members",
273 "unused_class_members",
274 "unused-class-members",
275 ),
276 (
277 "Unused store members",
278 "unused_store_members",
279 "unused-store-members",
280 ),
281 (
282 "Unresolved imports",
283 "unresolved_imports",
284 "unresolved-imports",
285 ),
286 (
287 "Unlisted dependencies",
288 "unlisted_dependencies",
289 "unlisted-dependencies",
290 ),
291 (
292 "Duplicate exports",
293 "duplicate_exports",
294 "duplicate-exports",
295 ),
296 (
297 "Circular dependencies",
298 "circular_dependencies",
299 "circular-dependencies",
300 ),
301 ("Re-export cycles", "re_export_cycles", "re-export-cycles"),
302 ("Package cycles", "package_cycles", "package-cycles"),
303 (
304 "Boundary violations",
305 "boundary_violations",
306 "boundary-violations",
307 ),
308 (
309 "Boundary coverage",
310 "boundary_coverage_violations",
311 "boundary-violations",
312 ),
313 (
314 "Boundary calls",
315 "boundary_call_violations",
316 "boundary-violations",
317 ),
318 (
319 "Policy violations",
320 "policy_violations",
321 "policy-violations",
322 ),
323 (
324 "Invalid client exports",
325 "invalid_client_exports",
326 "invalid-client-exports",
327 ),
328 (
329 "Mixed client/server barrels",
330 "mixed_client_server_barrels",
331 "mixed-client-server-barrels",
332 ),
333 (
334 "Misplaced directives",
335 "misplaced_directives",
336 "misplaced-directives",
337 ),
338 (
339 "Unused server actions",
340 "unused_server_actions",
341 "unused-server-actions",
342 ),
343 ("Route collisions", "route_collisions", "route-collisions"),
344 (
345 "Dynamic segment conflicts",
346 "dynamic_segment_name_conflicts",
347 "dynamic-segment-name-conflicts",
348 ),
349 (
350 "Unrendered components",
351 "unrendered_components",
352 "unrendered-components",
353 ),
354 (
355 "Unused component props",
356 "unused_component_props",
357 "unused-component-props",
358 ),
359 (
360 "Absent optional component props",
361 "absent_component_props",
362 "absent-component-props",
363 ),
364 (
365 "Unused component emits",
366 "unused_component_emits",
367 "unused-component-emits",
368 ),
369 (
370 "Unused component inputs",
371 "unused_component_inputs",
372 "unused-component-inputs",
373 ),
374 (
375 "Unused component outputs",
376 "unused_component_outputs",
377 "unused-component-outputs",
378 ),
379 (
380 "Unused Svelte events",
381 "unused_svelte_events",
382 "unused-svelte-events",
383 ),
384 (
385 "Unprovided injects",
386 "unprovided_injects",
387 "unprovided-injects",
388 ),
389 (
390 "Unused load data keys",
391 "unused_load_data_keys",
392 "unused-load-data-keys",
393 ),
394 (
395 "Type-only dependencies",
396 "type_only_dependencies",
397 "type-only-dependencies",
398 ),
399 (
400 "Test-only dependencies",
401 "test_only_dependencies",
402 "test-only-dependencies",
403 ),
404 (
405 "Dev dependencies used in production",
406 "dev_dependencies_in_production",
407 "dev-dependencies-in-production",
408 ),
409 (
410 "Stale suppressions",
411 "stale_suppressions",
412 "stale-suppressions",
413 ),
414 (
415 "Unused catalog entries",
416 "unused_catalog_entries",
417 "unused-catalog-entries",
418 ),
419 (
420 "Empty catalog groups",
421 "empty_catalog_groups",
422 "empty-catalog-groups",
423 ),
424 (
425 "Unresolved catalog references",
426 "unresolved_catalog_references",
427 "unresolved-catalog-references",
428 ),
429 (
430 "Unused dependency overrides",
431 "unused_dependency_overrides",
432 "unused-dependency-overrides",
433 ),
434 (
435 "Misconfigured dependency overrides",
436 "misconfigured_dependency_overrides",
437 "misconfigured-dependency-overrides",
438 ),
439];
440
441fn dead_code_docs(anchor: &str) -> String {
442 format!("{DEAD_CODE_DOCS}#{anchor}")
443}
444
445fn dead_code_category_table(env: &Value) -> String {
446 DEAD_CODE_CATEGORIES
447 .iter()
448 .filter_map(|(name, key, anchor)| {
449 let n = arr(env, key).count();
450 (n > 0).then(|| format!("| [{name}]({}) | {n} |", dead_code_docs(anchor)))
451 })
452 .collect::<Vec<_>>()
453 .join("\n")
454}
455
456struct SectionSpec {
461 name: &'static str,
462 key: &'static str,
463 header: &'static str,
464 row: fn(&Value) -> String,
465}
466
467fn render_check_section(env: &Value, spec: &SectionSpec) -> String {
468 let items = rows_in_path_order(env, spec.key);
474 let n = items.len();
475 if n == 0 {
476 return String::new();
477 }
478 let rows = items
479 .iter()
480 .take(CHECK_SECTION_ROW_LIMIT)
481 .map(|item| (spec.row)(item))
482 .collect::<Vec<_>>()
483 .join("\n");
484 let tail = if n > CHECK_SECTION_ROW_LIMIT {
485 format!(
486 "\n\n> {} more - run `fallow` locally for the full list",
487 n - CHECK_SECTION_ROW_LIMIT
488 )
489 } else {
490 String::new()
491 };
492 format!(
493 "\n<details><summary><strong>{} ({n})</strong></summary>\n\n{}{rows}{tail}\n\n</details>\n",
494 spec.name, spec.header,
495 )
496}
497
498const CHECK_SECTION_ROW_LIMIT: usize = 25;
500
501fn rows_in_path_order<'a>(env: &'a Value, key: &str) -> Vec<&'a Value> {
508 let mut keyed: Vec<(Option<&str>, Option<u64>, &Value)> = arr(env, key)
509 .map(|item| {
510 (
511 row_sort_path(item),
512 item.get("line").and_then(Value::as_u64),
513 item,
514 )
515 })
516 .collect();
517 keyed.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
518 let mut start = 0;
519 for run in keyed.chunk_by_mut(|a, b| (a.0, a.1) == (b.0, b.1)) {
520 if start >= CHECK_SECTION_ROW_LIMIT {
521 break;
522 }
523 if run.len() > 1 {
524 run.sort_by_cached_key(|(_, _, item)| item.to_string());
525 }
526 start += run.len();
527 }
528 keyed.into_iter().map(|(_, _, item)| item).collect()
529}
530
531fn row_sort_path(item: &Value) -> Option<&str> {
536 item.get("path")
537 .or_else(|| item.get("from_path"))
538 .or_else(|| item.get("files").and_then(|files| files.get(0)))
539 .and_then(Value::as_str)
540}
541
542fn check_workspace_context(item: &Value) -> String {
543 let workspaces = backtick_join(item, "used_in_workspaces");
544 if !workspaces.is_empty() {
545 return workspaces;
546 }
547 let declaring = backtick_join(item, "declared_and_imported_in");
548 if declaring.is_empty() {
549 String::new()
550 } else {
551 format!("declared and imported in {declaring}")
552 }
553}
554
555#[expect(
556 clippy::too_many_lines,
557 reason = "a flat data table of 14 per-kind row templates ported 1:1 from summary-check.jq; splitting would obscure the correspondence"
558)]
559fn check_sections_core() -> Vec<SectionSpec> {
560 vec![
561 SectionSpec {
562 name: "Unused files",
563 key: "unused_files",
564 header: "Files not reachable from any entry point.\n\n| File |\n|------|\n",
565 row: |it| format!("| {}{} |", code_cell(it, "path"), caveat_cell_suffix(it)),
566 },
567 SectionSpec {
568 name: "Unused exports",
569 key: "unused_exports",
570 header: "Exported symbols with no known consumers.\n\n| File | Line | Export |\n|------|-----:|--------|\n",
571 row: |it| {
572 format!(
573 "| {} | {} | {}{}{} |",
574 code_cell(it, "path"),
575 num(it, "line"),
576 code_cell(it, "export_name"),
577 if b(it, "is_re_export") {
578 " *(re-export)*"
579 } else {
580 ""
581 },
582 caveat_cell_suffix(it),
583 )
584 },
585 },
586 SectionSpec {
587 name: "Unused types",
588 key: "unused_types",
589 header: "Type exports with no known consumers.\n\n| File | Line | Type |\n|------|-----:|------|\n",
590 row: |it| {
591 format!(
592 "| {} | {} | {}{} |",
593 code_cell(it, "path"),
594 num(it, "line"),
595 code_cell(it, "export_name"),
596 caveat_cell_suffix(it),
597 )
598 },
599 },
600 SectionSpec {
601 name: "Private type leaks",
602 key: "private_type_leaks",
603 header: "Exported signatures that reference same-file private types.\n\n| File | Line | Export | Private type |\n|------|-----:|--------|--------------|\n",
604 row: |it| {
605 format!(
606 "| {} | {} | {} | {} |",
607 code_cell(it, "path"),
608 num(it, "line"),
609 code_cell(it, "export_name"),
610 code_cell(it, "type_name"),
611 )
612 },
613 },
614 SectionSpec {
615 name: "Deprecated exports in use",
616 key: "deprecated_exports_in_use",
617 header: "Exports marked `@deprecated` that still have consumers.\n\n| File | Line | Export | Consumers |\n|------|-----:|--------|----------:|\n",
618 row: |it| {
619 format!(
620 "| {} | {} | {} | {} |",
621 code_cell(it, "path"),
622 num(it, "line"),
623 code_cell(it, "export_name"),
624 num(it, "consumer_count"),
625 )
626 },
627 },
628 SectionSpec {
629 name: "Unused dependencies",
630 key: "unused_dependencies",
631 header: "Listed in `dependencies` but never imported by the declaring workspace.\n\n| Package | Imported elsewhere |\n|---------|--------------------|\n",
632 row: |it| {
633 format!(
634 "| {}{} | {} |",
635 code_cell(it, "package_name"),
636 caveat_cell_suffix(it),
637 check_workspace_context(it),
638 )
639 },
640 },
641 SectionSpec {
642 name: "Unused devDependencies",
643 key: "unused_dev_dependencies",
644 header: "Listed in `devDependencies` but never imported or referenced by the declaring workspace.\n\n| Package | Imported elsewhere |\n|---------|--------------------|\n",
645 row: |it| {
646 format!(
647 "| {}{} | {} |",
648 code_cell(it, "package_name"),
649 caveat_cell_suffix(it),
650 check_workspace_context(it),
651 )
652 },
653 },
654 SectionSpec {
655 name: "Unused optionalDependencies",
656 key: "unused_optional_dependencies",
657 header: "Listed in `optionalDependencies` but never imported by the declaring workspace.\n\n| Package | Imported elsewhere |\n|---------|--------------------|\n",
658 row: |it| {
659 format!(
660 "| {}{} | {} |",
661 code_cell(it, "package_name"),
662 caveat_cell_suffix(it),
663 check_workspace_context(it),
664 )
665 },
666 },
667 SectionSpec {
668 name: "Unused enum members",
669 key: "unused_enum_members",
670 header: "Enum members never referenced outside their declaration.\n\n| File | Line | Enum | Member |\n|------|-----:|------|--------|\n",
671 row: member_row,
672 },
673 SectionSpec {
674 name: "Unused class members",
675 key: "unused_class_members",
676 header: "Class methods or properties never referenced outside their class.\n\n| File | Line | Class | Member |\n|------|-----:|-------|--------|\n",
677 row: member_row,
678 },
679 SectionSpec {
680 name: "Unused store members",
681 key: "unused_store_members",
682 header: "Pinia store members (state, getter, action) never accessed by any consumer.\n\n| File | Line | Store | Member |\n|------|-----:|-------|--------|\n",
683 row: member_row,
684 },
685 SectionSpec {
686 name: "Unresolved imports",
687 key: "unresolved_imports",
688 header: "Import paths that could not be resolved - check for missing packages or broken paths.\n\n| File | Line | Import |\n|------|-----:|--------|\n",
689 row: |it| {
690 format!(
691 "| {} | {} | {} |",
692 code_cell(it, "path"),
693 num(it, "line"),
694 code_cell(it, "specifier"),
695 )
696 },
697 },
698 SectionSpec {
699 name: "Unlisted dependencies",
700 key: "unlisted_dependencies",
701 header: "Packages imported in code but missing from `package.json`.\n\n| Package | Used in |\n|---------|--------|\n",
702 row: |it| {
703 let sites: Vec<&Value> = arr(it, "imported_from").collect();
704 let cell = if sites.is_empty() {
705 String::new()
706 } else {
707 let shown = sites
708 .iter()
709 .take(3)
710 .map(|site| {
711 markdown_table_code_span(&format!(
712 "{}:{}",
713 s(site, "path"),
714 num(site, "line")
715 ))
716 })
717 .collect::<Vec<_>>()
718 .join(", ");
719 let more = if sites.len() > 3 {
720 format!(" *+{} more*", sites.len() - 3)
721 } else {
722 String::new()
723 };
724 format!("{shown}{more}")
725 };
726 format!("| {} | {cell} |", code_cell(it, "package_name"))
727 },
728 },
729 SectionSpec {
730 name: "Duplicate exports",
731 key: "duplicate_exports",
732 header: "Same export name defined in multiple files - barrel re-exports may resolve ambiguously.\n\n| Export | Locations |\n|--------|-----------|\n",
733 row: |it| {
734 let locations: Vec<&Value> = arr(it, "locations").collect();
735 let shown = locations
736 .iter()
737 .take(3)
738 .map(|location| {
739 markdown_table_code_span(&format!(
740 "{}:{}",
741 s(location, "path"),
742 num(location, "line")
743 ))
744 })
745 .collect::<Vec<_>>()
746 .join(", ");
747 let more = if locations.len() > 3 {
748 format!(" *+{} more*", locations.len() - 3)
749 } else {
750 String::new()
751 };
752 format!("| {} | {shown}{more} |", code_cell(it, "export_name"))
753 },
754 },
755 SectionSpec {
756 name: "Circular dependencies",
757 key: "circular_dependencies",
758 header: "Import cycles that can cause initialization failures and prevent tree-shaking.\n\n| Cycle | Length |\n|-------|-------:|\n",
759 row: |it| {
760 let cycle = arr(it, "files")
761 .filter_map(Value::as_str)
762 .map(markdown_table_code_span)
763 .collect::<Vec<_>>()
764 .join(" \u{2192} ");
765 format!("| {cycle} | {} |", num(it, "length"))
766 },
767 },
768 ]
769}
770
771fn member_row(it: &Value) -> String {
776 format!(
777 "| {} | {} | {} | {}{} |",
778 code_cell(it, "path"),
779 num(it, "line"),
780 code_cell(it, "parent_name"),
781 code_cell(it, "member_name"),
782 caveat_cell_suffix(it),
783 )
784}
785
786fn plain_join(item: &Value, key: &str, separator: &str) -> String {
787 arr(item, key)
788 .filter_map(Value::as_str)
789 .collect::<Vec<_>>()
790 .join(separator)
791}
792
793fn path_line_cell(it: &Value) -> String {
794 markdown_table_code_span(&format!("{}:{}", s(it, "path"), num(it, "line")))
795}
796
797#[expect(
798 clippy::too_many_lines,
799 reason = "a flat data table of 14 per-kind row templates ported 1:1 from summary-check.jq; splitting would obscure the correspondence"
800)]
801fn check_sections_architecture() -> Vec<SectionSpec> {
802 vec![
803 SectionSpec {
804 name: "Re-export cycles",
805 key: "re_export_cycles",
806 header: "Barrel files that re-export from each other in a loop. Chain propagation through the loop is a no-op, so imports through any member may silently come up empty.\n\n| Cycle | Kind | Members |\n|-------|------|--------:|\n",
807 row: |it| {
808 let cycle = arr(it, "files")
809 .filter_map(Value::as_str)
810 .map(markdown_table_code_span)
811 .collect::<Vec<_>>()
812 .join(" <-> ");
813 format!(
814 "| {cycle} | {} | {} |",
815 markdown_table_text(s(it, "kind")),
816 arr(it, "files").count()
817 )
818 },
819 },
820 SectionSpec {
821 name: "Package cycles",
822 key: "package_cycles",
823 header: "Workspace packages that import each other in a loop. Packages in a cycle cannot be built in dependency order.\n\n| Cycle | Example import | Packages |\n|-------|----------------|---------:|\n",
824 row: |it| {
825 let mut chain: Vec<String> = arr(it, "packages")
826 .filter_map(Value::as_str)
827 .map(markdown_table_code_span)
828 .collect();
829 if let Some(first) = chain.first().cloned() {
830 chain.push(first);
831 }
832 let example = arr(it, "edges")
833 .next()
834 .map_or_else(String::new, path_line_cell);
835 let note = if it.get("group_truncated").and_then(Value::as_bool) == Some(true) {
836 format!(
837 " ({})",
838 fallow_types::results::PackageCycle::GROUP_TRUNCATED_NOTE
839 )
840 } else {
841 String::new()
842 };
843 format!(
844 "| {}{note} | {example} | {} |",
845 chain.join(" \u{2192} "),
846 arr(it, "packages").count()
847 )
848 },
849 },
850 SectionSpec {
851 name: "Boundary violations",
852 key: "boundary_violations",
853 header: "Imports that cross defined architecture zone boundaries.\n\n| From | To | Zones |\n|------|-----|-------|\n",
854 row: |it| {
855 format!(
856 "| {} | {} | {} \u{2192} {} |",
857 markdown_table_code_span(&format!(
858 "{}:{}",
859 s(it, "from_path"),
860 num(it, "line")
861 )),
862 code_cell(it, "to_path"),
863 markdown_table_code_span(s(it, "from_zone")),
864 markdown_table_code_span(s(it, "to_zone")),
865 )
866 },
867 },
868 SectionSpec {
869 name: "Boundary coverage",
870 key: "boundary_coverage_violations",
871 header: "Files that match no configured architecture boundary zone.\n\n| File |\n|------|\n",
872 row: |it| format!("| {} |", path_line_cell(it)),
873 },
874 SectionSpec {
875 name: "Boundary calls",
876 key: "boundary_call_violations",
877 header: "Calls from zoned files to callees forbidden for that zone.\n\n| File | Callee | Zone | Pattern |\n|------|--------|------|---------|\n",
878 row: |it| {
879 format!(
880 "| {} | {} | {} | {} |",
881 path_line_cell(it),
882 code_cell(it, "callee"),
883 markdown_table_code_span(s(it, "zone")),
884 code_cell(it, "pattern"),
885 )
886 },
887 },
888 SectionSpec {
889 name: "Policy violations",
890 key: "policy_violations",
891 header: "Banned calls, imports, and catalogue-derived effects matched by configured rule packs.\n\n| File | Matched | Rule | Severity |\n|------|---------|------|----------|\n",
892 row: |it| {
893 format!(
894 "| {} | {} | {} | {} |",
895 path_line_cell(it),
896 code_cell(it, "matched"),
897 markdown_table_code_span(&format!("{}/{}", s(it, "pack"), s(it, "rule_id"))),
898 markdown_table_text(s(it, "severity")),
899 )
900 },
901 },
902 SectionSpec {
903 name: "Invalid client exports",
904 key: "invalid_client_exports",
905 header: "`\"use client\"` files exporting a Next.js server-only / route-config name. Next.js rejects this at build time.\n\n| File | Export | Directive |\n|------|--------|-----------|\n",
906 row: |it| {
907 format!(
908 "| {} | {} | {} |",
909 path_line_cell(it),
910 code_cell(it, "export_name"),
911 markdown_table_code_span(&format!("\"{}\"", s(it, "directive"))),
912 )
913 },
914 },
915 SectionSpec {
916 name: "Mixed client/server barrels",
917 key: "mixed_client_server_barrels",
918 header: "Barrels re-exporting both a `\"use client\"` module and a server-only module. One import drags the other's directive across the boundary.\n\n| File | Client origin | Server origin |\n|------|---------------|---------------|\n",
919 row: |it| {
920 format!(
921 "| {} | {} | {} |",
922 path_line_cell(it),
923 code_cell(it, "client_origin"),
924 code_cell(it, "server_origin"),
925 )
926 },
927 },
928 SectionSpec {
929 name: "Misplaced directives",
930 key: "misplaced_directives",
931 header: "`\"use client\"` / `\"use server\"` directives written after a non-directive statement, so the RSC bundler ignores them. Move the directive to the top of the file.\n\n| File | Directive |\n|------|-----------|\n",
932 row: |it| {
933 format!(
934 "| {} | {} |",
935 path_line_cell(it),
936 markdown_table_code_span(&format!("\"{}\"", s(it, "directive"))),
937 )
938 },
939 },
940 SectionSpec {
941 name: "Unused server actions",
942 key: "unused_server_actions",
943 header: "Next.js Server Actions (exports of a `\"use server\"` file) that no project code references. The endpoint stays POST-able, but no code calls it (likely dead).\n\n| File | Action |\n|------|--------|\n",
944 row: |it| {
945 format!(
946 "| {} | {} |",
947 path_line_cell(it),
948 code_cell(it, "action_name")
949 )
950 },
951 },
952 SectionSpec {
953 name: "Route collisions",
954 key: "route_collisions",
955 header: "Next.js App Router route files that resolve to the same URL within one app-root. Next.js fails the build because a URL can have only one owner.\n\n| File | URL |\n|------|-----|\n",
956 row: |it| format!("| {} | {} |", code_cell(it, "path"), code_cell(it, "url")),
957 },
958 SectionSpec {
959 name: "Dynamic segment conflicts",
960 key: "dynamic_segment_name_conflicts",
961 header: "Sibling Next.js dynamic route segments at one position using different slug names. Next.js requires one consistent name per dynamic path.\n\n| File | Position | Segments |\n|------|----------|----------|\n",
962 row: |it| {
963 format!(
964 "| {} | {} | {} |",
965 code_cell(it, "path"),
966 code_cell(it, "position"),
967 markdown_table_code_span(&plain_join(it, "conflicting_segments", ", ")),
968 )
969 },
970 },
971 ]
972}
973
974#[expect(
975 clippy::too_many_lines,
976 reason = "a flat data table of 14 per-kind row templates ported 1:1 from summary-check.jq; splitting would obscure the correspondence"
977)]
978fn check_sections_frameworks_and_hygiene() -> Vec<SectionSpec> {
979 vec![
980 SectionSpec {
981 name: "Unrendered components",
982 key: "unrendered_components",
983 header: "Vue/Svelte components reachable in the module graph but rendered nowhere: no tag, no dynamic binding, no registration. A barrel re-export keeps them alive even though nothing instantiates them.\n\n| File | Component | Framework |\n|------|-----------|-----------|\n",
984 row: |it| {
985 format!(
986 "| {} | {} | {} |",
987 path_line_cell(it),
988 code_cell(it, "component_name"),
989 markdown_table_text(s(it, "framework")),
990 )
991 },
992 },
993 SectionSpec {
994 name: "Absent optional component props",
995 key: "absent_component_props",
996 header: "Known reachable callers omit this used optional prop. Review defaults and API intent manually; static analysis does not prove runtime unreachability.\n\n| File | Component prop | Default | Inspected callers |\n|------|----------------|---------|-------------------|\n",
997 row: |it| {
998 let callers = arr(it, "inspected_call_sites")
999 .map(path_line_cell)
1000 .collect::<Vec<_>>()
1001 .join(", ");
1002 format!(
1003 "| {} | {} | {} | {} |",
1004 path_line_cell(it),
1005 component_member_item(it, "prop_name"),
1006 if it.get("has_default").and_then(Value::as_bool) == Some(true) {
1007 "present"
1008 } else {
1009 "absent"
1010 },
1011 callers
1012 )
1013 },
1014 },
1015 SectionSpec {
1016 name: "Unused component props",
1017 key: "unused_component_props",
1018 header: "Vue `defineProps` props referenced nowhere inside their own single-file component (neither script nor template).\n\n| File | Component | Prop |\n|------|-----------|------|\n",
1019 row: |it| component_detail_row(it, "prop_name"),
1020 },
1021 SectionSpec {
1022 name: "Unused component emits",
1023 key: "unused_component_emits",
1024 header: "Vue `defineEmits` events emitted nowhere inside their own single-file component (no matching `emit()` call).\n\n| File | Component | Event |\n|------|-----------|-------|\n",
1025 row: |it| component_detail_row(it, "emit_name"),
1026 },
1027 SectionSpec {
1028 name: "Unused component inputs",
1029 key: "unused_component_inputs",
1030 header: "Angular `@Input()` / signal `input()` declarations read nowhere inside their own component (neither class body nor template).\n\n| File | Component | Input |\n|------|-----------|-------|\n",
1031 row: |it| component_detail_row(it, "input_name"),
1032 },
1033 SectionSpec {
1034 name: "Unused component outputs",
1035 key: "unused_component_outputs",
1036 header: "Angular `@Output()` / signal `output()` declarations emitted nowhere inside their own component (no matching `emit()` call).\n\n| File | Component | Output |\n|------|-----------|--------|\n",
1037 row: |it| component_detail_row(it, "output_name"),
1038 },
1039 SectionSpec {
1040 name: "Unused Svelte events",
1041 key: "unused_svelte_events",
1042 header: "Svelte components dispatching a `createEventDispatcher` event listened to nowhere in the project (cross-file dead-output direction).\n\n| File | Component | Event |\n|------|-----------|-------|\n",
1043 row: |it| component_detail_row(it, "event_name"),
1044 },
1045 SectionSpec {
1046 name: "Unprovided injects",
1047 key: "unprovided_injects",
1048 header: "Vue `inject` / Svelte `getContext` calls for a key that no ancestor `provide` / `setContext` supplies.\n\n| File | Key | Framework |\n|------|-----|-----------|\n",
1049 row: |it| {
1050 format!(
1051 "| {} | {} | {} |",
1052 path_line_cell(it),
1053 code_cell(it, "key_name"),
1054 markdown_table_text(s(it, "framework")),
1055 )
1056 },
1057 },
1058 SectionSpec {
1059 name: "Unused load data keys",
1060 key: "unused_load_data_keys",
1061 header: "SvelteKit `load()` return-object keys read by no consumer (neither the sibling `+page.svelte` nor `$page.data`). The key runs a real server fetch / DB cost per request for data nothing renders.\n\n| File | Route | Key |\n|------|-------|-----|\n",
1062 row: |it| {
1063 format!(
1064 "| {} | {} | {} |",
1065 path_line_cell(it),
1066 code_cell(it, "route_dir"),
1067 code_cell(it, "key_name"),
1068 )
1069 },
1070 },
1071 SectionSpec {
1072 name: "Type-only dependencies",
1073 key: "type_only_dependencies",
1074 header: "Dependencies only used for type imports - consider moving to `devDependencies`.\n\n| Package |\n|---------|\n",
1075 row: package_row,
1076 },
1077 SectionSpec {
1078 name: "Test-only dependencies",
1079 key: "test_only_dependencies",
1080 header: "Production dependencies only imported by test files - consider moving to `devDependencies`.\n\n| Package |\n|---------|\n",
1081 row: package_row,
1082 },
1083 SectionSpec {
1084 name: "Dev dependencies used in production",
1085 key: "dev_dependencies_in_production",
1086 header: "`devDependencies` imported by production code at runtime - consider moving to `dependencies` so a production-only install does not break.\n\n| Package |\n|---------|\n",
1087 row: package_row,
1088 },
1089 SectionSpec {
1090 name: "Stale suppressions",
1091 key: "stale_suppressions",
1092 header: "Suppression comments or JSDoc tags that no longer match any active issue.\n\n| File | Line | Description |\n|------|-----:|-------------|\n",
1093 row: |it| {
1094 format!(
1095 "| {} | {} | {} |",
1096 code_cell(it, "path"),
1097 num(it, "line"),
1098 stale_suppression_description(it),
1099 )
1100 },
1101 },
1102 ]
1103}
1104
1105fn component_detail_row(it: &Value, detail_key: &str) -> String {
1106 format!(
1107 "| {} | {} | {} |",
1108 path_line_cell(it),
1109 code_cell(it, "component_name"),
1110 code_cell(it, detail_key),
1111 )
1112}
1113
1114fn package_row(it: &Value) -> String {
1115 format!("| {} |", code_cell(it, "package_name"))
1116}
1117
1118fn stale_suppression_description(it: &Value) -> String {
1119 let origin = it.get("origin").cloned().unwrap_or(Value::Null);
1120 if s(&origin, "type") == "jsdoc_tag" {
1121 return format!(
1122 "`@expected-unused` on {}",
1123 code_cell(&origin, "export_name")
1124 );
1125 }
1126 if origin.get("kind_known").and_then(Value::as_bool) == Some(false) {
1127 return format!("unknown kind {}", code_cell(&origin, "issue_kind"));
1128 }
1129 match origin.get("issue_kind").and_then(Value::as_str) {
1130 Some(kind) => markdown_table_code_span(kind),
1131 None => "blanket".to_owned(),
1132 }
1133}
1134
1135fn check_sections_catalog() -> Vec<SectionSpec> {
1136 vec![
1137 SectionSpec {
1138 name: "Unused catalog entries",
1139 key: "unused_catalog_entries",
1140 header: "pnpm catalog entries not referenced by any workspace package.\n\n| Entry | Catalog | Location | Hardcoded consumers |\n|-------|---------|----------|---------------------|\n",
1141 row: |it| {
1142 format!(
1143 "| {} | {} | {} | {} |",
1144 code_cell(it, "entry_name"),
1145 code_cell(it, "catalog_name"),
1146 path_line_cell(it),
1147 backtick_join(it, "hardcoded_consumers"),
1148 )
1149 },
1150 },
1151 SectionSpec {
1152 name: "Empty catalog groups",
1153 key: "empty_catalog_groups",
1154 header: "Named pnpm catalog groups with no entries.\n\n| Catalog | Location |\n|---------|----------|\n",
1155 row: |it| {
1156 format!(
1157 "| {} | {} |",
1158 code_cell(it, "catalog_name"),
1159 path_line_cell(it)
1160 )
1161 },
1162 },
1163 SectionSpec {
1164 name: "Unresolved catalog references",
1165 key: "unresolved_catalog_references",
1166 header: "Workspace `package.json` references to catalogs that do not declare the package. `pnpm install` will fail until each entry is added to its named catalog or the reference is switched.\n\n| Entry | Catalog | Location | Available in |\n|-------|---------|----------|--------------|\n",
1167 row: |it| {
1168 format!(
1169 "| {} | {} | {} | {} |",
1170 code_cell(it, "entry_name"),
1171 code_cell(it, "catalog_name"),
1172 path_line_cell(it),
1173 backtick_join(it, "available_in_catalogs"),
1174 )
1175 },
1176 },
1177 SectionSpec {
1178 name: "Unused dependency overrides",
1179 key: "unused_dependency_overrides",
1180 header: "Package-manager override entries forcing a version no workspace package depends on. Some entries may be intentional pins for transitive CVEs; the hint column flags those.\n\n| Override | Forces | Source | Location | Hint |\n|----------|--------|--------|----------|------|\n",
1181 row: |it| {
1182 format!(
1183 "| {} | {} -> {} | {} | {} | {} |",
1184 code_cell(it, "raw_key"),
1185 code_cell(it, "target_package"),
1186 code_cell(it, "version_range"),
1187 code_cell(it, "source"),
1188 path_line_cell(it),
1189 markdown_table_text(str_or(it, "hint", "")),
1190 )
1191 },
1192 },
1193 SectionSpec {
1194 name: "Misconfigured dependency overrides",
1195 key: "misconfigured_dependency_overrides",
1196 header: "Package-manager override entries with an unparsable key or empty value. The active package manager will reject or ignore these.\n\n| Override | Value | Source | Location | Reason |\n|----------|-------|--------|----------|--------|\n",
1197 row: |it| {
1198 format!(
1199 "| {} | {} | {} | {} | {} |",
1200 markdown_table_code_span(str_or(it, "raw_key", "")),
1201 markdown_table_code_span(str_or(it, "raw_value", "")),
1202 code_cell(it, "source"),
1203 path_line_cell(it),
1204 markdown_table_text(str_or(it, "reason", "unparsable")),
1205 )
1206 },
1207 },
1208 ]
1209}
1210
1211fn check_tips(env: &Value) -> String {
1212 let fixable = arr(env, "unused_exports").count()
1213 + arr(env, "unused_dependencies").count()
1214 + arr(env, "unused_enum_members").count();
1215 let mut tips = String::from("\n\n> [!TIP]\n");
1216 if fixable > 0 {
1217 tips.push_str("> Run `fallow fix --dry-run` to preview safe auto-fixes.\n");
1218 }
1219 if arr(env, "unused_exports").count() > 0 {
1220 let _ = writeln!(
1221 tips,
1222 "> Intentionally public? Add [`/** @public */`]({SUPPRESSION_DOCS}) above exports to preserve them."
1223 );
1224 }
1225 let _ = write!(
1226 tips,
1227 "> Add [`// fallow-ignore-next-line`]({SUPPRESSION_DOCS}) above a line to suppress a specific finding."
1228 );
1229 tips
1230}
1231
1232#[must_use]
1234fn render_check_summary(env: &Value) -> String {
1235 let elapsed = num(env, "elapsed_ms");
1236 let total_issues = u(env, "total_issues");
1237 if total_issues == 0 {
1238 return format!(
1239 "# Fallow Analysis\n\n> [!NOTE]\n> **No issues found** \u{b7} {elapsed}ms\n\nAll exports are used, all dependencies are declared, and no issues were detected."
1240 );
1241 }
1242 let mut sections = String::new();
1243 for group in [
1244 check_sections_core(),
1245 check_sections_architecture(),
1246 check_sections_frameworks_and_hygiene(),
1247 check_sections_catalog(),
1248 ] {
1249 for spec in &group {
1250 sections.push_str(&render_check_section(env, spec));
1251 }
1252 }
1253 let issue_noun = if total_issues == 1 { "issue" } else { "issues" };
1254 format!(
1255 "# Fallow Analysis\n\n> [!WARNING]\n> **{total_issues} {issue_noun}** found \u{b7} {elapsed}ms\n\n| Category | Count |\n|----------|------:|\n{}\n\n---\n{sections}{}",
1256 dead_code_category_table(env),
1257 check_tips(env),
1258 )
1259}
1260
1261fn dupes_family_entry(family: &Value) -> String {
1266 let files: Vec<String> = arr(family, "files")
1267 .filter_map(Value::as_str)
1268 .map(markdown_code_span)
1269 .collect();
1270 let shown = files[..files.len().min(3)].join(", ");
1271 let more = if files.len() > 3 {
1272 format!(" (+{} more)", files.len() - 3)
1273 } else {
1274 String::new()
1275 };
1276 let mut entry = format!(
1277 "- **{shown}{more}** - {} lines, {} groups",
1278 num(family, "total_duplicated_lines"),
1279 arr(family, "groups").count(),
1280 );
1281 if let Some(best_group) = best_clone_group(family)
1282 && arr(best_group, "instances").next().is_some()
1283 {
1284 let locations = arr(best_group, "instances")
1285 .map(instance_location)
1286 .collect::<Vec<_>>()
1287 .join(", ");
1288 let _ = write!(entry, "\n - {locations}");
1289 }
1290 if arr(family, "suggestions").next().is_some() {
1291 let suggestions = arr(family, "suggestions")
1292 .map(|suggestion| {
1293 format!(
1294 " - {} (~{} lines)",
1295 markdown_table_text(s(suggestion, "description")),
1296 num(suggestion, "estimated_savings"),
1297 )
1298 })
1299 .collect::<Vec<_>>()
1300 .join("\n");
1301 let _ = write!(entry, "\n{suggestions}");
1302 }
1303 entry
1304}
1305
1306fn instance_location(instance: &Value) -> String {
1307 markdown_code_span(&format!(
1308 "{}:{}-{}",
1309 s(instance, "file"),
1310 num(instance, "start_line"),
1311 num(instance, "end_line"),
1312 ))
1313}
1314
1315type CloneGroupJsonRankKey = (
1316 std::cmp::Reverse<u128>,
1317 std::cmp::Reverse<u64>,
1318 std::cmp::Reverse<u64>,
1319 std::cmp::Reverse<usize>,
1320 std::cmp::Reverse<u64>,
1321 String,
1322 u64,
1323);
1324
1325fn clone_group_rank_key(group: &Value) -> CloneGroupJsonRankKey {
1326 const WEIGHTS: [u64; 9] = [
1327 1_000_000_000,
1328 1_047_319_732,
1329 1_075_000_000,
1330 1_094_639_463,
1331 1_109_873_014,
1332 1_122_319_732,
1333 1_132_843_281,
1334 1_141_959_195,
1335 1_150_000_000,
1336 ];
1337 let spread = u(group, "spread");
1338 let token_count = u(group, "token_count");
1339 let instances: Vec<&Value> = arr(group, "instances").collect();
1340 let first = instances
1341 .iter()
1342 .min_by_key(|instance| (s(instance, "file").to_string(), u(instance, "start_line")));
1343 let weight = WEIGHTS[usize::try_from(spread.min(8)).unwrap_or(8)];
1344 let score = u128::from(token_count)
1345 .saturating_mul(u128::try_from(instances.len()).unwrap_or(u128::MAX))
1346 .saturating_mul(u128::from(weight));
1347 (
1348 std::cmp::Reverse(score),
1349 std::cmp::Reverse(spread),
1350 std::cmp::Reverse(token_count),
1351 std::cmp::Reverse(instances.len()),
1352 std::cmp::Reverse(u(group, "line_count")),
1353 first.map_or_else(String::new, |instance| s(instance, "file").to_string()),
1354 first.map_or(0, |instance| u(instance, "start_line")),
1355 )
1356}
1357
1358fn sorted_clone_groups(env: &Value) -> Vec<&Value> {
1360 let mut groups: Vec<&Value> = arr(env, "clone_groups").collect();
1361 groups.sort_by_cached_key(|group| clone_group_rank_key(group));
1362 groups
1363}
1364
1365fn best_clone_group(family: &Value) -> Option<&Value> {
1366 arr(family, "groups").min_by_key(|group| clone_group_rank_key(group))
1367}
1368
1369type CloneFamilyJsonRankKey = (bool, Option<CloneGroupJsonRankKey>, Vec<String>);
1370
1371fn clone_family_rank_key(family: &Value) -> CloneFamilyJsonRankKey {
1372 let best_group = best_clone_group(family).map(clone_group_rank_key);
1373 let files = arr(family, "files")
1374 .filter_map(Value::as_str)
1375 .map(str::to_owned)
1376 .collect();
1377 (best_group.is_none(), best_group, files)
1378}
1379
1380fn sorted_clone_families(env: &Value) -> Vec<&Value> {
1381 let mut families: Vec<&Value> = arr(env, "clone_families").collect();
1382 families.sort_by_cached_key(|family| clone_family_rank_key(family));
1383 families
1384}
1385
1386fn dupes_corpus_total(env: &Value, listed: usize, omitted_key: &str) -> usize {
1393 listed.saturating_add(u(env, omitted_key) as usize)
1394}
1395
1396fn dupes_omission_tail(withheld: usize, capped_by_top: usize, noun: &str) -> String {
1405 if withheld == 0 {
1406 return String::new();
1407 }
1408 if capped_by_top == 0 {
1409 return format!("\n- *... and {withheld} more {noun}*");
1410 }
1411 format!(
1412 "\n- *... and {withheld} more {noun}, {capped_by_top} of them withheld by a display limit before this report*"
1413 )
1414}
1415
1416fn dupes_details(env: &Value) -> String {
1417 let families = sorted_clone_families(env);
1418 if families.is_empty() {
1419 let groups = sorted_clone_groups(env);
1420 let rows = groups
1421 .iter()
1422 .take(20)
1423 .map(|group| {
1424 let locations = arr(group, "instances")
1425 .map(instance_location)
1426 .collect::<Vec<_>>()
1427 .join(", ");
1428 format!(
1429 "- **{} lines, {} tokens**, {locations}",
1430 num(group, "line_count"),
1431 num(group, "token_count"),
1432 )
1433 })
1434 .collect::<Vec<_>>()
1435 .join("\n");
1436 let total = dupes_corpus_total(env, groups.len(), "clone_groups_omitted");
1440 let tail = dupes_omission_tail(
1441 total.saturating_sub(groups.len().min(20)),
1442 u(env, "clone_groups_omitted") as usize,
1443 "groups",
1444 );
1445 format!("{rows}{tail}")
1446 } else {
1447 let entries = families
1448 .iter()
1449 .take(15)
1450 .map(|family| dupes_family_entry(family))
1451 .collect::<Vec<_>>()
1452 .join("\n");
1453 let total = dupes_corpus_total(env, families.len(), "clone_families_omitted");
1454 let tail = dupes_omission_tail(
1455 total.saturating_sub(families.len().min(15)),
1456 u(env, "clone_families_omitted") as usize,
1457 "families",
1458 );
1459 format!("**Clone Families ({total})**\n\n{entries}{tail}")
1460 }
1461}
1462
1463#[must_use]
1465fn render_dupes_summary(env: &Value) -> String {
1466 let stats = env.get("stats").cloned().unwrap_or(Value::Null);
1467 let elapsed = num(env, "elapsed_ms");
1468 if u(&stats, "clone_groups") == 0 {
1469 return format!(
1470 "## Fallow - Code Duplication\n\nNo code duplication found.\n\n*Analyzed {} files in {elapsed}ms*",
1471 num(&stats, "total_files"),
1472 );
1473 }
1474 format!(
1475 "## Fallow - Code Duplication\n\nFound **{} clone groups** ({} instances) across {} files in {elapsed}ms\n\n| Metric | Value |\n|--------|-------|\n| Files analyzed | {} |\n| Files with clones | {} |\n| Clone groups | {} |\n| Clone instances | {} |\n| Duplicated lines | {} / {} ({}%) |\n\n<details>\n<summary>View details</summary>\n\n{}\n\n</details>",
1476 num(&stats, "clone_groups"),
1477 num(&stats, "clone_instances"),
1478 num(&stats, "files_with_clones"),
1479 num(&stats, "total_files"),
1480 num(&stats, "files_with_clones"),
1481 num(&stats, "clone_groups"),
1482 num(&stats, "clone_instances"),
1483 num(&stats, "duplicated_lines"),
1484 num(&stats, "total_lines"),
1485 pct(f_or_zero(&stats, "duplication_percentage")),
1486 dupes_details(env),
1487 )
1488}
1489
1490fn metric_delta<'v>(score_env: &'v Value, name: &str) -> Option<&'v Value> {
1495 score_env
1496 .get("health_trend")
1497 .and_then(|trend| trend.get("metrics"))
1498 .and_then(Value::as_array)
1499 .and_then(|metrics| metrics.iter().find(|metric| s(metric, "name") == name))
1500}
1501
1502fn health_score_header(score_env: &Value) -> String {
1505 let Some(score) = score_env
1506 .get("health_score")
1507 .filter(|value| !value.is_null())
1508 else {
1509 return String::new();
1510 };
1511 let mut header = format!(
1512 "> **Health: {} ({})**",
1513 s(score, "grade"),
1514 pct(f_or_zero(score, "score")),
1515 );
1516 if let Some(score_delta) = metric_delta(score_env, "score") {
1517 let compared = score_env
1518 .get("health_trend")
1519 .and_then(|trend| trend.get("compared_to"))
1520 .cloned()
1521 .unwrap_or(Value::Null);
1522 let _ = write!(
1523 header,
1524 " \u{b7} {} pts vs previous ({} {})",
1525 signed(f_or_zero(score_delta, "delta")),
1526 s(&compared, "grade"),
1527 pct(f_or_zero(&compared, "score")),
1528 );
1529 if let Some(dead_delta) = metric_delta(score_env, "dead_export_pct")
1530 && f_or_zero(dead_delta, "delta") != 0.0
1531 {
1532 let _ = write!(
1533 header,
1534 " \u{b7} {} {}% ({}%)",
1535 s(dead_delta, "label").to_ascii_lowercase(),
1536 pct(f_or_zero(dead_delta, "current")),
1537 signed(f_or_zero(dead_delta, "delta")),
1538 );
1539 if f_or_zero(dead_delta, "delta") > 0.0 {
1540 let _ = write!(header, " [suppress?]({SUPPRESSION_DOCS})");
1541 }
1542 }
1543 if let Some(cx_delta) = metric_delta(score_env, "avg_cyclomatic")
1544 && f_or_zero(cx_delta, "delta") != 0.0
1545 {
1546 let _ = write!(
1547 header,
1548 " \u{b7} {} {} ({})",
1549 s(cx_delta, "label").to_ascii_lowercase(),
1550 pct(f_or_zero(cx_delta, "current")),
1551 signed(f_or_zero(cx_delta, "delta")),
1552 );
1553 }
1554 } else if let Some(compared) = score_env
1555 .get("health_trend")
1556 .and_then(|trend| trend.get("compared_to"))
1557 {
1558 let previous_formula = compared
1559 .get("score_formula_version")
1560 .and_then(Value::as_u64)
1561 .and_then(|version| u32::try_from(version).ok());
1562 let current_formula = score
1563 .get("formula_version")
1564 .and_then(Value::as_u64)
1565 .and_then(|version| u32::try_from(version).ok());
1566 let note = compared
1567 .get("score")
1568 .and_then(Value::as_f64)
1569 .and_then(|_| {
1570 fallow_output::health_score_comparison_note(previous_formula, current_formula)
1571 })
1572 .unwrap_or("Score comparison unavailable for this snapshot.");
1573 let _ = write!(header, "\n> _{note}_");
1574 } else {
1575 header.push_str("\n> _Enable `save-snapshot: true` to track score trends over time._");
1576 }
1577 header.push_str("\n\n");
1578 header
1579}
1580
1581fn exceeded_marker(it: &Value, needles: &[&str]) -> &'static str {
1582 let exceeded = s(it, "exceeded");
1583 if needles.iter().any(|needle| exceeded.contains(needle)) {
1584 " **!**"
1585 } else {
1586 ""
1587 }
1588}
1589
1590fn crap_cell(it: &Value) -> String {
1591 match it.get("crap").filter(|crap| !crap.is_null()) {
1592 None => "-".to_owned(),
1593 Some(crap) => format!("{}{}", fmt_num(crap), exceeded_marker(it, &["crap", "all"])),
1594 }
1595}
1596
1597fn complexity_table_row(it: &Value) -> String {
1598 format!(
1599 "| {} | {} | {} | {}{} | {}{} | {} | {} |",
1600 path_line_cell(it),
1601 code_cell(it, "name"),
1602 markdown_table_text(str_or(it, "severity", "moderate")),
1603 num(it, "cyclomatic"),
1604 exceeded_marker(it, &["cyclomatic", "both", "all"]),
1605 num(it, "cognitive"),
1606 exceeded_marker(it, &["cognitive", "both", "all"]),
1607 crap_cell(it),
1608 num(it, "line_count"),
1609 )
1610}
1611
1612const COMPLEXITY_TABLE_HEADER: &str = "| File | Function | Severity | Cyclomatic | Cognitive | CRAP | Lines |\n|:-----|:---------|:---------|:-----------|:----------|:-----|:------|\n";
1613
1614fn health_thresholds_footer(env: &Value) -> String {
1615 let summary = env.get("summary").cloned().unwrap_or(Value::Null);
1616 format!(
1617 "\n\n**!** marks the dimension that breached.\n\n**{}** files, **{}** functions analyzed (thresholds: cyclomatic > {}, cognitive > {}, CRAP >= {})",
1618 num(&summary, "files_analyzed"),
1619 num(&summary, "functions_analyzed"),
1620 num(&summary, "max_cyclomatic_threshold"),
1621 num(&summary, "max_cognitive_threshold"),
1622 threshold_or(&summary, "max_crap_threshold", "30"),
1623 )
1624}
1625
1626fn threshold_or(summary: &Value, key: &str, default: &str) -> String {
1627 summary
1628 .get(key)
1629 .filter(|value| !value.is_null())
1630 .map_or_else(|| default.to_owned(), fmt_num)
1631}
1632
1633fn complexity_rows(findings: &[&Value], cap: usize) -> String {
1634 findings
1635 .iter()
1636 .take(cap)
1637 .map(|finding| complexity_table_row(finding))
1638 .collect::<Vec<_>>()
1639 .join("\n")
1640}
1641
1642fn runtime_finding_row(it: &Value) -> String {
1643 let invocations = it
1644 .get("invocations")
1645 .filter(|value| !value.is_null())
1646 .map_or_else(|| "-".to_owned(), fmt_num);
1647 format!(
1648 "| {} | {} | {} | {invocations} | {} |",
1649 path_line_cell(it),
1650 code_cell(it, "function"),
1651 code_cell(it, "verdict"),
1652 markdown_table_text(s(it, "confidence")),
1653 )
1654}
1655
1656fn baselined_complexity(summary: &Value) -> Option<(u64, u64)> {
1664 let remaining = summary
1665 .get("baseline_staleness")?
1666 .get("remaining_findings")?
1667 .as_u64()?;
1668 let accepted = u(summary, "functions_above_threshold").saturating_sub(remaining);
1669 Some((remaining, accepted))
1670}
1671
1672fn render_health_complexity_only(env: &Value, complex: usize, elapsed: &str) -> String {
1673 let summary = env.get("summary").cloned().unwrap_or(Value::Null);
1674 let above = u(&summary, "functions_above_threshold");
1675 let listing = complexity_listing(env, &summary, complex, above);
1676 if listing.count_unlisted() {
1677 return render_health_complexity_not_listed(&summary, above, &listing, elapsed);
1678 }
1679 let baselined = baselined_complexity(&summary).filter(|&(_, accepted)| accepted > 0);
1680 if complex == 0 {
1681 let headline = baselined
1684 .filter(|&(remaining, _)| remaining == 0)
1685 .map_or_else(
1686 || "No functions exceed complexity thresholds".to_owned(),
1687 |(_, accepted)| {
1688 format!(
1689 "No new functions exceed complexity thresholds ({accepted} in the baseline)"
1690 )
1691 },
1692 );
1693 return format!(
1694 "## Fallow - Code Complexity\n\n> [!NOTE]\n> **{headline}** \u{b7} {elapsed}ms\n\n{} functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {})",
1695 num(&summary, "functions_analyzed"),
1696 num(&summary, "max_cyclomatic_threshold"),
1697 num(&summary, "max_cognitive_threshold"),
1698 threshold_or(&summary, "max_crap_threshold", "30"),
1699 );
1700 }
1701 let findings: Vec<&Value> = arr(env, "findings").collect();
1702 let tail = if complex > 25 {
1703 format!(
1704 "\n\n> {} more - run `fallow health` locally for the full list",
1705 complex - 25
1706 )
1707 } else {
1708 String::new()
1709 };
1710 let (shown, new, baseline_note) = baselined
1711 .map_or((above, "", String::new()), |(remaining, accepted)| {
1712 (remaining, "new ", format!(" ({accepted} in the baseline)"))
1713 });
1714 format!(
1715 "## Fallow - Code Complexity\n\n> [!WARNING]\n> **{shown} {new}function{} exceed{} thresholds{baseline_note}** \u{b7} {elapsed}ms\n\n{COMPLEXITY_TABLE_HEADER}{}{tail}{}",
1716 if shown == 1 { "" } else { "s" },
1717 if shown == 1 { "s" } else { "" },
1718 complexity_rows(&findings, 25),
1719 health_thresholds_footer(env),
1720 )
1721}
1722
1723fn complexity_listing(
1726 env: &Value,
1727 summary: &Value,
1728 complex: usize,
1729 above: u64,
1730) -> fallow_api::ComplexityListing {
1731 let staleness = summary
1732 .get("baseline_staleness")
1733 .filter(|value| !value.is_null());
1734 fallow_api::ComplexityListing {
1735 listed: complex,
1736 above: usize::try_from(above).unwrap_or(usize::MAX),
1737 complexity_section: env
1739 .get("sections")
1740 .and_then(Value::as_array)
1741 .map(|sections| sections.iter().any(|s| s.as_str() == Some("complexity"))),
1742 score_only: env.get("vital_signs").is_none_or(Value::is_null),
1744 baselined: staleness.is_some(),
1745 remaining: staleness
1746 .and_then(|staleness| staleness.get("remaining_findings"))
1747 .and_then(Value::as_u64)
1748 .map(|remaining| usize::try_from(remaining).unwrap_or(usize::MAX)),
1749 }
1750}
1751
1752fn render_health_complexity_not_listed(
1756 summary: &Value,
1757 above: u64,
1758 listing: &fallow_api::ComplexityListing,
1759 elapsed: &str,
1760) -> String {
1761 format!(
1762 "## Fallow - Code Complexity\n\n> [!NOTE]\n> **{above} function{} exceed{} thresholds** \u{b7} {elapsed}ms\n\n{} functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {}). {}",
1763 if above == 1 { "" } else { "s" },
1764 if above == 1 { "s" } else { "" },
1765 num(summary, "functions_analyzed"),
1766 num(summary, "max_cyclomatic_threshold"),
1767 num(summary, "max_cognitive_threshold"),
1768 threshold_or(summary, "max_crap_threshold", "30"),
1769 listing.not_listed_note(),
1770 )
1771}
1772
1773fn prod_phrase(complex: usize, prod: usize) -> String {
1774 let complexity = format!(
1775 "{complex} complexity finding{}",
1776 if complex == 1 { "" } else { "s" }
1777 );
1778 let runtime = format!(
1779 "{prod} runtime coverage finding{}",
1780 if prod == 1 { "" } else { "s" }
1781 );
1782 if complex > 0 && prod > 0 {
1783 format!("{complexity} and {runtime}")
1784 } else if complex > 0 {
1785 complexity
1786 } else {
1787 runtime
1788 }
1789}
1790
1791fn render_health_with_runtime(env: &Value, complex: usize, elapsed: &str) -> String {
1792 let runtime = env.get("runtime_coverage").cloned().unwrap_or(Value::Null);
1793 let prod_findings: Vec<&Value> = arr(&runtime, "findings").collect();
1794 let hot_paths: Vec<&Value> = arr(&runtime, "hot_paths").collect();
1795 let prod = prod_findings.len();
1796 let hot = hot_paths.len();
1797 let mut out = String::from("## Fallow - Health\n\n");
1798 if complex == 0 && prod == 0 {
1799 let _ = write!(
1800 out,
1801 "> [!NOTE]\n> **No failing health findings** \u{b7} {elapsed}ms\n\n"
1802 );
1803 } else {
1804 let _ = write!(
1805 out,
1806 "> [!WARNING]\n> **{}** \u{b7} {elapsed}ms\n\n",
1807 prod_phrase(complex, prod),
1808 );
1809 }
1810 if complex > 0 {
1811 let findings: Vec<&Value> = arr(env, "findings").collect();
1812 let _ = write!(
1813 out,
1814 "### Complexity\n\n{COMPLEXITY_TABLE_HEADER}{}",
1815 complexity_rows(&findings, 25),
1816 );
1817 if complex > 25 {
1818 let _ = write!(
1819 out,
1820 "\n\n> {} more complexity findings - run `fallow health` locally for the full list",
1821 complex - 25,
1822 );
1823 }
1824 }
1825 if prod > 0 {
1826 if complex > 0 {
1827 out.push_str("\n\n");
1828 }
1829 out.push_str("### Runtime Coverage\n\n| File | Function | Verdict | Invocations | Confidence |\n|:-----|:---------|:--------|------------:|:-----------|\n");
1830 out.push_str(
1831 &prod_findings
1832 .iter()
1833 .take(25)
1834 .map(|finding| runtime_finding_row(finding))
1835 .collect::<Vec<_>>()
1836 .join("\n"),
1837 );
1838 if prod > 25 {
1839 let _ = write!(
1840 out,
1841 "\n\n> {} more runtime coverage findings - run `fallow health` locally for the full list",
1842 prod - 25,
1843 );
1844 }
1845 }
1846 if hot > 0 {
1847 if complex > 0 || prod > 0 {
1848 out.push_str("\n\n");
1849 }
1850 out.push_str("### Hot Paths\n\n| File | Function | Invocations | Percentile |\n|:-----|:---------|------------:|-----------:|\n");
1851 out.push_str(
1852 &hot_paths
1853 .iter()
1854 .take(10)
1855 .map(|path| {
1856 format!(
1857 "| {} | {} | {} | {} |",
1858 path_line_cell(path),
1859 code_cell(path, "function"),
1860 num(path, "invocations"),
1861 num(path, "percentile"),
1862 )
1863 })
1864 .collect::<Vec<_>>()
1865 .join("\n"),
1866 );
1867 if hot > 10 {
1868 let _ = write!(out, "\n\n> {} more hot paths in the full report", hot - 10);
1869 }
1870 }
1871 out.push_str(&health_runtime_footer(env, complex, prod, hot, &runtime));
1872 out
1873}
1874
1875fn health_runtime_footer(
1876 env: &Value,
1877 complex: usize,
1878 prod: usize,
1879 hot: usize,
1880 runtime: &Value,
1881) -> String {
1882 if complex > 0 {
1883 return health_thresholds_footer(env);
1884 }
1885 if prod > 0 {
1886 let summary = runtime.get("summary").cloned().unwrap_or(Value::Null);
1887 return format!(
1888 "\n\n**{}** tracked functions, **{}** hit, **{}** unhit, **{}** untracked",
1889 num(&summary, "functions_tracked"),
1890 num(&summary, "functions_hit"),
1891 num(&summary, "functions_unhit"),
1892 num(&summary, "functions_untracked"),
1893 );
1894 }
1895 format!(
1896 "\n\nObserved **{hot}** hot path{} in runtime coverage.",
1897 if hot == 1 { "" } else { "s" },
1898 )
1899}
1900
1901#[must_use]
1903fn render_health_summary(env: &Value) -> String {
1904 let elapsed = num(env, "elapsed_ms");
1905 let complex = arr(env, "findings").count();
1906 let runtime = env.get("runtime_coverage").cloned().unwrap_or(Value::Null);
1907 let prod = arr(&runtime, "findings").count();
1908 let hot = arr(&runtime, "hot_paths").count();
1909 let body = if prod == 0 && hot == 0 {
1910 render_health_complexity_only(env, complex, &elapsed)
1911 } else {
1912 render_health_with_runtime(env, complex, &elapsed)
1913 };
1914 let groups = fallow_api::build_health_groups_markdown(env, "");
1915 if groups.is_empty() {
1916 format!("{}{body}", health_score_header(env))
1917 } else {
1918 format!("{}{body}\n\n{groups}", health_score_header(env))
1919 }
1920}
1921
1922const fn audit_verdict_label(verdict: &str) -> &'static str {
1927 match verdict.as_bytes() {
1928 b"fail" => "[!WARNING]\n> **Audit failed**",
1929 b"warn" => "[!WARNING]\n> **Audit passed with warnings**",
1930 _ => "[!NOTE]\n> **Audit passed**",
1931 }
1932}
1933
1934fn introduced_label(item: &Value) -> &'static str {
1935 match item.get("introduced").and_then(Value::as_bool) {
1936 Some(true) => "new",
1937 Some(false) => "inherited",
1938 None => "-",
1939 }
1940}
1941
1942struct AuditRow {
1943 kind: &'static str,
1944 location: String,
1945 item: String,
1946 status: &'static str,
1947}
1948
1949fn audit_row(kind: &'static str, location: String, item: String, finding: &Value) -> AuditRow {
1950 AuditRow {
1951 kind,
1952 location,
1953 item,
1954 status: introduced_label(finding),
1955 }
1956}
1957
1958type AuditRowSpec = (&'static str, &'static str, fn(&Value) -> String);
1959
1960const AUDIT_EXPORT_DEP_ROWS: &[AuditRowSpec] = &[
1963 ("Unused export", "unused_exports", |it| {
1964 code_cell(it, "export_name")
1965 }),
1966 ("Unused type", "unused_types", |it| {
1967 code_cell(it, "export_name")
1968 }),
1969 ("Private type leak", "private_type_leaks", |it| {
1970 format!(
1971 "{} -> {}",
1972 code_cell(it, "export_name"),
1973 code_cell(it, "type_name")
1974 )
1975 }),
1976 (
1977 "Deprecated export in use",
1978 "deprecated_exports_in_use",
1979 |it| code_cell(it, "export_name"),
1980 ),
1981 ("Unused dependency", "unused_dependencies", |it| {
1982 code_cell(it, "package_name")
1983 }),
1984 ("Unused devDependency", "unused_dev_dependencies", |it| {
1985 code_cell(it, "package_name")
1986 }),
1987 (
1988 "Unused optionalDependency",
1989 "unused_optional_dependencies",
1990 |it| code_cell(it, "package_name"),
1991 ),
1992 ("Unused enum member", "unused_enum_members", member_item),
1993 ("Unused class member", "unused_class_members", member_item),
1994 ("Unused store member", "unused_store_members", member_item),
1995 ("Unresolved import", "unresolved_imports", |it| {
1996 code_cell(it, "specifier")
1997 }),
1998];
1999
2000const AUDIT_COMPONENT_ROWS: &[AuditRowSpec] = &[
2002 ("Unrendered component", "unrendered_components", |it| {
2003 format!(
2004 "{} ({})",
2005 code_cell(it, "component_name"),
2006 markdown_table_text(s(it, "framework"))
2007 )
2008 }),
2009 ("Unused component prop", "unused_component_props", |it| {
2010 component_member_item(it, "prop_name")
2011 }),
2012 (
2013 "Absent optional component prop",
2014 "absent_component_props",
2015 |it| component_member_item(it, "prop_name"),
2016 ),
2017 ("Unused component emit", "unused_component_emits", |it| {
2018 format!(
2019 "{} emit {}",
2020 code_cell(it, "component_name"),
2021 code_cell(it, "emit_name")
2022 )
2023 }),
2024 ("Unused component input", "unused_component_inputs", |it| {
2025 component_member_item(it, "input_name")
2026 }),
2027 (
2028 "Unused component output",
2029 "unused_component_outputs",
2030 |it| {
2031 format!(
2032 "{} output {}",
2033 code_cell(it, "component_name"),
2034 code_cell(it, "output_name")
2035 )
2036 },
2037 ),
2038 ("Unused Svelte event", "unused_svelte_events", |it| {
2039 format!(
2040 "{} event {}",
2041 code_cell(it, "component_name"),
2042 code_cell(it, "event_name")
2043 )
2044 }),
2045 ("Unprovided inject", "unprovided_injects", |it| {
2046 format!(
2047 "{} ({})",
2048 code_cell(it, "key_name"),
2049 markdown_table_text(s(it, "framework"))
2050 )
2051 }),
2052 ("Unused load data key", "unused_load_data_keys", |it| {
2053 code_cell(it, "key_name")
2054 }),
2055];
2056
2057const AUDIT_HYGIENE_ROWS: &[AuditRowSpec] = &[
2059 ("Type-only dependency", "type_only_dependencies", |it| {
2060 code_cell(it, "package_name")
2061 }),
2062 ("Test-only dependency", "test_only_dependencies", |it| {
2063 code_cell(it, "package_name")
2064 }),
2065 (
2066 "Dev dependency in production",
2067 "dev_dependencies_in_production",
2068 |it| code_cell(it, "package_name"),
2069 ),
2070 ("Stale suppression", "stale_suppressions", |it| {
2071 markdown_table_code_span(str_or(it, "description", "suppression"))
2074 }),
2075 ("Unused catalog entry", "unused_catalog_entries", |it| {
2076 format!(
2077 "{} ({})",
2078 code_cell(it, "entry_name"),
2079 code_cell(it, "catalog_name")
2080 )
2081 }),
2082 ("Empty catalog group", "empty_catalog_groups", |it| {
2083 code_cell(it, "catalog_name")
2084 }),
2085 (
2086 "Unresolved catalog reference",
2087 "unresolved_catalog_references",
2088 |it| {
2089 format!(
2090 "{} -> {}",
2091 code_cell(it, "entry_name"),
2092 code_cell(it, "catalog_name")
2093 )
2094 },
2095 ),
2096 (
2097 "Unused dependency override",
2098 "unused_dependency_overrides",
2099 |it| format!("{} ({})", code_cell(it, "raw_key"), code_cell(it, "source")),
2100 ),
2101 (
2102 "Misconfigured dependency override",
2103 "misconfigured_dependency_overrides",
2104 |it| format!("{} ({})", code_cell(it, "raw_key"), code_cell(it, "source")),
2105 ),
2106];
2107
2108fn audit_rows_from_table(dead_code: &Value, table: &[AuditRowSpec], rows: &mut Vec<AuditRow>) {
2109 for (kind, key, item_fn) in table {
2110 for finding in arr(dead_code, key) {
2111 rows.push(audit_row(
2112 kind,
2113 path_line(finding),
2114 item_fn(finding),
2115 finding,
2116 ));
2117 }
2118 }
2119}
2120
2121fn member_item(it: &Value) -> String {
2122 markdown_table_code_span(&format!(
2123 "{}.{}",
2124 s(it, "parent_name"),
2125 s(it, "member_name")
2126 ))
2127}
2128
2129fn component_member_item(it: &Value, member_key: &str) -> String {
2130 markdown_table_code_span(&format!(
2131 "{}.{}",
2132 s(it, "component_name"),
2133 s(it, member_key)
2134 ))
2135}
2136
2137fn first_import_site(it: &Value) -> String {
2138 arr(it, "imported_from").next().map_or_else(
2139 || path_line(it),
2140 |site| {
2141 markdown_table_code_span(&format!(
2142 "{}:{}",
2143 rel_path_absolute_only(s(site, "path")),
2144 num(site, "line"),
2145 ))
2146 },
2147 )
2148}
2149
2150fn audit_rows_graph(dead_code: &Value, rows: &mut Vec<AuditRow>) {
2153 for it in arr(dead_code, "unlisted_dependencies") {
2154 rows.push(audit_row(
2155 "Unlisted dependency",
2156 first_import_site(it),
2157 code_cell(it, "package_name"),
2158 it,
2159 ));
2160 }
2161 for it in arr(dead_code, "duplicate_exports") {
2162 let location = arr(it, "locations")
2163 .take(3)
2164 .map(|loc| {
2165 markdown_table_code_span(&format!(
2166 "{}:{}",
2167 rel_path_absolute_only(s(loc, "path")),
2168 num(loc, "line")
2169 ))
2170 })
2171 .collect::<Vec<_>>()
2172 .join(", ");
2173 rows.push(audit_row(
2174 "Duplicate export",
2175 location,
2176 code_cell(it, "export_name"),
2177 it,
2178 ));
2179 }
2180 for it in arr(dead_code, "circular_dependencies") {
2181 let location = arr(it, "files")
2182 .filter_map(Value::as_str)
2183 .map(|file| markdown_table_code_span(&rel_path_absolute_only(file)))
2184 .collect::<Vec<_>>()
2185 .join(" -> ");
2186 rows.push(audit_row(
2187 "Circular dependency",
2188 location,
2189 "cycle".to_owned(),
2190 it,
2191 ));
2192 }
2193 for it in arr(dead_code, "re_export_cycles") {
2194 let location = arr(it, "files")
2195 .filter_map(Value::as_str)
2196 .map(|file| markdown_table_code_span(&rel_path_absolute_only(file)))
2197 .collect::<Vec<_>>()
2198 .join(" <-> ");
2199 rows.push(audit_row(
2200 "Re-export cycle",
2201 location,
2202 markdown_table_text(str_or(it, "kind", "cycle")),
2203 it,
2204 ));
2205 }
2206 for it in arr(dead_code, "package_cycles") {
2207 let location = arr(it, "packages")
2208 .filter_map(Value::as_str)
2209 .map(markdown_table_code_span)
2210 .collect::<Vec<_>>()
2211 .join(" -> ");
2212 let detail = if it.get("group_truncated").and_then(Value::as_bool) == Some(true) {
2213 format!(
2214 "cycle ({})",
2215 fallow_types::results::PackageCycle::GROUP_TRUNCATED_NOTE
2216 )
2217 } else {
2218 "cycle".to_owned()
2219 };
2220 rows.push(audit_row("Package cycle", location, detail, it));
2221 }
2222}
2223
2224fn audit_rows_boundaries(dead_code: &Value, rows: &mut Vec<AuditRow>) {
2226 for it in arr(dead_code, "boundary_violations") {
2227 rows.push(audit_row(
2228 "Boundary violation",
2229 rel_path_line_cell(it, "from_path"),
2230 format!(
2231 "{} -> {}",
2232 markdown_table_code_span(s(it, "from_zone")),
2233 markdown_table_code_span(s(it, "to_zone"))
2234 ),
2235 it,
2236 ));
2237 }
2238 for it in arr(dead_code, "boundary_coverage_violations") {
2239 rows.push(audit_row(
2240 "Boundary coverage",
2241 rel_path_line_cell(it, "path"),
2242 "no matching zone".to_owned(),
2243 it,
2244 ));
2245 }
2246 for it in arr(dead_code, "boundary_call_violations") {
2247 rows.push(audit_row(
2248 "Boundary call",
2249 rel_path_line_cell(it, "path"),
2250 format!(
2251 "{} in {}",
2252 code_cell(it, "callee"),
2253 markdown_table_code_span(s(it, "zone"))
2254 ),
2255 it,
2256 ));
2257 }
2258 for it in arr(dead_code, "policy_violations") {
2259 rows.push(audit_row(
2260 "Policy violation",
2261 rel_path_line_cell(it, "path"),
2262 format!(
2263 "{} banned by {}",
2264 code_cell(it, "matched"),
2265 markdown_table_code_span(&format!("{}/{}", s(it, "pack"), s(it, "rule_id")))
2266 ),
2267 it,
2268 ));
2269 }
2270}
2271
2272fn audit_rows_frameworks(dead_code: &Value, rows: &mut Vec<AuditRow>) {
2275 for it in arr(dead_code, "invalid_client_exports") {
2276 rows.push(audit_row(
2277 "Invalid client export",
2278 rel_path_line_cell(it, "path"),
2279 format!(
2280 "{} in {}",
2281 code_cell(it, "export_name"),
2282 markdown_table_code_span(&format!("\"{}\"", s(it, "directive")))
2283 ),
2284 it,
2285 ));
2286 }
2287 for it in arr(dead_code, "mixed_client_server_barrels") {
2288 rows.push(audit_row(
2289 "Mixed client/server barrel",
2290 rel_path_line_cell(it, "path"),
2291 format!(
2292 "{} + {}",
2293 code_cell(it, "client_origin"),
2294 code_cell(it, "server_origin")
2295 ),
2296 it,
2297 ));
2298 }
2299 for it in arr(dead_code, "misplaced_directives") {
2300 rows.push(audit_row(
2301 "Misplaced directive",
2302 rel_path_line_cell(it, "path"),
2303 markdown_table_code_span(&format!("\"{}\"", s(it, "directive"))),
2304 it,
2305 ));
2306 }
2307 for it in arr(dead_code, "unused_server_actions") {
2308 rows.push(audit_row(
2309 "Unused server action",
2310 path_line(it),
2311 code_cell(it, "action_name"),
2312 it,
2313 ));
2314 }
2315 for it in arr(dead_code, "route_collisions") {
2316 rows.push(audit_row(
2317 "Route collision",
2318 markdown_table_code_span(&rel_path_absolute_only(s(it, "path"))),
2319 code_cell(it, "url"),
2320 it,
2321 ));
2322 }
2323 for it in arr(dead_code, "dynamic_segment_name_conflicts") {
2324 rows.push(audit_row(
2325 "Dynamic segment conflict",
2326 markdown_table_code_span(&rel_path_absolute_only(s(it, "path"))),
2327 markdown_table_code_span(&plain_join(it, "conflicting_segments", ", ")),
2328 it,
2329 ));
2330 }
2331}
2332
2333fn audit_dead_code_rows(dead_code: &Value) -> Vec<AuditRow> {
2335 let mut rows: Vec<AuditRow> = Vec::new();
2336 for it in arr(dead_code, "unused_files") {
2337 rows.push(audit_row(
2338 "Unused file",
2339 markdown_table_code_span(&rel_path_absolute_only(s(it, "path"))),
2340 "-".to_owned(),
2341 it,
2342 ));
2343 }
2344 audit_rows_from_table(dead_code, AUDIT_EXPORT_DEP_ROWS, &mut rows);
2345 audit_rows_graph(dead_code, &mut rows);
2346 audit_rows_boundaries(dead_code, &mut rows);
2347 audit_rows_frameworks(dead_code, &mut rows);
2348 audit_rows_from_table(dead_code, AUDIT_COMPONENT_ROWS, &mut rows);
2349 audit_rows_from_table(dead_code, AUDIT_HYGIENE_ROWS, &mut rows);
2350 rows
2351}
2352
2353fn audit_complexity_section(env: &Value) -> String {
2354 let complexity = env.get("complexity").cloned().unwrap_or(Value::Null);
2355 let findings: Vec<&Value> = arr(&complexity, "findings").collect();
2356 if findings.is_empty() {
2357 return String::new();
2358 }
2359 let rows = findings
2360 .iter()
2361 .take(15)
2362 .map(|it| {
2363 format!(
2364 "| {} | {} | {} | {} | {} | {} | {} | {} |",
2365 path_line_cell(it),
2366 code_cell(it, "name"),
2367 introduced_label(it),
2368 markdown_table_text(str_or(it, "severity", "moderate")),
2369 num(it, "cyclomatic"),
2370 num(it, "cognitive"),
2371 markdown_table_text(str_or(it, "coverage_tier", "-")),
2372 it.get("crap")
2373 .filter(|crap| !crap.is_null())
2374 .map_or_else(|| "-".to_owned(), fmt_num),
2375 )
2376 })
2377 .collect::<Vec<_>>()
2378 .join("\n");
2379 let tail = if findings.len() > 15 {
2380 format!(
2381 "\n\n> {} more complexity findings in the full audit report",
2382 findings.len() - 15,
2383 )
2384 } else {
2385 String::new()
2386 };
2387 format!(
2388 "### Complexity\n\n| File | Function | Status | Severity | Cyclomatic | Cognitive | Coverage | CRAP |\n|:-----|:---------|:-------|:---------|:-----------|:----------|:---------|:-----|\n{rows}{tail}{}\n\n",
2389 audit_coverage_model_note(&complexity),
2390 )
2391}
2392
2393fn audit_coverage_join_suffix(summary: &Value) -> String {
2401 let matched = summary
2402 .get("istanbul_files_matched")
2403 .and_then(Value::as_u64);
2404 let total = summary.get("istanbul_files_total").and_then(Value::as_u64);
2405 match (matched, total) {
2406 (Some(matched), Some(total)) if total > 0 && matched == 0 => format!(
2407 ", from a coverage file describing {total} files that none of the analyzed files matched; check `--coverage-root` is correct for this checkout."
2408 ),
2409 (Some(matched), Some(total)) if total > 0 && matched < total => format!(
2410 ", from {matched} of {total} files in the coverage file; the rest matched no analyzed file."
2411 ),
2412 _ => ".".to_owned(),
2413 }
2414}
2415
2416fn audit_coverage_model_note(complexity: &Value) -> String {
2417 let summary = complexity.get("summary").cloned().unwrap_or(Value::Null);
2418 let model = summary.get("coverage_model").and_then(Value::as_str);
2419 match model {
2420 Some("istanbul") => {
2421 let matched = summary.get("istanbul_matched").and_then(Value::as_u64);
2422 let total = summary.get("istanbul_total").and_then(Value::as_u64);
2423 match (matched, total) {
2424 (Some(matched), Some(total)) if total > 0 => {
2425 format!(
2426 "\n\n*Coverage model: istanbul. Matched {matched}/{total} functions{}*",
2427 audit_coverage_join_suffix(&summary)
2428 )
2429 }
2430 _ => "\n\n*Coverage model: istanbul (exact, from `--coverage`).*".to_owned(),
2431 }
2432 }
2433 Some("static_estimated" | "static_binary") => {
2434 "\n\n*Coverage model: static (estimated). Pair with `--coverage <coverage-final.json>` for measured coverage instead of estimates.*".to_owned()
2435 }
2436 _ => String::new(),
2437 }
2438}
2439
2440fn audit_duplication_section(env: &Value) -> String {
2441 let duplication = env.get("duplication").cloned().unwrap_or(Value::Null);
2442 let groups: Vec<&Value> = arr(&duplication, "clone_groups").collect();
2443 if groups.is_empty() {
2444 return String::new();
2445 }
2446 let rows = groups
2447 .iter()
2448 .take(10)
2449 .map(|group| {
2450 let instances: Vec<&Value> = arr(group, "instances").collect();
2451 let location = instances.first().map_or_else(
2452 || "-".to_owned(),
2453 |first| {
2454 let file = s(first, "file");
2455 if file.is_empty() {
2456 "-".to_owned()
2457 } else {
2458 let start = first
2459 .get("start_line")
2460 .filter(|line| !line.is_null())
2461 .map_or_else(|| "1".to_owned(), fmt_num);
2462 markdown_table_code_span(&format!(
2463 "{}:{start}",
2464 rel_path_absolute_only(file)
2465 ))
2466 }
2467 },
2468 );
2469 let mut files: Vec<String> = instances
2470 .iter()
2471 .map(|instance| {
2472 markdown_table_code_span(&rel_path_absolute_only(s(instance, "file")))
2473 })
2474 .collect();
2475 files.sort();
2476 files.dedup();
2477 let files = files.into_iter().take(3).collect::<Vec<_>>().join(", ");
2478 format!(
2479 "| {location} | {files} | {} lines / {} tokens | {} | {} |",
2480 num(group, "line_count"),
2481 num(group, "token_count"),
2482 instances.len(),
2483 introduced_label(group),
2484 )
2485 })
2486 .collect::<Vec<_>>()
2487 .join("\n");
2488 let tail = if groups.len() > 10 {
2489 format!(
2490 "\n\n> {} more clone groups in the full audit report",
2491 groups.len() - 10
2492 )
2493 } else {
2494 String::new()
2495 };
2496 format!(
2497 "### Duplication\n\n| Location | Files | Size | Instances | Status |\n|:---------|:------|:-----|----------:|:-------|\n{rows}{tail}\n\n"
2498 )
2499}
2500
2501#[must_use]
2503fn render_audit_summary(env: &Value) -> String {
2504 let verdict = str_or(env, "verdict", "pass");
2505 let summary = env.get("summary").cloned().unwrap_or(Value::Null);
2506 let attribution = env.get("attribution").cloned().unwrap_or(Value::Null);
2507 let dead_code = env.get("dead_code").cloned().unwrap_or(Value::Null);
2508 let dead_rows = audit_dead_code_rows(&dead_code);
2509
2510 let mut out = format!(
2511 "## Fallow Audit\n\n> {} \u{b7} {} \u{b7} {}ms\n\n| Category | Findings | Introduced | Inherited |\n|:---------|---------:|-----------:|----------:|\n| Dead code | {} | {} | {} |\n| Complexity | {} | {} | {} |\n| Duplication | {} | {} | {} |\n\n",
2512 audit_verdict_label(verdict),
2513 plural_n(u(env, "changed_files_count") as usize, "changed file"),
2514 num(env, "elapsed_ms"),
2515 num(&summary, "dead_code_issues"),
2516 num(&attribution, "dead_code_introduced"),
2517 num(&attribution, "dead_code_inherited"),
2518 num(&summary, "complexity_findings"),
2519 num(&attribution, "complexity_introduced"),
2520 num(&attribution, "complexity_inherited"),
2521 num(&summary, "duplication_clone_groups"),
2522 num(&attribution, "duplication_introduced"),
2523 num(&attribution, "duplication_inherited"),
2524 );
2525 if !dead_rows.is_empty() {
2526 let rows = dead_rows
2527 .iter()
2528 .take(10)
2529 .map(|row| {
2530 format!(
2531 "| {} | {} | {} | {} |",
2532 row.kind, row.location, row.item, row.status
2533 )
2534 })
2535 .collect::<Vec<_>>()
2536 .join("\n");
2537 let tail = if dead_rows.len() > 10 {
2538 format!(
2539 "\n\n> {} more dead-code findings in the full audit report",
2540 dead_rows.len() - 10
2541 )
2542 } else {
2543 String::new()
2544 };
2545 let _ = write!(
2546 out,
2547 "### Dead Code\n\n| Type | Location | Item | Status |\n|:-----|:---------|:-----|:-------|\n{rows}{tail}\n\n"
2548 );
2549 }
2550 out.push_str(&audit_complexity_section(env));
2551 out.push_str(&audit_duplication_section(env));
2552 out.push_str(if s(&attribution, "gate") == "all" {
2553 "*Audit gate: all. Every finding in changed files affects the verdict.*"
2554 } else {
2555 "*Audit gate: new-only. Inherited findings are reported but do not fail the verdict.*"
2556 });
2557 out
2558}
2559
2560#[must_use]
2566fn render_security_summary(env: &Value) -> String {
2567 let findings: Vec<&Value> = arr(env, "security_findings").collect();
2568 let gate = env.get("gate").filter(|gate| !gate.is_null());
2569 let count = gate.map_or_else(
2570 || {
2571 env.get("summary")
2572 .and_then(|summary| summary.get("security_findings"))
2573 .and_then(Value::as_u64)
2574 .unwrap_or(findings.len() as u64) as usize
2575 },
2576 |gate| u(gate, "new_count") as usize,
2577 );
2578 let mut out = String::from("## Fallow Security\n\n");
2579 if count == 0 {
2580 let _ = write!(
2581 out,
2582 "> [!NOTE]\n> **No security candidates matched** \u{b7} {}ms",
2583 num(env, "elapsed_ms"),
2584 );
2585 } else {
2586 let _ = write!(
2587 out,
2588 "> [!WARNING]\n> **{} matched** \u{b7} {}ms",
2589 plural_n(count, "security candidate"),
2590 num(env, "elapsed_ms"),
2591 );
2592 }
2593 if let Some(gate) = gate {
2594 let _ = write!(
2595 out,
2596 "\n\nSecurity gate: `{}`, verdict: `{}`, matching candidates: **{}**.",
2597 s(gate, "mode"),
2598 s(gate, "verdict"),
2599 num(gate, "new_count"),
2600 );
2601 }
2602 if !findings.is_empty() {
2603 let rows = findings
2604 .iter()
2605 .take(15)
2606 .map(|finding| {
2607 format!(
2608 "| {} | {} | {} | {} |",
2609 path_line(finding),
2610 markdown_table_text(s(finding, "kind")),
2611 markdown_table_text(str_or(finding, "severity", "unknown")),
2612 markdown_table_code_span(
2613 finding
2614 .get("candidate")
2615 .and_then(|candidate| candidate.get("sink"))
2616 .and_then(|sink| sink.get("callee"))
2617 .and_then(Value::as_str)
2618 .unwrap_or("-")
2619 ),
2620 )
2621 })
2622 .collect::<Vec<_>>()
2623 .join("\n");
2624 let _ = write!(
2625 out,
2626 "\n\n| Location | Kind | Severity | Sink |\n|:---------|:-----|:---------|:-----|\n{rows}"
2627 );
2628 if findings.len() > 15 {
2629 let _ = write!(
2630 out,
2631 "\n\n> {} more candidates in the full report",
2632 findings.len() - 15,
2633 );
2634 }
2635 }
2636 out.push_str("\n\nTreat these as candidates for verification, not confirmed vulnerabilities.");
2637 out
2638}
2639
2640fn fix_entries<'v>(env: &'v Value, entry_type: &str) -> Vec<&'v Value> {
2650 arr(env, "fixes")
2651 .filter(|fix| s(fix, "type") == entry_type && !b(fix, "skipped"))
2652 .collect()
2653}
2654
2655fn fix_detail_block(label: &str, entries: &[&Value], row: impl Fn(&Value) -> String) -> String {
2656 let rows = entries
2657 .iter()
2658 .take(25)
2659 .map(|entry| row(entry))
2660 .collect::<Vec<_>>()
2661 .join("\n");
2662 let tail = if entries.len() > 25 {
2663 format!("\n- *... and {} more*", entries.len() - 25)
2664 } else {
2665 String::new()
2666 };
2667 format!("**{label} ({})**\n{rows}{tail}", entries.len())
2668}
2669
2670#[must_use]
2672pub fn render_fix_summary(env: &Value) -> String {
2673 let exports = fix_entries(env, "remove_export");
2674 let dependencies = fix_entries(env, "remove_dependency");
2675 let fix_attempts = arr(env, "fixes").filter(|fix| !b(fix, "skipped")).count();
2676 let content_changed = u(env, "skipped_content_changed") as usize;
2677 let mixed_eol = u(env, "skipped_mixed_line_endings") as usize;
2678 let low_confidence = u(env, "skipped_low_confidence_exports") as usize;
2679 let low_confidence_deps = u(env, "skipped_low_confidence_dependencies") as usize;
2680 let low_confidence_members = u(env, "skipped_low_confidence_members") as usize;
2681 let dry_run = b(env, "dry_run");
2682
2683 if fix_attempts == 0
2684 && content_changed == 0
2685 && mixed_eol == 0
2686 && low_confidence == 0
2687 && low_confidence_deps == 0
2688 && low_confidence_members == 0
2689 {
2690 return "## Fallow - Auto-fix\n\nNo fixable issues found.".to_owned();
2691 }
2692
2693 let mut out = String::from("## Fallow - Auto-fix\n\n");
2694 out.push_str(if dry_run {
2695 "**Dry run**: would apply"
2696 } else {
2697 "Applied"
2698 });
2699 let fix_noun = if fix_attempts == 1 { "fix" } else { "fixes" };
2700 let _ = write!(out, " **{fix_attempts} {fix_noun}**");
2701 if !dry_run {
2702 let _ = write!(out, " ({} succeeded)", num(env, "total_fixed"));
2703 }
2704 if content_changed > 0 {
2705 let _ = write!(
2706 out,
2707 ", skipped {content_changed} file(s) that changed since analysis"
2708 );
2709 }
2710 if mixed_eol > 0 {
2711 let _ = write!(out, ", skipped {mixed_eol} file(s) with mixed line endings");
2712 }
2713 if low_confidence > 0 {
2714 let _ = write!(
2715 out,
2716 ", kept exports in {low_confidence} file(s) where consumers may be hidden from static analysis"
2717 );
2718 }
2719 if low_confidence_deps > 0 {
2720 let _ = write!(
2721 out,
2722 ", kept {low_confidence_deps} declared package(s) whose only import may sit in a file this run did not fully read"
2723 );
2724 }
2725 if low_confidence_members > 0 {
2726 let _ = write!(
2727 out,
2728 ", kept {low_confidence_members} unused enum member(s) whose only reference may sit in a file this run did not fully analyze"
2729 );
2730 }
2731 out.push_str("\n\n| Type | Count |\n|------|-------|\n");
2732 if !exports.is_empty() {
2733 let _ = writeln!(out, "| Export removals | {} |", exports.len());
2734 }
2735 if !dependencies.is_empty() {
2736 let _ = writeln!(out, "| Dependency removals | {} |", dependencies.len());
2737 }
2738 out.push_str("\n<details>\n<summary>View details</summary>\n\n");
2739 if !exports.is_empty() {
2740 out.push_str(&fix_detail_block("Export removals", &exports, |it| {
2741 format!(
2742 "- {} - {}",
2743 markdown_code_span(&format!("{}:{}", s(it, "path"), num(it, "line"))),
2744 markdown_code_span(s(it, "name"))
2745 )
2746 }));
2747 out.push_str("\n\n");
2748 }
2749 if !dependencies.is_empty() {
2750 out.push_str(&fix_detail_block(
2751 "Dependency removals",
2752 &dependencies,
2753 |it| {
2754 format!(
2755 "- {} from {} in {}",
2756 markdown_code_span(s(it, "package")),
2757 markdown_code_span(s(it, "location")),
2758 markdown_code_span(s(it, "file")),
2759 )
2760 },
2761 ));
2762 out.push('\n');
2763 }
2764 out.push_str("\n\n</details>");
2765 out
2766}
2767
2768fn file_link(links: &LinkContext, path: &str, start: &str, end: &str) -> String {
2773 let display = markdown_table_code_span(&format!("{}:{start}-{end}", last_three_segments(path)));
2774 if links.repo.is_empty() || links.sha.is_empty() {
2775 display
2776 } else {
2777 format!(
2778 "[{display}](https://github.com/{}/blob/{}/{}{}#L{start}-L{end})",
2779 links.repo,
2780 links.sha,
2781 links.prefix,
2782 encode_link_path(path),
2783 )
2784 }
2785}
2786
2787fn encode_link_path(path: &str) -> String {
2790 path.replace('%', "%25")
2791 .replace(' ', "%20")
2792 .replace('(', "%28")
2793 .replace(')', "%29")
2794 .replace('<', "%3C")
2795 .replace('>', "%3E")
2796 .replace('|', "%7C")
2797}
2798
2799fn exceeded_priority(it: &Value) -> u8 {
2800 match s(it, "exceeded") {
2801 "all" => 5,
2802 "cyclomatic_crap" | "cognitive_crap" => 4,
2803 "crap" => 3,
2804 "both" => 2,
2805 "cyclomatic" | "cognitive" => 1,
2806 _ => 0,
2807 }
2808}
2809
2810fn severity_priority(it: &Value) -> u8 {
2811 match s(it, "severity") {
2812 "critical" => 3,
2813 "high" => 2,
2814 "moderate" => 1,
2815 _ => 0,
2816 }
2817}
2818
2819fn ranked_health_findings(health: &Value) -> Vec<&Value> {
2822 let mut findings: Vec<&Value> = arr(health, "findings").collect();
2823 findings.sort_by_key(|it| {
2824 (
2825 exceeded_priority(it),
2826 severity_priority(it),
2827 it.get("crap").is_some_and(|crap| !crap.is_null()),
2828 u(it, "cyclomatic"),
2829 u(it, "cognitive"),
2830 u(it, "line_count"),
2831 )
2832 });
2833 findings.reverse();
2834 findings
2835}
2836
2837const PROD_FAILING_VERDICTS: &[&str] = &["safe_to_delete", "review_required", "low_traffic"];
2838
2839struct CombinedCounts {
2840 check: usize,
2841 dupes: usize,
2842 complex: usize,
2843 prod_failing: usize,
2844 prod_advisory: usize,
2845 hot_paths: usize,
2846}
2847
2848impl CombinedCounts {
2849 fn health(&self) -> usize {
2850 self.complex + self.prod_failing
2851 }
2852
2853 fn total(&self) -> usize {
2854 self.check + self.dupes + self.health()
2855 }
2856}
2857
2858fn combined_counts(env: &Value) -> CombinedCounts {
2859 let check = env
2860 .get("check")
2861 .map_or(0, |check| u(check, "total_issues") as usize);
2862 let dupes = env.get("dupes").map_or(0, |dupes| {
2872 arr(dupes, "clone_groups")
2873 .count()
2874 .saturating_add(u(dupes, "clone_groups_omitted") as usize)
2875 });
2876 let health = env.get("health").cloned().unwrap_or(Value::Null);
2877 let complex = health.get("summary").map_or(0, |summary| {
2880 baselined_complexity(summary).map_or_else(
2881 || u(summary, "functions_above_threshold"),
2882 |(remaining, _)| remaining,
2883 ) as usize
2884 });
2885 let runtime = health
2886 .get("runtime_coverage")
2887 .cloned()
2888 .unwrap_or(Value::Null);
2889 let prod_failing = arr(&runtime, "findings")
2890 .filter(|finding| PROD_FAILING_VERDICTS.contains(&s(finding, "verdict")))
2891 .count();
2892 let prod_advisory = arr(&runtime, "findings")
2893 .filter(|finding| !PROD_FAILING_VERDICTS.contains(&s(finding, "verdict")))
2894 .count();
2895 let hot_paths = arr(&runtime, "hot_paths").count();
2896 CombinedCounts {
2897 check,
2898 dupes,
2899 complex,
2900 prod_failing,
2901 prod_advisory,
2902 hot_paths,
2903 }
2904}
2905
2906fn hot_path_label(env: &Value, n: usize) -> String {
2907 let touched = env
2908 .get("health")
2909 .and_then(|health| health.get("runtime_coverage"))
2910 .is_some_and(|runtime| s(runtime, "verdict") == "hot-path-touched");
2911 let plural = if n == 1 { "" } else { "s" };
2912 if touched {
2913 format!("hot path{plural} touched")
2914 } else {
2915 format!("hot path{plural}")
2916 }
2917}
2918
2919fn combined_zero_case(env: &Value, counts: &CombinedCounts) -> String {
2920 let vitals = env
2921 .get("health")
2922 .and_then(|health| health.get("vital_signs"))
2923 .cloned()
2924 .unwrap_or(Value::Null);
2925 let mut out = String::from("# \u{1F33F} Fallow\n\n");
2926 if counts.prod_advisory > 0 || counts.hot_paths > 0 {
2927 out.push_str(
2928 "> [!NOTE]\n> **Quality gate passed**\n\n:white_check_mark: No code issues \u{b7} :white_check_mark: No duplication \u{b7} :white_check_mark: No blocking health findings",
2929 );
2930 if counts.prod_advisory > 0 {
2931 let _ = write!(
2932 out,
2933 " \u{b7} :information_source: **{}** runtime coverage advisory finding{}",
2934 counts.prod_advisory,
2935 if counts.prod_advisory == 1 { "" } else { "s" },
2936 );
2937 }
2938 if counts.hot_paths > 0 {
2939 let _ = write!(
2940 out,
2941 " \u{b7} :eyes: **{}** {}",
2942 counts.hot_paths,
2943 hot_path_label(env, counts.hot_paths),
2944 );
2945 }
2946 } else {
2947 out.push_str(
2948 "> [!NOTE]\n> **Quality gate passed**\n\n:white_check_mark: No code issues \u{b7} :white_check_mark: No duplication \u{b7} :white_check_mark: ",
2949 );
2950 let accepted = env
2953 .get("health")
2954 .and_then(|health| health.get("summary"))
2955 .and_then(baselined_complexity)
2956 .map_or(0, |(_, accepted)| accepted);
2957 if accepted > 0 {
2958 let _ = write!(out, "No new complex functions ({accepted} in the baseline)");
2959 } else {
2960 out.push_str("No complex functions");
2961 }
2962 }
2963 if let Some(maintainability) = opt_f(&vitals, "maintainability_avg") {
2964 let _ = write!(
2965 out,
2966 "\n\n| Metric | Value |\n|:-------|------:|\n| [Maintainability]({HEALTH_DOCS}#maintainability-index-mi) | **{}** / 100 |\n",
2967 pct(maintainability),
2968 );
2969 }
2970 out
2971}
2972
2973fn combined_status_line(env: &Value, counts: &CombinedCounts) -> String {
2974 let mut out = String::new();
2975 if counts.check > 0 {
2976 let _ = write!(
2977 out,
2978 ":warning: **{}** code {}",
2979 counts.check,
2980 if counts.check == 1 { "issue" } else { "issues" },
2981 );
2982 } else {
2983 out.push_str(":white_check_mark: No code issues");
2984 }
2985 out.push_str(" \u{b7} ");
2986 if counts.dupes > 0 {
2987 let _ = write!(
2988 out,
2989 ":warning: **{}** clone {}",
2990 counts.dupes,
2991 if counts.dupes == 1 { "group" } else { "groups" },
2992 );
2993 } else {
2994 out.push_str(":white_check_mark: No duplication");
2995 }
2996 out.push_str(" \u{b7} ");
2997 let health = counts.health();
2998 if health > 0 {
2999 let _ = write!(
3000 out,
3001 ":warning: **{health}** health {}",
3002 if health == 1 { "finding" } else { "findings" },
3003 );
3004 } else {
3005 out.push_str(":white_check_mark: No blocking health findings");
3006 }
3007 if counts.prod_advisory > 0 {
3008 let _ = write!(
3009 out,
3010 " \u{b7} :information_source: **{}** coverage advisory finding{}",
3011 counts.prod_advisory,
3012 if counts.prod_advisory == 1 { "" } else { "s" },
3013 );
3014 }
3015 if counts.hot_paths > 0 {
3016 let _ = write!(
3017 out,
3018 " \u{b7} :eyes: **{}** {}",
3019 counts.hot_paths,
3020 hot_path_label(env, counts.hot_paths),
3021 );
3022 }
3023 out.push_str("\n\n");
3024 out
3025}
3026
3027fn combined_check_breakdown(env: &Value, counts: &CombinedCounts) -> String {
3028 if counts.check == 0 {
3029 return String::new();
3030 }
3031 let check = env.get("check").cloned().unwrap_or(Value::Null);
3032 format!(
3033 "<details>\n<summary><strong><a href=\"{DEAD_CODE_DOCS}\">Code issues</a> ({})</strong></summary>\n\n| Category | Count |\n|:---------|------:|\n{}\n\n</details>\n\n",
3034 counts.check,
3035 dead_code_category_table(&check),
3036 )
3037}
3038
3039fn combined_dupes_breakdown(env: &Value, counts: &CombinedCounts, links: &LinkContext) -> String {
3040 if counts.dupes == 0 {
3041 return String::new();
3042 }
3043 let dupes = env.get("dupes").cloned().unwrap_or(Value::Null);
3044 let stats = dupes.get("stats").cloned().unwrap_or(Value::Null);
3045 let groups = sorted_clone_groups(&dupes);
3046 let files_with_clones = u(&stats, "files_with_clones") as usize;
3047 let rows = groups
3048 .iter()
3049 .take(5)
3050 .map(|group| {
3051 let locations = arr(group, "instances")
3052 .map(|instance| {
3053 file_link(
3054 links,
3055 s(instance, "file"),
3056 &num(instance, "start_line"),
3057 &num(instance, "end_line"),
3058 )
3059 })
3060 .collect::<Vec<_>>()
3061 .join("<br>");
3062 format!(
3063 "| {locations} | {} | {} |",
3064 num(group, "line_count"),
3065 num(group, "token_count"),
3066 )
3067 })
3068 .collect::<Vec<_>>()
3069 .join("\n");
3070 let tail = if counts.dupes > 5 {
3071 format!("\n\n*\u{2026} and {} more groups.*", counts.dupes - 5)
3072 } else {
3073 String::new()
3074 };
3075 format!(
3076 "<details>\n<summary><strong><a href=\"{DUPES_DOCS}\">Duplication</a> ({} {} \u{b7} {} lines \u{b7} {}%)</strong></summary>\n\n| Locations | Lines | Tokens |\n|:----------|------:|-------:|\n{rows}{tail}\n\nAcross {files_with_clones} {}.\n\n</details>\n\n",
3077 counts.dupes,
3078 if counts.dupes == 1 { "group" } else { "groups" },
3079 num(&stats, "duplicated_lines"),
3080 pct(f_or_zero(&stats, "duplication_percentage")),
3081 if files_with_clones == 1 {
3082 "file"
3083 } else {
3084 "files"
3085 },
3086 )
3087}
3088
3089fn combined_complexity_breakdown(env: &Value, counts: &CombinedCounts) -> String {
3090 if counts.complex == 0 {
3091 return String::new();
3092 }
3093 let health = env.get("health").cloned().unwrap_or(Value::Null);
3094 let summary = health.get("summary").cloned().unwrap_or(Value::Null);
3095 let findings = ranked_health_findings(&health);
3096 let show_crap = summary
3097 .get("max_crap_threshold")
3098 .is_some_and(|threshold| !threshold.is_null())
3099 || findings
3100 .iter()
3101 .any(|finding| finding.get("crap").is_some_and(|crap| !crap.is_null()));
3102 let cyc_t = threshold_or(&summary, "max_cyclomatic_threshold", "default");
3103 let cog_t = threshold_or(&summary, "max_cognitive_threshold", "default");
3104 let crap_t = threshold_or(&summary, "max_crap_threshold", "default");
3105 let crap_header = if show_crap {
3106 format!(" | [CRAP]({HEALTH_DOCS}#crap-score)")
3107 } else {
3108 String::new()
3109 };
3110 let crap_separator = if show_crap { "|-----:" } else { "" };
3111 let rows = findings
3112 .iter()
3113 .take(5)
3114 .map(|it| {
3115 let crap_column = if show_crap {
3116 format!(" | {}", crap_cell(it))
3117 } else {
3118 String::new()
3119 };
3120 format!(
3121 "| {} | {} | {} | {}{} | {}{}{crap_column} | {} |",
3122 markdown_table_code_span(&format!(
3123 "{}:{}",
3124 last_three_segments(s(it, "path")),
3125 num(it, "line")
3126 )),
3127 code_cell(it, "name"),
3128 markdown_table_text(str_or(it, "severity", "moderate")),
3129 num(it, "cyclomatic"),
3130 exceeded_marker(it, &["cyclomatic", "both", "all"]),
3131 num(it, "cognitive"),
3132 exceeded_marker(it, &["cognitive", "both", "all"]),
3133 num(it, "line_count"),
3134 )
3135 })
3136 .collect::<Vec<_>>()
3137 .join("\n");
3138 let crap_footer = if show_crap {
3139 format!(", CRAP >= {crap_t}")
3140 } else {
3141 String::new()
3142 };
3143 let label = match baselined_complexity(&summary).filter(|&(_, accepted)| accepted > 0) {
3144 Some((_, accepted)) => format!(
3145 "{} new {} above threshold, {accepted} in the baseline",
3146 counts.complex,
3147 if counts.complex == 1 {
3148 "function"
3149 } else {
3150 "functions"
3151 },
3152 ),
3153 None => format!(
3154 "{} {} above threshold",
3155 counts.complex,
3156 if counts.complex == 1 {
3157 "function"
3158 } else {
3159 "functions"
3160 },
3161 ),
3162 };
3163 format!(
3164 "<details>\n<summary><strong><a href=\"{HEALTH_DOCS}#complexity-metrics\">Complexity</a> ({label})</strong></summary>\n\n| File | Function | Severity | [Cyclomatic]({HEALTH_DOCS}#cyclomatic-complexity) | [Cognitive]({HEALTH_DOCS}#cognitive-complexity){crap_header} | Lines |\n|:-----|:---------|:---------|----------:|---------:{crap_separator}|------:|\n{rows}\n\n**{}** files, **{}** functions analyzed (thresholds: cyclomatic > {cyc_t}, cognitive > {cog_t}{crap_footer})\n\n</details>\n\n",
3165 threshold_or(&summary, "files_analyzed", "unknown"),
3166 threshold_or(&summary, "functions_analyzed", "unknown"),
3167 )
3168}
3169
3170fn combined_runtime_breakdown(env: &Value, counts: &CombinedCounts) -> String {
3171 let prod_total = counts.prod_failing + counts.prod_advisory;
3172 if prod_total == 0 && counts.hot_paths == 0 {
3173 return String::new();
3174 }
3175 let runtime = env
3176 .get("health")
3177 .and_then(|health| health.get("runtime_coverage"))
3178 .cloned()
3179 .unwrap_or(Value::Null);
3180 let hot_suffix = if counts.hot_paths > 0 {
3181 format!(
3182 ", {} {}",
3183 counts.hot_paths,
3184 hot_path_label(env, counts.hot_paths)
3185 )
3186 } else {
3187 String::new()
3188 };
3189 let mut out = format!(
3190 "<details>\n<summary><strong><a href=\"{HEALTH_DOCS}#runtime-coverage\">Runtime coverage</a> ({prod_total} finding{}{hot_suffix})</strong></summary>\n\n",
3191 if prod_total == 1 { "" } else { "s" },
3192 );
3193 if prod_total > 0 {
3194 out.push_str("| File | Function | Verdict | Invocations | Confidence |\n|:-----|:---------|:--------|------------:|:-----------|\n");
3195 out.push_str(
3196 &arr(&runtime, "findings")
3197 .take(5)
3198 .map(|it| {
3199 let invocations = it
3200 .get("invocations")
3201 .filter(|value| !value.is_null())
3202 .map_or_else(|| "-".to_owned(), fmt_num);
3203 format!(
3204 "| {} | {} | {} | {invocations} | {} |",
3205 markdown_table_code_span(&format!(
3206 "{}:{}",
3207 last_three_segments(s(it, "path")),
3208 num(it, "line")
3209 )),
3210 code_cell(it, "function"),
3211 code_cell(it, "verdict"),
3212 markdown_table_text(s(it, "confidence")),
3213 )
3214 })
3215 .collect::<Vec<_>>()
3216 .join("\n"),
3217 );
3218 if counts.hot_paths > 0 {
3219 out.push_str("\n\n");
3220 }
3221 }
3222 if counts.hot_paths > 0 {
3223 out.push_str("| File | Function | Invocations | Percentile |\n|:-----|:---------|------------:|-----------:|\n");
3224 out.push_str(
3225 &arr(&runtime, "hot_paths")
3226 .take(5)
3227 .map(|it| {
3228 format!(
3229 "| {} | {} | {} | {} |",
3230 markdown_table_code_span(&format!(
3231 "{}:{}",
3232 last_three_segments(s(it, "path")),
3233 num(it, "line")
3234 )),
3235 code_cell(it, "function"),
3236 num(it, "invocations"),
3237 num(it, "percentile"),
3238 )
3239 })
3240 .collect::<Vec<_>>()
3241 .join("\n"),
3242 );
3243 out.push_str("\n\n");
3244 }
3245 out.push_str("</details>\n\n");
3246 out
3247}
3248
3249fn combined_vitals(env: &Value) -> String {
3250 let health = env.get("health").cloned().unwrap_or(Value::Null);
3251 let vitals = health.get("vital_signs").cloned().unwrap_or(Value::Null);
3252 let has_vitals = vitals.as_object().is_some_and(|vitals| !vitals.is_empty());
3253 if !has_vitals {
3254 return String::new();
3255 }
3256 let scores: Vec<f64> = arr(&health, "file_scores")
3257 .filter_map(|score| opt_f(score, "maintainability_index"))
3258 .collect();
3259 let scoped_maintainability = if scores.is_empty() {
3260 None
3261 } else {
3262 let avg = scores.iter().sum::<f64>() / scores.len() as f64;
3263 Some((avg * 10.0).round() / 10.0)
3264 };
3265 let mut out = format!(
3266 "#### [Codebase health]({HEALTH_DOCS})\n\n| Metric | Value |\n|:-------|------:|\n"
3267 );
3268 let maintainability = opt_f(&vitals, "maintainability_avg");
3269 if let Some(avg) = maintainability {
3270 let _ = writeln!(
3271 out,
3272 "| [Maintainability]({HEALTH_DOCS}#maintainability-index-mi) | **{}** / 100 |",
3273 pct(avg),
3274 );
3275 }
3276 if let Some(scoped) = scoped_maintainability {
3277 let rounded_avg = (maintainability.unwrap_or_default() * 10.0).round() / 10.0;
3278 if (scoped - rounded_avg).abs() > f64::EPSILON {
3279 let _ = writeln!(
3280 out,
3281 "| [Maintainability]({HEALTH_DOCS}#maintainability-index-mi) (changed files) | **{}** / 100 |",
3282 fmt_num(&serde_json::json!(scoped)),
3283 );
3284 }
3285 }
3286 if let Some(avg_cyclomatic) = opt_f(&vitals, "avg_cyclomatic") {
3287 let _ = writeln!(
3288 out,
3289 "| [Avg complexity]({HEALTH_DOCS}#cyclomatic-complexity) | {} |",
3290 pct(avg_cyclomatic),
3291 );
3292 }
3293 if let Some(population) = vitals
3294 .get("cyclomatic_population")
3295 .filter(|value| value.is_object())
3296 {
3297 let _ = writeln!(
3298 out,
3299 "| Cyclomatic units | Functions: {}, module scopes: {}, templates: {} |",
3300 num(&population["functions"], "count"),
3301 num(&population["modules"], "count"),
3302 num(&population["templates"], "count")
3303 );
3304 if let Some(max) = population["modules"]["max"].as_u64() {
3305 let _ = writeln!(
3306 out,
3307 "| Module-scope max cyclomatic (aggregate only) | {max} |"
3308 );
3309 }
3310 }
3311 out.push('\n');
3312 out
3313}
3314
3315fn combined_tips(env: &Value) -> String {
3316 let check = env.get("check").cloned().unwrap_or(Value::Null);
3317 let fixable = arr(&check, "unused_exports").count()
3318 + arr(&check, "unused_dependencies").count()
3319 + arr(&check, "unused_enum_members").count();
3320 if fixable == 0 {
3321 return String::new();
3322 }
3323 let mut out = String::from("> [!TIP]\n> Run `fallow fix --dry-run` to preview auto-fixes.\n");
3324 if arr(&check, "unused_exports").count() > 0 {
3325 let _ = writeln!(
3326 out,
3327 "> Add [`/** @public */`]({SUPPRESSION_DOCS}) above exports to preserve them."
3328 );
3329 }
3330 out
3331}
3332
3333#[must_use]
3335fn render_combined_summary(env: &Value, links: &LinkContext) -> String {
3336 let counts = combined_counts(env);
3337 let header = health_score_header(&env.get("health").cloned().unwrap_or(Value::Null));
3338 if counts.total() == 0 {
3339 return format!("{header}{}", combined_zero_case(env, &counts));
3340 }
3341 let pointer = if counts.check > 0 || counts.dupes > 0 || counts.health() > 0 {
3342 "See inline review comments for per-finding details.\n\n"
3343 } else {
3344 ""
3345 };
3346 format!(
3347 "{header}# \u{1F33F} Fallow\n\n> [!WARNING]\n> **Review needed**\n\n{}{pointer}{}{}{}{}{}{}",
3348 combined_status_line(env, &counts),
3349 combined_check_breakdown(env, &counts),
3350 combined_dupes_breakdown(env, &counts, links),
3351 combined_complexity_breakdown(env, &counts),
3352 combined_runtime_breakdown(env, &counts),
3353 combined_vitals(env),
3354 combined_tips(env),
3355 )
3356}
3357
3358#[cfg(test)]
3359mod tests {
3360 use std::collections::BTreeSet;
3361
3362 use fallow_types::issue_meta::counted_result_issue_metas;
3363
3364 use super::{
3365 DEAD_CODE_CATEGORIES, LinkContext, render_combined_summary, render_health_summary,
3366 };
3367
3368 fn complexity_finding(name: &str) -> serde_json::Value {
3370 serde_json::json!({
3371 "path": "src/a.ts", "name": name, "line": 1, "col": 0,
3372 "cyclomatic": 25, "cognitive": 30, "line_count": 40,
3373 "exceeded": "both", "severity": "high"
3374 })
3375 }
3376
3377 fn health_with_baseline(above: u64, remaining: u64) -> serde_json::Value {
3380 let findings: Vec<serde_json::Value> = (0..remaining)
3381 .map(|index| complexity_finding(&format!("f{index}")))
3382 .collect();
3383 serde_json::json!({
3384 "elapsed_ms": 1,
3385 "findings": findings,
3386 "vital_signs": { "avg_cyclomatic": 2.0 },
3387 "summary": {
3388 "files_analyzed": 3,
3389 "functions_analyzed": 90,
3390 "functions_above_threshold": above,
3391 "max_cyclomatic_threshold": 20,
3392 "max_cognitive_threshold": 15,
3393 "max_crap_threshold": 30.0,
3394 "baseline_staleness": {
3395 "baseline_entries": above,
3396 "matched_entries": above,
3397 "stale_entries": 0,
3398 "current_findings": above,
3399 "remaining_findings": remaining,
3400 "stale": false
3401 }
3402 }
3403 })
3404 }
3405
3406 fn no_links() -> LinkContext {
3407 LinkContext {
3408 prefix: String::new(),
3409 repo: String::new(),
3410 sha: String::new(),
3411 }
3412 }
3413
3414 #[test]
3417 fn health_summary_headline_counts_after_the_baseline() {
3418 let out = render_health_summary(&health_with_baseline(76, 0));
3419 assert!(
3420 out.contains("**No new functions exceed complexity thresholds (76 in the baseline)**"),
3421 "{out}"
3422 );
3423 let out = render_health_summary(&health_with_baseline(76, 2));
3424 assert!(
3425 out.contains("**2 new functions exceed thresholds (74 in the baseline)**"),
3426 "{out}"
3427 );
3428 assert!(!out.contains("76 functions"), "{out}");
3429 }
3430
3431 #[test]
3433 fn combined_summary_counts_health_after_the_baseline() {
3434 let combined = |health: serde_json::Value| {
3435 serde_json::json!({
3436 "check": { "total_issues": 0 },
3437 "dupes": { "clone_groups": [], "stats": {} },
3438 "health": health
3439 })
3440 };
3441 let out = render_combined_summary(&combined(health_with_baseline(76, 0)), &no_links());
3442 assert!(
3443 out.contains(":white_check_mark: No new complex functions (76 in the baseline)"),
3444 "{out}"
3445 );
3446 assert!(!out.contains("above threshold"), "{out}");
3447
3448 let out = render_combined_summary(&combined(health_with_baseline(76, 2)), &no_links());
3449 assert!(out.contains(":warning: **2** health findings"), "{out}");
3450 assert!(
3451 out.contains("(2 new functions above threshold, 74 in the baseline)"),
3452 "{out}"
3453 );
3454 }
3455
3456 #[test]
3458 fn combined_summary_without_a_baseline_keeps_the_summary_count() {
3459 let mut health = health_with_baseline(3, 3);
3460 health["summary"]
3461 .as_object_mut()
3462 .expect("summary")
3463 .remove("baseline_staleness");
3464 let env = serde_json::json!({
3465 "check": { "total_issues": 0 },
3466 "dupes": { "clone_groups": [], "stats": {} },
3467 "health": health
3468 });
3469 let out = render_combined_summary(&env, &no_links());
3470 assert!(out.contains(":warning: **3** health findings"), "{out}");
3471 assert!(out.contains("(3 functions above threshold)"), "{out}");
3472 assert!(!out.contains("baseline"), "{out}");
3473 }
3474
3475 #[test]
3478 fn health_summary_counts_unlisted_functions() {
3479 let envelope = |above: u64| {
3480 serde_json::json!({
3481 "elapsed_ms": 1,
3482 "summary": {
3483 "functions_analyzed": 5,
3484 "functions_above_threshold": above,
3485 "max_cyclomatic_threshold": 20,
3486 "max_cognitive_threshold": 15,
3487 "max_crap_threshold": 30.0
3488 }
3489 })
3490 };
3491 let out = render_health_summary(&envelope(1));
3492 assert!(out.contains("**1 function exceeds thresholds**"), "{out}");
3493 assert!(out.contains("`fallow health --complexity`"), "{out}");
3494 assert!(!out.contains("baseline"), "{out}");
3495 assert!(!out.contains("No functions exceed"), "{out}");
3496 let out = render_health_summary(&envelope(0));
3497 assert!(
3498 out.contains("**No functions exceed complexity thresholds**"),
3499 "{out}"
3500 );
3501 }
3502
3503 fn baselined_envelope(lists_findings: bool) -> serde_json::Value {
3506 let mut envelope = serde_json::json!({
3507 "elapsed_ms": 1,
3508 "findings": [],
3509 "summary": {
3510 "functions_analyzed": 5,
3511 "functions_above_threshold": 1,
3512 "max_cyclomatic_threshold": 20,
3513 "max_cognitive_threshold": 15,
3514 "max_crap_threshold": 30.0,
3515 "baseline_staleness": {
3516 "baseline_entries": 2,
3517 "matched_entries": 2,
3518 "stale_entries": 0,
3519 "current_findings": 1,
3520 "stale": false
3521 }
3522 }
3523 });
3524 if lists_findings {
3525 envelope["vital_signs"] = serde_json::json!({ "avg_cyclomatic": 2.0 });
3526 }
3527 envelope
3528 }
3529
3530 #[test]
3534 fn health_summary_keeps_a_baselined_listed_run_clean() {
3535 let out = render_health_summary(&baselined_envelope(true));
3536 assert!(
3537 out.contains("**No functions exceed complexity thresholds**"),
3538 "{out}"
3539 );
3540 assert!(!out.contains("--complexity"), "{out}");
3541 }
3542
3543 #[test]
3546 fn health_summary_counts_unlisted_functions_on_a_baselined_score_run() {
3547 let out = render_health_summary(&baselined_envelope(false));
3548 assert!(out.contains("**1 function exceeds thresholds**"), "{out}");
3549 assert!(
3550 out.contains("The count includes the functions that the baseline accepts."),
3551 "{out}"
3552 );
3553 assert!(
3554 out.contains("Run `fallow health --complexity` with the same `--baseline`"),
3555 "{out}"
3556 );
3557 assert!(!out.contains("No functions exceed"), "{out}");
3558 }
3559
3560 #[test]
3564 fn health_summary_reads_the_sections_member() {
3565 let mut env = baselined_envelope(true);
3566 env["sections"] = serde_json::json!(["vital-signs", "hotspots"]);
3567 env["summary"]["functions_above_threshold"] = serde_json::json!(3);
3568 env["summary"]["baseline_staleness"]["remaining_findings"] = serde_json::json!(1);
3569 let out = render_health_summary(&env);
3570 assert!(out.contains("**3 functions exceed thresholds**"), "{out}");
3571 assert!(out.contains("The baseline accepts 2. 1 is new."), "{out}");
3572 assert!(
3573 out.contains("Run `fallow health --complexity` with the same `--baseline`"),
3574 "{out}"
3575 );
3576 assert!(!out.contains("No functions exceed"), "{out}");
3577 assert!(!out.contains("The count includes"), "{out}");
3578
3579 env["sections"] = serde_json::json!(["complexity", "vital-signs", "hotspots"]);
3580 let out = render_health_summary(&env);
3581 assert!(
3582 out.contains("**No functions exceed complexity thresholds**"),
3583 "{out}"
3584 );
3585 }
3586
3587 #[test]
3590 fn health_summary_with_no_new_function_does_not_ask_for_a_rerun() {
3591 let mut env = baselined_envelope(false);
3592 env["sections"] = serde_json::json!(["score"]);
3593 env["summary"]["baseline_staleness"]["remaining_findings"] = serde_json::json!(0);
3594 let out = render_health_summary(&env);
3595 assert!(out.contains("**1 function exceeds thresholds**"), "{out}");
3596 assert!(out.contains("The baseline accepts 1. 0 are new."), "{out}");
3597 assert!(!out.contains("--complexity"), "{out}");
3598 }
3599
3600 #[test]
3603 fn dead_code_categories_match_counted_result_metadata() {
3604 let native: BTreeSet<(&str, &str, &str)> = DEAD_CODE_CATEGORIES.iter().copied().collect();
3605 assert_eq!(
3606 native.len(),
3607 DEAD_CODE_CATEGORIES.len(),
3608 "DEAD_CODE_CATEGORIES has a duplicate row"
3609 );
3610 let registry: BTreeSet<(&str, &str, &str)> = counted_result_issue_metas()
3611 .map(|meta| (meta.summary_label, meta.result_key, meta.docs_anchor))
3612 .collect();
3613 let missing: Vec<_> = registry.difference(&native).collect();
3614 let extra: Vec<_> = native.difference(®istry).collect();
3615 assert!(
3616 missing.is_empty() && extra.is_empty(),
3617 "DEAD_CODE_CATEGORIES must match the counted result metadata \
3618 (label, result key, docs anchor).\nMissing rows: {missing:?}\nRows not in the registry: {extra:?}"
3619 );
3620 }
3621
3622 #[test]
3626 fn pathless_rows_render_in_one_order_for_any_input_order() {
3627 let envelope = |names: &[String]| {
3628 serde_json::json!({
3629 "total_issues": names.len(),
3630 "elapsed_ms": 1,
3631 "unlisted_dependencies": names
3632 .iter()
3633 .map(|name| serde_json::json!({ "package_name": name, "imported_from": [] }))
3634 .collect::<Vec<_>>(),
3635 })
3636 };
3637 let names: Vec<String> = (0..40).map(|index| format!("pkg-{index:02}")).collect();
3638 let mut reversed = names.clone();
3639 reversed.reverse();
3640 let ordered = super::render_check_summary(&envelope(&names));
3641 assert_eq!(super::render_check_summary(&envelope(&reversed)), ordered);
3642 let first = ordered.find("pkg-00").expect("first row");
3643 let second = ordered.find("pkg-01").expect("second row");
3644 assert!(
3645 first < second,
3646 "rows follow the serialized order: {ordered}"
3647 );
3648 assert!(
3649 !ordered.contains("pkg-39"),
3650 "rows past the limit are counted only"
3651 );
3652 assert!(
3653 ordered.contains("15 more"),
3654 "the tail counts the rest: {ordered}"
3655 );
3656 }
3657
3658 #[test]
3661 fn dead_code_rows_render_in_one_order_for_any_input_order() {
3662 let envelope = |paths: [&str; 3]| {
3663 serde_json::json!({
3664 "total_issues": 3,
3665 "elapsed_ms": 1,
3666 "unused_files": paths.map(|path| serde_json::json!({ "path": path })),
3667 })
3668 };
3669 let flat = super::render_check_summary(&envelope(["src/a.ts", "src/b.ts", "src/c.ts"]));
3670 let grouped = super::render_check_summary(&envelope(["src/a.ts", "src/c.ts", "src/b.ts"]));
3671 assert_eq!(grouped, flat);
3672 let a = flat.find("`src/a.ts`").expect("a");
3673 let b = flat.find("`src/b.ts`").expect("b");
3674 let c = flat.find("`src/c.ts`").expect("c");
3675 assert!(a < b && b < c, "rows follow path order: {flat}");
3676 }
3677}