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