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