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