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