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