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 ("Prop drilling", "prop_drilling_chains", "prop-drilling"),
239 ("Unused files", "unused_files", "unused-files"),
240 ("Unused exports", "unused_exports", "unused-exports"),
241 ("Unused types", "unused_types", "unused-types"),
242 (
243 "Private type leaks",
244 "private_type_leaks",
245 "private-type-leaks",
246 ),
247 (
248 "Deprecated exports in use",
249 "deprecated_exports_in_use",
250 "deprecated-exports-in-use",
251 ),
252 (
253 "Unused dependencies",
254 "unused_dependencies",
255 "unused-dependencies",
256 ),
257 (
258 "Unused devDependencies",
259 "unused_dev_dependencies",
260 "unused-dependencies",
261 ),
262 (
263 "Unused optionalDependencies",
264 "unused_optional_dependencies",
265 "unused-dependencies",
266 ),
267 (
268 "Unused enum members",
269 "unused_enum_members",
270 "unused-enum-members",
271 ),
272 (
273 "Unused class members",
274 "unused_class_members",
275 "unused-class-members",
276 ),
277 (
278 "Unused store members",
279 "unused_store_members",
280 "unused-store-members",
281 ),
282 (
283 "Unresolved imports",
284 "unresolved_imports",
285 "unresolved-imports",
286 ),
287 (
288 "Unlisted dependencies",
289 "unlisted_dependencies",
290 "unlisted-dependencies",
291 ),
292 (
293 "Duplicate exports",
294 "duplicate_exports",
295 "duplicate-exports",
296 ),
297 (
298 "Circular dependencies",
299 "circular_dependencies",
300 "circular-dependencies",
301 ),
302 ("Re-export cycles", "re_export_cycles", "re-export-cycles"),
303 ("Package cycles", "package_cycles", "package-cycles"),
304 (
305 "Boundary violations",
306 "boundary_violations",
307 "boundary-violations",
308 ),
309 (
310 "Boundary coverage",
311 "boundary_coverage_violations",
312 "boundary-violations",
313 ),
314 (
315 "Boundary calls",
316 "boundary_call_violations",
317 "boundary-violations",
318 ),
319 (
320 "Policy violations",
321 "policy_violations",
322 "policy-violations",
323 ),
324 (
325 "Invalid client exports",
326 "invalid_client_exports",
327 "invalid-client-exports",
328 ),
329 (
330 "Mixed client/server barrels",
331 "mixed_client_server_barrels",
332 "mixed-client-server-barrels",
333 ),
334 (
335 "Misplaced directives",
336 "misplaced_directives",
337 "misplaced-directives",
338 ),
339 (
340 "Unused server actions",
341 "unused_server_actions",
342 "unused-server-actions",
343 ),
344 ("Route collisions", "route_collisions", "route-collisions"),
345 (
346 "Dynamic segment conflicts",
347 "dynamic_segment_name_conflicts",
348 "dynamic-segment-name-conflicts",
349 ),
350 (
351 "Unrendered components",
352 "unrendered_components",
353 "unrendered-components",
354 ),
355 (
356 "Unused component props",
357 "unused_component_props",
358 "unused-component-props",
359 ),
360 (
361 "Absent optional component props",
362 "absent_component_props",
363 "absent-component-props",
364 ),
365 (
366 "Unused component emits",
367 "unused_component_emits",
368 "unused-component-emits",
369 ),
370 (
371 "Unused component inputs",
372 "unused_component_inputs",
373 "unused-component-inputs",
374 ),
375 (
376 "Unused component outputs",
377 "unused_component_outputs",
378 "unused-component-outputs",
379 ),
380 (
381 "Unused Svelte events",
382 "unused_svelte_events",
383 "unused-svelte-events",
384 ),
385 (
386 "Unused Effect layers",
387 "unused_effect_layers",
388 "unused-effect-layers",
389 ),
390 (
391 "Unused tagged errors",
392 "unused_tagged_errors",
393 "unused-tagged-errors",
394 ),
395 (
396 "Unproduced catch tags",
397 "unproduced_catch_tags",
398 "unproduced-catch-tags",
399 ),
400 (
401 "Unprovided injects",
402 "unprovided_injects",
403 "unprovided-injects",
404 ),
405 (
406 "Unused load data keys",
407 "unused_load_data_keys",
408 "unused-load-data-keys",
409 ),
410 (
411 "Type-only dependencies",
412 "type_only_dependencies",
413 "type-only-dependencies",
414 ),
415 (
416 "Test-only dependencies",
417 "test_only_dependencies",
418 "test-only-dependencies",
419 ),
420 (
421 "Dev dependencies used in production",
422 "dev_dependencies_in_production",
423 "dev-dependencies-in-production",
424 ),
425 (
426 "Stale suppressions",
427 "stale_suppressions",
428 "stale-suppressions",
429 ),
430 (
431 "Unused catalog entries",
432 "unused_catalog_entries",
433 "unused-catalog-entries",
434 ),
435 (
436 "Empty catalog groups",
437 "empty_catalog_groups",
438 "empty-catalog-groups",
439 ),
440 (
441 "Unresolved catalog references",
442 "unresolved_catalog_references",
443 "unresolved-catalog-references",
444 ),
445 (
446 "Unused dependency overrides",
447 "unused_dependency_overrides",
448 "unused-dependency-overrides",
449 ),
450 (
451 "Misconfigured dependency overrides",
452 "misconfigured_dependency_overrides",
453 "misconfigured-dependency-overrides",
454 ),
455];
456
457fn dead_code_docs(anchor: &str) -> String {
458 format!("{DEAD_CODE_DOCS}#{anchor}")
459}
460
461fn dead_code_category_table(env: &Value) -> String {
462 DEAD_CODE_CATEGORIES
463 .iter()
464 .filter_map(|(name, key, anchor)| {
465 let n = arr(env, key)
466 .filter(|item| {
467 *key != "prop_drilling_chains" || s(item, "effective_severity") == "error"
468 })
469 .count();
470 (n > 0).then(|| format!("| [{name}]({}) | {n} |", dead_code_docs(anchor)))
471 })
472 .collect::<Vec<_>>()
473 .join("\n")
474}
475
476struct SectionSpec {
481 name: &'static str,
482 key: &'static str,
483 header: &'static str,
484 row: fn(&Value) -> String,
485}
486
487fn render_check_section(env: &Value, spec: &SectionSpec) -> String {
488 let items = rows_in_path_order(env, spec.key);
494 let n = items.len();
495 if n == 0 {
496 return String::new();
497 }
498 let rows = items
499 .iter()
500 .take(CHECK_SECTION_ROW_LIMIT)
501 .map(|item| (spec.row)(item))
502 .collect::<Vec<_>>()
503 .join("\n");
504 let tail = if n > CHECK_SECTION_ROW_LIMIT {
505 format!(
506 "\n\n> {} more - run `fallow` locally for the full list",
507 n - CHECK_SECTION_ROW_LIMIT
508 )
509 } else {
510 String::new()
511 };
512 format!(
513 "\n<details><summary><strong>{} ({n})</strong></summary>\n\n{}{rows}{tail}\n\n</details>\n",
514 spec.name, spec.header,
515 )
516}
517
518const CHECK_SECTION_ROW_LIMIT: usize = 25;
520
521fn rows_in_path_order<'a>(env: &'a Value, key: &str) -> Vec<&'a Value> {
528 let mut keyed: Vec<(Option<&str>, Option<u64>, &Value)> = arr(env, key)
529 .map(|item| {
530 (
531 row_sort_path(item),
532 item.get("line").and_then(Value::as_u64),
533 item,
534 )
535 })
536 .collect();
537 keyed.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
538 let mut start = 0;
539 for run in keyed.chunk_by_mut(|a, b| (a.0, a.1) == (b.0, b.1)) {
540 if start >= CHECK_SECTION_ROW_LIMIT {
541 break;
542 }
543 if run.len() > 1 {
544 run.sort_by_cached_key(|(_, _, item)| item.to_string());
545 }
546 start += run.len();
547 }
548 keyed.into_iter().map(|(_, _, item)| item).collect()
549}
550
551fn row_sort_path(item: &Value) -> Option<&str> {
556 item.get("path")
557 .or_else(|| item.get("from_path"))
558 .or_else(|| item.get("files").and_then(|files| files.get(0)))
559 .and_then(Value::as_str)
560}
561
562fn check_workspace_context(item: &Value) -> String {
563 let workspaces = backtick_join(item, "used_in_workspaces");
564 if !workspaces.is_empty() {
565 return workspaces;
566 }
567 let declaring = backtick_join(item, "declared_and_imported_in");
568 if declaring.is_empty() {
569 String::new()
570 } else {
571 format!("declared and imported in {declaring}")
572 }
573}
574
575#[expect(
576 clippy::too_many_lines,
577 reason = "a flat data table of 14 per-kind row templates ported 1:1 from summary-check.jq; splitting would obscure the correspondence"
578)]
579fn check_sections_core() -> Vec<SectionSpec> {
580 vec![
581 SectionSpec {
582 name: "Unused files",
583 key: "unused_files",
584 header: "Files not reachable from any entry point.\n\n| File |\n|------|\n",
585 row: |it| format!("| {}{} |", code_cell(it, "path"), caveat_cell_suffix(it)),
586 },
587 SectionSpec {
588 name: "Unused exports",
589 key: "unused_exports",
590 header: "Exported symbols with no known consumers.\n\n| File | Line | Export |\n|------|-----:|--------|\n",
591 row: |it| {
592 format!(
593 "| {} | {} | {}{}{} |",
594 code_cell(it, "path"),
595 num(it, "line"),
596 code_cell(it, "export_name"),
597 if b(it, "is_re_export") {
598 " *(re-export)*"
599 } else {
600 ""
601 },
602 caveat_cell_suffix(it),
603 )
604 },
605 },
606 SectionSpec {
607 name: "Unused types",
608 key: "unused_types",
609 header: "Type exports with no known consumers.\n\n| File | Line | Type |\n|------|-----:|------|\n",
610 row: |it| {
611 format!(
612 "| {} | {} | {}{} |",
613 code_cell(it, "path"),
614 num(it, "line"),
615 code_cell(it, "export_name"),
616 caveat_cell_suffix(it),
617 )
618 },
619 },
620 SectionSpec {
621 name: "Private type leaks",
622 key: "private_type_leaks",
623 header: "Exported signatures that reference same-file private types.\n\n| File | Line | Export | Private type |\n|------|-----:|--------|--------------|\n",
624 row: |it| {
625 format!(
626 "| {} | {} | {} | {} |",
627 code_cell(it, "path"),
628 num(it, "line"),
629 code_cell(it, "export_name"),
630 code_cell(it, "type_name"),
631 )
632 },
633 },
634 SectionSpec {
635 name: "Deprecated exports in use",
636 key: "deprecated_exports_in_use",
637 header: "Exports marked `@deprecated` that still have consumers.\n\n| File | Line | Export | Consumers |\n|------|-----:|--------|----------:|\n",
638 row: |it| {
639 format!(
640 "| {} | {} | {} | {} |",
641 code_cell(it, "path"),
642 num(it, "line"),
643 code_cell(it, "export_name"),
644 num(it, "consumer_count"),
645 )
646 },
647 },
648 SectionSpec {
649 name: "Unused dependencies",
650 key: "unused_dependencies",
651 header: "Listed in `dependencies` but never imported by the declaring workspace.\n\n| Package | Imported elsewhere |\n|---------|--------------------|\n",
652 row: |it| {
653 format!(
654 "| {}{} | {} |",
655 code_cell(it, "package_name"),
656 caveat_cell_suffix(it),
657 check_workspace_context(it),
658 )
659 },
660 },
661 SectionSpec {
662 name: "Unused devDependencies",
663 key: "unused_dev_dependencies",
664 header: "Listed in `devDependencies` but never imported or referenced by the declaring workspace.\n\n| Package | Imported elsewhere |\n|---------|--------------------|\n",
665 row: |it| {
666 format!(
667 "| {}{} | {} |",
668 code_cell(it, "package_name"),
669 caveat_cell_suffix(it),
670 check_workspace_context(it),
671 )
672 },
673 },
674 SectionSpec {
675 name: "Unused optionalDependencies",
676 key: "unused_optional_dependencies",
677 header: "Listed in `optionalDependencies` but never imported by the declaring workspace.\n\n| Package | Imported elsewhere |\n|---------|--------------------|\n",
678 row: |it| {
679 format!(
680 "| {}{} | {} |",
681 code_cell(it, "package_name"),
682 caveat_cell_suffix(it),
683 check_workspace_context(it),
684 )
685 },
686 },
687 SectionSpec {
688 name: "Unused enum members",
689 key: "unused_enum_members",
690 header: "Enum members never referenced outside their declaration.\n\n| File | Line | Enum | Member |\n|------|-----:|------|--------|\n",
691 row: member_row,
692 },
693 SectionSpec {
694 name: "Unused class members",
695 key: "unused_class_members",
696 header: "Class methods or properties never referenced outside their class.\n\n| File | Line | Class | Member |\n|------|-----:|-------|--------|\n",
697 row: member_row,
698 },
699 SectionSpec {
700 name: "Unused store members",
701 key: "unused_store_members",
702 header: "Pinia store members (state, getter, action) never accessed by any consumer.\n\n| File | Line | Store | Member |\n|------|-----:|-------|--------|\n",
703 row: member_row,
704 },
705 SectionSpec {
706 name: "Unresolved imports",
707 key: "unresolved_imports",
708 header: "Import paths that could not be resolved - check for missing packages or broken paths.\n\n| File | Line | Import |\n|------|-----:|--------|\n",
709 row: |it| {
710 format!(
711 "| {} | {} | {} |",
712 code_cell(it, "path"),
713 num(it, "line"),
714 code_cell(it, "specifier"),
715 )
716 },
717 },
718 SectionSpec {
719 name: "Unlisted dependencies",
720 key: "unlisted_dependencies",
721 header: "Packages imported in code but missing from `package.json`.\n\n| Package | Used in |\n|---------|--------|\n",
722 row: |it| {
723 let sites: Vec<&Value> = arr(it, "imported_from").collect();
724 let cell = if sites.is_empty() {
725 String::new()
726 } else {
727 let shown = sites
728 .iter()
729 .take(3)
730 .map(|site| {
731 markdown_table_code_span(&format!(
732 "{}:{}",
733 s(site, "path"),
734 num(site, "line")
735 ))
736 })
737 .collect::<Vec<_>>()
738 .join(", ");
739 let more = if sites.len() > 3 {
740 format!(" *+{} more*", sites.len() - 3)
741 } else {
742 String::new()
743 };
744 format!("{shown}{more}")
745 };
746 format!("| {} | {cell} |", code_cell(it, "package_name"))
747 },
748 },
749 SectionSpec {
750 name: "Duplicate exports",
751 key: "duplicate_exports",
752 header: "Same export name defined in multiple files - barrel re-exports may resolve ambiguously.\n\n| Export | Locations |\n|--------|-----------|\n",
753 row: |it| {
754 let locations: Vec<&Value> = arr(it, "locations").collect();
755 let shown = locations
756 .iter()
757 .take(3)
758 .map(|location| {
759 markdown_table_code_span(&format!(
760 "{}:{}",
761 s(location, "path"),
762 num(location, "line")
763 ))
764 })
765 .collect::<Vec<_>>()
766 .join(", ");
767 let more = if locations.len() > 3 {
768 format!(" *+{} more*", locations.len() - 3)
769 } else {
770 String::new()
771 };
772 format!("| {} | {shown}{more} |", code_cell(it, "export_name"))
773 },
774 },
775 SectionSpec {
776 name: "Circular dependencies",
777 key: "circular_dependencies",
778 header: "Import cycles that can cause initialization failures and prevent tree-shaking.\n\n| Cycle | Length |\n|-------|-------:|\n",
779 row: |it| {
780 let cycle = arr(it, "files")
781 .filter_map(Value::as_str)
782 .map(markdown_table_code_span)
783 .collect::<Vec<_>>()
784 .join(" \u{2192} ");
785 format!("| {cycle} | {} |", num(it, "length"))
786 },
787 },
788 ]
789}
790
791fn member_row(it: &Value) -> String {
796 format!(
797 "| {} | {} | {} | {}{} |",
798 code_cell(it, "path"),
799 num(it, "line"),
800 code_cell(it, "parent_name"),
801 code_cell(it, "member_name"),
802 caveat_cell_suffix(it),
803 )
804}
805
806fn plain_join(item: &Value, key: &str, separator: &str) -> String {
807 arr(item, key)
808 .filter_map(Value::as_str)
809 .collect::<Vec<_>>()
810 .join(separator)
811}
812
813fn path_line_cell(it: &Value) -> String {
814 markdown_table_code_span(&format!("{}:{}", s(it, "path"), num(it, "line")))
815}
816
817#[expect(
818 clippy::too_many_lines,
819 reason = "a flat data table of 14 per-kind row templates ported 1:1 from summary-check.jq; splitting would obscure the correspondence"
820)]
821fn check_sections_architecture() -> Vec<SectionSpec> {
822 vec![
823 SectionSpec {
824 name: "Re-export cycles",
825 key: "re_export_cycles",
826 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",
827 row: |it| {
828 let cycle = arr(it, "files")
829 .filter_map(Value::as_str)
830 .map(markdown_table_code_span)
831 .collect::<Vec<_>>()
832 .join(" <-> ");
833 format!(
834 "| {cycle} | {} | {} |",
835 markdown_table_text(s(it, "kind")),
836 arr(it, "files").count()
837 )
838 },
839 },
840 SectionSpec {
841 name: "Package cycles",
842 key: "package_cycles",
843 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",
844 row: |it| {
845 let mut chain: Vec<String> = arr(it, "packages")
846 .filter_map(Value::as_str)
847 .map(markdown_table_code_span)
848 .collect();
849 if let Some(first) = chain.first().cloned() {
850 chain.push(first);
851 }
852 let example = arr(it, "edges")
853 .next()
854 .map_or_else(String::new, path_line_cell);
855 let note = if it.get("group_truncated").and_then(Value::as_bool) == Some(true) {
856 format!(
857 " ({})",
858 fallow_types::results::PackageCycle::GROUP_TRUNCATED_NOTE
859 )
860 } else {
861 String::new()
862 };
863 format!(
864 "| {}{note} | {example} | {} |",
865 chain.join(" \u{2192} "),
866 arr(it, "packages").count()
867 )
868 },
869 },
870 SectionSpec {
871 name: "Boundary violations",
872 key: "boundary_violations",
873 header: "Imports that cross defined architecture zone boundaries.\n\n| From | To | Zones |\n|------|-----|-------|\n",
874 row: |it| {
875 format!(
876 "| {} | {} | {} \u{2192} {} |",
877 markdown_table_code_span(&format!(
878 "{}:{}",
879 s(it, "from_path"),
880 num(it, "line")
881 )),
882 code_cell(it, "to_path"),
883 markdown_table_code_span(s(it, "from_zone")),
884 markdown_table_code_span(s(it, "to_zone")),
885 )
886 },
887 },
888 SectionSpec {
889 name: "Boundary coverage",
890 key: "boundary_coverage_violations",
891 header: "Files that match no configured architecture boundary zone.\n\n| File |\n|------|\n",
892 row: |it| format!("| {} |", path_line_cell(it)),
893 },
894 SectionSpec {
895 name: "Boundary calls",
896 key: "boundary_call_violations",
897 header: "Calls from zoned files to callees forbidden for that zone.\n\n| File | Callee | Zone | Pattern |\n|------|--------|------|---------|\n",
898 row: |it| {
899 format!(
900 "| {} | {} | {} | {} |",
901 path_line_cell(it),
902 code_cell(it, "callee"),
903 markdown_table_code_span(s(it, "zone")),
904 code_cell(it, "pattern"),
905 )
906 },
907 },
908 SectionSpec {
909 name: "Policy violations",
910 key: "policy_violations",
911 header: "Banned calls, imports, and catalogue-derived effects matched by configured rule packs.\n\n| File | Matched | Rule | Severity |\n|------|---------|------|----------|\n",
912 row: |it| {
913 format!(
914 "| {} | {} | {} | {} |",
915 path_line_cell(it),
916 code_cell(it, "matched"),
917 markdown_table_code_span(&format!("{}/{}", s(it, "pack"), s(it, "rule_id"))),
918 markdown_table_text(s(it, "severity")),
919 )
920 },
921 },
922 SectionSpec {
923 name: "Invalid client exports",
924 key: "invalid_client_exports",
925 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",
926 row: |it| {
927 format!(
928 "| {} | {} | {} |",
929 path_line_cell(it),
930 code_cell(it, "export_name"),
931 markdown_table_code_span(&format!("\"{}\"", s(it, "directive"))),
932 )
933 },
934 },
935 SectionSpec {
936 name: "Mixed client/server barrels",
937 key: "mixed_client_server_barrels",
938 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",
939 row: |it| {
940 format!(
941 "| {} | {} | {} |",
942 path_line_cell(it),
943 code_cell(it, "client_origin"),
944 code_cell(it, "server_origin"),
945 )
946 },
947 },
948 SectionSpec {
949 name: "Misplaced directives",
950 key: "misplaced_directives",
951 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",
952 row: |it| {
953 format!(
954 "| {} | {} |",
955 path_line_cell(it),
956 markdown_table_code_span(&format!("\"{}\"", s(it, "directive"))),
957 )
958 },
959 },
960 SectionSpec {
961 name: "Unused server actions",
962 key: "unused_server_actions",
963 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",
964 row: |it| {
965 format!(
966 "| {} | {} |",
967 path_line_cell(it),
968 code_cell(it, "action_name")
969 )
970 },
971 },
972 SectionSpec {
973 name: "Route collisions",
974 key: "route_collisions",
975 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",
976 row: |it| format!("| {} | {} |", code_cell(it, "path"), code_cell(it, "url")),
977 },
978 SectionSpec {
979 name: "Dynamic segment conflicts",
980 key: "dynamic_segment_name_conflicts",
981 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",
982 row: |it| {
983 format!(
984 "| {} | {} | {} |",
985 code_cell(it, "path"),
986 code_cell(it, "position"),
987 markdown_table_code_span(&plain_join(it, "conflicting_segments", ", ")),
988 )
989 },
990 },
991 ]
992}
993
994#[expect(
995 clippy::too_many_lines,
996 reason = "a flat data table of 14 per-kind row templates ported 1:1 from summary-check.jq; splitting would obscure the correspondence"
997)]
998fn check_sections_frameworks_and_hygiene() -> Vec<SectionSpec> {
999 vec![
1000 SectionSpec {
1001 name: "Unrendered components",
1002 key: "unrendered_components",
1003 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",
1004 row: |it| {
1005 format!(
1006 "| {} | {} | {} |",
1007 path_line_cell(it),
1008 code_cell(it, "component_name"),
1009 markdown_table_text(s(it, "framework")),
1010 )
1011 },
1012 },
1013 SectionSpec {
1014 name: "Absent optional component props",
1015 key: "absent_component_props",
1016 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",
1017 row: |it| {
1018 let callers = arr(it, "inspected_call_sites")
1019 .map(path_line_cell)
1020 .collect::<Vec<_>>()
1021 .join(", ");
1022 format!(
1023 "| {} | {} | {} | {} |",
1024 path_line_cell(it),
1025 component_member_item(it, "prop_name"),
1026 if it.get("has_default").and_then(Value::as_bool) == Some(true) {
1027 "present"
1028 } else {
1029 "absent"
1030 },
1031 callers
1032 )
1033 },
1034 },
1035 SectionSpec {
1036 name: "Unused component props",
1037 key: "unused_component_props",
1038 header: "Vue `defineProps` props referenced nowhere inside their own single-file component (neither script nor template).\n\n| File | Component | Prop |\n|------|-----------|------|\n",
1039 row: |it| component_detail_row(it, "prop_name"),
1040 },
1041 SectionSpec {
1042 name: "Unused component emits",
1043 key: "unused_component_emits",
1044 header: "Vue `defineEmits` events emitted nowhere inside their own single-file component (no matching `emit()` call).\n\n| File | Component | Event |\n|------|-----------|-------|\n",
1045 row: |it| component_detail_row(it, "emit_name"),
1046 },
1047 SectionSpec {
1048 name: "Unused component inputs",
1049 key: "unused_component_inputs",
1050 header: "Angular `@Input()` / signal `input()` declarations read nowhere inside their own component (neither class body nor template).\n\n| File | Component | Input |\n|------|-----------|-------|\n",
1051 row: |it| component_detail_row(it, "input_name"),
1052 },
1053 SectionSpec {
1054 name: "Unused component outputs",
1055 key: "unused_component_outputs",
1056 header: "Angular `@Output()` / signal `output()` declarations emitted nowhere inside their own component (no matching `emit()` call).\n\n| File | Component | Output |\n|------|-----------|--------|\n",
1057 row: |it| component_detail_row(it, "output_name"),
1058 },
1059 SectionSpec {
1060 name: "Unused Svelte events",
1061 key: "unused_svelte_events",
1062 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",
1063 row: |it| component_detail_row(it, "event_name"),
1064 },
1065 SectionSpec {
1066 name: "Unused Effect layers",
1067 key: "unused_effect_layers",
1068 header: "Effect layers composed only into layers that nothing uses.\n\n| File | Layer |\n|------|-------|\n",
1069 row: |it| format!("| {} | {} |", path_line_cell(it), code_cell(it, "name")),
1070 },
1071 SectionSpec {
1072 name: "Unused tagged errors",
1073 key: "unused_tagged_errors",
1074 header: "Effect tagged errors that no code constructs or fails with.\n\n| File | Class | Tag |\n|------|-------|-----|\n",
1075 row: |it| {
1076 format!(
1077 "| {} | {} | {} |",
1078 path_line_cell(it),
1079 code_cell(it, "name"),
1080 code_cell(it, "tag")
1081 )
1082 },
1083 },
1084 SectionSpec {
1085 name: "Unproduced catch tags",
1086 key: "unproduced_catch_tags",
1087 header: "`catchTag` handlers for a project tag that no code produces.\n\n| File | Tag |\n|------|-----|\n",
1088 row: |it| format!("| {} | {} |", path_line_cell(it), code_cell(it, "tag")),
1089 },
1090 SectionSpec {
1091 name: "Unprovided injects",
1092 key: "unprovided_injects",
1093 header: "Vue `inject` / Svelte `getContext` calls for a key that no ancestor `provide` / `setContext` supplies.\n\n| File | Key | Framework |\n|------|-----|-----------|\n",
1094 row: |it| {
1095 format!(
1096 "| {} | {} | {} |",
1097 path_line_cell(it),
1098 code_cell(it, "key_name"),
1099 markdown_table_text(s(it, "framework")),
1100 )
1101 },
1102 },
1103 SectionSpec {
1104 name: "Unused load data keys",
1105 key: "unused_load_data_keys",
1106 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",
1107 row: |it| {
1108 format!(
1109 "| {} | {} | {} |",
1110 path_line_cell(it),
1111 code_cell(it, "route_dir"),
1112 code_cell(it, "key_name"),
1113 )
1114 },
1115 },
1116 SectionSpec {
1117 name: "Type-only dependencies",
1118 key: "type_only_dependencies",
1119 header: "Dependencies only used for type imports - consider moving to `devDependencies`.\n\n| Package |\n|---------|\n",
1120 row: package_row,
1121 },
1122 SectionSpec {
1123 name: "Test-only dependencies",
1124 key: "test_only_dependencies",
1125 header: "Production dependencies only imported by test files - consider moving to `devDependencies`.\n\n| Package |\n|---------|\n",
1126 row: package_row,
1127 },
1128 SectionSpec {
1129 name: "Dev dependencies used in production",
1130 key: "dev_dependencies_in_production",
1131 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",
1132 row: package_row,
1133 },
1134 SectionSpec {
1135 name: "Stale suppressions",
1136 key: "stale_suppressions",
1137 header: "Suppression comments or JSDoc tags that no longer match any active issue.\n\n| File | Line | Description |\n|------|-----:|-------------|\n",
1138 row: |it| {
1139 format!(
1140 "| {} | {} | {} |",
1141 code_cell(it, "path"),
1142 num(it, "line"),
1143 stale_suppression_description(it),
1144 )
1145 },
1146 },
1147 ]
1148}
1149
1150fn component_detail_row(it: &Value, detail_key: &str) -> String {
1151 format!(
1152 "| {} | {} | {} |",
1153 path_line_cell(it),
1154 code_cell(it, "component_name"),
1155 code_cell(it, detail_key),
1156 )
1157}
1158
1159fn package_row(it: &Value) -> String {
1160 format!("| {} |", code_cell(it, "package_name"))
1161}
1162
1163fn stale_suppression_description(it: &Value) -> String {
1164 let origin = it.get("origin").cloned().unwrap_or(Value::Null);
1165 if s(&origin, "type") == "jsdoc_tag" {
1166 return format!(
1167 "`@expected-unused` on {}",
1168 code_cell(&origin, "export_name")
1169 );
1170 }
1171 if origin.get("kind_known").and_then(Value::as_bool) == Some(false) {
1172 return format!("unknown kind {}", code_cell(&origin, "issue_kind"));
1173 }
1174 match origin.get("issue_kind").and_then(Value::as_str) {
1175 Some(kind) => markdown_table_code_span(kind),
1176 None => "blanket".to_owned(),
1177 }
1178}
1179
1180fn check_sections_catalog() -> Vec<SectionSpec> {
1181 vec![
1182 SectionSpec {
1183 name: "Unused catalog entries",
1184 key: "unused_catalog_entries",
1185 header: "pnpm catalog entries not referenced by any workspace package.\n\n| Entry | Catalog | Location | Hardcoded consumers |\n|-------|---------|----------|---------------------|\n",
1186 row: |it| {
1187 format!(
1188 "| {} | {} | {} | {} |",
1189 code_cell(it, "entry_name"),
1190 code_cell(it, "catalog_name"),
1191 path_line_cell(it),
1192 backtick_join(it, "hardcoded_consumers"),
1193 )
1194 },
1195 },
1196 SectionSpec {
1197 name: "Empty catalog groups",
1198 key: "empty_catalog_groups",
1199 header: "Named pnpm catalog groups with no entries.\n\n| Catalog | Location |\n|---------|----------|\n",
1200 row: |it| {
1201 format!(
1202 "| {} | {} |",
1203 code_cell(it, "catalog_name"),
1204 path_line_cell(it)
1205 )
1206 },
1207 },
1208 SectionSpec {
1209 name: "Unresolved catalog references",
1210 key: "unresolved_catalog_references",
1211 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",
1212 row: |it| {
1213 format!(
1214 "| {} | {} | {} | {} |",
1215 code_cell(it, "entry_name"),
1216 code_cell(it, "catalog_name"),
1217 path_line_cell(it),
1218 backtick_join(it, "available_in_catalogs"),
1219 )
1220 },
1221 },
1222 SectionSpec {
1223 name: "Unused dependency overrides",
1224 key: "unused_dependency_overrides",
1225 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",
1226 row: |it| {
1227 format!(
1228 "| {} | {} -> {} | {} | {} | {} |",
1229 code_cell(it, "raw_key"),
1230 code_cell(it, "target_package"),
1231 code_cell(it, "version_range"),
1232 code_cell(it, "source"),
1233 path_line_cell(it),
1234 markdown_table_text(str_or(it, "hint", "")),
1235 )
1236 },
1237 },
1238 SectionSpec {
1239 name: "Misconfigured dependency overrides",
1240 key: "misconfigured_dependency_overrides",
1241 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",
1242 row: |it| {
1243 format!(
1244 "| {} | {} | {} | {} | {} |",
1245 markdown_table_code_span(str_or(it, "raw_key", "")),
1246 markdown_table_code_span(str_or(it, "raw_value", "")),
1247 code_cell(it, "source"),
1248 path_line_cell(it),
1249 markdown_table_text(str_or(it, "reason", "unparsable")),
1250 )
1251 },
1252 },
1253 ]
1254}
1255
1256fn check_tips(env: &Value) -> String {
1257 let fixable = arr(env, "unused_exports").count()
1258 + arr(env, "unused_dependencies").count()
1259 + arr(env, "unused_enum_members").count();
1260 let mut tips = String::from("\n\n> [!TIP]\n");
1261 if fixable > 0 {
1262 tips.push_str("> Run `fallow fix --dry-run` to preview safe auto-fixes.\n");
1263 }
1264 if arr(env, "unused_exports").count() > 0 {
1265 let _ = writeln!(
1266 tips,
1267 "> Intentionally public? Add [`/** @public */`]({SUPPRESSION_DOCS}) above exports to preserve them."
1268 );
1269 }
1270 let _ = write!(
1271 tips,
1272 "> Add [`// fallow-ignore-next-line`]({SUPPRESSION_DOCS}) above a line to suppress a specific finding."
1273 );
1274 tips
1275}
1276
1277#[must_use]
1278fn prop_drilling_section(env: &Value) -> String {
1279 let rows = arr(env, "prop_drilling_chains")
1280 .filter(|item| s(item, "effective_severity") == "error")
1281 .filter_map(|item| {
1282 let source = arr(item, "hops").next()?;
1283 Some(format!(
1284 "| {} | {} | {} |",
1285 markdown_table_code_span(s(source, "file")),
1286 code_cell(item, "prop"),
1287 u(item, "depth")
1288 ))
1289 })
1290 .collect::<Vec<_>>();
1291 if rows.is_empty() {
1292 return String::new();
1293 }
1294 format!(
1295 "\n### Prop drilling\n\n| Source | Prop | Components |\n|:-------|:-----|-----------:|\n{}\n\nReview composition, colocation or context, or suppress the source prop declaration.\n",
1296 rows.join("\n")
1297 )
1298}
1299
1300fn render_check_summary(env: &Value) -> String {
1301 let elapsed = num(env, "elapsed_ms");
1302 let total_issues = u(env, "total_issues");
1303 if total_issues == 0 {
1304 return format!(
1305 "# 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."
1306 );
1307 }
1308 let mut sections = String::new();
1309 for group in [
1310 check_sections_core(),
1311 check_sections_architecture(),
1312 check_sections_frameworks_and_hygiene(),
1313 check_sections_catalog(),
1314 ] {
1315 for spec in &group {
1316 sections.push_str(&render_check_section(env, spec));
1317 }
1318 }
1319 sections.push_str(&prop_drilling_section(env));
1320 let issue_noun = if total_issues == 1 { "issue" } else { "issues" };
1321 format!(
1322 "# 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}{}",
1323 dead_code_category_table(env),
1324 check_tips(env),
1325 )
1326}
1327
1328fn dupes_family_entry(family: &Value) -> String {
1333 let files: Vec<String> = arr(family, "files")
1334 .filter_map(Value::as_str)
1335 .map(markdown_code_span)
1336 .collect();
1337 let shown = files[..files.len().min(3)].join(", ");
1338 let more = if files.len() > 3 {
1339 format!(" (+{} more)", files.len() - 3)
1340 } else {
1341 String::new()
1342 };
1343 let mut entry = format!(
1344 "- **{shown}{more}** - {} lines, {} groups",
1345 num(family, "total_duplicated_lines"),
1346 arr(family, "groups").count(),
1347 );
1348 if let Some(best_group) = best_clone_group(family)
1349 && arr(best_group, "instances").next().is_some()
1350 {
1351 let locations = arr(best_group, "instances")
1352 .map(instance_location)
1353 .collect::<Vec<_>>()
1354 .join(", ");
1355 let _ = write!(entry, "\n - {locations}");
1356 }
1357 if arr(family, "suggestions").next().is_some() {
1358 let suggestions = arr(family, "suggestions")
1359 .map(|suggestion| {
1360 format!(
1361 " - {} (~{} lines)",
1362 markdown_table_text(s(suggestion, "description")),
1363 num(suggestion, "estimated_savings"),
1364 )
1365 })
1366 .collect::<Vec<_>>()
1367 .join("\n");
1368 let _ = write!(entry, "\n{suggestions}");
1369 }
1370 entry
1371}
1372
1373fn instance_location(instance: &Value) -> String {
1374 markdown_code_span(&format!(
1375 "{}:{}-{}",
1376 s(instance, "file"),
1377 num(instance, "start_line"),
1378 num(instance, "end_line"),
1379 ))
1380}
1381
1382type CloneGroupJsonRankKey = (
1383 std::cmp::Reverse<u128>,
1384 std::cmp::Reverse<u64>,
1385 std::cmp::Reverse<u64>,
1386 std::cmp::Reverse<usize>,
1387 std::cmp::Reverse<u64>,
1388 String,
1389 u64,
1390);
1391
1392fn clone_group_rank_key(group: &Value) -> CloneGroupJsonRankKey {
1393 const WEIGHTS: [u64; 9] = [
1394 1_000_000_000,
1395 1_047_319_732,
1396 1_075_000_000,
1397 1_094_639_463,
1398 1_109_873_014,
1399 1_122_319_732,
1400 1_132_843_281,
1401 1_141_959_195,
1402 1_150_000_000,
1403 ];
1404 let spread = u(group, "spread");
1405 let token_count = u(group, "token_count");
1406 let instances: Vec<&Value> = arr(group, "instances").collect();
1407 let first = instances
1408 .iter()
1409 .min_by_key(|instance| (s(instance, "file").to_string(), u(instance, "start_line")));
1410 let weight = WEIGHTS[usize::try_from(spread.min(8)).unwrap_or(8)];
1411 let score = u128::from(token_count)
1412 .saturating_mul(u128::try_from(instances.len()).unwrap_or(u128::MAX))
1413 .saturating_mul(u128::from(weight));
1414 (
1415 std::cmp::Reverse(score),
1416 std::cmp::Reverse(spread),
1417 std::cmp::Reverse(token_count),
1418 std::cmp::Reverse(instances.len()),
1419 std::cmp::Reverse(u(group, "line_count")),
1420 first.map_or_else(String::new, |instance| s(instance, "file").to_string()),
1421 first.map_or(0, |instance| u(instance, "start_line")),
1422 )
1423}
1424
1425fn sorted_clone_groups(env: &Value) -> Vec<&Value> {
1427 let mut groups: Vec<&Value> = arr(env, "clone_groups").collect();
1428 groups.sort_by_cached_key(|group| clone_group_rank_key(group));
1429 groups
1430}
1431
1432fn best_clone_group(family: &Value) -> Option<&Value> {
1433 arr(family, "groups").min_by_key(|group| clone_group_rank_key(group))
1434}
1435
1436type CloneFamilyJsonRankKey = (bool, Option<CloneGroupJsonRankKey>, Vec<String>);
1437
1438fn clone_family_rank_key(family: &Value) -> CloneFamilyJsonRankKey {
1439 let best_group = best_clone_group(family).map(clone_group_rank_key);
1440 let files = arr(family, "files")
1441 .filter_map(Value::as_str)
1442 .map(str::to_owned)
1443 .collect();
1444 (best_group.is_none(), best_group, files)
1445}
1446
1447fn sorted_clone_families(env: &Value) -> Vec<&Value> {
1448 let mut families: Vec<&Value> = arr(env, "clone_families").collect();
1449 families.sort_by_cached_key(|family| clone_family_rank_key(family));
1450 families
1451}
1452
1453fn dupes_corpus_total(env: &Value, listed: usize, omitted_key: &str) -> usize {
1460 listed.saturating_add(u(env, omitted_key) as usize)
1461}
1462
1463fn dupes_omission_tail(withheld: usize, capped_by_top: usize, noun: &str) -> String {
1472 if withheld == 0 {
1473 return String::new();
1474 }
1475 if capped_by_top == 0 {
1476 return format!("\n- *... and {withheld} more {noun}*");
1477 }
1478 format!(
1479 "\n- *... and {withheld} more {noun}, {capped_by_top} of them withheld by a display limit before this report*"
1480 )
1481}
1482
1483fn dupes_details(env: &Value) -> String {
1484 let families = sorted_clone_families(env);
1485 if families.is_empty() {
1486 let groups = sorted_clone_groups(env);
1487 let rows = groups
1488 .iter()
1489 .take(20)
1490 .map(|group| {
1491 let locations = arr(group, "instances")
1492 .map(instance_location)
1493 .collect::<Vec<_>>()
1494 .join(", ");
1495 format!(
1496 "- **{} lines, {} tokens**, {locations}",
1497 num(group, "line_count"),
1498 num(group, "token_count"),
1499 )
1500 })
1501 .collect::<Vec<_>>()
1502 .join("\n");
1503 let total = dupes_corpus_total(env, groups.len(), "clone_groups_omitted");
1507 let tail = dupes_omission_tail(
1508 total.saturating_sub(groups.len().min(20)),
1509 u(env, "clone_groups_omitted") as usize,
1510 "groups",
1511 );
1512 format!("{rows}{tail}")
1513 } else {
1514 let entries = families
1515 .iter()
1516 .take(15)
1517 .map(|family| dupes_family_entry(family))
1518 .collect::<Vec<_>>()
1519 .join("\n");
1520 let total = dupes_corpus_total(env, families.len(), "clone_families_omitted");
1521 let tail = dupes_omission_tail(
1522 total.saturating_sub(families.len().min(15)),
1523 u(env, "clone_families_omitted") as usize,
1524 "families",
1525 );
1526 format!("**Clone Families ({total})**\n\n{entries}{tail}")
1527 }
1528}
1529
1530#[must_use]
1532fn render_dupes_summary(env: &Value) -> String {
1533 let stats = env.get("stats").cloned().unwrap_or(Value::Null);
1534 let elapsed = num(env, "elapsed_ms");
1535 if u(&stats, "clone_groups") == 0 {
1536 return format!(
1537 "## Fallow - Code Duplication\n\nNo code duplication found.\n\n*Analyzed {} files in {elapsed}ms*",
1538 num(&stats, "total_files"),
1539 );
1540 }
1541 format!(
1542 "## 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>",
1543 num(&stats, "clone_groups"),
1544 num(&stats, "clone_instances"),
1545 num(&stats, "files_with_clones"),
1546 num(&stats, "total_files"),
1547 num(&stats, "files_with_clones"),
1548 num(&stats, "clone_groups"),
1549 num(&stats, "clone_instances"),
1550 num(&stats, "duplicated_lines"),
1551 num(&stats, "total_lines"),
1552 pct(f_or_zero(&stats, "duplication_percentage")),
1553 dupes_details(env),
1554 )
1555}
1556
1557fn metric_delta<'v>(score_env: &'v Value, name: &str) -> Option<&'v Value> {
1562 score_env
1563 .get("health_trend")
1564 .and_then(|trend| trend.get("metrics"))
1565 .and_then(Value::as_array)
1566 .and_then(|metrics| metrics.iter().find(|metric| s(metric, "name") == name))
1567}
1568
1569fn health_score_header(score_env: &Value) -> String {
1572 let Some(score) = score_env
1573 .get("health_score")
1574 .filter(|value| !value.is_null())
1575 else {
1576 return String::new();
1577 };
1578 let mut header = format!(
1579 "> **Health: {} ({})**",
1580 s(score, "grade"),
1581 pct(f_or_zero(score, "score")),
1582 );
1583 if let Some(score_delta) = metric_delta(score_env, "score") {
1584 let compared = score_env
1585 .get("health_trend")
1586 .and_then(|trend| trend.get("compared_to"))
1587 .cloned()
1588 .unwrap_or(Value::Null);
1589 let _ = write!(
1590 header,
1591 " \u{b7} {} pts vs previous ({} {})",
1592 signed(f_or_zero(score_delta, "delta")),
1593 s(&compared, "grade"),
1594 pct(f_or_zero(&compared, "score")),
1595 );
1596 if let Some(dead_delta) = metric_delta(score_env, "dead_export_pct")
1597 && f_or_zero(dead_delta, "delta") != 0.0
1598 {
1599 let _ = write!(
1600 header,
1601 " \u{b7} {} {}% ({}%)",
1602 s(dead_delta, "label").to_ascii_lowercase(),
1603 pct(f_or_zero(dead_delta, "current")),
1604 signed(f_or_zero(dead_delta, "delta")),
1605 );
1606 if f_or_zero(dead_delta, "delta") > 0.0 {
1607 let _ = write!(header, " [suppress?]({SUPPRESSION_DOCS})");
1608 }
1609 }
1610 if let Some(cx_delta) = metric_delta(score_env, "avg_cyclomatic")
1611 && f_or_zero(cx_delta, "delta") != 0.0
1612 {
1613 let _ = write!(
1614 header,
1615 " \u{b7} {} {} ({})",
1616 s(cx_delta, "label").to_ascii_lowercase(),
1617 pct(f_or_zero(cx_delta, "current")),
1618 signed(f_or_zero(cx_delta, "delta")),
1619 );
1620 }
1621 } else if let Some(compared) = score_env
1622 .get("health_trend")
1623 .and_then(|trend| trend.get("compared_to"))
1624 {
1625 let previous_formula = compared
1626 .get("score_formula_version")
1627 .and_then(Value::as_u64)
1628 .and_then(|version| u32::try_from(version).ok());
1629 let current_formula = score
1630 .get("formula_version")
1631 .and_then(Value::as_u64)
1632 .and_then(|version| u32::try_from(version).ok());
1633 let note = compared
1634 .get("score")
1635 .and_then(Value::as_f64)
1636 .and_then(|_| {
1637 fallow_output::health_score_comparison_note(previous_formula, current_formula)
1638 })
1639 .unwrap_or("Score comparison unavailable for this snapshot.");
1640 let _ = write!(header, "\n> _{note}_");
1641 } else {
1642 header.push_str("\n> _Enable `save-snapshot: true` to track score trends over time._");
1643 }
1644 header.push_str("\n\n");
1645 header
1646}
1647
1648fn exceeded_marker(it: &Value, needles: &[&str]) -> &'static str {
1649 let exceeded = s(it, "exceeded");
1650 if needles.iter().any(|needle| exceeded.contains(needle)) {
1651 " **!**"
1652 } else {
1653 ""
1654 }
1655}
1656
1657fn crap_cell(it: &Value) -> String {
1658 match it.get("crap").filter(|crap| !crap.is_null()) {
1659 None => "-".to_owned(),
1660 Some(crap) => format!("{}{}", fmt_num(crap), exceeded_marker(it, &["crap", "all"])),
1661 }
1662}
1663
1664fn complexity_table_row(it: &Value) -> String {
1665 format!(
1666 "| {} | {} | {} | {}{} | {}{} | {} | {} |",
1667 path_line_cell(it),
1668 code_cell(it, "name"),
1669 markdown_table_text(str_or(it, "severity", "moderate")),
1670 num(it, "cyclomatic"),
1671 exceeded_marker(it, &["cyclomatic", "both", "all"]),
1672 num(it, "cognitive"),
1673 exceeded_marker(it, &["cognitive", "both", "all"]),
1674 crap_cell(it),
1675 num(it, "line_count"),
1676 )
1677}
1678
1679const COMPLEXITY_TABLE_HEADER: &str = "| File | Function | Severity | Cyclomatic | Cognitive | CRAP | Lines |\n|:-----|:---------|:---------|:-----------|:----------|:-----|:------|\n";
1680
1681fn health_thresholds_footer(env: &Value) -> String {
1682 let summary = env.get("summary").cloned().unwrap_or(Value::Null);
1683 format!(
1684 "\n\n**!** marks the dimension that breached.\n\n**{}** files, **{}** functions analyzed (thresholds: cyclomatic > {}, cognitive > {}, CRAP >= {})",
1685 num(&summary, "files_analyzed"),
1686 num(&summary, "functions_analyzed"),
1687 num(&summary, "max_cyclomatic_threshold"),
1688 num(&summary, "max_cognitive_threshold"),
1689 threshold_or(&summary, "max_crap_threshold", "30"),
1690 )
1691}
1692
1693fn threshold_or(summary: &Value, key: &str, default: &str) -> String {
1694 summary
1695 .get(key)
1696 .filter(|value| !value.is_null())
1697 .map_or_else(|| default.to_owned(), fmt_num)
1698}
1699
1700fn complexity_rows(findings: &[&Value], cap: usize) -> String {
1701 findings
1702 .iter()
1703 .take(cap)
1704 .map(|finding| complexity_table_row(finding))
1705 .collect::<Vec<_>>()
1706 .join("\n")
1707}
1708
1709fn runtime_finding_row(it: &Value) -> String {
1710 let invocations = it
1711 .get("invocations")
1712 .filter(|value| !value.is_null())
1713 .map_or_else(|| "-".to_owned(), fmt_num);
1714 format!(
1715 "| {} | {} | {} | {invocations} | {} |",
1716 path_line_cell(it),
1717 code_cell(it, "function"),
1718 code_cell(it, "verdict"),
1719 markdown_table_text(s(it, "confidence")),
1720 )
1721}
1722
1723fn baselined_complexity(summary: &Value) -> Option<(u64, u64)> {
1731 let remaining = summary
1732 .get("baseline_staleness")?
1733 .get("remaining_findings")?
1734 .as_u64()?;
1735 let accepted = u(summary, "functions_above_threshold").saturating_sub(remaining);
1736 Some((remaining, accepted))
1737}
1738
1739fn render_health_complexity_only(env: &Value, complex: usize, elapsed: &str) -> String {
1740 let summary = env.get("summary").cloned().unwrap_or(Value::Null);
1741 let above = u(&summary, "functions_above_threshold");
1742 let listing = complexity_listing(env, &summary, complex, above);
1743 if listing.count_unlisted() {
1744 return render_health_complexity_not_listed(&summary, above, &listing, elapsed);
1745 }
1746 let baselined = baselined_complexity(&summary).filter(|&(_, accepted)| accepted > 0);
1747 if complex == 0 {
1748 let headline = baselined
1751 .filter(|&(remaining, _)| remaining == 0)
1752 .map_or_else(
1753 || "No functions exceed complexity thresholds".to_owned(),
1754 |(_, accepted)| {
1755 format!(
1756 "No new functions exceed complexity thresholds ({accepted} in the baseline)"
1757 )
1758 },
1759 );
1760 return format!(
1761 "## Fallow - Code Complexity\n\n> [!NOTE]\n> **{headline}** \u{b7} {elapsed}ms\n\n{} functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {})",
1762 num(&summary, "functions_analyzed"),
1763 num(&summary, "max_cyclomatic_threshold"),
1764 num(&summary, "max_cognitive_threshold"),
1765 threshold_or(&summary, "max_crap_threshold", "30"),
1766 );
1767 }
1768 let findings: Vec<&Value> = arr(env, "findings").collect();
1769 let tail = if complex > 25 {
1770 format!(
1771 "\n\n> {} more - run `fallow health` locally for the full list",
1772 complex - 25
1773 )
1774 } else {
1775 String::new()
1776 };
1777 let (shown, new, baseline_note) = baselined
1778 .map_or((above, "", String::new()), |(remaining, accepted)| {
1779 (remaining, "new ", format!(" ({accepted} in the baseline)"))
1780 });
1781 format!(
1782 "## Fallow - Code Complexity\n\n> [!WARNING]\n> **{shown} {new}function{} exceed{} thresholds{baseline_note}** \u{b7} {elapsed}ms\n\n{COMPLEXITY_TABLE_HEADER}{}{tail}{}",
1783 if shown == 1 { "" } else { "s" },
1784 if shown == 1 { "s" } else { "" },
1785 complexity_rows(&findings, 25),
1786 health_thresholds_footer(env),
1787 )
1788}
1789
1790fn complexity_listing(
1793 env: &Value,
1794 summary: &Value,
1795 complex: usize,
1796 above: u64,
1797) -> fallow_api::ComplexityListing {
1798 let staleness = summary
1799 .get("baseline_staleness")
1800 .filter(|value| !value.is_null());
1801 fallow_api::ComplexityListing {
1802 listed: complex,
1803 above: usize::try_from(above).unwrap_or(usize::MAX),
1804 complexity_section: env
1806 .get("sections")
1807 .and_then(Value::as_array)
1808 .map(|sections| sections.iter().any(|s| s.as_str() == Some("complexity"))),
1809 score_only: env.get("vital_signs").is_none_or(Value::is_null),
1811 baselined: staleness.is_some(),
1812 remaining: staleness
1813 .and_then(|staleness| staleness.get("remaining_findings"))
1814 .and_then(Value::as_u64)
1815 .map(|remaining| usize::try_from(remaining).unwrap_or(usize::MAX)),
1816 }
1817}
1818
1819fn render_health_complexity_not_listed(
1823 summary: &Value,
1824 above: u64,
1825 listing: &fallow_api::ComplexityListing,
1826 elapsed: &str,
1827) -> String {
1828 format!(
1829 "## 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: {}). {}",
1830 if above == 1 { "" } else { "s" },
1831 if above == 1 { "s" } else { "" },
1832 num(summary, "functions_analyzed"),
1833 num(summary, "max_cyclomatic_threshold"),
1834 num(summary, "max_cognitive_threshold"),
1835 threshold_or(summary, "max_crap_threshold", "30"),
1836 listing.not_listed_note(),
1837 )
1838}
1839
1840fn prod_phrase(complex: usize, prod: usize) -> String {
1841 let complexity = format!(
1842 "{complex} complexity finding{}",
1843 if complex == 1 { "" } else { "s" }
1844 );
1845 let runtime = format!(
1846 "{prod} runtime coverage finding{}",
1847 if prod == 1 { "" } else { "s" }
1848 );
1849 if complex > 0 && prod > 0 {
1850 format!("{complexity} and {runtime}")
1851 } else if complex > 0 {
1852 complexity
1853 } else {
1854 runtime
1855 }
1856}
1857
1858fn render_health_with_runtime(env: &Value, complex: usize, elapsed: &str) -> String {
1859 let runtime = env.get("runtime_coverage").cloned().unwrap_or(Value::Null);
1860 let prod_findings: Vec<&Value> = arr(&runtime, "findings").collect();
1861 let hot_paths: Vec<&Value> = arr(&runtime, "hot_paths").collect();
1862 let prod = prod_findings.len();
1863 let hot = hot_paths.len();
1864 let mut out = String::from("## Fallow - Health\n\n");
1865 if complex == 0 && prod == 0 {
1866 let _ = write!(
1867 out,
1868 "> [!NOTE]\n> **No failing health findings** \u{b7} {elapsed}ms\n\n"
1869 );
1870 } else {
1871 let _ = write!(
1872 out,
1873 "> [!WARNING]\n> **{}** \u{b7} {elapsed}ms\n\n",
1874 prod_phrase(complex, prod),
1875 );
1876 }
1877 if complex > 0 {
1878 let findings: Vec<&Value> = arr(env, "findings").collect();
1879 let _ = write!(
1880 out,
1881 "### Complexity\n\n{COMPLEXITY_TABLE_HEADER}{}",
1882 complexity_rows(&findings, 25),
1883 );
1884 if complex > 25 {
1885 let _ = write!(
1886 out,
1887 "\n\n> {} more complexity findings - run `fallow health` locally for the full list",
1888 complex - 25,
1889 );
1890 }
1891 }
1892 if prod > 0 {
1893 if complex > 0 {
1894 out.push_str("\n\n");
1895 }
1896 out.push_str("### Runtime Coverage\n\n| File | Function | Verdict | Invocations | Confidence |\n|:-----|:---------|:--------|------------:|:-----------|\n");
1897 out.push_str(
1898 &prod_findings
1899 .iter()
1900 .take(25)
1901 .map(|finding| runtime_finding_row(finding))
1902 .collect::<Vec<_>>()
1903 .join("\n"),
1904 );
1905 if prod > 25 {
1906 let _ = write!(
1907 out,
1908 "\n\n> {} more runtime coverage findings - run `fallow health` locally for the full list",
1909 prod - 25,
1910 );
1911 }
1912 }
1913 if hot > 0 {
1914 if complex > 0 || prod > 0 {
1915 out.push_str("\n\n");
1916 }
1917 out.push_str("### Hot Paths\n\n| File | Function | Invocations | Percentile |\n|:-----|:---------|------------:|-----------:|\n");
1918 out.push_str(
1919 &hot_paths
1920 .iter()
1921 .take(10)
1922 .map(|path| {
1923 format!(
1924 "| {} | {} | {} | {} |",
1925 path_line_cell(path),
1926 code_cell(path, "function"),
1927 num(path, "invocations"),
1928 num(path, "percentile"),
1929 )
1930 })
1931 .collect::<Vec<_>>()
1932 .join("\n"),
1933 );
1934 if hot > 10 {
1935 let _ = write!(out, "\n\n> {} more hot paths in the full report", hot - 10);
1936 }
1937 }
1938 out.push_str(&health_runtime_footer(env, complex, prod, hot, &runtime));
1939 out
1940}
1941
1942fn health_runtime_footer(
1943 env: &Value,
1944 complex: usize,
1945 prod: usize,
1946 hot: usize,
1947 runtime: &Value,
1948) -> String {
1949 if complex > 0 {
1950 return health_thresholds_footer(env);
1951 }
1952 if prod > 0 {
1953 let summary = runtime.get("summary").cloned().unwrap_or(Value::Null);
1954 return format!(
1955 "\n\n**{}** tracked functions, **{}** hit, **{}** unhit, **{}** untracked",
1956 num(&summary, "functions_tracked"),
1957 num(&summary, "functions_hit"),
1958 num(&summary, "functions_unhit"),
1959 num(&summary, "functions_untracked"),
1960 );
1961 }
1962 format!(
1963 "\n\nObserved **{hot}** hot path{} in runtime coverage.",
1964 if hot == 1 { "" } else { "s" },
1965 )
1966}
1967
1968#[must_use]
1970fn render_health_summary(env: &Value) -> String {
1971 let elapsed = num(env, "elapsed_ms");
1972 let complex = arr(env, "findings").count();
1973 let runtime = env.get("runtime_coverage").cloned().unwrap_or(Value::Null);
1974 let prod = arr(&runtime, "findings").count();
1975 let hot = arr(&runtime, "hot_paths").count();
1976 let body = if prod == 0 && hot == 0 {
1977 render_health_complexity_only(env, complex, &elapsed)
1978 } else {
1979 render_health_with_runtime(env, complex, &elapsed)
1980 };
1981 let groups = fallow_api::build_health_groups_markdown(env, "");
1982 if groups.is_empty() {
1983 format!("{}{body}", health_score_header(env))
1984 } else {
1985 format!("{}{body}\n\n{groups}", health_score_header(env))
1986 }
1987}
1988
1989const fn audit_verdict_label(verdict: &str) -> &'static str {
1994 match verdict.as_bytes() {
1995 b"fail" => "[!WARNING]\n> **Audit failed**",
1996 b"warn" => "[!WARNING]\n> **Audit passed with warnings**",
1997 _ => "[!NOTE]\n> **Audit passed**",
1998 }
1999}
2000
2001fn introduced_label(item: &Value) -> &'static str {
2002 match item.get("introduced").and_then(Value::as_bool) {
2003 Some(true) => "new",
2004 Some(false) => "inherited",
2005 None => "unknown",
2006 }
2007}
2008
2009fn complexity_status_label(item: &Value) -> &'static str {
2011 if item.get("attribution").and_then(Value::as_str) == Some("worsened") {
2012 "worsened"
2013 } else {
2014 introduced_label(item)
2015 }
2016}
2017
2018fn worsened_gates(env: &Value) -> bool {
2021 env["attribution"]["worsened"] == "error"
2022}
2023
2024fn audit_rule_severity(item: &Value) -> &'static str {
2025 match item
2026 .get("effective_severity")
2027 .or_else(|| item.get("severity"))
2028 .and_then(Value::as_str)
2029 {
2030 Some("error") => "error",
2031 Some("warn") if item["gate_exemption"] == "test-file" => "warn (test file)",
2032 Some("warn") => "warn",
2033 _ => "unknown",
2034 }
2035}
2036
2037fn audit_priority(env: &Value, severity: &str, status: &str) -> (u8, u8) {
2038 let severity = match severity {
2039 "error" => 0,
2040 "warn" => 1,
2041 _ => 2,
2042 };
2043 let status = if env["attribution"]["gate"] == "new-only" {
2044 match status {
2045 "new" => 0,
2046 "worsened" if worsened_gates(env) => 0,
2047 "worsened" => 1,
2048 "inherited" => 2,
2049 _ => 3,
2050 }
2051 } else {
2052 0
2053 };
2054 (severity, status)
2055}
2056
2057fn sorted_audit_findings<'a>(env: &Value, section: &'a Value, key: &str) -> Vec<&'a Value> {
2058 let mut findings: Vec<&Value> = arr(section, key).collect();
2059 findings.sort_by(|a, b| {
2060 audit_priority(env, audit_rule_severity(a), complexity_status_label(a))
2061 .cmp(&audit_priority(
2062 env,
2063 audit_rule_severity(b),
2064 complexity_status_label(b),
2065 ))
2066 .then_with(|| s(a, "path").cmp(s(b, "path")))
2067 .then_with(|| u(a, "line").cmp(&u(b, "line")))
2068 .then_with(|| s(a, "name").cmp(s(b, "name")))
2069 .then_with(|| s(a, "code").cmp(s(b, "code")))
2070 .then_with(|| s(a, "value").cmp(s(b, "value")))
2071 });
2072 findings
2073}
2074
2075struct AuditRow {
2076 kind: &'static str,
2077 location: String,
2078 item: String,
2079 status: &'static str,
2080 severity: &'static str,
2081}
2082
2083fn audit_row(kind: &'static str, location: String, item: String, finding: &Value) -> AuditRow {
2084 AuditRow {
2085 kind,
2086 location,
2087 item,
2088 status: introduced_label(finding),
2089 severity: audit_rule_severity(finding),
2090 }
2091}
2092
2093type AuditRowSpec = (&'static str, &'static str, fn(&Value) -> String);
2094
2095const AUDIT_EXPORT_DEP_ROWS: &[AuditRowSpec] = &[
2098 ("Unused export", "unused_exports", |it| {
2099 code_cell(it, "export_name")
2100 }),
2101 ("Unused type", "unused_types", |it| {
2102 code_cell(it, "export_name")
2103 }),
2104 ("Private type leak", "private_type_leaks", |it| {
2105 format!(
2106 "{} -> {}",
2107 code_cell(it, "export_name"),
2108 code_cell(it, "type_name")
2109 )
2110 }),
2111 (
2112 "Deprecated export in use",
2113 "deprecated_exports_in_use",
2114 |it| code_cell(it, "export_name"),
2115 ),
2116 ("Unused dependency", "unused_dependencies", |it| {
2117 code_cell(it, "package_name")
2118 }),
2119 ("Unused devDependency", "unused_dev_dependencies", |it| {
2120 code_cell(it, "package_name")
2121 }),
2122 (
2123 "Unused optionalDependency",
2124 "unused_optional_dependencies",
2125 |it| code_cell(it, "package_name"),
2126 ),
2127 ("Unused enum member", "unused_enum_members", member_item),
2128 ("Unused class member", "unused_class_members", member_item),
2129 ("Unused store member", "unused_store_members", member_item),
2130 ("Unresolved import", "unresolved_imports", |it| {
2131 code_cell(it, "specifier")
2132 }),
2133];
2134
2135const AUDIT_COMPONENT_ROWS: &[AuditRowSpec] = &[
2137 ("Unrendered component", "unrendered_components", |it| {
2138 format!(
2139 "{} ({})",
2140 code_cell(it, "component_name"),
2141 markdown_table_text(s(it, "framework"))
2142 )
2143 }),
2144 ("Unused component prop", "unused_component_props", |it| {
2145 component_member_item(it, "prop_name")
2146 }),
2147 (
2148 "Absent optional component prop",
2149 "absent_component_props",
2150 |it| component_member_item(it, "prop_name"),
2151 ),
2152 ("Unused component emit", "unused_component_emits", |it| {
2153 format!(
2154 "{} emit {}",
2155 code_cell(it, "component_name"),
2156 code_cell(it, "emit_name")
2157 )
2158 }),
2159 ("Unused component input", "unused_component_inputs", |it| {
2160 component_member_item(it, "input_name")
2161 }),
2162 (
2163 "Unused component output",
2164 "unused_component_outputs",
2165 |it| {
2166 format!(
2167 "{} output {}",
2168 code_cell(it, "component_name"),
2169 code_cell(it, "output_name")
2170 )
2171 },
2172 ),
2173 ("Unused Svelte event", "unused_svelte_events", |it| {
2174 format!(
2175 "{} event {}",
2176 code_cell(it, "component_name"),
2177 code_cell(it, "event_name")
2178 )
2179 }),
2180 ("Unused Effect layer", "unused_effect_layers", |it| {
2181 format!("layer {}", code_cell(it, "name"))
2182 }),
2183 ("Unused tagged error", "unused_tagged_errors", |it| {
2184 format!("{} tag {}", code_cell(it, "name"), code_cell(it, "tag"))
2185 }),
2186 ("Unproduced catch tag", "unproduced_catch_tags", |it| {
2187 format!("catchTag {}", code_cell(it, "tag"))
2188 }),
2189 ("Unprovided inject", "unprovided_injects", |it| {
2190 format!(
2191 "{} ({})",
2192 code_cell(it, "key_name"),
2193 markdown_table_text(s(it, "framework"))
2194 )
2195 }),
2196 ("Unused load data key", "unused_load_data_keys", |it| {
2197 code_cell(it, "key_name")
2198 }),
2199];
2200
2201const AUDIT_HYGIENE_ROWS: &[AuditRowSpec] = &[
2203 ("Type-only dependency", "type_only_dependencies", |it| {
2204 code_cell(it, "package_name")
2205 }),
2206 ("Test-only dependency", "test_only_dependencies", |it| {
2207 code_cell(it, "package_name")
2208 }),
2209 (
2210 "Dev dependency in production",
2211 "dev_dependencies_in_production",
2212 |it| code_cell(it, "package_name"),
2213 ),
2214 ("Stale suppression", "stale_suppressions", |it| {
2215 markdown_table_code_span(str_or(it, "description", "suppression"))
2218 }),
2219 ("Unused catalog entry", "unused_catalog_entries", |it| {
2220 format!(
2221 "{} ({})",
2222 code_cell(it, "entry_name"),
2223 code_cell(it, "catalog_name")
2224 )
2225 }),
2226 ("Empty catalog group", "empty_catalog_groups", |it| {
2227 code_cell(it, "catalog_name")
2228 }),
2229 (
2230 "Unresolved catalog reference",
2231 "unresolved_catalog_references",
2232 |it| {
2233 format!(
2234 "{} -> {}",
2235 code_cell(it, "entry_name"),
2236 code_cell(it, "catalog_name")
2237 )
2238 },
2239 ),
2240 (
2241 "Unused dependency override",
2242 "unused_dependency_overrides",
2243 |it| format!("{} ({})", code_cell(it, "raw_key"), code_cell(it, "source")),
2244 ),
2245 (
2246 "Misconfigured dependency override",
2247 "misconfigured_dependency_overrides",
2248 |it| format!("{} ({})", code_cell(it, "raw_key"), code_cell(it, "source")),
2249 ),
2250];
2251
2252fn audit_rows_from_table(dead_code: &Value, table: &[AuditRowSpec], rows: &mut Vec<AuditRow>) {
2253 for (kind, key, item_fn) in table {
2254 for finding in arr(dead_code, key) {
2255 rows.push(audit_row(
2256 kind,
2257 path_line(finding),
2258 item_fn(finding),
2259 finding,
2260 ));
2261 }
2262 }
2263}
2264
2265fn member_item(it: &Value) -> String {
2266 markdown_table_code_span(&format!(
2267 "{}.{}",
2268 s(it, "parent_name"),
2269 s(it, "member_name")
2270 ))
2271}
2272
2273fn component_member_item(it: &Value, member_key: &str) -> String {
2274 markdown_table_code_span(&format!(
2275 "{}.{}",
2276 s(it, "component_name"),
2277 s(it, member_key)
2278 ))
2279}
2280
2281fn first_import_site(it: &Value) -> String {
2282 arr(it, "imported_from").next().map_or_else(
2283 || path_line(it),
2284 |site| {
2285 markdown_table_code_span(&format!(
2286 "{}:{}",
2287 rel_path_absolute_only(s(site, "path")),
2288 num(site, "line"),
2289 ))
2290 },
2291 )
2292}
2293
2294fn audit_rows_graph(dead_code: &Value, rows: &mut Vec<AuditRow>) {
2297 for it in arr(dead_code, "unlisted_dependencies") {
2298 rows.push(audit_row(
2299 "Unlisted dependency",
2300 first_import_site(it),
2301 code_cell(it, "package_name"),
2302 it,
2303 ));
2304 }
2305 for it in arr(dead_code, "duplicate_exports") {
2306 let location = arr(it, "locations")
2307 .take(3)
2308 .map(|loc| {
2309 markdown_table_code_span(&format!(
2310 "{}:{}",
2311 rel_path_absolute_only(s(loc, "path")),
2312 num(loc, "line")
2313 ))
2314 })
2315 .collect::<Vec<_>>()
2316 .join(", ");
2317 rows.push(audit_row(
2318 "Duplicate export",
2319 location,
2320 code_cell(it, "export_name"),
2321 it,
2322 ));
2323 }
2324 for it in arr(dead_code, "circular_dependencies") {
2325 let location = arr(it, "files")
2326 .filter_map(Value::as_str)
2327 .map(|file| markdown_table_code_span(&rel_path_absolute_only(file)))
2328 .collect::<Vec<_>>()
2329 .join(" -> ");
2330 rows.push(audit_row(
2331 "Circular dependency",
2332 location,
2333 "cycle".to_owned(),
2334 it,
2335 ));
2336 }
2337 for it in arr(dead_code, "re_export_cycles") {
2338 let location = arr(it, "files")
2339 .filter_map(Value::as_str)
2340 .map(|file| markdown_table_code_span(&rel_path_absolute_only(file)))
2341 .collect::<Vec<_>>()
2342 .join(" <-> ");
2343 rows.push(audit_row(
2344 "Re-export cycle",
2345 location,
2346 markdown_table_text(str_or(it, "kind", "cycle")),
2347 it,
2348 ));
2349 }
2350 for it in arr(dead_code, "package_cycles") {
2351 let location = arr(it, "packages")
2352 .filter_map(Value::as_str)
2353 .map(markdown_table_code_span)
2354 .collect::<Vec<_>>()
2355 .join(" -> ");
2356 let detail = if it.get("group_truncated").and_then(Value::as_bool) == Some(true) {
2357 format!(
2358 "cycle ({})",
2359 fallow_types::results::PackageCycle::GROUP_TRUNCATED_NOTE
2360 )
2361 } else {
2362 "cycle".to_owned()
2363 };
2364 rows.push(audit_row("Package cycle", location, detail, it));
2365 }
2366}
2367
2368fn audit_rows_boundaries(dead_code: &Value, rows: &mut Vec<AuditRow>) {
2370 for it in arr(dead_code, "boundary_violations") {
2371 rows.push(audit_row(
2372 "Boundary violation",
2373 rel_path_line_cell(it, "from_path"),
2374 format!(
2375 "{} -> {}",
2376 markdown_table_code_span(s(it, "from_zone")),
2377 markdown_table_code_span(s(it, "to_zone"))
2378 ),
2379 it,
2380 ));
2381 }
2382 for it in arr(dead_code, "boundary_coverage_violations") {
2383 rows.push(audit_row(
2384 "Boundary coverage",
2385 rel_path_line_cell(it, "path"),
2386 "no matching zone".to_owned(),
2387 it,
2388 ));
2389 }
2390 for it in arr(dead_code, "boundary_call_violations") {
2391 rows.push(audit_row(
2392 "Boundary call",
2393 rel_path_line_cell(it, "path"),
2394 format!(
2395 "{} in {}",
2396 code_cell(it, "callee"),
2397 markdown_table_code_span(s(it, "zone"))
2398 ),
2399 it,
2400 ));
2401 }
2402 for it in arr(dead_code, "policy_violations") {
2403 rows.push(audit_row(
2404 "Policy violation",
2405 rel_path_line_cell(it, "path"),
2406 format!(
2407 "{} banned by {}",
2408 code_cell(it, "matched"),
2409 markdown_table_code_span(&format!("{}/{}", s(it, "pack"), s(it, "rule_id")))
2410 ),
2411 it,
2412 ));
2413 }
2414}
2415
2416fn audit_rows_frameworks(dead_code: &Value, rows: &mut Vec<AuditRow>) {
2419 for it in arr(dead_code, "invalid_client_exports") {
2420 rows.push(audit_row(
2421 "Invalid client export",
2422 rel_path_line_cell(it, "path"),
2423 format!(
2424 "{} in {}",
2425 code_cell(it, "export_name"),
2426 markdown_table_code_span(&format!("\"{}\"", s(it, "directive")))
2427 ),
2428 it,
2429 ));
2430 }
2431 for it in arr(dead_code, "mixed_client_server_barrels") {
2432 rows.push(audit_row(
2433 "Mixed client/server barrel",
2434 rel_path_line_cell(it, "path"),
2435 format!(
2436 "{} + {}",
2437 code_cell(it, "client_origin"),
2438 code_cell(it, "server_origin")
2439 ),
2440 it,
2441 ));
2442 }
2443 for it in arr(dead_code, "misplaced_directives") {
2444 rows.push(audit_row(
2445 "Misplaced directive",
2446 rel_path_line_cell(it, "path"),
2447 markdown_table_code_span(&format!("\"{}\"", s(it, "directive"))),
2448 it,
2449 ));
2450 }
2451 for it in arr(dead_code, "unused_server_actions") {
2452 rows.push(audit_row(
2453 "Unused server action",
2454 path_line(it),
2455 code_cell(it, "action_name"),
2456 it,
2457 ));
2458 }
2459 for it in arr(dead_code, "route_collisions") {
2460 rows.push(audit_row(
2461 "Route collision",
2462 markdown_table_code_span(&rel_path_absolute_only(s(it, "path"))),
2463 code_cell(it, "url"),
2464 it,
2465 ));
2466 }
2467 for it in arr(dead_code, "dynamic_segment_name_conflicts") {
2468 rows.push(audit_row(
2469 "Dynamic segment conflict",
2470 markdown_table_code_span(&rel_path_absolute_only(s(it, "path"))),
2471 markdown_table_code_span(&plain_join(it, "conflicting_segments", ", ")),
2472 it,
2473 ));
2474 }
2475}
2476
2477fn audit_dead_code_rows(dead_code: &Value) -> Vec<AuditRow> {
2479 let mut rows: Vec<AuditRow> = Vec::new();
2480 for it in arr(dead_code, "unused_files") {
2481 rows.push(audit_row(
2482 "Unused file",
2483 markdown_table_code_span(&rel_path_absolute_only(s(it, "path"))),
2484 "-".to_owned(),
2485 it,
2486 ));
2487 }
2488 audit_rows_from_table(dead_code, AUDIT_EXPORT_DEP_ROWS, &mut rows);
2489 audit_rows_graph(dead_code, &mut rows);
2490 audit_rows_boundaries(dead_code, &mut rows);
2491 audit_rows_frameworks(dead_code, &mut rows);
2492 audit_rows_from_table(dead_code, AUDIT_COMPONENT_ROWS, &mut rows);
2493 audit_rows_from_table(dead_code, AUDIT_HYGIENE_ROWS, &mut rows);
2494 for item in arr(dead_code, "prop_drilling_chains")
2495 .filter(|item| s(item, "effective_severity") == "error")
2496 {
2497 let Some(source) = arr(item, "hops").next() else {
2498 continue;
2499 };
2500 rows.push(audit_row(
2501 "Prop drilling",
2502 markdown_table_code_span(s(source, "file")),
2503 code_cell(item, "prop"),
2504 item,
2505 ));
2506 }
2507 rows
2508}
2509
2510fn audit_complexity_section(env: &Value) -> String {
2511 let complexity = env.get("complexity").cloned().unwrap_or(Value::Null);
2512 let findings = sorted_audit_findings(env, &complexity, "findings");
2513 if findings.is_empty() {
2514 return String::new();
2515 }
2516 let rows = findings
2517 .iter()
2518 .take(15)
2519 .map(|it| {
2520 format!(
2521 "| {} | {} | {} | {} | {} | {} | {} | {} | {} |",
2522 path_line_cell(it),
2523 code_cell(it, "name"),
2524 complexity_status_label(it),
2525 audit_rule_severity(it),
2526 markdown_table_text(str_or(it, "severity", "moderate")),
2527 num(it, "cyclomatic"),
2528 num(it, "cognitive"),
2529 markdown_table_text(str_or(it, "coverage_tier", "-")),
2530 it.get("crap")
2531 .filter(|crap| !crap.is_null())
2532 .map_or_else(|| "-".to_owned(), fmt_num),
2533 )
2534 })
2535 .collect::<Vec<_>>()
2536 .join("\n");
2537 let tail = if findings.len() > 15 {
2538 format!(
2539 "\n\n> {} more complexity findings in the full audit report",
2540 findings.len() - 15,
2541 )
2542 } else {
2543 String::new()
2544 };
2545 format!(
2546 "### Complexity\n\n| File | Function | Status | Rule severity | Complexity band | Cyclomatic | Cognitive | Coverage | CRAP |\n|:-----|:---------|:-------|:--------------|:----------------|:-----------|:----------|:---------|:-----|\n{rows}{tail}{}\n\n",
2547 audit_coverage_model_note(&complexity),
2548 )
2549}
2550
2551fn audit_coverage_join_suffix(summary: &Value) -> String {
2559 let matched = summary
2560 .get("istanbul_files_matched")
2561 .and_then(Value::as_u64);
2562 let total = summary.get("istanbul_files_total").and_then(Value::as_u64);
2563 match (matched, total) {
2564 (Some(matched), Some(total)) if total > 0 && matched == 0 => format!(
2565 ", from a coverage file describing {total} files that none of the analyzed files matched; check `--coverage-root` is correct for this checkout."
2566 ),
2567 (Some(matched), Some(total)) if total > 0 && matched < total => format!(
2568 ", from {matched} of {total} files in the coverage file; the rest matched no analyzed file."
2569 ),
2570 _ => ".".to_owned(),
2571 }
2572}
2573
2574fn audit_coverage_model_note(complexity: &Value) -> String {
2575 let summary = complexity.get("summary").cloned().unwrap_or(Value::Null);
2576 let model = summary.get("coverage_model").and_then(Value::as_str);
2577 match model {
2578 Some("istanbul") => {
2579 let matched = summary.get("istanbul_matched").and_then(Value::as_u64);
2580 let total = summary.get("istanbul_total").and_then(Value::as_u64);
2581 match (matched, total) {
2582 (Some(matched), Some(total)) if total > 0 => {
2583 format!(
2584 "\n\n*Coverage model: istanbul. Matched {matched}/{total} functions{}*",
2585 audit_coverage_join_suffix(&summary)
2586 )
2587 }
2588 _ => "\n\n*Coverage model: istanbul (exact, from `--coverage`).*".to_owned(),
2589 }
2590 }
2591 Some("static_estimated" | "static_binary") => {
2592 "\n\n*Coverage model: static (estimated). Pair with `--coverage <coverage-final.json>` for measured coverage instead of estimates.*".to_owned()
2593 }
2594 _ => String::new(),
2595 }
2596}
2597
2598fn audit_duplication_section(env: &Value) -> String {
2599 let duplication = env.get("duplication").cloned().unwrap_or(Value::Null);
2600 let mut groups: Vec<&Value> = arr(&duplication, "clone_groups").collect();
2601 groups.sort_by(|a, b| {
2602 audit_priority(env, "unknown", introduced_label(a))
2603 .cmp(&audit_priority(env, "unknown", introduced_label(b)))
2604 .then_with(|| s(&a["instances"][0], "file").cmp(s(&b["instances"][0], "file")))
2605 .then_with(|| {
2606 u(&a["instances"][0], "start_line").cmp(&u(&b["instances"][0], "start_line"))
2607 })
2608 .then_with(|| u(a, "token_count").cmp(&u(b, "token_count")))
2609 });
2610 if groups.is_empty() {
2611 return String::new();
2612 }
2613 let rows = groups
2614 .iter()
2615 .take(10)
2616 .map(|group| {
2617 let instances: Vec<&Value> = arr(group, "instances").collect();
2618 let location = instances.first().map_or_else(
2619 || "-".to_owned(),
2620 |first| {
2621 let file = s(first, "file");
2622 if file.is_empty() {
2623 "-".to_owned()
2624 } else {
2625 let start = first
2626 .get("start_line")
2627 .filter(|line| !line.is_null())
2628 .map_or_else(|| "1".to_owned(), fmt_num);
2629 markdown_table_code_span(&format!(
2630 "{}:{start}",
2631 rel_path_absolute_only(file)
2632 ))
2633 }
2634 },
2635 );
2636 let mut files: Vec<String> = instances
2637 .iter()
2638 .map(|instance| {
2639 markdown_table_code_span(&rel_path_absolute_only(s(instance, "file")))
2640 })
2641 .collect();
2642 files.sort();
2643 files.dedup();
2644 let files = files.into_iter().take(3).collect::<Vec<_>>().join(", ");
2645 format!(
2646 "| {location} | {files} | {} lines / {} tokens | {} | {} |",
2647 num(group, "line_count"),
2648 num(group, "token_count"),
2649 instances.len(),
2650 introduced_label(group),
2651 )
2652 })
2653 .collect::<Vec<_>>()
2654 .join("\n");
2655 let tail = if groups.len() > 10 {
2656 format!(
2657 "\n\n> {} more clone groups in the full audit report",
2658 groups.len() - 10
2659 )
2660 } else {
2661 String::new()
2662 };
2663 format!(
2664 "### Duplication\n\n| Location | Files | Size | Instances | Status |\n|:---------|:------|:-----|----------:|:-------|\n{rows}{tail}\n\n"
2665 )
2666}
2667
2668fn audit_styling_section(env: &Value) -> String {
2669 let findings = sorted_audit_findings(env, &env["complexity"], "styling_findings");
2670 if findings.is_empty() {
2671 return String::new();
2672 }
2673 let rows = findings
2674 .iter()
2675 .take(15)
2676 .map(|finding| {
2677 format!(
2678 "| {} | {} | {} | {} | {} |",
2679 code_cell(finding, "code"),
2680 path_line(finding),
2681 code_cell(finding, "value"),
2682 introduced_label(finding),
2683 audit_rule_severity(finding),
2684 )
2685 })
2686 .collect::<Vec<_>>()
2687 .join("\n");
2688 let tail = if findings.len() > 15 {
2689 format!(
2690 "\n\n> {} more styling findings in the full audit report",
2691 findings.len() - 15,
2692 )
2693 } else {
2694 String::new()
2695 };
2696 format!(
2697 "### Styling\n\n| Rule | Location | Value | Status | Rule severity |\n|:-----|:---------|:------|:-------|:--------------|\n{rows}{tail}\n\n"
2698 )
2699}
2700
2701fn audit_diff_risk_section(env: &Value) -> String {
2704 let diff_risk = env.get("diff_risk").cloned().unwrap_or(Value::Null);
2705 let findings = sorted_audit_findings(env, &diff_risk, "findings");
2706 if findings.is_empty() {
2707 return String::new();
2708 }
2709 let rows = findings
2710 .iter()
2711 .take(15)
2712 .map(|finding| {
2713 let path = rel_path_absolute_only(s(finding, "path"));
2714 let line = u(finding, "line");
2715 let location = if line > 0 {
2716 format!("{path}:{line}")
2717 } else {
2718 path
2719 };
2720 format!(
2721 "| {} | {} | {} | {} |",
2722 markdown_table_code_span(s(finding, "rule")),
2723 markdown_table_code_span(&location),
2724 markdown_table_code_span(s(finding, "evidence")),
2725 str_or(finding, "severity", "warn"),
2726 )
2727 })
2728 .collect::<Vec<_>>()
2729 .join("\n");
2730 let tail = if findings.len() > 15 {
2731 format!(
2732 "\n\n> {} more diff-risk findings in the full audit report",
2733 findings.len() - 15
2734 )
2735 } else {
2736 String::new()
2737 };
2738 format!(
2739 "### Diff Risk\n\n| Rule | Location | Evidence | Severity |\n|:-----|:---------|:---------|:---------|\n{rows}{tail}\n\n"
2740 )
2741}
2742
2743fn audit_category_row(
2746 label: &str,
2747 summary: &Value,
2748 skipped_key: &str,
2749 findings: &str,
2750 attribution: &Value,
2751 attribution_prefix: &str,
2752) -> String {
2753 if summary
2754 .get(skipped_key)
2755 .and_then(Value::as_bool)
2756 .unwrap_or(false)
2757 {
2758 return format!("| {label} | skipped | - | - |");
2759 }
2760 let count = |suffix: &str| {
2761 attribution
2762 .get(format!("{attribution_prefix}_{suffix}"))
2763 .and_then(Value::as_u64)
2764 .map_or_else(|| "unknown".to_owned(), |count| count.to_string())
2765 };
2766 format!(
2767 "| {label} | {findings} | {} | {} |",
2768 count("introduced"),
2769 count("inherited"),
2770 )
2771}
2772
2773fn audit_worsened_note(env: &Value, attribution: &Value) -> String {
2777 let count = attribution
2778 .get("complexity_worsened")
2779 .and_then(Value::as_u64)
2780 .unwrap_or(0);
2781 if count == 0 {
2782 return String::new();
2783 }
2784 let effect = if worsened_gates(env) {
2785 "`--worsened error` counts them like introduced findings"
2786 } else {
2787 "they make the verdict at least warn and do not fail it"
2788 };
2789 format!(
2790 "> {} got worse than the base (status `worsened`): {effect}.\n\n",
2791 plural_n(
2792 usize::try_from(count).unwrap_or(usize::MAX),
2793 "inherited complexity finding"
2794 ),
2795 )
2796}
2797
2798#[must_use]
2800fn render_audit_summary(env: &Value) -> String {
2801 let verdict = str_or(env, "verdict", "pass");
2802 let summary = env.get("summary").cloned().unwrap_or(Value::Null);
2803 let attribution = env.get("attribution").cloned().unwrap_or(Value::Null);
2804 let dead_code = env.get("dead_code").cloned().unwrap_or(Value::Null);
2805 let mut dead_rows = audit_dead_code_rows(&dead_code);
2806 dead_rows.sort_by(|a, b| {
2807 audit_priority(env, a.severity, a.status)
2808 .cmp(&audit_priority(env, b.severity, b.status))
2809 .then_with(|| a.location.cmp(&b.location))
2810 .then_with(|| a.kind.cmp(b.kind))
2811 .then_with(|| a.item.cmp(&b.item))
2812 });
2813
2814 let styling_count = env
2815 .get("complexity")
2816 .and_then(|complexity| complexity.get("styling_findings"))
2817 .and_then(Value::as_array)
2818 .map_or(0, Vec::len)
2819 .to_string();
2820 let rows = [
2821 audit_category_row(
2822 "Dead code",
2823 &summary,
2824 "dead_code_skipped",
2825 &num(&summary, "dead_code_issues"),
2826 &attribution,
2827 "dead_code",
2828 ),
2829 audit_category_row(
2830 "Complexity",
2831 &summary,
2832 "complexity_skipped",
2833 &num(&summary, "complexity_findings"),
2834 &attribution,
2835 "complexity",
2836 ),
2837 audit_category_row(
2838 "Duplication",
2839 &summary,
2840 "duplication_skipped",
2841 &num(&summary, "duplication_clone_groups"),
2842 &attribution,
2843 "duplication",
2844 ),
2845 audit_category_row(
2846 "Styling",
2847 &summary,
2848 "complexity_skipped",
2849 &styling_count,
2850 &attribution,
2851 "styling",
2852 ),
2853 ]
2854 .join("\n");
2855
2856 let mut out = format!(
2857 "## Fallow Audit\n\n> {} \u{b7} {} \u{b7} {}ms\n\n| Category | Findings | Introduced | Inherited |\n|:---------|---------:|-----------:|----------:|\n{rows}\n\n",
2858 audit_verdict_label(verdict),
2859 plural_n(u(env, "changed_files_count") as usize, "changed file"),
2860 num(env, "elapsed_ms"),
2861 );
2862 out.push_str(&audit_worsened_note(env, &attribution));
2863 if !dead_rows.is_empty() {
2864 let rows = dead_rows
2865 .iter()
2866 .take(10)
2867 .map(|row| {
2868 format!(
2869 "| {} | {} | {} | {} | {} |",
2870 row.kind, row.location, row.item, row.status, row.severity,
2871 )
2872 })
2873 .collect::<Vec<_>>()
2874 .join("\n");
2875 let tail = if dead_rows.len() > 10 {
2876 format!(
2877 "\n\n> {} more dead-code findings in the full audit report",
2878 dead_rows.len() - 10
2879 )
2880 } else {
2881 String::new()
2882 };
2883 let _ = write!(
2884 out,
2885 "### Dead Code\n\n| Type | Location | Item | Status | Rule severity |\n|:-----|:---------|:-----|:-------|:--------------|\n{rows}{tail}\n\n"
2886 );
2887 }
2888 out.push_str(&audit_complexity_section(env));
2889 out.push_str(&audit_duplication_section(env));
2890 out.push_str(&audit_styling_section(env));
2891 out.push_str(&audit_diff_risk_section(env));
2892 out.push_str(match s(&attribution, "gate") {
2893 "all" => "*Audit gate: all. Every finding in changed files affects the verdict.*",
2894 "new-only" if worsened_gates(env) => {
2895 "*Audit gate: new-only, worsened: error. Inherited findings that got worse fail the verdict like introduced findings. Other inherited findings do not fail the verdict.*"
2896 }
2897 "new-only" => {
2898 "*Audit gate: new-only. Inherited findings are reported but do not fail the verdict.*"
2899 }
2900 _ => "*Audit gate: unknown. New-versus-inherited attribution is unavailable.*",
2901 });
2902 out
2903}
2904
2905#[must_use]
2911fn render_security_summary(env: &Value) -> String {
2912 let findings: Vec<&Value> = arr(env, "security_findings").collect();
2913 let gate = env.get("gate").filter(|gate| !gate.is_null());
2914 let count = gate.map_or_else(
2915 || {
2916 env.get("summary")
2917 .and_then(|summary| summary.get("security_findings"))
2918 .and_then(Value::as_u64)
2919 .unwrap_or(findings.len() as u64) as usize
2920 },
2921 |gate| u(gate, "new_count") as usize,
2922 );
2923 let mut out = String::from("## Fallow Security\n\n");
2924 if count == 0 {
2925 let _ = write!(
2926 out,
2927 "> [!NOTE]\n> **No security candidates matched** \u{b7} {}ms",
2928 num(env, "elapsed_ms"),
2929 );
2930 } else {
2931 let _ = write!(
2932 out,
2933 "> [!WARNING]\n> **{} matched** \u{b7} {}ms",
2934 plural_n(count, "security candidate"),
2935 num(env, "elapsed_ms"),
2936 );
2937 }
2938 if let Some(gate) = gate {
2939 let _ = write!(
2940 out,
2941 "\n\nSecurity gate: `{}`, verdict: `{}`, matching candidates: **{}**.",
2942 s(gate, "mode"),
2943 s(gate, "verdict"),
2944 num(gate, "new_count"),
2945 );
2946 }
2947 if !findings.is_empty() {
2948 let rows = findings
2949 .iter()
2950 .take(15)
2951 .map(|finding| {
2952 format!(
2953 "| {} | {} | {} | {} |",
2954 path_line(finding),
2955 markdown_table_text(s(finding, "kind")),
2956 markdown_table_text(str_or(finding, "severity", "unknown")),
2957 markdown_table_code_span(
2958 finding
2959 .get("candidate")
2960 .and_then(|candidate| candidate.get("sink"))
2961 .and_then(|sink| sink.get("callee"))
2962 .and_then(Value::as_str)
2963 .unwrap_or("-")
2964 ),
2965 )
2966 })
2967 .collect::<Vec<_>>()
2968 .join("\n");
2969 let _ = write!(
2970 out,
2971 "\n\n| Location | Kind | Severity | Sink |\n|:---------|:-----|:---------|:-----|\n{rows}"
2972 );
2973 if findings.len() > 15 {
2974 let _ = write!(
2975 out,
2976 "\n\n> {} more candidates in the full report",
2977 findings.len() - 15,
2978 );
2979 }
2980 }
2981 out.push_str("\n\nTreat these as candidates for verification, not confirmed vulnerabilities.");
2982 out
2983}
2984
2985fn fix_entries<'v>(env: &'v Value, entry_type: &str) -> Vec<&'v Value> {
2995 arr(env, "fixes")
2996 .filter(|fix| s(fix, "type") == entry_type && !b(fix, "skipped"))
2997 .collect()
2998}
2999
3000fn fix_detail_block(label: &str, entries: &[&Value], row: impl Fn(&Value) -> String) -> String {
3001 let rows = entries
3002 .iter()
3003 .take(25)
3004 .map(|entry| row(entry))
3005 .collect::<Vec<_>>()
3006 .join("\n");
3007 let tail = if entries.len() > 25 {
3008 format!("\n- *... and {} more*", entries.len() - 25)
3009 } else {
3010 String::new()
3011 };
3012 format!("**{label} ({})**\n{rows}{tail}", entries.len())
3013}
3014
3015#[must_use]
3017pub fn render_fix_summary(env: &Value) -> String {
3018 let exports = fix_entries(env, "remove_export");
3019 let dependencies = fix_entries(env, "remove_dependency");
3020 let fix_attempts = arr(env, "fixes").filter(|fix| !b(fix, "skipped")).count();
3021 let content_changed = u(env, "skipped_content_changed") as usize;
3022 let mixed_eol = u(env, "skipped_mixed_line_endings") as usize;
3023 let low_confidence = u(env, "skipped_low_confidence_exports") as usize;
3024 let low_confidence_deps = u(env, "skipped_low_confidence_dependencies") as usize;
3025 let low_confidence_members = u(env, "skipped_low_confidence_members") as usize;
3026 let dry_run = b(env, "dry_run");
3027
3028 if fix_attempts == 0
3029 && content_changed == 0
3030 && mixed_eol == 0
3031 && low_confidence == 0
3032 && low_confidence_deps == 0
3033 && low_confidence_members == 0
3034 {
3035 return "## Fallow - Auto-fix\n\nNo fixable issues found.".to_owned();
3036 }
3037
3038 let mut out = String::from("## Fallow - Auto-fix\n\n");
3039 out.push_str(if dry_run {
3040 "**Dry run**: would apply"
3041 } else {
3042 "Applied"
3043 });
3044 let fix_noun = if fix_attempts == 1 { "fix" } else { "fixes" };
3045 let _ = write!(out, " **{fix_attempts} {fix_noun}**");
3046 if !dry_run {
3047 let _ = write!(out, " ({} succeeded)", num(env, "total_fixed"));
3048 }
3049 if content_changed > 0 {
3050 let _ = write!(
3051 out,
3052 ", skipped {content_changed} file(s) that changed since analysis"
3053 );
3054 }
3055 if mixed_eol > 0 {
3056 let _ = write!(out, ", skipped {mixed_eol} file(s) with mixed line endings");
3057 }
3058 if low_confidence > 0 {
3059 let _ = write!(
3060 out,
3061 ", kept exports in {low_confidence} file(s) where consumers may be hidden from static analysis"
3062 );
3063 }
3064 if low_confidence_deps > 0 {
3065 let _ = write!(
3066 out,
3067 ", kept {low_confidence_deps} declared package(s) whose only import may sit in a file this run did not fully read"
3068 );
3069 }
3070 if low_confidence_members > 0 {
3071 let _ = write!(
3072 out,
3073 ", kept {low_confidence_members} unused enum member(s) whose only reference may sit in a file this run did not fully analyze"
3074 );
3075 }
3076 out.push_str("\n\n| Type | Count |\n|------|-------|\n");
3077 if !exports.is_empty() {
3078 let _ = writeln!(out, "| Export removals | {} |", exports.len());
3079 }
3080 if !dependencies.is_empty() {
3081 let _ = writeln!(out, "| Dependency removals | {} |", dependencies.len());
3082 }
3083 out.push_str("\n<details>\n<summary>View details</summary>\n\n");
3084 if !exports.is_empty() {
3085 out.push_str(&fix_detail_block("Export removals", &exports, |it| {
3086 format!(
3087 "- {} - {}",
3088 markdown_code_span(&format!("{}:{}", s(it, "path"), num(it, "line"))),
3089 markdown_code_span(s(it, "name"))
3090 )
3091 }));
3092 out.push_str("\n\n");
3093 }
3094 if !dependencies.is_empty() {
3095 out.push_str(&fix_detail_block(
3096 "Dependency removals",
3097 &dependencies,
3098 |it| {
3099 format!(
3100 "- {} from {} in {}",
3101 markdown_code_span(s(it, "package")),
3102 markdown_code_span(s(it, "location")),
3103 markdown_code_span(s(it, "file")),
3104 )
3105 },
3106 ));
3107 out.push('\n');
3108 }
3109 out.push_str("\n\n</details>");
3110 out
3111}
3112
3113fn file_link(links: &LinkContext, path: &str, start: &str, end: &str) -> String {
3118 let display = markdown_table_code_span(&format!("{}:{start}-{end}", last_three_segments(path)));
3119 if links.repo.is_empty() || links.sha.is_empty() {
3120 display
3121 } else {
3122 format!(
3123 "[{display}](https://github.com/{}/blob/{}/{}{}#L{start}-L{end})",
3124 links.repo,
3125 links.sha,
3126 links.prefix,
3127 encode_link_path(path),
3128 )
3129 }
3130}
3131
3132fn encode_link_path(path: &str) -> String {
3135 path.replace('%', "%25")
3136 .replace(' ', "%20")
3137 .replace('(', "%28")
3138 .replace(')', "%29")
3139 .replace('<', "%3C")
3140 .replace('>', "%3E")
3141 .replace('|', "%7C")
3142}
3143
3144fn exceeded_priority(it: &Value) -> u8 {
3145 match s(it, "exceeded") {
3146 "all" => 5,
3147 "cyclomatic_crap" | "cognitive_crap" => 4,
3148 "crap" => 3,
3149 "both" => 2,
3150 "cyclomatic" | "cognitive" => 1,
3151 _ => 0,
3152 }
3153}
3154
3155fn severity_priority(it: &Value) -> u8 {
3156 match s(it, "severity") {
3157 "critical" => 3,
3158 "high" => 2,
3159 "moderate" => 1,
3160 _ => 0,
3161 }
3162}
3163
3164fn ranked_health_findings(health: &Value) -> Vec<&Value> {
3167 let mut findings: Vec<&Value> = arr(health, "findings").collect();
3168 findings.sort_by_key(|it| {
3169 (
3170 exceeded_priority(it),
3171 severity_priority(it),
3172 it.get("crap").is_some_and(|crap| !crap.is_null()),
3173 u(it, "cyclomatic"),
3174 u(it, "cognitive"),
3175 u(it, "line_count"),
3176 )
3177 });
3178 findings.reverse();
3179 findings
3180}
3181
3182const PROD_FAILING_VERDICTS: &[&str] = &["safe_to_delete", "review_required", "low_traffic"];
3183
3184struct CombinedCounts {
3185 check: usize,
3186 dupes: usize,
3187 complex: usize,
3188 prod_failing: usize,
3189 prod_advisory: usize,
3190 hot_paths: usize,
3191}
3192
3193impl CombinedCounts {
3194 fn health(&self) -> usize {
3195 self.complex + self.prod_failing
3196 }
3197
3198 fn total(&self) -> usize {
3199 self.check + self.dupes + self.health()
3200 }
3201}
3202
3203fn combined_counts(env: &Value) -> CombinedCounts {
3204 let check = env
3205 .get("check")
3206 .map_or(0, |check| u(check, "total_issues") as usize);
3207 let dupes = env.get("dupes").map_or(0, |dupes| {
3217 arr(dupes, "clone_groups")
3218 .count()
3219 .saturating_add(u(dupes, "clone_groups_omitted") as usize)
3220 });
3221 let health = env.get("health").cloned().unwrap_or(Value::Null);
3222 let complex = health.get("summary").map_or(0, |summary| {
3225 baselined_complexity(summary).map_or_else(
3226 || u(summary, "functions_above_threshold"),
3227 |(remaining, _)| remaining,
3228 ) as usize
3229 });
3230 let runtime = health
3231 .get("runtime_coverage")
3232 .cloned()
3233 .unwrap_or(Value::Null);
3234 let prod_failing = arr(&runtime, "findings")
3235 .filter(|finding| PROD_FAILING_VERDICTS.contains(&s(finding, "verdict")))
3236 .count();
3237 let prod_advisory = arr(&runtime, "findings")
3238 .filter(|finding| !PROD_FAILING_VERDICTS.contains(&s(finding, "verdict")))
3239 .count();
3240 let hot_paths = arr(&runtime, "hot_paths").count();
3241 CombinedCounts {
3242 check,
3243 dupes,
3244 complex,
3245 prod_failing,
3246 prod_advisory,
3247 hot_paths,
3248 }
3249}
3250
3251fn hot_path_label(env: &Value, n: usize) -> String {
3252 let touched = env
3253 .get("health")
3254 .and_then(|health| health.get("runtime_coverage"))
3255 .is_some_and(|runtime| s(runtime, "verdict") == "hot-path-touched");
3256 let plural = if n == 1 { "" } else { "s" };
3257 if touched {
3258 format!("hot path{plural} touched")
3259 } else {
3260 format!("hot path{plural}")
3261 }
3262}
3263
3264fn combined_zero_case(env: &Value, counts: &CombinedCounts) -> String {
3265 let vitals = env
3266 .get("health")
3267 .and_then(|health| health.get("vital_signs"))
3268 .cloned()
3269 .unwrap_or(Value::Null);
3270 let mut out = String::from("# \u{1F33F} Fallow\n\n");
3271 if counts.prod_advisory > 0 || counts.hot_paths > 0 {
3272 out.push_str(
3273 "> [!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",
3274 );
3275 if counts.prod_advisory > 0 {
3276 let _ = write!(
3277 out,
3278 " \u{b7} :information_source: **{}** runtime coverage advisory finding{}",
3279 counts.prod_advisory,
3280 if counts.prod_advisory == 1 { "" } else { "s" },
3281 );
3282 }
3283 if counts.hot_paths > 0 {
3284 let _ = write!(
3285 out,
3286 " \u{b7} :eyes: **{}** {}",
3287 counts.hot_paths,
3288 hot_path_label(env, counts.hot_paths),
3289 );
3290 }
3291 } else {
3292 out.push_str(
3293 "> [!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: ",
3294 );
3295 let accepted = env
3298 .get("health")
3299 .and_then(|health| health.get("summary"))
3300 .and_then(baselined_complexity)
3301 .map_or(0, |(_, accepted)| accepted);
3302 if accepted > 0 {
3303 let _ = write!(out, "No new complex functions ({accepted} in the baseline)");
3304 } else {
3305 out.push_str("No complex functions");
3306 }
3307 }
3308 if let Some(maintainability) = opt_f(&vitals, "maintainability_avg") {
3309 let _ = write!(
3310 out,
3311 "\n\n| Metric | Value |\n|:-------|------:|\n| [Maintainability]({HEALTH_DOCS}#maintainability-index-mi) | **{}** / 100 |\n",
3312 pct(maintainability),
3313 );
3314 }
3315 out
3316}
3317
3318fn combined_status_line(env: &Value, counts: &CombinedCounts) -> String {
3319 let mut out = String::new();
3320 if counts.check > 0 {
3321 let _ = write!(
3322 out,
3323 ":warning: **{}** code {}",
3324 counts.check,
3325 if counts.check == 1 { "issue" } else { "issues" },
3326 );
3327 } else {
3328 out.push_str(":white_check_mark: No code issues");
3329 }
3330 out.push_str(" \u{b7} ");
3331 if counts.dupes > 0 {
3332 let _ = write!(
3333 out,
3334 ":warning: **{}** clone {}",
3335 counts.dupes,
3336 if counts.dupes == 1 { "group" } else { "groups" },
3337 );
3338 } else {
3339 out.push_str(":white_check_mark: No duplication");
3340 }
3341 out.push_str(" \u{b7} ");
3342 let health = counts.health();
3343 if health > 0 {
3344 let _ = write!(
3345 out,
3346 ":warning: **{health}** health {}",
3347 if health == 1 { "finding" } else { "findings" },
3348 );
3349 } else {
3350 out.push_str(":white_check_mark: No blocking health findings");
3351 }
3352 if counts.prod_advisory > 0 {
3353 let _ = write!(
3354 out,
3355 " \u{b7} :information_source: **{}** coverage advisory finding{}",
3356 counts.prod_advisory,
3357 if counts.prod_advisory == 1 { "" } else { "s" },
3358 );
3359 }
3360 if counts.hot_paths > 0 {
3361 let _ = write!(
3362 out,
3363 " \u{b7} :eyes: **{}** {}",
3364 counts.hot_paths,
3365 hot_path_label(env, counts.hot_paths),
3366 );
3367 }
3368 out.push_str("\n\n");
3369 out
3370}
3371
3372fn combined_check_breakdown(env: &Value, counts: &CombinedCounts) -> String {
3373 if counts.check == 0 {
3374 return String::new();
3375 }
3376 let check = env.get("check").cloned().unwrap_or(Value::Null);
3377 format!(
3378 "<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",
3379 counts.check,
3380 dead_code_category_table(&check),
3381 )
3382}
3383
3384fn combined_dupes_breakdown(env: &Value, counts: &CombinedCounts, links: &LinkContext) -> String {
3385 if counts.dupes == 0 {
3386 return String::new();
3387 }
3388 let dupes = env.get("dupes").cloned().unwrap_or(Value::Null);
3389 let stats = dupes.get("stats").cloned().unwrap_or(Value::Null);
3390 let groups = sorted_clone_groups(&dupes);
3391 let files_with_clones = u(&stats, "files_with_clones") as usize;
3392 let rows = groups
3393 .iter()
3394 .take(5)
3395 .map(|group| {
3396 let locations = arr(group, "instances")
3397 .map(|instance| {
3398 file_link(
3399 links,
3400 s(instance, "file"),
3401 &num(instance, "start_line"),
3402 &num(instance, "end_line"),
3403 )
3404 })
3405 .collect::<Vec<_>>()
3406 .join("<br>");
3407 format!(
3408 "| {locations} | {} | {} |",
3409 num(group, "line_count"),
3410 num(group, "token_count"),
3411 )
3412 })
3413 .collect::<Vec<_>>()
3414 .join("\n");
3415 let tail = if counts.dupes > 5 {
3416 format!("\n\n*\u{2026} and {} more groups.*", counts.dupes - 5)
3417 } else {
3418 String::new()
3419 };
3420 format!(
3421 "<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",
3422 counts.dupes,
3423 if counts.dupes == 1 { "group" } else { "groups" },
3424 num(&stats, "duplicated_lines"),
3425 pct(f_or_zero(&stats, "duplication_percentage")),
3426 if files_with_clones == 1 {
3427 "file"
3428 } else {
3429 "files"
3430 },
3431 )
3432}
3433
3434fn combined_complexity_breakdown(env: &Value, counts: &CombinedCounts) -> String {
3435 if counts.complex == 0 {
3436 return String::new();
3437 }
3438 let health = env.get("health").cloned().unwrap_or(Value::Null);
3439 let summary = health.get("summary").cloned().unwrap_or(Value::Null);
3440 let findings = ranked_health_findings(&health);
3441 let show_crap = summary
3442 .get("max_crap_threshold")
3443 .is_some_and(|threshold| !threshold.is_null())
3444 || findings
3445 .iter()
3446 .any(|finding| finding.get("crap").is_some_and(|crap| !crap.is_null()));
3447 let cyc_t = threshold_or(&summary, "max_cyclomatic_threshold", "default");
3448 let cog_t = threshold_or(&summary, "max_cognitive_threshold", "default");
3449 let crap_t = threshold_or(&summary, "max_crap_threshold", "default");
3450 let crap_header = if show_crap {
3451 format!(" | [CRAP]({HEALTH_DOCS}#crap-score)")
3452 } else {
3453 String::new()
3454 };
3455 let crap_separator = if show_crap { "|-----:" } else { "" };
3456 let rows = findings
3457 .iter()
3458 .take(5)
3459 .map(|it| {
3460 let crap_column = if show_crap {
3461 format!(" | {}", crap_cell(it))
3462 } else {
3463 String::new()
3464 };
3465 format!(
3466 "| {} | {} | {} | {}{} | {}{}{crap_column} | {} |",
3467 markdown_table_code_span(&format!(
3468 "{}:{}",
3469 last_three_segments(s(it, "path")),
3470 num(it, "line")
3471 )),
3472 code_cell(it, "name"),
3473 markdown_table_text(str_or(it, "severity", "moderate")),
3474 num(it, "cyclomatic"),
3475 exceeded_marker(it, &["cyclomatic", "both", "all"]),
3476 num(it, "cognitive"),
3477 exceeded_marker(it, &["cognitive", "both", "all"]),
3478 num(it, "line_count"),
3479 )
3480 })
3481 .collect::<Vec<_>>()
3482 .join("\n");
3483 let crap_footer = if show_crap {
3484 format!(", CRAP >= {crap_t}")
3485 } else {
3486 String::new()
3487 };
3488 let label = match baselined_complexity(&summary).filter(|&(_, accepted)| accepted > 0) {
3489 Some((_, accepted)) => format!(
3490 "{} new {} above threshold, {accepted} in the baseline",
3491 counts.complex,
3492 if counts.complex == 1 {
3493 "function"
3494 } else {
3495 "functions"
3496 },
3497 ),
3498 None => format!(
3499 "{} {} above threshold",
3500 counts.complex,
3501 if counts.complex == 1 {
3502 "function"
3503 } else {
3504 "functions"
3505 },
3506 ),
3507 };
3508 format!(
3509 "<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",
3510 threshold_or(&summary, "files_analyzed", "unknown"),
3511 threshold_or(&summary, "functions_analyzed", "unknown"),
3512 )
3513}
3514
3515fn combined_runtime_breakdown(env: &Value, counts: &CombinedCounts) -> String {
3516 let prod_total = counts.prod_failing + counts.prod_advisory;
3517 if prod_total == 0 && counts.hot_paths == 0 {
3518 return String::new();
3519 }
3520 let runtime = env
3521 .get("health")
3522 .and_then(|health| health.get("runtime_coverage"))
3523 .cloned()
3524 .unwrap_or(Value::Null);
3525 let hot_suffix = if counts.hot_paths > 0 {
3526 format!(
3527 ", {} {}",
3528 counts.hot_paths,
3529 hot_path_label(env, counts.hot_paths)
3530 )
3531 } else {
3532 String::new()
3533 };
3534 let mut out = format!(
3535 "<details>\n<summary><strong><a href=\"{HEALTH_DOCS}#runtime-coverage\">Runtime coverage</a> ({prod_total} finding{}{hot_suffix})</strong></summary>\n\n",
3536 if prod_total == 1 { "" } else { "s" },
3537 );
3538 if prod_total > 0 {
3539 out.push_str("| File | Function | Verdict | Invocations | Confidence |\n|:-----|:---------|:--------|------------:|:-----------|\n");
3540 out.push_str(
3541 &arr(&runtime, "findings")
3542 .take(5)
3543 .map(|it| {
3544 let invocations = it
3545 .get("invocations")
3546 .filter(|value| !value.is_null())
3547 .map_or_else(|| "-".to_owned(), fmt_num);
3548 format!(
3549 "| {} | {} | {} | {invocations} | {} |",
3550 markdown_table_code_span(&format!(
3551 "{}:{}",
3552 last_three_segments(s(it, "path")),
3553 num(it, "line")
3554 )),
3555 code_cell(it, "function"),
3556 code_cell(it, "verdict"),
3557 markdown_table_text(s(it, "confidence")),
3558 )
3559 })
3560 .collect::<Vec<_>>()
3561 .join("\n"),
3562 );
3563 if counts.hot_paths > 0 {
3564 out.push_str("\n\n");
3565 }
3566 }
3567 if counts.hot_paths > 0 {
3568 out.push_str("| File | Function | Invocations | Percentile |\n|:-----|:---------|------------:|-----------:|\n");
3569 out.push_str(
3570 &arr(&runtime, "hot_paths")
3571 .take(5)
3572 .map(|it| {
3573 format!(
3574 "| {} | {} | {} | {} |",
3575 markdown_table_code_span(&format!(
3576 "{}:{}",
3577 last_three_segments(s(it, "path")),
3578 num(it, "line")
3579 )),
3580 code_cell(it, "function"),
3581 num(it, "invocations"),
3582 num(it, "percentile"),
3583 )
3584 })
3585 .collect::<Vec<_>>()
3586 .join("\n"),
3587 );
3588 out.push_str("\n\n");
3589 }
3590 out.push_str("</details>\n\n");
3591 out
3592}
3593
3594fn combined_vitals(env: &Value) -> String {
3595 let health = env.get("health").cloned().unwrap_or(Value::Null);
3596 let vitals = health.get("vital_signs").cloned().unwrap_or(Value::Null);
3597 let has_vitals = vitals.as_object().is_some_and(|vitals| !vitals.is_empty());
3598 if !has_vitals {
3599 return String::new();
3600 }
3601 let scores: Vec<f64> = arr(&health, "file_scores")
3602 .filter_map(|score| opt_f(score, "maintainability_index"))
3603 .collect();
3604 let scoped_maintainability = if scores.is_empty() {
3605 None
3606 } else {
3607 let avg = scores.iter().sum::<f64>() / scores.len() as f64;
3608 Some((avg * 10.0).round() / 10.0)
3609 };
3610 let mut out = format!(
3611 "#### [Codebase health]({HEALTH_DOCS})\n\n| Metric | Value |\n|:-------|------:|\n"
3612 );
3613 let maintainability = opt_f(&vitals, "maintainability_avg");
3614 if let Some(avg) = maintainability {
3615 let _ = writeln!(
3616 out,
3617 "| [Maintainability]({HEALTH_DOCS}#maintainability-index-mi) | **{}** / 100 |",
3618 pct(avg),
3619 );
3620 }
3621 if let Some(scoped) = scoped_maintainability {
3622 let rounded_avg = (maintainability.unwrap_or_default() * 10.0).round() / 10.0;
3623 if (scoped - rounded_avg).abs() > f64::EPSILON {
3624 let _ = writeln!(
3625 out,
3626 "| [Maintainability]({HEALTH_DOCS}#maintainability-index-mi) (changed files) | **{}** / 100 |",
3627 fmt_num(&serde_json::json!(scoped)),
3628 );
3629 }
3630 }
3631 if let Some(avg_cyclomatic) = opt_f(&vitals, "avg_cyclomatic") {
3632 let _ = writeln!(
3633 out,
3634 "| [Avg complexity]({HEALTH_DOCS}#cyclomatic-complexity) | {} |",
3635 pct(avg_cyclomatic),
3636 );
3637 }
3638 if let Some(population) = vitals
3639 .get("cyclomatic_population")
3640 .filter(|value| value.is_object())
3641 {
3642 let _ = writeln!(
3643 out,
3644 "| Cyclomatic units | Functions: {}, module scopes: {}, templates: {} |",
3645 num(&population["functions"], "count"),
3646 num(&population["modules"], "count"),
3647 num(&population["templates"], "count")
3648 );
3649 if let Some(max) = population["modules"]["max"].as_u64() {
3650 let _ = writeln!(
3651 out,
3652 "| Module-scope max cyclomatic (aggregate only) | {max} |"
3653 );
3654 }
3655 }
3656 out.push('\n');
3657 out
3658}
3659
3660fn combined_tips(env: &Value) -> String {
3661 let check = env.get("check").cloned().unwrap_or(Value::Null);
3662 let fixable = arr(&check, "unused_exports").count()
3663 + arr(&check, "unused_dependencies").count()
3664 + arr(&check, "unused_enum_members").count();
3665 if fixable == 0 {
3666 return String::new();
3667 }
3668 let mut out = String::from("> [!TIP]\n> Run `fallow fix --dry-run` to preview auto-fixes.\n");
3669 if arr(&check, "unused_exports").count() > 0 {
3670 let _ = writeln!(
3671 out,
3672 "> Add [`/** @public */`]({SUPPRESSION_DOCS}) above exports to preserve them."
3673 );
3674 }
3675 out
3676}
3677
3678#[must_use]
3680fn render_combined_summary(env: &Value, links: &LinkContext) -> String {
3681 let counts = combined_counts(env);
3682 let header = health_score_header(&env.get("health").cloned().unwrap_or(Value::Null));
3683 if counts.total() == 0 {
3684 return format!("{header}{}", combined_zero_case(env, &counts));
3685 }
3686 let pointer = if counts.check > 0 || counts.dupes > 0 || counts.health() > 0 {
3687 "See inline review comments for per-finding details.\n\n"
3688 } else {
3689 ""
3690 };
3691 format!(
3692 "{header}# \u{1F33F} Fallow\n\n> [!WARNING]\n> **Review needed**\n\n{}{pointer}{}{}{}{}{}{}",
3693 combined_status_line(env, &counts),
3694 combined_check_breakdown(env, &counts),
3695 combined_dupes_breakdown(env, &counts, links),
3696 combined_complexity_breakdown(env, &counts),
3697 combined_runtime_breakdown(env, &counts),
3698 combined_vitals(env),
3699 combined_tips(env),
3700 )
3701}
3702
3703#[cfg(test)]
3704mod tests {
3705 use std::collections::BTreeSet;
3706
3707 use fallow_types::issue_meta::counted_result_issue_metas;
3708
3709 use super::{
3710 DEAD_CODE_CATEGORIES, LinkContext, render_audit_summary, render_combined_summary,
3711 render_health_summary,
3712 };
3713
3714 fn complexity_finding(name: &str) -> serde_json::Value {
3716 serde_json::json!({
3717 "path": "src/a.ts", "name": name, "line": 1, "col": 0,
3718 "cyclomatic": 25, "cognitive": 30, "line_count": 40,
3719 "exceeded": "both", "severity": "high"
3720 })
3721 }
3722
3723 fn health_with_baseline(above: u64, remaining: u64) -> serde_json::Value {
3726 let findings: Vec<serde_json::Value> = (0..remaining)
3727 .map(|index| complexity_finding(&format!("f{index}")))
3728 .collect();
3729 serde_json::json!({
3730 "elapsed_ms": 1,
3731 "findings": findings,
3732 "vital_signs": { "avg_cyclomatic": 2.0 },
3733 "summary": {
3734 "files_analyzed": 3,
3735 "functions_analyzed": 90,
3736 "functions_above_threshold": above,
3737 "max_cyclomatic_threshold": 20,
3738 "max_cognitive_threshold": 15,
3739 "max_crap_threshold": 30.0,
3740 "baseline_staleness": {
3741 "baseline_entries": above,
3742 "matched_entries": above,
3743 "stale_entries": 0,
3744 "current_findings": above,
3745 "remaining_findings": remaining,
3746 "stale": false
3747 }
3748 }
3749 })
3750 }
3751
3752 fn no_links() -> LinkContext {
3753 LinkContext {
3754 prefix: String::new(),
3755 repo: String::new(),
3756 sha: String::new(),
3757 }
3758 }
3759
3760 #[test]
3763 fn audit_summary_marks_skipped_sub_analyses() {
3764 let audit = |dead_code: bool, complexity: bool, duplication: bool| {
3765 serde_json::json!({
3766 "command": "audit",
3767 "verdict": "warn",
3768 "changed_files_count": 1,
3769 "elapsed_ms": 3,
3770 "summary": {
3771 "dead_code_issues": 0,
3772 "dead_code_skipped": dead_code,
3773 "complexity_skipped": complexity,
3774 "duplication_skipped": duplication,
3775 "complexity_findings": 1,
3776 "duplication_clone_groups": 0
3777 },
3778 "attribution": {
3779 "gate": "new-only",
3780 "dead_code_introduced": 0,
3781 "dead_code_inherited": 0,
3782 "complexity_introduced": 1,
3783 "complexity_inherited": 0,
3784 "duplication_introduced": 0,
3785 "duplication_inherited": 0,
3786 "styling_introduced": 0,
3787 "styling_inherited": 0
3788 }
3789 })
3790 };
3791 let out = render_audit_summary(&audit(true, false, false));
3792 assert!(out.contains("| Dead code | skipped | - | - |"), "{out}");
3793 assert!(out.contains("| Complexity | 1 | 1 | 0 |"), "{out}");
3794 assert!(out.contains("| Styling | 0 | 0 | 0 |"), "{out}");
3795 let out = render_audit_summary(&audit(false, true, true));
3796 assert!(out.contains("| Dead code | 0 | 0 | 0 |"), "{out}");
3797 assert!(out.contains("| Complexity | skipped | - | - |"), "{out}");
3798 assert!(out.contains("| Duplication | skipped | - | - |"), "{out}");
3799 assert!(out.contains("| Styling | skipped | - | - |"), "{out}");
3800 }
3801
3802 #[test]
3803 fn audit_summary_names_worsened_findings_and_their_mode() {
3804 let audit = |mode: &str, verdict: &str| {
3805 serde_json::json!({
3806 "command": "audit",
3807 "verdict": verdict,
3808 "changed_files_count": 1,
3809 "elapsed_ms": 3,
3810 "summary": {"dead_code_issues": 0, "complexity_findings": 1, "duplication_clone_groups": 0},
3811 "attribution": {
3812 "gate": "new-only",
3813 "worsened": mode,
3814 "dead_code_introduced": 0,
3815 "dead_code_inherited": 0,
3816 "complexity_introduced": 0,
3817 "complexity_inherited": 1,
3818 "complexity_worsened": 1,
3819 "duplication_introduced": 0,
3820 "duplication_inherited": 0
3821 },
3822 "complexity": {"findings": [{
3823 "path": "src/a.ts",
3824 "line": 1,
3825 "name": "run",
3826 "introduced": false,
3827 "attribution": "worsened",
3828 "effective_severity": "error",
3829 "cyclomatic": 30,
3830 "cognitive": 20
3831 }]}
3832 })
3833 };
3834 let out = render_audit_summary(&audit("error", "fail"));
3835 assert!(out.contains("| `run` | worsened |"), "{out}");
3836 assert!(
3837 out.contains("> 1 inherited complexity finding got worse than the base (status `worsened`): `--worsened error` counts them like introduced findings."),
3838 "{out}"
3839 );
3840 assert!(
3841 out.contains("*Audit gate: new-only, worsened: error."),
3842 "{out}"
3843 );
3844 assert!(
3845 !out.contains("Inherited findings are reported but"),
3846 "{out}"
3847 );
3848
3849 let out = render_audit_summary(&audit("warn", "warn"));
3850 assert!(out.contains("| `run` | worsened |"), "{out}");
3851 assert!(
3852 out.contains("they make the verdict at least warn and do not fail it."),
3853 "{out}"
3854 );
3855 assert!(
3856 out.contains("*Audit gate: new-only. Inherited findings are reported but do not fail the verdict.*"),
3857 "{out}"
3858 );
3859 }
3860
3861 #[test]
3864 fn health_summary_headline_counts_after_the_baseline() {
3865 let out = render_health_summary(&health_with_baseline(76, 0));
3866 assert!(
3867 out.contains("**No new functions exceed complexity thresholds (76 in the baseline)**"),
3868 "{out}"
3869 );
3870 let out = render_health_summary(&health_with_baseline(76, 2));
3871 assert!(
3872 out.contains("**2 new functions exceed thresholds (74 in the baseline)**"),
3873 "{out}"
3874 );
3875 assert!(!out.contains("76 functions"), "{out}");
3876 }
3877
3878 #[test]
3880 fn combined_summary_counts_health_after_the_baseline() {
3881 let combined = |health: serde_json::Value| {
3882 serde_json::json!({
3883 "check": { "total_issues": 0 },
3884 "dupes": { "clone_groups": [], "stats": {} },
3885 "health": health
3886 })
3887 };
3888 let out = render_combined_summary(&combined(health_with_baseline(76, 0)), &no_links());
3889 assert!(
3890 out.contains(":white_check_mark: No new complex functions (76 in the baseline)"),
3891 "{out}"
3892 );
3893 assert!(!out.contains("above threshold"), "{out}");
3894
3895 let out = render_combined_summary(&combined(health_with_baseline(76, 2)), &no_links());
3896 assert!(out.contains(":warning: **2** health findings"), "{out}");
3897 assert!(
3898 out.contains("(2 new functions above threshold, 74 in the baseline)"),
3899 "{out}"
3900 );
3901 }
3902
3903 #[test]
3905 fn combined_summary_without_a_baseline_keeps_the_summary_count() {
3906 let mut health = health_with_baseline(3, 3);
3907 health["summary"]
3908 .as_object_mut()
3909 .expect("summary")
3910 .remove("baseline_staleness");
3911 let env = serde_json::json!({
3912 "check": { "total_issues": 0 },
3913 "dupes": { "clone_groups": [], "stats": {} },
3914 "health": health
3915 });
3916 let out = render_combined_summary(&env, &no_links());
3917 assert!(out.contains(":warning: **3** health findings"), "{out}");
3918 assert!(out.contains("(3 functions above threshold)"), "{out}");
3919 assert!(!out.contains("baseline"), "{out}");
3920 }
3921
3922 #[test]
3925 fn health_summary_counts_unlisted_functions() {
3926 let envelope = |above: u64| {
3927 serde_json::json!({
3928 "elapsed_ms": 1,
3929 "summary": {
3930 "functions_analyzed": 5,
3931 "functions_above_threshold": above,
3932 "max_cyclomatic_threshold": 20,
3933 "max_cognitive_threshold": 15,
3934 "max_crap_threshold": 30.0
3935 }
3936 })
3937 };
3938 let out = render_health_summary(&envelope(1));
3939 assert!(out.contains("**1 function exceeds thresholds**"), "{out}");
3940 assert!(out.contains("`fallow health --complexity`"), "{out}");
3941 assert!(!out.contains("baseline"), "{out}");
3942 assert!(!out.contains("No functions exceed"), "{out}");
3943 let out = render_health_summary(&envelope(0));
3944 assert!(
3945 out.contains("**No functions exceed complexity thresholds**"),
3946 "{out}"
3947 );
3948 }
3949
3950 fn baselined_envelope(lists_findings: bool) -> serde_json::Value {
3953 let mut envelope = serde_json::json!({
3954 "elapsed_ms": 1,
3955 "findings": [],
3956 "summary": {
3957 "functions_analyzed": 5,
3958 "functions_above_threshold": 1,
3959 "max_cyclomatic_threshold": 20,
3960 "max_cognitive_threshold": 15,
3961 "max_crap_threshold": 30.0,
3962 "baseline_staleness": {
3963 "baseline_entries": 2,
3964 "matched_entries": 2,
3965 "stale_entries": 0,
3966 "current_findings": 1,
3967 "stale": false
3968 }
3969 }
3970 });
3971 if lists_findings {
3972 envelope["vital_signs"] = serde_json::json!({ "avg_cyclomatic": 2.0 });
3973 }
3974 envelope
3975 }
3976
3977 #[test]
3981 fn health_summary_keeps_a_baselined_listed_run_clean() {
3982 let out = render_health_summary(&baselined_envelope(true));
3983 assert!(
3984 out.contains("**No functions exceed complexity thresholds**"),
3985 "{out}"
3986 );
3987 assert!(!out.contains("--complexity"), "{out}");
3988 }
3989
3990 #[test]
3993 fn health_summary_counts_unlisted_functions_on_a_baselined_score_run() {
3994 let out = render_health_summary(&baselined_envelope(false));
3995 assert!(out.contains("**1 function exceeds thresholds**"), "{out}");
3996 assert!(
3997 out.contains("The count includes the functions that the baseline accepts."),
3998 "{out}"
3999 );
4000 assert!(
4001 out.contains("Run `fallow health --complexity` with the same `--baseline`"),
4002 "{out}"
4003 );
4004 assert!(!out.contains("No functions exceed"), "{out}");
4005 }
4006
4007 #[test]
4011 fn health_summary_reads_the_sections_member() {
4012 let mut env = baselined_envelope(true);
4013 env["sections"] = serde_json::json!(["vital-signs", "hotspots"]);
4014 env["summary"]["functions_above_threshold"] = serde_json::json!(3);
4015 env["summary"]["baseline_staleness"]["remaining_findings"] = serde_json::json!(1);
4016 let out = render_health_summary(&env);
4017 assert!(out.contains("**3 functions exceed thresholds**"), "{out}");
4018 assert!(out.contains("The baseline accepts 2. 1 is new."), "{out}");
4019 assert!(
4020 out.contains("Run `fallow health --complexity` with the same `--baseline`"),
4021 "{out}"
4022 );
4023 assert!(!out.contains("No functions exceed"), "{out}");
4024 assert!(!out.contains("The count includes"), "{out}");
4025
4026 env["sections"] = serde_json::json!(["complexity", "vital-signs", "hotspots"]);
4027 let out = render_health_summary(&env);
4028 assert!(
4029 out.contains("**No functions exceed complexity thresholds**"),
4030 "{out}"
4031 );
4032 }
4033
4034 #[test]
4037 fn health_summary_with_no_new_function_does_not_ask_for_a_rerun() {
4038 let mut env = baselined_envelope(false);
4039 env["sections"] = serde_json::json!(["score"]);
4040 env["summary"]["baseline_staleness"]["remaining_findings"] = serde_json::json!(0);
4041 let out = render_health_summary(&env);
4042 assert!(out.contains("**1 function exceeds thresholds**"), "{out}");
4043 assert!(out.contains("The baseline accepts 1. 0 are new."), "{out}");
4044 assert!(!out.contains("--complexity"), "{out}");
4045 }
4046
4047 #[test]
4050 fn dead_code_categories_match_counted_result_metadata() {
4051 let native: BTreeSet<(&str, &str, &str)> = DEAD_CODE_CATEGORIES.iter().copied().collect();
4052 assert_eq!(
4053 native.len(),
4054 DEAD_CODE_CATEGORIES.len(),
4055 "DEAD_CODE_CATEGORIES has a duplicate row"
4056 );
4057 let registry: BTreeSet<(&str, &str, &str)> = counted_result_issue_metas()
4058 .map(|meta| (meta.summary_label, meta.result_key, meta.docs_anchor))
4059 .collect();
4060 let missing: Vec<_> = registry.difference(&native).collect();
4061 let extra: Vec<_> = native.difference(®istry).collect();
4062 assert!(
4063 missing.is_empty() && extra.is_empty(),
4064 "DEAD_CODE_CATEGORIES must match the counted result metadata \
4065 (label, result key, docs anchor).\nMissing rows: {missing:?}\nRows not in the registry: {extra:?}"
4066 );
4067 }
4068
4069 #[test]
4073 fn pathless_rows_render_in_one_order_for_any_input_order() {
4074 let envelope = |names: &[String]| {
4075 serde_json::json!({
4076 "total_issues": names.len(),
4077 "elapsed_ms": 1,
4078 "unlisted_dependencies": names
4079 .iter()
4080 .map(|name| serde_json::json!({ "package_name": name, "imported_from": [] }))
4081 .collect::<Vec<_>>(),
4082 })
4083 };
4084 let names: Vec<String> = (0..40).map(|index| format!("pkg-{index:02}")).collect();
4085 let mut reversed = names.clone();
4086 reversed.reverse();
4087 let ordered = super::render_check_summary(&envelope(&names));
4088 assert_eq!(super::render_check_summary(&envelope(&reversed)), ordered);
4089 let first = ordered.find("pkg-00").expect("first row");
4090 let second = ordered.find("pkg-01").expect("second row");
4091 assert!(
4092 first < second,
4093 "rows follow the serialized order: {ordered}"
4094 );
4095 assert!(
4096 !ordered.contains("pkg-39"),
4097 "rows past the limit are counted only"
4098 );
4099 assert!(
4100 ordered.contains("15 more"),
4101 "the tail counts the rest: {ordered}"
4102 );
4103 }
4104
4105 #[test]
4108 fn dead_code_rows_render_in_one_order_for_any_input_order() {
4109 let envelope = |paths: [&str; 3]| {
4110 serde_json::json!({
4111 "total_issues": 3,
4112 "elapsed_ms": 1,
4113 "unused_files": paths.map(|path| serde_json::json!({ "path": path })),
4114 })
4115 };
4116 let flat = super::render_check_summary(&envelope(["src/a.ts", "src/b.ts", "src/c.ts"]));
4117 let grouped = super::render_check_summary(&envelope(["src/a.ts", "src/c.ts", "src/b.ts"]));
4118 assert_eq!(grouped, flat);
4119 let a = flat.find("`src/a.ts`").expect("a");
4120 let b = flat.find("`src/b.ts`").expect("b");
4121 let c = flat.find("`src/c.ts`").expect("c");
4122 assert!(a < b && b < c, "rows follow path order: {flat}");
4123 }
4124}