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 audit_rule_severity(item: &Value) -> &'static str {
2010 match item
2011 .get("effective_severity")
2012 .or_else(|| item.get("severity"))
2013 .and_then(Value::as_str)
2014 {
2015 Some("error") => "error",
2016 Some("warn") => "warn",
2017 _ => "unknown",
2018 }
2019}
2020
2021fn audit_priority(env: &Value, severity: &str, status: &str) -> (u8, u8) {
2022 let severity = match severity {
2023 "error" => 0,
2024 "warn" => 1,
2025 _ => 2,
2026 };
2027 let status = if env["attribution"]["gate"] == "new-only" {
2028 match status {
2029 "new" => 0,
2030 "inherited" => 1,
2031 _ => 2,
2032 }
2033 } else {
2034 0
2035 };
2036 (severity, status)
2037}
2038
2039fn sorted_audit_findings<'a>(env: &Value, section: &'a Value, key: &str) -> Vec<&'a Value> {
2040 let mut findings: Vec<&Value> = arr(section, key).collect();
2041 findings.sort_by(|a, b| {
2042 audit_priority(env, audit_rule_severity(a), introduced_label(a))
2043 .cmp(&audit_priority(
2044 env,
2045 audit_rule_severity(b),
2046 introduced_label(b),
2047 ))
2048 .then_with(|| s(a, "path").cmp(s(b, "path")))
2049 .then_with(|| u(a, "line").cmp(&u(b, "line")))
2050 .then_with(|| s(a, "name").cmp(s(b, "name")))
2051 .then_with(|| s(a, "code").cmp(s(b, "code")))
2052 .then_with(|| s(a, "value").cmp(s(b, "value")))
2053 });
2054 findings
2055}
2056
2057struct AuditRow {
2058 kind: &'static str,
2059 location: String,
2060 item: String,
2061 status: &'static str,
2062 severity: &'static str,
2063}
2064
2065fn audit_row(kind: &'static str, location: String, item: String, finding: &Value) -> AuditRow {
2066 AuditRow {
2067 kind,
2068 location,
2069 item,
2070 status: introduced_label(finding),
2071 severity: audit_rule_severity(finding),
2072 }
2073}
2074
2075type AuditRowSpec = (&'static str, &'static str, fn(&Value) -> String);
2076
2077const AUDIT_EXPORT_DEP_ROWS: &[AuditRowSpec] = &[
2080 ("Unused export", "unused_exports", |it| {
2081 code_cell(it, "export_name")
2082 }),
2083 ("Unused type", "unused_types", |it| {
2084 code_cell(it, "export_name")
2085 }),
2086 ("Private type leak", "private_type_leaks", |it| {
2087 format!(
2088 "{} -> {}",
2089 code_cell(it, "export_name"),
2090 code_cell(it, "type_name")
2091 )
2092 }),
2093 (
2094 "Deprecated export in use",
2095 "deprecated_exports_in_use",
2096 |it| code_cell(it, "export_name"),
2097 ),
2098 ("Unused dependency", "unused_dependencies", |it| {
2099 code_cell(it, "package_name")
2100 }),
2101 ("Unused devDependency", "unused_dev_dependencies", |it| {
2102 code_cell(it, "package_name")
2103 }),
2104 (
2105 "Unused optionalDependency",
2106 "unused_optional_dependencies",
2107 |it| code_cell(it, "package_name"),
2108 ),
2109 ("Unused enum member", "unused_enum_members", member_item),
2110 ("Unused class member", "unused_class_members", member_item),
2111 ("Unused store member", "unused_store_members", member_item),
2112 ("Unresolved import", "unresolved_imports", |it| {
2113 code_cell(it, "specifier")
2114 }),
2115];
2116
2117const AUDIT_COMPONENT_ROWS: &[AuditRowSpec] = &[
2119 ("Unrendered component", "unrendered_components", |it| {
2120 format!(
2121 "{} ({})",
2122 code_cell(it, "component_name"),
2123 markdown_table_text(s(it, "framework"))
2124 )
2125 }),
2126 ("Unused component prop", "unused_component_props", |it| {
2127 component_member_item(it, "prop_name")
2128 }),
2129 (
2130 "Absent optional component prop",
2131 "absent_component_props",
2132 |it| component_member_item(it, "prop_name"),
2133 ),
2134 ("Unused component emit", "unused_component_emits", |it| {
2135 format!(
2136 "{} emit {}",
2137 code_cell(it, "component_name"),
2138 code_cell(it, "emit_name")
2139 )
2140 }),
2141 ("Unused component input", "unused_component_inputs", |it| {
2142 component_member_item(it, "input_name")
2143 }),
2144 (
2145 "Unused component output",
2146 "unused_component_outputs",
2147 |it| {
2148 format!(
2149 "{} output {}",
2150 code_cell(it, "component_name"),
2151 code_cell(it, "output_name")
2152 )
2153 },
2154 ),
2155 ("Unused Svelte event", "unused_svelte_events", |it| {
2156 format!(
2157 "{} event {}",
2158 code_cell(it, "component_name"),
2159 code_cell(it, "event_name")
2160 )
2161 }),
2162 ("Unused Effect layer", "unused_effect_layers", |it| {
2163 format!("layer {}", code_cell(it, "name"))
2164 }),
2165 ("Unused tagged error", "unused_tagged_errors", |it| {
2166 format!("{} tag {}", code_cell(it, "name"), code_cell(it, "tag"))
2167 }),
2168 ("Unproduced catch tag", "unproduced_catch_tags", |it| {
2169 format!("catchTag {}", code_cell(it, "tag"))
2170 }),
2171 ("Unprovided inject", "unprovided_injects", |it| {
2172 format!(
2173 "{} ({})",
2174 code_cell(it, "key_name"),
2175 markdown_table_text(s(it, "framework"))
2176 )
2177 }),
2178 ("Unused load data key", "unused_load_data_keys", |it| {
2179 code_cell(it, "key_name")
2180 }),
2181];
2182
2183const AUDIT_HYGIENE_ROWS: &[AuditRowSpec] = &[
2185 ("Type-only dependency", "type_only_dependencies", |it| {
2186 code_cell(it, "package_name")
2187 }),
2188 ("Test-only dependency", "test_only_dependencies", |it| {
2189 code_cell(it, "package_name")
2190 }),
2191 (
2192 "Dev dependency in production",
2193 "dev_dependencies_in_production",
2194 |it| code_cell(it, "package_name"),
2195 ),
2196 ("Stale suppression", "stale_suppressions", |it| {
2197 markdown_table_code_span(str_or(it, "description", "suppression"))
2200 }),
2201 ("Unused catalog entry", "unused_catalog_entries", |it| {
2202 format!(
2203 "{} ({})",
2204 code_cell(it, "entry_name"),
2205 code_cell(it, "catalog_name")
2206 )
2207 }),
2208 ("Empty catalog group", "empty_catalog_groups", |it| {
2209 code_cell(it, "catalog_name")
2210 }),
2211 (
2212 "Unresolved catalog reference",
2213 "unresolved_catalog_references",
2214 |it| {
2215 format!(
2216 "{} -> {}",
2217 code_cell(it, "entry_name"),
2218 code_cell(it, "catalog_name")
2219 )
2220 },
2221 ),
2222 (
2223 "Unused dependency override",
2224 "unused_dependency_overrides",
2225 |it| format!("{} ({})", code_cell(it, "raw_key"), code_cell(it, "source")),
2226 ),
2227 (
2228 "Misconfigured dependency override",
2229 "misconfigured_dependency_overrides",
2230 |it| format!("{} ({})", code_cell(it, "raw_key"), code_cell(it, "source")),
2231 ),
2232];
2233
2234fn audit_rows_from_table(dead_code: &Value, table: &[AuditRowSpec], rows: &mut Vec<AuditRow>) {
2235 for (kind, key, item_fn) in table {
2236 for finding in arr(dead_code, key) {
2237 rows.push(audit_row(
2238 kind,
2239 path_line(finding),
2240 item_fn(finding),
2241 finding,
2242 ));
2243 }
2244 }
2245}
2246
2247fn member_item(it: &Value) -> String {
2248 markdown_table_code_span(&format!(
2249 "{}.{}",
2250 s(it, "parent_name"),
2251 s(it, "member_name")
2252 ))
2253}
2254
2255fn component_member_item(it: &Value, member_key: &str) -> String {
2256 markdown_table_code_span(&format!(
2257 "{}.{}",
2258 s(it, "component_name"),
2259 s(it, member_key)
2260 ))
2261}
2262
2263fn first_import_site(it: &Value) -> String {
2264 arr(it, "imported_from").next().map_or_else(
2265 || path_line(it),
2266 |site| {
2267 markdown_table_code_span(&format!(
2268 "{}:{}",
2269 rel_path_absolute_only(s(site, "path")),
2270 num(site, "line"),
2271 ))
2272 },
2273 )
2274}
2275
2276fn audit_rows_graph(dead_code: &Value, rows: &mut Vec<AuditRow>) {
2279 for it in arr(dead_code, "unlisted_dependencies") {
2280 rows.push(audit_row(
2281 "Unlisted dependency",
2282 first_import_site(it),
2283 code_cell(it, "package_name"),
2284 it,
2285 ));
2286 }
2287 for it in arr(dead_code, "duplicate_exports") {
2288 let location = arr(it, "locations")
2289 .take(3)
2290 .map(|loc| {
2291 markdown_table_code_span(&format!(
2292 "{}:{}",
2293 rel_path_absolute_only(s(loc, "path")),
2294 num(loc, "line")
2295 ))
2296 })
2297 .collect::<Vec<_>>()
2298 .join(", ");
2299 rows.push(audit_row(
2300 "Duplicate export",
2301 location,
2302 code_cell(it, "export_name"),
2303 it,
2304 ));
2305 }
2306 for it in arr(dead_code, "circular_dependencies") {
2307 let location = arr(it, "files")
2308 .filter_map(Value::as_str)
2309 .map(|file| markdown_table_code_span(&rel_path_absolute_only(file)))
2310 .collect::<Vec<_>>()
2311 .join(" -> ");
2312 rows.push(audit_row(
2313 "Circular dependency",
2314 location,
2315 "cycle".to_owned(),
2316 it,
2317 ));
2318 }
2319 for it in arr(dead_code, "re_export_cycles") {
2320 let location = arr(it, "files")
2321 .filter_map(Value::as_str)
2322 .map(|file| markdown_table_code_span(&rel_path_absolute_only(file)))
2323 .collect::<Vec<_>>()
2324 .join(" <-> ");
2325 rows.push(audit_row(
2326 "Re-export cycle",
2327 location,
2328 markdown_table_text(str_or(it, "kind", "cycle")),
2329 it,
2330 ));
2331 }
2332 for it in arr(dead_code, "package_cycles") {
2333 let location = arr(it, "packages")
2334 .filter_map(Value::as_str)
2335 .map(markdown_table_code_span)
2336 .collect::<Vec<_>>()
2337 .join(" -> ");
2338 let detail = if it.get("group_truncated").and_then(Value::as_bool) == Some(true) {
2339 format!(
2340 "cycle ({})",
2341 fallow_types::results::PackageCycle::GROUP_TRUNCATED_NOTE
2342 )
2343 } else {
2344 "cycle".to_owned()
2345 };
2346 rows.push(audit_row("Package cycle", location, detail, it));
2347 }
2348}
2349
2350fn audit_rows_boundaries(dead_code: &Value, rows: &mut Vec<AuditRow>) {
2352 for it in arr(dead_code, "boundary_violations") {
2353 rows.push(audit_row(
2354 "Boundary violation",
2355 rel_path_line_cell(it, "from_path"),
2356 format!(
2357 "{} -> {}",
2358 markdown_table_code_span(s(it, "from_zone")),
2359 markdown_table_code_span(s(it, "to_zone"))
2360 ),
2361 it,
2362 ));
2363 }
2364 for it in arr(dead_code, "boundary_coverage_violations") {
2365 rows.push(audit_row(
2366 "Boundary coverage",
2367 rel_path_line_cell(it, "path"),
2368 "no matching zone".to_owned(),
2369 it,
2370 ));
2371 }
2372 for it in arr(dead_code, "boundary_call_violations") {
2373 rows.push(audit_row(
2374 "Boundary call",
2375 rel_path_line_cell(it, "path"),
2376 format!(
2377 "{} in {}",
2378 code_cell(it, "callee"),
2379 markdown_table_code_span(s(it, "zone"))
2380 ),
2381 it,
2382 ));
2383 }
2384 for it in arr(dead_code, "policy_violations") {
2385 rows.push(audit_row(
2386 "Policy violation",
2387 rel_path_line_cell(it, "path"),
2388 format!(
2389 "{} banned by {}",
2390 code_cell(it, "matched"),
2391 markdown_table_code_span(&format!("{}/{}", s(it, "pack"), s(it, "rule_id")))
2392 ),
2393 it,
2394 ));
2395 }
2396}
2397
2398fn audit_rows_frameworks(dead_code: &Value, rows: &mut Vec<AuditRow>) {
2401 for it in arr(dead_code, "invalid_client_exports") {
2402 rows.push(audit_row(
2403 "Invalid client export",
2404 rel_path_line_cell(it, "path"),
2405 format!(
2406 "{} in {}",
2407 code_cell(it, "export_name"),
2408 markdown_table_code_span(&format!("\"{}\"", s(it, "directive")))
2409 ),
2410 it,
2411 ));
2412 }
2413 for it in arr(dead_code, "mixed_client_server_barrels") {
2414 rows.push(audit_row(
2415 "Mixed client/server barrel",
2416 rel_path_line_cell(it, "path"),
2417 format!(
2418 "{} + {}",
2419 code_cell(it, "client_origin"),
2420 code_cell(it, "server_origin")
2421 ),
2422 it,
2423 ));
2424 }
2425 for it in arr(dead_code, "misplaced_directives") {
2426 rows.push(audit_row(
2427 "Misplaced directive",
2428 rel_path_line_cell(it, "path"),
2429 markdown_table_code_span(&format!("\"{}\"", s(it, "directive"))),
2430 it,
2431 ));
2432 }
2433 for it in arr(dead_code, "unused_server_actions") {
2434 rows.push(audit_row(
2435 "Unused server action",
2436 path_line(it),
2437 code_cell(it, "action_name"),
2438 it,
2439 ));
2440 }
2441 for it in arr(dead_code, "route_collisions") {
2442 rows.push(audit_row(
2443 "Route collision",
2444 markdown_table_code_span(&rel_path_absolute_only(s(it, "path"))),
2445 code_cell(it, "url"),
2446 it,
2447 ));
2448 }
2449 for it in arr(dead_code, "dynamic_segment_name_conflicts") {
2450 rows.push(audit_row(
2451 "Dynamic segment conflict",
2452 markdown_table_code_span(&rel_path_absolute_only(s(it, "path"))),
2453 markdown_table_code_span(&plain_join(it, "conflicting_segments", ", ")),
2454 it,
2455 ));
2456 }
2457}
2458
2459fn audit_dead_code_rows(dead_code: &Value) -> Vec<AuditRow> {
2461 let mut rows: Vec<AuditRow> = Vec::new();
2462 for it in arr(dead_code, "unused_files") {
2463 rows.push(audit_row(
2464 "Unused file",
2465 markdown_table_code_span(&rel_path_absolute_only(s(it, "path"))),
2466 "-".to_owned(),
2467 it,
2468 ));
2469 }
2470 audit_rows_from_table(dead_code, AUDIT_EXPORT_DEP_ROWS, &mut rows);
2471 audit_rows_graph(dead_code, &mut rows);
2472 audit_rows_boundaries(dead_code, &mut rows);
2473 audit_rows_frameworks(dead_code, &mut rows);
2474 audit_rows_from_table(dead_code, AUDIT_COMPONENT_ROWS, &mut rows);
2475 audit_rows_from_table(dead_code, AUDIT_HYGIENE_ROWS, &mut rows);
2476 for item in arr(dead_code, "prop_drilling_chains")
2477 .filter(|item| s(item, "effective_severity") == "error")
2478 {
2479 let Some(source) = arr(item, "hops").next() else {
2480 continue;
2481 };
2482 rows.push(audit_row(
2483 "Prop drilling",
2484 markdown_table_code_span(s(source, "file")),
2485 code_cell(item, "prop"),
2486 item,
2487 ));
2488 }
2489 rows
2490}
2491
2492fn audit_complexity_section(env: &Value) -> String {
2493 let complexity = env.get("complexity").cloned().unwrap_or(Value::Null);
2494 let findings = sorted_audit_findings(env, &complexity, "findings");
2495 if findings.is_empty() {
2496 return String::new();
2497 }
2498 let rows = findings
2499 .iter()
2500 .take(15)
2501 .map(|it| {
2502 format!(
2503 "| {} | {} | {} | {} | {} | {} | {} | {} | {} |",
2504 path_line_cell(it),
2505 code_cell(it, "name"),
2506 introduced_label(it),
2507 audit_rule_severity(it),
2508 markdown_table_text(str_or(it, "severity", "moderate")),
2509 num(it, "cyclomatic"),
2510 num(it, "cognitive"),
2511 markdown_table_text(str_or(it, "coverage_tier", "-")),
2512 it.get("crap")
2513 .filter(|crap| !crap.is_null())
2514 .map_or_else(|| "-".to_owned(), fmt_num),
2515 )
2516 })
2517 .collect::<Vec<_>>()
2518 .join("\n");
2519 let tail = if findings.len() > 15 {
2520 format!(
2521 "\n\n> {} more complexity findings in the full audit report",
2522 findings.len() - 15,
2523 )
2524 } else {
2525 String::new()
2526 };
2527 format!(
2528 "### Complexity\n\n| File | Function | Status | Rule severity | Complexity band | Cyclomatic | Cognitive | Coverage | CRAP |\n|:-----|:---------|:-------|:--------------|:----------------|:-----------|:----------|:---------|:-----|\n{rows}{tail}{}\n\n",
2529 audit_coverage_model_note(&complexity),
2530 )
2531}
2532
2533fn audit_coverage_join_suffix(summary: &Value) -> String {
2541 let matched = summary
2542 .get("istanbul_files_matched")
2543 .and_then(Value::as_u64);
2544 let total = summary.get("istanbul_files_total").and_then(Value::as_u64);
2545 match (matched, total) {
2546 (Some(matched), Some(total)) if total > 0 && matched == 0 => format!(
2547 ", from a coverage file describing {total} files that none of the analyzed files matched; check `--coverage-root` is correct for this checkout."
2548 ),
2549 (Some(matched), Some(total)) if total > 0 && matched < total => format!(
2550 ", from {matched} of {total} files in the coverage file; the rest matched no analyzed file."
2551 ),
2552 _ => ".".to_owned(),
2553 }
2554}
2555
2556fn audit_coverage_model_note(complexity: &Value) -> String {
2557 let summary = complexity.get("summary").cloned().unwrap_or(Value::Null);
2558 let model = summary.get("coverage_model").and_then(Value::as_str);
2559 match model {
2560 Some("istanbul") => {
2561 let matched = summary.get("istanbul_matched").and_then(Value::as_u64);
2562 let total = summary.get("istanbul_total").and_then(Value::as_u64);
2563 match (matched, total) {
2564 (Some(matched), Some(total)) if total > 0 => {
2565 format!(
2566 "\n\n*Coverage model: istanbul. Matched {matched}/{total} functions{}*",
2567 audit_coverage_join_suffix(&summary)
2568 )
2569 }
2570 _ => "\n\n*Coverage model: istanbul (exact, from `--coverage`).*".to_owned(),
2571 }
2572 }
2573 Some("static_estimated" | "static_binary") => {
2574 "\n\n*Coverage model: static (estimated). Pair with `--coverage <coverage-final.json>` for measured coverage instead of estimates.*".to_owned()
2575 }
2576 _ => String::new(),
2577 }
2578}
2579
2580fn audit_duplication_section(env: &Value) -> String {
2581 let duplication = env.get("duplication").cloned().unwrap_or(Value::Null);
2582 let mut groups: Vec<&Value> = arr(&duplication, "clone_groups").collect();
2583 groups.sort_by(|a, b| {
2584 audit_priority(env, "unknown", introduced_label(a))
2585 .cmp(&audit_priority(env, "unknown", introduced_label(b)))
2586 .then_with(|| s(&a["instances"][0], "file").cmp(s(&b["instances"][0], "file")))
2587 .then_with(|| {
2588 u(&a["instances"][0], "start_line").cmp(&u(&b["instances"][0], "start_line"))
2589 })
2590 .then_with(|| u(a, "token_count").cmp(&u(b, "token_count")))
2591 });
2592 if groups.is_empty() {
2593 return String::new();
2594 }
2595 let rows = groups
2596 .iter()
2597 .take(10)
2598 .map(|group| {
2599 let instances: Vec<&Value> = arr(group, "instances").collect();
2600 let location = instances.first().map_or_else(
2601 || "-".to_owned(),
2602 |first| {
2603 let file = s(first, "file");
2604 if file.is_empty() {
2605 "-".to_owned()
2606 } else {
2607 let start = first
2608 .get("start_line")
2609 .filter(|line| !line.is_null())
2610 .map_or_else(|| "1".to_owned(), fmt_num);
2611 markdown_table_code_span(&format!(
2612 "{}:{start}",
2613 rel_path_absolute_only(file)
2614 ))
2615 }
2616 },
2617 );
2618 let mut files: Vec<String> = instances
2619 .iter()
2620 .map(|instance| {
2621 markdown_table_code_span(&rel_path_absolute_only(s(instance, "file")))
2622 })
2623 .collect();
2624 files.sort();
2625 files.dedup();
2626 let files = files.into_iter().take(3).collect::<Vec<_>>().join(", ");
2627 format!(
2628 "| {location} | {files} | {} lines / {} tokens | {} | {} |",
2629 num(group, "line_count"),
2630 num(group, "token_count"),
2631 instances.len(),
2632 introduced_label(group),
2633 )
2634 })
2635 .collect::<Vec<_>>()
2636 .join("\n");
2637 let tail = if groups.len() > 10 {
2638 format!(
2639 "\n\n> {} more clone groups in the full audit report",
2640 groups.len() - 10
2641 )
2642 } else {
2643 String::new()
2644 };
2645 format!(
2646 "### Duplication\n\n| Location | Files | Size | Instances | Status |\n|:---------|:------|:-----|----------:|:-------|\n{rows}{tail}\n\n"
2647 )
2648}
2649
2650fn audit_styling_section(env: &Value) -> String {
2651 let findings = sorted_audit_findings(env, &env["complexity"], "styling_findings");
2652 if findings.is_empty() {
2653 return String::new();
2654 }
2655 let rows = findings
2656 .iter()
2657 .take(15)
2658 .map(|finding| {
2659 format!(
2660 "| {} | {} | {} | {} | {} |",
2661 code_cell(finding, "code"),
2662 path_line(finding),
2663 code_cell(finding, "value"),
2664 introduced_label(finding),
2665 audit_rule_severity(finding),
2666 )
2667 })
2668 .collect::<Vec<_>>()
2669 .join("\n");
2670 let tail = if findings.len() > 15 {
2671 format!(
2672 "\n\n> {} more styling findings in the full audit report",
2673 findings.len() - 15,
2674 )
2675 } else {
2676 String::new()
2677 };
2678 format!(
2679 "### Styling\n\n| Rule | Location | Value | Status | Rule severity |\n|:-----|:---------|:------|:-------|:--------------|\n{rows}{tail}\n\n"
2680 )
2681}
2682
2683fn audit_diff_risk_section(env: &Value) -> String {
2686 let diff_risk = env.get("diff_risk").cloned().unwrap_or(Value::Null);
2687 let findings = sorted_audit_findings(env, &diff_risk, "findings");
2688 if findings.is_empty() {
2689 return String::new();
2690 }
2691 let rows = findings
2692 .iter()
2693 .take(15)
2694 .map(|finding| {
2695 let path = rel_path_absolute_only(s(finding, "path"));
2696 let line = u(finding, "line");
2697 let location = if line > 0 {
2698 format!("{path}:{line}")
2699 } else {
2700 path
2701 };
2702 format!(
2703 "| {} | {} | {} | {} |",
2704 markdown_table_code_span(s(finding, "rule")),
2705 markdown_table_code_span(&location),
2706 markdown_table_code_span(s(finding, "evidence")),
2707 str_or(finding, "severity", "warn"),
2708 )
2709 })
2710 .collect::<Vec<_>>()
2711 .join("\n");
2712 let tail = if findings.len() > 15 {
2713 format!(
2714 "\n\n> {} more diff-risk findings in the full audit report",
2715 findings.len() - 15
2716 )
2717 } else {
2718 String::new()
2719 };
2720 format!(
2721 "### Diff Risk\n\n| Rule | Location | Evidence | Severity |\n|:-----|:---------|:---------|:---------|\n{rows}{tail}\n\n"
2722 )
2723}
2724
2725fn audit_category_row(
2728 label: &str,
2729 summary: &Value,
2730 skipped_key: &str,
2731 findings: &str,
2732 attribution: &Value,
2733 attribution_prefix: &str,
2734) -> String {
2735 if summary
2736 .get(skipped_key)
2737 .and_then(Value::as_bool)
2738 .unwrap_or(false)
2739 {
2740 return format!("| {label} | skipped | - | - |");
2741 }
2742 let count = |suffix: &str| {
2743 attribution
2744 .get(format!("{attribution_prefix}_{suffix}"))
2745 .and_then(Value::as_u64)
2746 .map_or_else(|| "unknown".to_owned(), |count| count.to_string())
2747 };
2748 format!(
2749 "| {label} | {findings} | {} | {} |",
2750 count("introduced"),
2751 count("inherited"),
2752 )
2753}
2754
2755#[must_use]
2757fn render_audit_summary(env: &Value) -> String {
2758 let verdict = str_or(env, "verdict", "pass");
2759 let summary = env.get("summary").cloned().unwrap_or(Value::Null);
2760 let attribution = env.get("attribution").cloned().unwrap_or(Value::Null);
2761 let dead_code = env.get("dead_code").cloned().unwrap_or(Value::Null);
2762 let mut dead_rows = audit_dead_code_rows(&dead_code);
2763 dead_rows.sort_by(|a, b| {
2764 audit_priority(env, a.severity, a.status)
2765 .cmp(&audit_priority(env, b.severity, b.status))
2766 .then_with(|| a.location.cmp(&b.location))
2767 .then_with(|| a.kind.cmp(b.kind))
2768 .then_with(|| a.item.cmp(&b.item))
2769 });
2770
2771 let styling_count = env
2772 .get("complexity")
2773 .and_then(|complexity| complexity.get("styling_findings"))
2774 .and_then(Value::as_array)
2775 .map_or(0, Vec::len)
2776 .to_string();
2777 let rows = [
2778 audit_category_row(
2779 "Dead code",
2780 &summary,
2781 "dead_code_skipped",
2782 &num(&summary, "dead_code_issues"),
2783 &attribution,
2784 "dead_code",
2785 ),
2786 audit_category_row(
2787 "Complexity",
2788 &summary,
2789 "complexity_skipped",
2790 &num(&summary, "complexity_findings"),
2791 &attribution,
2792 "complexity",
2793 ),
2794 audit_category_row(
2795 "Duplication",
2796 &summary,
2797 "duplication_skipped",
2798 &num(&summary, "duplication_clone_groups"),
2799 &attribution,
2800 "duplication",
2801 ),
2802 audit_category_row(
2803 "Styling",
2804 &summary,
2805 "complexity_skipped",
2806 &styling_count,
2807 &attribution,
2808 "styling",
2809 ),
2810 ]
2811 .join("\n");
2812
2813 let mut out = format!(
2814 "## Fallow Audit\n\n> {} \u{b7} {} \u{b7} {}ms\n\n| Category | Findings | Introduced | Inherited |\n|:---------|---------:|-----------:|----------:|\n{rows}\n\n",
2815 audit_verdict_label(verdict),
2816 plural_n(u(env, "changed_files_count") as usize, "changed file"),
2817 num(env, "elapsed_ms"),
2818 );
2819 if !dead_rows.is_empty() {
2820 let rows = dead_rows
2821 .iter()
2822 .take(10)
2823 .map(|row| {
2824 format!(
2825 "| {} | {} | {} | {} | {} |",
2826 row.kind, row.location, row.item, row.status, row.severity,
2827 )
2828 })
2829 .collect::<Vec<_>>()
2830 .join("\n");
2831 let tail = if dead_rows.len() > 10 {
2832 format!(
2833 "\n\n> {} more dead-code findings in the full audit report",
2834 dead_rows.len() - 10
2835 )
2836 } else {
2837 String::new()
2838 };
2839 let _ = write!(
2840 out,
2841 "### Dead Code\n\n| Type | Location | Item | Status | Rule severity |\n|:-----|:---------|:-----|:-------|:--------------|\n{rows}{tail}\n\n"
2842 );
2843 }
2844 out.push_str(&audit_complexity_section(env));
2845 out.push_str(&audit_duplication_section(env));
2846 out.push_str(&audit_styling_section(env));
2847 out.push_str(&audit_diff_risk_section(env));
2848 out.push_str(match s(&attribution, "gate") {
2849 "all" => "*Audit gate: all. Every finding in changed files affects the verdict.*",
2850 "new-only" => {
2851 "*Audit gate: new-only. Inherited findings are reported but do not fail the verdict.*"
2852 }
2853 _ => "*Audit gate: unknown. New-versus-inherited attribution is unavailable.*",
2854 });
2855 out
2856}
2857
2858#[must_use]
2864fn render_security_summary(env: &Value) -> String {
2865 let findings: Vec<&Value> = arr(env, "security_findings").collect();
2866 let gate = env.get("gate").filter(|gate| !gate.is_null());
2867 let count = gate.map_or_else(
2868 || {
2869 env.get("summary")
2870 .and_then(|summary| summary.get("security_findings"))
2871 .and_then(Value::as_u64)
2872 .unwrap_or(findings.len() as u64) as usize
2873 },
2874 |gate| u(gate, "new_count") as usize,
2875 );
2876 let mut out = String::from("## Fallow Security\n\n");
2877 if count == 0 {
2878 let _ = write!(
2879 out,
2880 "> [!NOTE]\n> **No security candidates matched** \u{b7} {}ms",
2881 num(env, "elapsed_ms"),
2882 );
2883 } else {
2884 let _ = write!(
2885 out,
2886 "> [!WARNING]\n> **{} matched** \u{b7} {}ms",
2887 plural_n(count, "security candidate"),
2888 num(env, "elapsed_ms"),
2889 );
2890 }
2891 if let Some(gate) = gate {
2892 let _ = write!(
2893 out,
2894 "\n\nSecurity gate: `{}`, verdict: `{}`, matching candidates: **{}**.",
2895 s(gate, "mode"),
2896 s(gate, "verdict"),
2897 num(gate, "new_count"),
2898 );
2899 }
2900 if !findings.is_empty() {
2901 let rows = findings
2902 .iter()
2903 .take(15)
2904 .map(|finding| {
2905 format!(
2906 "| {} | {} | {} | {} |",
2907 path_line(finding),
2908 markdown_table_text(s(finding, "kind")),
2909 markdown_table_text(str_or(finding, "severity", "unknown")),
2910 markdown_table_code_span(
2911 finding
2912 .get("candidate")
2913 .and_then(|candidate| candidate.get("sink"))
2914 .and_then(|sink| sink.get("callee"))
2915 .and_then(Value::as_str)
2916 .unwrap_or("-")
2917 ),
2918 )
2919 })
2920 .collect::<Vec<_>>()
2921 .join("\n");
2922 let _ = write!(
2923 out,
2924 "\n\n| Location | Kind | Severity | Sink |\n|:---------|:-----|:---------|:-----|\n{rows}"
2925 );
2926 if findings.len() > 15 {
2927 let _ = write!(
2928 out,
2929 "\n\n> {} more candidates in the full report",
2930 findings.len() - 15,
2931 );
2932 }
2933 }
2934 out.push_str("\n\nTreat these as candidates for verification, not confirmed vulnerabilities.");
2935 out
2936}
2937
2938fn fix_entries<'v>(env: &'v Value, entry_type: &str) -> Vec<&'v Value> {
2948 arr(env, "fixes")
2949 .filter(|fix| s(fix, "type") == entry_type && !b(fix, "skipped"))
2950 .collect()
2951}
2952
2953fn fix_detail_block(label: &str, entries: &[&Value], row: impl Fn(&Value) -> String) -> String {
2954 let rows = entries
2955 .iter()
2956 .take(25)
2957 .map(|entry| row(entry))
2958 .collect::<Vec<_>>()
2959 .join("\n");
2960 let tail = if entries.len() > 25 {
2961 format!("\n- *... and {} more*", entries.len() - 25)
2962 } else {
2963 String::new()
2964 };
2965 format!("**{label} ({})**\n{rows}{tail}", entries.len())
2966}
2967
2968#[must_use]
2970pub fn render_fix_summary(env: &Value) -> String {
2971 let exports = fix_entries(env, "remove_export");
2972 let dependencies = fix_entries(env, "remove_dependency");
2973 let fix_attempts = arr(env, "fixes").filter(|fix| !b(fix, "skipped")).count();
2974 let content_changed = u(env, "skipped_content_changed") as usize;
2975 let mixed_eol = u(env, "skipped_mixed_line_endings") as usize;
2976 let low_confidence = u(env, "skipped_low_confidence_exports") as usize;
2977 let low_confidence_deps = u(env, "skipped_low_confidence_dependencies") as usize;
2978 let low_confidence_members = u(env, "skipped_low_confidence_members") as usize;
2979 let dry_run = b(env, "dry_run");
2980
2981 if fix_attempts == 0
2982 && content_changed == 0
2983 && mixed_eol == 0
2984 && low_confidence == 0
2985 && low_confidence_deps == 0
2986 && low_confidence_members == 0
2987 {
2988 return "## Fallow - Auto-fix\n\nNo fixable issues found.".to_owned();
2989 }
2990
2991 let mut out = String::from("## Fallow - Auto-fix\n\n");
2992 out.push_str(if dry_run {
2993 "**Dry run**: would apply"
2994 } else {
2995 "Applied"
2996 });
2997 let fix_noun = if fix_attempts == 1 { "fix" } else { "fixes" };
2998 let _ = write!(out, " **{fix_attempts} {fix_noun}**");
2999 if !dry_run {
3000 let _ = write!(out, " ({} succeeded)", num(env, "total_fixed"));
3001 }
3002 if content_changed > 0 {
3003 let _ = write!(
3004 out,
3005 ", skipped {content_changed} file(s) that changed since analysis"
3006 );
3007 }
3008 if mixed_eol > 0 {
3009 let _ = write!(out, ", skipped {mixed_eol} file(s) with mixed line endings");
3010 }
3011 if low_confidence > 0 {
3012 let _ = write!(
3013 out,
3014 ", kept exports in {low_confidence} file(s) where consumers may be hidden from static analysis"
3015 );
3016 }
3017 if low_confidence_deps > 0 {
3018 let _ = write!(
3019 out,
3020 ", kept {low_confidence_deps} declared package(s) whose only import may sit in a file this run did not fully read"
3021 );
3022 }
3023 if low_confidence_members > 0 {
3024 let _ = write!(
3025 out,
3026 ", kept {low_confidence_members} unused enum member(s) whose only reference may sit in a file this run did not fully analyze"
3027 );
3028 }
3029 out.push_str("\n\n| Type | Count |\n|------|-------|\n");
3030 if !exports.is_empty() {
3031 let _ = writeln!(out, "| Export removals | {} |", exports.len());
3032 }
3033 if !dependencies.is_empty() {
3034 let _ = writeln!(out, "| Dependency removals | {} |", dependencies.len());
3035 }
3036 out.push_str("\n<details>\n<summary>View details</summary>\n\n");
3037 if !exports.is_empty() {
3038 out.push_str(&fix_detail_block("Export removals", &exports, |it| {
3039 format!(
3040 "- {} - {}",
3041 markdown_code_span(&format!("{}:{}", s(it, "path"), num(it, "line"))),
3042 markdown_code_span(s(it, "name"))
3043 )
3044 }));
3045 out.push_str("\n\n");
3046 }
3047 if !dependencies.is_empty() {
3048 out.push_str(&fix_detail_block(
3049 "Dependency removals",
3050 &dependencies,
3051 |it| {
3052 format!(
3053 "- {} from {} in {}",
3054 markdown_code_span(s(it, "package")),
3055 markdown_code_span(s(it, "location")),
3056 markdown_code_span(s(it, "file")),
3057 )
3058 },
3059 ));
3060 out.push('\n');
3061 }
3062 out.push_str("\n\n</details>");
3063 out
3064}
3065
3066fn file_link(links: &LinkContext, path: &str, start: &str, end: &str) -> String {
3071 let display = markdown_table_code_span(&format!("{}:{start}-{end}", last_three_segments(path)));
3072 if links.repo.is_empty() || links.sha.is_empty() {
3073 display
3074 } else {
3075 format!(
3076 "[{display}](https://github.com/{}/blob/{}/{}{}#L{start}-L{end})",
3077 links.repo,
3078 links.sha,
3079 links.prefix,
3080 encode_link_path(path),
3081 )
3082 }
3083}
3084
3085fn encode_link_path(path: &str) -> String {
3088 path.replace('%', "%25")
3089 .replace(' ', "%20")
3090 .replace('(', "%28")
3091 .replace(')', "%29")
3092 .replace('<', "%3C")
3093 .replace('>', "%3E")
3094 .replace('|', "%7C")
3095}
3096
3097fn exceeded_priority(it: &Value) -> u8 {
3098 match s(it, "exceeded") {
3099 "all" => 5,
3100 "cyclomatic_crap" | "cognitive_crap" => 4,
3101 "crap" => 3,
3102 "both" => 2,
3103 "cyclomatic" | "cognitive" => 1,
3104 _ => 0,
3105 }
3106}
3107
3108fn severity_priority(it: &Value) -> u8 {
3109 match s(it, "severity") {
3110 "critical" => 3,
3111 "high" => 2,
3112 "moderate" => 1,
3113 _ => 0,
3114 }
3115}
3116
3117fn ranked_health_findings(health: &Value) -> Vec<&Value> {
3120 let mut findings: Vec<&Value> = arr(health, "findings").collect();
3121 findings.sort_by_key(|it| {
3122 (
3123 exceeded_priority(it),
3124 severity_priority(it),
3125 it.get("crap").is_some_and(|crap| !crap.is_null()),
3126 u(it, "cyclomatic"),
3127 u(it, "cognitive"),
3128 u(it, "line_count"),
3129 )
3130 });
3131 findings.reverse();
3132 findings
3133}
3134
3135const PROD_FAILING_VERDICTS: &[&str] = &["safe_to_delete", "review_required", "low_traffic"];
3136
3137struct CombinedCounts {
3138 check: usize,
3139 dupes: usize,
3140 complex: usize,
3141 prod_failing: usize,
3142 prod_advisory: usize,
3143 hot_paths: usize,
3144}
3145
3146impl CombinedCounts {
3147 fn health(&self) -> usize {
3148 self.complex + self.prod_failing
3149 }
3150
3151 fn total(&self) -> usize {
3152 self.check + self.dupes + self.health()
3153 }
3154}
3155
3156fn combined_counts(env: &Value) -> CombinedCounts {
3157 let check = env
3158 .get("check")
3159 .map_or(0, |check| u(check, "total_issues") as usize);
3160 let dupes = env.get("dupes").map_or(0, |dupes| {
3170 arr(dupes, "clone_groups")
3171 .count()
3172 .saturating_add(u(dupes, "clone_groups_omitted") as usize)
3173 });
3174 let health = env.get("health").cloned().unwrap_or(Value::Null);
3175 let complex = health.get("summary").map_or(0, |summary| {
3178 baselined_complexity(summary).map_or_else(
3179 || u(summary, "functions_above_threshold"),
3180 |(remaining, _)| remaining,
3181 ) as usize
3182 });
3183 let runtime = health
3184 .get("runtime_coverage")
3185 .cloned()
3186 .unwrap_or(Value::Null);
3187 let prod_failing = arr(&runtime, "findings")
3188 .filter(|finding| PROD_FAILING_VERDICTS.contains(&s(finding, "verdict")))
3189 .count();
3190 let prod_advisory = arr(&runtime, "findings")
3191 .filter(|finding| !PROD_FAILING_VERDICTS.contains(&s(finding, "verdict")))
3192 .count();
3193 let hot_paths = arr(&runtime, "hot_paths").count();
3194 CombinedCounts {
3195 check,
3196 dupes,
3197 complex,
3198 prod_failing,
3199 prod_advisory,
3200 hot_paths,
3201 }
3202}
3203
3204fn hot_path_label(env: &Value, n: usize) -> String {
3205 let touched = env
3206 .get("health")
3207 .and_then(|health| health.get("runtime_coverage"))
3208 .is_some_and(|runtime| s(runtime, "verdict") == "hot-path-touched");
3209 let plural = if n == 1 { "" } else { "s" };
3210 if touched {
3211 format!("hot path{plural} touched")
3212 } else {
3213 format!("hot path{plural}")
3214 }
3215}
3216
3217fn combined_zero_case(env: &Value, counts: &CombinedCounts) -> String {
3218 let vitals = env
3219 .get("health")
3220 .and_then(|health| health.get("vital_signs"))
3221 .cloned()
3222 .unwrap_or(Value::Null);
3223 let mut out = String::from("# \u{1F33F} Fallow\n\n");
3224 if counts.prod_advisory > 0 || counts.hot_paths > 0 {
3225 out.push_str(
3226 "> [!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",
3227 );
3228 if counts.prod_advisory > 0 {
3229 let _ = write!(
3230 out,
3231 " \u{b7} :information_source: **{}** runtime coverage advisory finding{}",
3232 counts.prod_advisory,
3233 if counts.prod_advisory == 1 { "" } else { "s" },
3234 );
3235 }
3236 if counts.hot_paths > 0 {
3237 let _ = write!(
3238 out,
3239 " \u{b7} :eyes: **{}** {}",
3240 counts.hot_paths,
3241 hot_path_label(env, counts.hot_paths),
3242 );
3243 }
3244 } else {
3245 out.push_str(
3246 "> [!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: ",
3247 );
3248 let accepted = env
3251 .get("health")
3252 .and_then(|health| health.get("summary"))
3253 .and_then(baselined_complexity)
3254 .map_or(0, |(_, accepted)| accepted);
3255 if accepted > 0 {
3256 let _ = write!(out, "No new complex functions ({accepted} in the baseline)");
3257 } else {
3258 out.push_str("No complex functions");
3259 }
3260 }
3261 if let Some(maintainability) = opt_f(&vitals, "maintainability_avg") {
3262 let _ = write!(
3263 out,
3264 "\n\n| Metric | Value |\n|:-------|------:|\n| [Maintainability]({HEALTH_DOCS}#maintainability-index-mi) | **{}** / 100 |\n",
3265 pct(maintainability),
3266 );
3267 }
3268 out
3269}
3270
3271fn combined_status_line(env: &Value, counts: &CombinedCounts) -> String {
3272 let mut out = String::new();
3273 if counts.check > 0 {
3274 let _ = write!(
3275 out,
3276 ":warning: **{}** code {}",
3277 counts.check,
3278 if counts.check == 1 { "issue" } else { "issues" },
3279 );
3280 } else {
3281 out.push_str(":white_check_mark: No code issues");
3282 }
3283 out.push_str(" \u{b7} ");
3284 if counts.dupes > 0 {
3285 let _ = write!(
3286 out,
3287 ":warning: **{}** clone {}",
3288 counts.dupes,
3289 if counts.dupes == 1 { "group" } else { "groups" },
3290 );
3291 } else {
3292 out.push_str(":white_check_mark: No duplication");
3293 }
3294 out.push_str(" \u{b7} ");
3295 let health = counts.health();
3296 if health > 0 {
3297 let _ = write!(
3298 out,
3299 ":warning: **{health}** health {}",
3300 if health == 1 { "finding" } else { "findings" },
3301 );
3302 } else {
3303 out.push_str(":white_check_mark: No blocking health findings");
3304 }
3305 if counts.prod_advisory > 0 {
3306 let _ = write!(
3307 out,
3308 " \u{b7} :information_source: **{}** coverage advisory finding{}",
3309 counts.prod_advisory,
3310 if counts.prod_advisory == 1 { "" } else { "s" },
3311 );
3312 }
3313 if counts.hot_paths > 0 {
3314 let _ = write!(
3315 out,
3316 " \u{b7} :eyes: **{}** {}",
3317 counts.hot_paths,
3318 hot_path_label(env, counts.hot_paths),
3319 );
3320 }
3321 out.push_str("\n\n");
3322 out
3323}
3324
3325fn combined_check_breakdown(env: &Value, counts: &CombinedCounts) -> String {
3326 if counts.check == 0 {
3327 return String::new();
3328 }
3329 let check = env.get("check").cloned().unwrap_or(Value::Null);
3330 format!(
3331 "<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",
3332 counts.check,
3333 dead_code_category_table(&check),
3334 )
3335}
3336
3337fn combined_dupes_breakdown(env: &Value, counts: &CombinedCounts, links: &LinkContext) -> String {
3338 if counts.dupes == 0 {
3339 return String::new();
3340 }
3341 let dupes = env.get("dupes").cloned().unwrap_or(Value::Null);
3342 let stats = dupes.get("stats").cloned().unwrap_or(Value::Null);
3343 let groups = sorted_clone_groups(&dupes);
3344 let files_with_clones = u(&stats, "files_with_clones") as usize;
3345 let rows = groups
3346 .iter()
3347 .take(5)
3348 .map(|group| {
3349 let locations = arr(group, "instances")
3350 .map(|instance| {
3351 file_link(
3352 links,
3353 s(instance, "file"),
3354 &num(instance, "start_line"),
3355 &num(instance, "end_line"),
3356 )
3357 })
3358 .collect::<Vec<_>>()
3359 .join("<br>");
3360 format!(
3361 "| {locations} | {} | {} |",
3362 num(group, "line_count"),
3363 num(group, "token_count"),
3364 )
3365 })
3366 .collect::<Vec<_>>()
3367 .join("\n");
3368 let tail = if counts.dupes > 5 {
3369 format!("\n\n*\u{2026} and {} more groups.*", counts.dupes - 5)
3370 } else {
3371 String::new()
3372 };
3373 format!(
3374 "<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",
3375 counts.dupes,
3376 if counts.dupes == 1 { "group" } else { "groups" },
3377 num(&stats, "duplicated_lines"),
3378 pct(f_or_zero(&stats, "duplication_percentage")),
3379 if files_with_clones == 1 {
3380 "file"
3381 } else {
3382 "files"
3383 },
3384 )
3385}
3386
3387fn combined_complexity_breakdown(env: &Value, counts: &CombinedCounts) -> String {
3388 if counts.complex == 0 {
3389 return String::new();
3390 }
3391 let health = env.get("health").cloned().unwrap_or(Value::Null);
3392 let summary = health.get("summary").cloned().unwrap_or(Value::Null);
3393 let findings = ranked_health_findings(&health);
3394 let show_crap = summary
3395 .get("max_crap_threshold")
3396 .is_some_and(|threshold| !threshold.is_null())
3397 || findings
3398 .iter()
3399 .any(|finding| finding.get("crap").is_some_and(|crap| !crap.is_null()));
3400 let cyc_t = threshold_or(&summary, "max_cyclomatic_threshold", "default");
3401 let cog_t = threshold_or(&summary, "max_cognitive_threshold", "default");
3402 let crap_t = threshold_or(&summary, "max_crap_threshold", "default");
3403 let crap_header = if show_crap {
3404 format!(" | [CRAP]({HEALTH_DOCS}#crap-score)")
3405 } else {
3406 String::new()
3407 };
3408 let crap_separator = if show_crap { "|-----:" } else { "" };
3409 let rows = findings
3410 .iter()
3411 .take(5)
3412 .map(|it| {
3413 let crap_column = if show_crap {
3414 format!(" | {}", crap_cell(it))
3415 } else {
3416 String::new()
3417 };
3418 format!(
3419 "| {} | {} | {} | {}{} | {}{}{crap_column} | {} |",
3420 markdown_table_code_span(&format!(
3421 "{}:{}",
3422 last_three_segments(s(it, "path")),
3423 num(it, "line")
3424 )),
3425 code_cell(it, "name"),
3426 markdown_table_text(str_or(it, "severity", "moderate")),
3427 num(it, "cyclomatic"),
3428 exceeded_marker(it, &["cyclomatic", "both", "all"]),
3429 num(it, "cognitive"),
3430 exceeded_marker(it, &["cognitive", "both", "all"]),
3431 num(it, "line_count"),
3432 )
3433 })
3434 .collect::<Vec<_>>()
3435 .join("\n");
3436 let crap_footer = if show_crap {
3437 format!(", CRAP >= {crap_t}")
3438 } else {
3439 String::new()
3440 };
3441 let label = match baselined_complexity(&summary).filter(|&(_, accepted)| accepted > 0) {
3442 Some((_, accepted)) => format!(
3443 "{} new {} above threshold, {accepted} in the baseline",
3444 counts.complex,
3445 if counts.complex == 1 {
3446 "function"
3447 } else {
3448 "functions"
3449 },
3450 ),
3451 None => format!(
3452 "{} {} above threshold",
3453 counts.complex,
3454 if counts.complex == 1 {
3455 "function"
3456 } else {
3457 "functions"
3458 },
3459 ),
3460 };
3461 format!(
3462 "<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",
3463 threshold_or(&summary, "files_analyzed", "unknown"),
3464 threshold_or(&summary, "functions_analyzed", "unknown"),
3465 )
3466}
3467
3468fn combined_runtime_breakdown(env: &Value, counts: &CombinedCounts) -> String {
3469 let prod_total = counts.prod_failing + counts.prod_advisory;
3470 if prod_total == 0 && counts.hot_paths == 0 {
3471 return String::new();
3472 }
3473 let runtime = env
3474 .get("health")
3475 .and_then(|health| health.get("runtime_coverage"))
3476 .cloned()
3477 .unwrap_or(Value::Null);
3478 let hot_suffix = if counts.hot_paths > 0 {
3479 format!(
3480 ", {} {}",
3481 counts.hot_paths,
3482 hot_path_label(env, counts.hot_paths)
3483 )
3484 } else {
3485 String::new()
3486 };
3487 let mut out = format!(
3488 "<details>\n<summary><strong><a href=\"{HEALTH_DOCS}#runtime-coverage\">Runtime coverage</a> ({prod_total} finding{}{hot_suffix})</strong></summary>\n\n",
3489 if prod_total == 1 { "" } else { "s" },
3490 );
3491 if prod_total > 0 {
3492 out.push_str("| File | Function | Verdict | Invocations | Confidence |\n|:-----|:---------|:--------|------------:|:-----------|\n");
3493 out.push_str(
3494 &arr(&runtime, "findings")
3495 .take(5)
3496 .map(|it| {
3497 let invocations = it
3498 .get("invocations")
3499 .filter(|value| !value.is_null())
3500 .map_or_else(|| "-".to_owned(), fmt_num);
3501 format!(
3502 "| {} | {} | {} | {invocations} | {} |",
3503 markdown_table_code_span(&format!(
3504 "{}:{}",
3505 last_three_segments(s(it, "path")),
3506 num(it, "line")
3507 )),
3508 code_cell(it, "function"),
3509 code_cell(it, "verdict"),
3510 markdown_table_text(s(it, "confidence")),
3511 )
3512 })
3513 .collect::<Vec<_>>()
3514 .join("\n"),
3515 );
3516 if counts.hot_paths > 0 {
3517 out.push_str("\n\n");
3518 }
3519 }
3520 if counts.hot_paths > 0 {
3521 out.push_str("| File | Function | Invocations | Percentile |\n|:-----|:---------|------------:|-----------:|\n");
3522 out.push_str(
3523 &arr(&runtime, "hot_paths")
3524 .take(5)
3525 .map(|it| {
3526 format!(
3527 "| {} | {} | {} | {} |",
3528 markdown_table_code_span(&format!(
3529 "{}:{}",
3530 last_three_segments(s(it, "path")),
3531 num(it, "line")
3532 )),
3533 code_cell(it, "function"),
3534 num(it, "invocations"),
3535 num(it, "percentile"),
3536 )
3537 })
3538 .collect::<Vec<_>>()
3539 .join("\n"),
3540 );
3541 out.push_str("\n\n");
3542 }
3543 out.push_str("</details>\n\n");
3544 out
3545}
3546
3547fn combined_vitals(env: &Value) -> String {
3548 let health = env.get("health").cloned().unwrap_or(Value::Null);
3549 let vitals = health.get("vital_signs").cloned().unwrap_or(Value::Null);
3550 let has_vitals = vitals.as_object().is_some_and(|vitals| !vitals.is_empty());
3551 if !has_vitals {
3552 return String::new();
3553 }
3554 let scores: Vec<f64> = arr(&health, "file_scores")
3555 .filter_map(|score| opt_f(score, "maintainability_index"))
3556 .collect();
3557 let scoped_maintainability = if scores.is_empty() {
3558 None
3559 } else {
3560 let avg = scores.iter().sum::<f64>() / scores.len() as f64;
3561 Some((avg * 10.0).round() / 10.0)
3562 };
3563 let mut out = format!(
3564 "#### [Codebase health]({HEALTH_DOCS})\n\n| Metric | Value |\n|:-------|------:|\n"
3565 );
3566 let maintainability = opt_f(&vitals, "maintainability_avg");
3567 if let Some(avg) = maintainability {
3568 let _ = writeln!(
3569 out,
3570 "| [Maintainability]({HEALTH_DOCS}#maintainability-index-mi) | **{}** / 100 |",
3571 pct(avg),
3572 );
3573 }
3574 if let Some(scoped) = scoped_maintainability {
3575 let rounded_avg = (maintainability.unwrap_or_default() * 10.0).round() / 10.0;
3576 if (scoped - rounded_avg).abs() > f64::EPSILON {
3577 let _ = writeln!(
3578 out,
3579 "| [Maintainability]({HEALTH_DOCS}#maintainability-index-mi) (changed files) | **{}** / 100 |",
3580 fmt_num(&serde_json::json!(scoped)),
3581 );
3582 }
3583 }
3584 if let Some(avg_cyclomatic) = opt_f(&vitals, "avg_cyclomatic") {
3585 let _ = writeln!(
3586 out,
3587 "| [Avg complexity]({HEALTH_DOCS}#cyclomatic-complexity) | {} |",
3588 pct(avg_cyclomatic),
3589 );
3590 }
3591 if let Some(population) = vitals
3592 .get("cyclomatic_population")
3593 .filter(|value| value.is_object())
3594 {
3595 let _ = writeln!(
3596 out,
3597 "| Cyclomatic units | Functions: {}, module scopes: {}, templates: {} |",
3598 num(&population["functions"], "count"),
3599 num(&population["modules"], "count"),
3600 num(&population["templates"], "count")
3601 );
3602 if let Some(max) = population["modules"]["max"].as_u64() {
3603 let _ = writeln!(
3604 out,
3605 "| Module-scope max cyclomatic (aggregate only) | {max} |"
3606 );
3607 }
3608 }
3609 out.push('\n');
3610 out
3611}
3612
3613fn combined_tips(env: &Value) -> String {
3614 let check = env.get("check").cloned().unwrap_or(Value::Null);
3615 let fixable = arr(&check, "unused_exports").count()
3616 + arr(&check, "unused_dependencies").count()
3617 + arr(&check, "unused_enum_members").count();
3618 if fixable == 0 {
3619 return String::new();
3620 }
3621 let mut out = String::from("> [!TIP]\n> Run `fallow fix --dry-run` to preview auto-fixes.\n");
3622 if arr(&check, "unused_exports").count() > 0 {
3623 let _ = writeln!(
3624 out,
3625 "> Add [`/** @public */`]({SUPPRESSION_DOCS}) above exports to preserve them."
3626 );
3627 }
3628 out
3629}
3630
3631#[must_use]
3633fn render_combined_summary(env: &Value, links: &LinkContext) -> String {
3634 let counts = combined_counts(env);
3635 let header = health_score_header(&env.get("health").cloned().unwrap_or(Value::Null));
3636 if counts.total() == 0 {
3637 return format!("{header}{}", combined_zero_case(env, &counts));
3638 }
3639 let pointer = if counts.check > 0 || counts.dupes > 0 || counts.health() > 0 {
3640 "See inline review comments for per-finding details.\n\n"
3641 } else {
3642 ""
3643 };
3644 format!(
3645 "{header}# \u{1F33F} Fallow\n\n> [!WARNING]\n> **Review needed**\n\n{}{pointer}{}{}{}{}{}{}",
3646 combined_status_line(env, &counts),
3647 combined_check_breakdown(env, &counts),
3648 combined_dupes_breakdown(env, &counts, links),
3649 combined_complexity_breakdown(env, &counts),
3650 combined_runtime_breakdown(env, &counts),
3651 combined_vitals(env),
3652 combined_tips(env),
3653 )
3654}
3655
3656#[cfg(test)]
3657mod tests {
3658 use std::collections::BTreeSet;
3659
3660 use fallow_types::issue_meta::counted_result_issue_metas;
3661
3662 use super::{
3663 DEAD_CODE_CATEGORIES, LinkContext, render_audit_summary, render_combined_summary,
3664 render_health_summary,
3665 };
3666
3667 fn complexity_finding(name: &str) -> serde_json::Value {
3669 serde_json::json!({
3670 "path": "src/a.ts", "name": name, "line": 1, "col": 0,
3671 "cyclomatic": 25, "cognitive": 30, "line_count": 40,
3672 "exceeded": "both", "severity": "high"
3673 })
3674 }
3675
3676 fn health_with_baseline(above: u64, remaining: u64) -> serde_json::Value {
3679 let findings: Vec<serde_json::Value> = (0..remaining)
3680 .map(|index| complexity_finding(&format!("f{index}")))
3681 .collect();
3682 serde_json::json!({
3683 "elapsed_ms": 1,
3684 "findings": findings,
3685 "vital_signs": { "avg_cyclomatic": 2.0 },
3686 "summary": {
3687 "files_analyzed": 3,
3688 "functions_analyzed": 90,
3689 "functions_above_threshold": above,
3690 "max_cyclomatic_threshold": 20,
3691 "max_cognitive_threshold": 15,
3692 "max_crap_threshold": 30.0,
3693 "baseline_staleness": {
3694 "baseline_entries": above,
3695 "matched_entries": above,
3696 "stale_entries": 0,
3697 "current_findings": above,
3698 "remaining_findings": remaining,
3699 "stale": false
3700 }
3701 }
3702 })
3703 }
3704
3705 fn no_links() -> LinkContext {
3706 LinkContext {
3707 prefix: String::new(),
3708 repo: String::new(),
3709 sha: String::new(),
3710 }
3711 }
3712
3713 #[test]
3716 fn audit_summary_marks_skipped_sub_analyses() {
3717 let audit = |dead_code: bool, complexity: bool, duplication: bool| {
3718 serde_json::json!({
3719 "command": "audit",
3720 "verdict": "warn",
3721 "changed_files_count": 1,
3722 "elapsed_ms": 3,
3723 "summary": {
3724 "dead_code_issues": 0,
3725 "dead_code_skipped": dead_code,
3726 "complexity_skipped": complexity,
3727 "duplication_skipped": duplication,
3728 "complexity_findings": 1,
3729 "duplication_clone_groups": 0
3730 },
3731 "attribution": {
3732 "gate": "new-only",
3733 "dead_code_introduced": 0,
3734 "dead_code_inherited": 0,
3735 "complexity_introduced": 1,
3736 "complexity_inherited": 0,
3737 "duplication_introduced": 0,
3738 "duplication_inherited": 0,
3739 "styling_introduced": 0,
3740 "styling_inherited": 0
3741 }
3742 })
3743 };
3744 let out = render_audit_summary(&audit(true, false, false));
3745 assert!(out.contains("| Dead code | skipped | - | - |"), "{out}");
3746 assert!(out.contains("| Complexity | 1 | 1 | 0 |"), "{out}");
3747 assert!(out.contains("| Styling | 0 | 0 | 0 |"), "{out}");
3748 let out = render_audit_summary(&audit(false, true, true));
3749 assert!(out.contains("| Dead code | 0 | 0 | 0 |"), "{out}");
3750 assert!(out.contains("| Complexity | skipped | - | - |"), "{out}");
3751 assert!(out.contains("| Duplication | skipped | - | - |"), "{out}");
3752 assert!(out.contains("| Styling | skipped | - | - |"), "{out}");
3753 }
3754
3755 #[test]
3758 fn health_summary_headline_counts_after_the_baseline() {
3759 let out = render_health_summary(&health_with_baseline(76, 0));
3760 assert!(
3761 out.contains("**No new functions exceed complexity thresholds (76 in the baseline)**"),
3762 "{out}"
3763 );
3764 let out = render_health_summary(&health_with_baseline(76, 2));
3765 assert!(
3766 out.contains("**2 new functions exceed thresholds (74 in the baseline)**"),
3767 "{out}"
3768 );
3769 assert!(!out.contains("76 functions"), "{out}");
3770 }
3771
3772 #[test]
3774 fn combined_summary_counts_health_after_the_baseline() {
3775 let combined = |health: serde_json::Value| {
3776 serde_json::json!({
3777 "check": { "total_issues": 0 },
3778 "dupes": { "clone_groups": [], "stats": {} },
3779 "health": health
3780 })
3781 };
3782 let out = render_combined_summary(&combined(health_with_baseline(76, 0)), &no_links());
3783 assert!(
3784 out.contains(":white_check_mark: No new complex functions (76 in the baseline)"),
3785 "{out}"
3786 );
3787 assert!(!out.contains("above threshold"), "{out}");
3788
3789 let out = render_combined_summary(&combined(health_with_baseline(76, 2)), &no_links());
3790 assert!(out.contains(":warning: **2** health findings"), "{out}");
3791 assert!(
3792 out.contains("(2 new functions above threshold, 74 in the baseline)"),
3793 "{out}"
3794 );
3795 }
3796
3797 #[test]
3799 fn combined_summary_without_a_baseline_keeps_the_summary_count() {
3800 let mut health = health_with_baseline(3, 3);
3801 health["summary"]
3802 .as_object_mut()
3803 .expect("summary")
3804 .remove("baseline_staleness");
3805 let env = serde_json::json!({
3806 "check": { "total_issues": 0 },
3807 "dupes": { "clone_groups": [], "stats": {} },
3808 "health": health
3809 });
3810 let out = render_combined_summary(&env, &no_links());
3811 assert!(out.contains(":warning: **3** health findings"), "{out}");
3812 assert!(out.contains("(3 functions above threshold)"), "{out}");
3813 assert!(!out.contains("baseline"), "{out}");
3814 }
3815
3816 #[test]
3819 fn health_summary_counts_unlisted_functions() {
3820 let envelope = |above: u64| {
3821 serde_json::json!({
3822 "elapsed_ms": 1,
3823 "summary": {
3824 "functions_analyzed": 5,
3825 "functions_above_threshold": above,
3826 "max_cyclomatic_threshold": 20,
3827 "max_cognitive_threshold": 15,
3828 "max_crap_threshold": 30.0
3829 }
3830 })
3831 };
3832 let out = render_health_summary(&envelope(1));
3833 assert!(out.contains("**1 function exceeds thresholds**"), "{out}");
3834 assert!(out.contains("`fallow health --complexity`"), "{out}");
3835 assert!(!out.contains("baseline"), "{out}");
3836 assert!(!out.contains("No functions exceed"), "{out}");
3837 let out = render_health_summary(&envelope(0));
3838 assert!(
3839 out.contains("**No functions exceed complexity thresholds**"),
3840 "{out}"
3841 );
3842 }
3843
3844 fn baselined_envelope(lists_findings: bool) -> serde_json::Value {
3847 let mut envelope = serde_json::json!({
3848 "elapsed_ms": 1,
3849 "findings": [],
3850 "summary": {
3851 "functions_analyzed": 5,
3852 "functions_above_threshold": 1,
3853 "max_cyclomatic_threshold": 20,
3854 "max_cognitive_threshold": 15,
3855 "max_crap_threshold": 30.0,
3856 "baseline_staleness": {
3857 "baseline_entries": 2,
3858 "matched_entries": 2,
3859 "stale_entries": 0,
3860 "current_findings": 1,
3861 "stale": false
3862 }
3863 }
3864 });
3865 if lists_findings {
3866 envelope["vital_signs"] = serde_json::json!({ "avg_cyclomatic": 2.0 });
3867 }
3868 envelope
3869 }
3870
3871 #[test]
3875 fn health_summary_keeps_a_baselined_listed_run_clean() {
3876 let out = render_health_summary(&baselined_envelope(true));
3877 assert!(
3878 out.contains("**No functions exceed complexity thresholds**"),
3879 "{out}"
3880 );
3881 assert!(!out.contains("--complexity"), "{out}");
3882 }
3883
3884 #[test]
3887 fn health_summary_counts_unlisted_functions_on_a_baselined_score_run() {
3888 let out = render_health_summary(&baselined_envelope(false));
3889 assert!(out.contains("**1 function exceeds thresholds**"), "{out}");
3890 assert!(
3891 out.contains("The count includes the functions that the baseline accepts."),
3892 "{out}"
3893 );
3894 assert!(
3895 out.contains("Run `fallow health --complexity` with the same `--baseline`"),
3896 "{out}"
3897 );
3898 assert!(!out.contains("No functions exceed"), "{out}");
3899 }
3900
3901 #[test]
3905 fn health_summary_reads_the_sections_member() {
3906 let mut env = baselined_envelope(true);
3907 env["sections"] = serde_json::json!(["vital-signs", "hotspots"]);
3908 env["summary"]["functions_above_threshold"] = serde_json::json!(3);
3909 env["summary"]["baseline_staleness"]["remaining_findings"] = serde_json::json!(1);
3910 let out = render_health_summary(&env);
3911 assert!(out.contains("**3 functions exceed thresholds**"), "{out}");
3912 assert!(out.contains("The baseline accepts 2. 1 is new."), "{out}");
3913 assert!(
3914 out.contains("Run `fallow health --complexity` with the same `--baseline`"),
3915 "{out}"
3916 );
3917 assert!(!out.contains("No functions exceed"), "{out}");
3918 assert!(!out.contains("The count includes"), "{out}");
3919
3920 env["sections"] = serde_json::json!(["complexity", "vital-signs", "hotspots"]);
3921 let out = render_health_summary(&env);
3922 assert!(
3923 out.contains("**No functions exceed complexity thresholds**"),
3924 "{out}"
3925 );
3926 }
3927
3928 #[test]
3931 fn health_summary_with_no_new_function_does_not_ask_for_a_rerun() {
3932 let mut env = baselined_envelope(false);
3933 env["sections"] = serde_json::json!(["score"]);
3934 env["summary"]["baseline_staleness"]["remaining_findings"] = serde_json::json!(0);
3935 let out = render_health_summary(&env);
3936 assert!(out.contains("**1 function exceeds thresholds**"), "{out}");
3937 assert!(out.contains("The baseline accepts 1. 0 are new."), "{out}");
3938 assert!(!out.contains("--complexity"), "{out}");
3939 }
3940
3941 #[test]
3944 fn dead_code_categories_match_counted_result_metadata() {
3945 let native: BTreeSet<(&str, &str, &str)> = DEAD_CODE_CATEGORIES.iter().copied().collect();
3946 assert_eq!(
3947 native.len(),
3948 DEAD_CODE_CATEGORIES.len(),
3949 "DEAD_CODE_CATEGORIES has a duplicate row"
3950 );
3951 let registry: BTreeSet<(&str, &str, &str)> = counted_result_issue_metas()
3952 .map(|meta| (meta.summary_label, meta.result_key, meta.docs_anchor))
3953 .collect();
3954 let missing: Vec<_> = registry.difference(&native).collect();
3955 let extra: Vec<_> = native.difference(®istry).collect();
3956 assert!(
3957 missing.is_empty() && extra.is_empty(),
3958 "DEAD_CODE_CATEGORIES must match the counted result metadata \
3959 (label, result key, docs anchor).\nMissing rows: {missing:?}\nRows not in the registry: {extra:?}"
3960 );
3961 }
3962
3963 #[test]
3967 fn pathless_rows_render_in_one_order_for_any_input_order() {
3968 let envelope = |names: &[String]| {
3969 serde_json::json!({
3970 "total_issues": names.len(),
3971 "elapsed_ms": 1,
3972 "unlisted_dependencies": names
3973 .iter()
3974 .map(|name| serde_json::json!({ "package_name": name, "imported_from": [] }))
3975 .collect::<Vec<_>>(),
3976 })
3977 };
3978 let names: Vec<String> = (0..40).map(|index| format!("pkg-{index:02}")).collect();
3979 let mut reversed = names.clone();
3980 reversed.reverse();
3981 let ordered = super::render_check_summary(&envelope(&names));
3982 assert_eq!(super::render_check_summary(&envelope(&reversed)), ordered);
3983 let first = ordered.find("pkg-00").expect("first row");
3984 let second = ordered.find("pkg-01").expect("second row");
3985 assert!(
3986 first < second,
3987 "rows follow the serialized order: {ordered}"
3988 );
3989 assert!(
3990 !ordered.contains("pkg-39"),
3991 "rows past the limit are counted only"
3992 );
3993 assert!(
3994 ordered.contains("15 more"),
3995 "the tail counts the rest: {ordered}"
3996 );
3997 }
3998
3999 #[test]
4002 fn dead_code_rows_render_in_one_order_for_any_input_order() {
4003 let envelope = |paths: [&str; 3]| {
4004 serde_json::json!({
4005 "total_issues": 3,
4006 "elapsed_ms": 1,
4007 "unused_files": paths.map(|path| serde_json::json!({ "path": path })),
4008 })
4009 };
4010 let flat = super::render_check_summary(&envelope(["src/a.ts", "src/b.ts", "src/c.ts"]));
4011 let grouped = super::render_check_summary(&envelope(["src/a.ts", "src/c.ts", "src/b.ts"]));
4012 assert_eq!(grouped, flat);
4013 let a = flat.find("`src/a.ts`").expect("a");
4014 let b = flat.find("`src/b.ts`").expect("b");
4015 let c = flat.find("`src/c.ts`").expect("c");
4016 assert!(a < b && b < c, "rows follow path order: {flat}");
4017 }
4018}