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