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