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: "`pnpm.overrides` 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: "`pnpm.overrides` entries with an unparsable key or empty value. `pnpm install` will reject 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_model_note(complexity: &Value) -> String {
2033 let summary = complexity.get("summary").cloned().unwrap_or(Value::Null);
2034 let model = summary.get("coverage_model").and_then(Value::as_str);
2035 match model {
2036 Some("istanbul") => {
2037 let matched = summary.get("istanbul_matched").and_then(Value::as_u64);
2038 let total = summary.get("istanbul_total").and_then(Value::as_u64);
2039 match (matched, total) {
2040 (Some(matched), Some(total)) if total > 0 => {
2041 let suffix = if matched * 100 / total < 50 {
2042 ". Low match rate; check `--coverage-root` is correct for this checkout."
2043 } else {
2044 "."
2045 };
2046 format!(
2047 "\n\n*Coverage model: istanbul. Matched {matched}/{total} functions{suffix}*"
2048 )
2049 }
2050 _ => "\n\n*Coverage model: istanbul (exact, from `--coverage`).*".to_owned(),
2051 }
2052 }
2053 Some("static_estimated" | "static_binary") => {
2054 "\n\n*Coverage model: static (estimated). Pair with `--coverage <coverage-final.json>` for measured coverage instead of estimates.*".to_owned()
2055 }
2056 _ => String::new(),
2057 }
2058}
2059
2060fn audit_duplication_section(env: &Value) -> String {
2061 let duplication = env.get("duplication").cloned().unwrap_or(Value::Null);
2062 let groups: Vec<&Value> = arr(&duplication, "clone_groups").collect();
2063 if groups.is_empty() {
2064 return String::new();
2065 }
2066 let rows = groups
2067 .iter()
2068 .take(10)
2069 .map(|group| {
2070 let instances: Vec<&Value> = arr(group, "instances").collect();
2071 let location = instances.first().map_or_else(
2072 || "-".to_owned(),
2073 |first| {
2074 let file = s(first, "file");
2075 if file.is_empty() {
2076 "-".to_owned()
2077 } else {
2078 let start = first
2079 .get("start_line")
2080 .filter(|line| !line.is_null())
2081 .map_or_else(|| "1".to_owned(), fmt_num);
2082 markdown_table_code_span(&format!(
2083 "{}:{start}",
2084 rel_path_absolute_only(file)
2085 ))
2086 }
2087 },
2088 );
2089 let mut files: Vec<String> = instances
2090 .iter()
2091 .map(|instance| {
2092 markdown_table_code_span(&rel_path_absolute_only(s(instance, "file")))
2093 })
2094 .collect();
2095 files.sort();
2096 files.dedup();
2097 let files = files.into_iter().take(3).collect::<Vec<_>>().join(", ");
2098 format!(
2099 "| {location} | {files} | {} lines / {} tokens | {} | {} |",
2100 num(group, "line_count"),
2101 num(group, "token_count"),
2102 instances.len(),
2103 introduced_label(group),
2104 )
2105 })
2106 .collect::<Vec<_>>()
2107 .join("\n");
2108 let tail = if groups.len() > 10 {
2109 format!(
2110 "\n\n> {} more clone groups in the full audit report",
2111 groups.len() - 10
2112 )
2113 } else {
2114 String::new()
2115 };
2116 format!(
2117 "### Duplication\n\n| Location | Files | Size | Instances | Status |\n|:---------|:------|:-----|----------:|:-------|\n{rows}{tail}\n\n"
2118 )
2119}
2120
2121#[must_use]
2123fn render_audit_summary(env: &Value) -> String {
2124 let verdict = str_or(env, "verdict", "pass");
2125 let summary = env.get("summary").cloned().unwrap_or(Value::Null);
2126 let attribution = env.get("attribution").cloned().unwrap_or(Value::Null);
2127 let dead_code = env.get("dead_code").cloned().unwrap_or(Value::Null);
2128 let dead_rows = audit_dead_code_rows(&dead_code);
2129
2130 let mut out = format!(
2131 "## 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",
2132 audit_verdict_label(verdict),
2133 plural_n(u(env, "changed_files_count") as usize, "changed file"),
2134 num(env, "elapsed_ms"),
2135 num(&summary, "dead_code_issues"),
2136 num(&attribution, "dead_code_introduced"),
2137 num(&attribution, "dead_code_inherited"),
2138 num(&summary, "complexity_findings"),
2139 num(&attribution, "complexity_introduced"),
2140 num(&attribution, "complexity_inherited"),
2141 num(&summary, "duplication_clone_groups"),
2142 num(&attribution, "duplication_introduced"),
2143 num(&attribution, "duplication_inherited"),
2144 );
2145 if !dead_rows.is_empty() {
2146 let rows = dead_rows
2147 .iter()
2148 .take(10)
2149 .map(|row| {
2150 format!(
2151 "| {} | {} | {} | {} |",
2152 row.kind, row.location, row.item, row.status
2153 )
2154 })
2155 .collect::<Vec<_>>()
2156 .join("\n");
2157 let tail = if dead_rows.len() > 10 {
2158 format!(
2159 "\n\n> {} more dead-code findings in the full audit report",
2160 dead_rows.len() - 10
2161 )
2162 } else {
2163 String::new()
2164 };
2165 let _ = write!(
2166 out,
2167 "### Dead Code\n\n| Type | Location | Item | Status |\n|:-----|:---------|:-----|:-------|\n{rows}{tail}\n\n"
2168 );
2169 }
2170 out.push_str(&audit_complexity_section(env));
2171 out.push_str(&audit_duplication_section(env));
2172 out.push_str(if s(&attribution, "gate") == "all" {
2173 "*Audit gate: all. Every finding in changed files affects the verdict.*"
2174 } else {
2175 "*Audit gate: new-only. Inherited findings are reported but do not fail the verdict.*"
2176 });
2177 out
2178}
2179
2180#[must_use]
2186fn render_security_summary(env: &Value) -> String {
2187 let findings: Vec<&Value> = arr(env, "security_findings").collect();
2188 let gate = env.get("gate").filter(|gate| !gate.is_null());
2189 let count = gate.map_or_else(
2190 || {
2191 env.get("summary")
2192 .and_then(|summary| summary.get("security_findings"))
2193 .and_then(Value::as_u64)
2194 .unwrap_or(findings.len() as u64) as usize
2195 },
2196 |gate| u(gate, "new_count") as usize,
2197 );
2198 let mut out = String::from("## Fallow Security\n\n");
2199 if count == 0 {
2200 let _ = write!(
2201 out,
2202 "> [!NOTE]\n> **No security candidates matched** \u{b7} {}ms",
2203 num(env, "elapsed_ms"),
2204 );
2205 } else {
2206 let _ = write!(
2207 out,
2208 "> [!WARNING]\n> **{} matched** \u{b7} {}ms",
2209 plural_n(count, "security candidate"),
2210 num(env, "elapsed_ms"),
2211 );
2212 }
2213 if let Some(gate) = gate {
2214 let _ = write!(
2215 out,
2216 "\n\nSecurity gate: `{}`, verdict: `{}`, matching candidates: **{}**.",
2217 s(gate, "mode"),
2218 s(gate, "verdict"),
2219 num(gate, "new_count"),
2220 );
2221 }
2222 if !findings.is_empty() {
2223 let rows = findings
2224 .iter()
2225 .take(15)
2226 .map(|finding| {
2227 format!(
2228 "| {} | {} | {} | {} |",
2229 path_line(finding),
2230 markdown_table_text(s(finding, "kind")),
2231 markdown_table_text(str_or(finding, "severity", "unknown")),
2232 markdown_table_code_span(
2233 finding
2234 .get("candidate")
2235 .and_then(|candidate| candidate.get("sink"))
2236 .and_then(|sink| sink.get("callee"))
2237 .and_then(Value::as_str)
2238 .unwrap_or("-")
2239 ),
2240 )
2241 })
2242 .collect::<Vec<_>>()
2243 .join("\n");
2244 let _ = write!(
2245 out,
2246 "\n\n| Location | Kind | Severity | Sink |\n|:---------|:-----|:---------|:-----|\n{rows}"
2247 );
2248 if findings.len() > 15 {
2249 let _ = write!(
2250 out,
2251 "\n\n> {} more candidates in the full report",
2252 findings.len() - 15,
2253 );
2254 }
2255 }
2256 out.push_str("\n\nTreat these as candidates for verification, not confirmed vulnerabilities.");
2257 out
2258}
2259
2260fn fix_entries<'v>(env: &'v Value, entry_type: &str) -> Vec<&'v Value> {
2265 arr(env, "fixes")
2266 .filter(|fix| s(fix, "type") == entry_type)
2267 .collect()
2268}
2269
2270fn fix_detail_block(label: &str, entries: &[&Value], row: impl Fn(&Value) -> String) -> String {
2271 let rows = entries
2272 .iter()
2273 .take(25)
2274 .map(|entry| row(entry))
2275 .collect::<Vec<_>>()
2276 .join("\n");
2277 let tail = if entries.len() > 25 {
2278 format!("\n- *... and {} more*", entries.len() - 25)
2279 } else {
2280 String::new()
2281 };
2282 format!("**{label} ({})**\n{rows}{tail}", entries.len())
2283}
2284
2285#[must_use]
2287pub fn render_fix_summary(env: &Value) -> String {
2288 let exports = fix_entries(env, "remove_export");
2289 let dependencies = fix_entries(env, "remove_dependency");
2290 let fix_attempts = arr(env, "fixes").filter(|fix| !b(fix, "skipped")).count();
2291 let content_changed = u(env, "skipped_content_changed") as usize;
2292 let mixed_eol = u(env, "skipped_mixed_line_endings") as usize;
2293 let low_confidence = u(env, "skipped_low_confidence_exports") as usize;
2294 let dry_run = b(env, "dry_run");
2295
2296 if fix_attempts == 0 && content_changed == 0 && mixed_eol == 0 && low_confidence == 0 {
2297 return "## Fallow - Auto-fix\n\nNo fixable issues found.".to_owned();
2298 }
2299
2300 let mut out = String::from("## Fallow - Auto-fix\n\n");
2301 out.push_str(if dry_run {
2302 "**Dry run**: would apply"
2303 } else {
2304 "Applied"
2305 });
2306 let fix_noun = if fix_attempts == 1 { "fix" } else { "fixes" };
2307 let _ = write!(out, " **{fix_attempts} {fix_noun}**");
2308 if !dry_run {
2309 let _ = write!(out, " ({} succeeded)", num(env, "total_fixed"));
2310 }
2311 if content_changed > 0 {
2312 let _ = write!(
2313 out,
2314 ", skipped {content_changed} file(s) that changed since analysis"
2315 );
2316 }
2317 if mixed_eol > 0 {
2318 let _ = write!(out, ", skipped {mixed_eol} file(s) with mixed line endings");
2319 }
2320 if low_confidence > 0 {
2321 let _ = write!(
2322 out,
2323 ", kept exports in {low_confidence} file(s) where consumers may be hidden from static analysis"
2324 );
2325 }
2326 out.push_str("\n\n| Type | Count |\n|------|-------|\n");
2327 if !exports.is_empty() {
2328 let _ = writeln!(out, "| Export removals | {} |", exports.len());
2329 }
2330 if !dependencies.is_empty() {
2331 let _ = writeln!(out, "| Dependency removals | {} |", dependencies.len());
2332 }
2333 out.push_str("\n<details>\n<summary>View details</summary>\n\n");
2334 if !exports.is_empty() {
2335 out.push_str(&fix_detail_block("Export removals", &exports, |it| {
2336 format!(
2337 "- {} - {}",
2338 markdown_code_span(&format!("{}:{}", s(it, "path"), num(it, "line"))),
2339 markdown_code_span(s(it, "name"))
2340 )
2341 }));
2342 out.push_str("\n\n");
2343 }
2344 if !dependencies.is_empty() {
2345 out.push_str(&fix_detail_block(
2346 "Dependency removals",
2347 &dependencies,
2348 |it| {
2349 format!(
2350 "- {} from {} in {}",
2351 markdown_code_span(s(it, "package")),
2352 markdown_code_span(s(it, "location")),
2353 markdown_code_span(s(it, "file")),
2354 )
2355 },
2356 ));
2357 out.push('\n');
2358 }
2359 out.push_str("\n\n</details>");
2360 out
2361}
2362
2363fn file_link(links: &LinkContext, path: &str, start: &str, end: &str) -> String {
2368 let display = markdown_table_code_span(&format!("{}:{start}-{end}", last_three_segments(path)));
2369 if links.repo.is_empty() || links.sha.is_empty() {
2370 display
2371 } else {
2372 format!(
2373 "[{display}](https://github.com/{}/blob/{}/{}{}#L{start}-L{end})",
2374 links.repo,
2375 links.sha,
2376 links.prefix,
2377 encode_link_path(path),
2378 )
2379 }
2380}
2381
2382fn encode_link_path(path: &str) -> String {
2385 path.replace('%', "%25")
2386 .replace(' ', "%20")
2387 .replace('(', "%28")
2388 .replace(')', "%29")
2389 .replace('<', "%3C")
2390 .replace('>', "%3E")
2391 .replace('|', "%7C")
2392}
2393
2394fn exceeded_priority(it: &Value) -> u8 {
2395 match s(it, "exceeded") {
2396 "all" => 5,
2397 "cyclomatic_crap" | "cognitive_crap" => 4,
2398 "crap" => 3,
2399 "both" => 2,
2400 "cyclomatic" | "cognitive" => 1,
2401 _ => 0,
2402 }
2403}
2404
2405fn severity_priority(it: &Value) -> u8 {
2406 match s(it, "severity") {
2407 "critical" => 3,
2408 "high" => 2,
2409 "moderate" => 1,
2410 _ => 0,
2411 }
2412}
2413
2414fn ranked_health_findings(health: &Value) -> Vec<&Value> {
2417 let mut findings: Vec<&Value> = arr(health, "findings").collect();
2418 findings.sort_by_key(|it| {
2419 (
2420 exceeded_priority(it),
2421 severity_priority(it),
2422 it.get("crap").is_some_and(|crap| !crap.is_null()),
2423 u(it, "cyclomatic"),
2424 u(it, "cognitive"),
2425 u(it, "line_count"),
2426 )
2427 });
2428 findings.reverse();
2429 findings
2430}
2431
2432const PROD_FAILING_VERDICTS: &[&str] = &["safe_to_delete", "review_required", "low_traffic"];
2433
2434struct CombinedCounts {
2435 check: usize,
2436 dupes: usize,
2437 complex: usize,
2438 prod_failing: usize,
2439 prod_advisory: usize,
2440 hot_paths: usize,
2441}
2442
2443impl CombinedCounts {
2444 fn health(&self) -> usize {
2445 self.complex + self.prod_failing
2446 }
2447
2448 fn total(&self) -> usize {
2449 self.check + self.dupes + self.health()
2450 }
2451}
2452
2453fn combined_counts(env: &Value) -> CombinedCounts {
2454 let check = env
2455 .get("check")
2456 .map_or(0, |check| u(check, "total_issues") as usize);
2457 let dupes = env
2458 .get("dupes")
2459 .map_or(0, |dupes| arr(dupes, "clone_groups").count());
2460 let health = env.get("health").cloned().unwrap_or(Value::Null);
2461 let complex = health.get("summary").map_or(0, |summary| {
2462 u(summary, "functions_above_threshold") as usize
2463 });
2464 let runtime = health
2465 .get("runtime_coverage")
2466 .cloned()
2467 .unwrap_or(Value::Null);
2468 let prod_failing = arr(&runtime, "findings")
2469 .filter(|finding| PROD_FAILING_VERDICTS.contains(&s(finding, "verdict")))
2470 .count();
2471 let prod_advisory = arr(&runtime, "findings")
2472 .filter(|finding| !PROD_FAILING_VERDICTS.contains(&s(finding, "verdict")))
2473 .count();
2474 let hot_paths = arr(&runtime, "hot_paths").count();
2475 CombinedCounts {
2476 check,
2477 dupes,
2478 complex,
2479 prod_failing,
2480 prod_advisory,
2481 hot_paths,
2482 }
2483}
2484
2485fn hot_path_label(env: &Value, n: usize) -> String {
2486 let touched = env
2487 .get("health")
2488 .and_then(|health| health.get("runtime_coverage"))
2489 .is_some_and(|runtime| s(runtime, "verdict") == "hot-path-touched");
2490 let plural = if n == 1 { "" } else { "s" };
2491 if touched {
2492 format!("hot path{plural} touched")
2493 } else {
2494 format!("hot path{plural}")
2495 }
2496}
2497
2498fn combined_zero_case(env: &Value, counts: &CombinedCounts) -> String {
2499 let vitals = env
2500 .get("health")
2501 .and_then(|health| health.get("vital_signs"))
2502 .cloned()
2503 .unwrap_or(Value::Null);
2504 let mut out = String::from("# \u{1F33F} Fallow\n\n");
2505 if counts.prod_advisory > 0 || counts.hot_paths > 0 {
2506 out.push_str(
2507 "> [!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",
2508 );
2509 if counts.prod_advisory > 0 {
2510 let _ = write!(
2511 out,
2512 " \u{b7} :information_source: **{}** runtime coverage advisory finding{}",
2513 counts.prod_advisory,
2514 if counts.prod_advisory == 1 { "" } else { "s" },
2515 );
2516 }
2517 if counts.hot_paths > 0 {
2518 let _ = write!(
2519 out,
2520 " \u{b7} :eyes: **{}** {}",
2521 counts.hot_paths,
2522 hot_path_label(env, counts.hot_paths),
2523 );
2524 }
2525 } else {
2526 out.push_str(
2527 "> [!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",
2528 );
2529 }
2530 if let Some(maintainability) = opt_f(&vitals, "maintainability_avg") {
2531 let _ = write!(
2532 out,
2533 "\n\n| Metric | Value |\n|:-------|------:|\n| [Maintainability]({HEALTH_DOCS}#maintainability-index-mi) | **{}** / 100 |\n",
2534 pct(maintainability),
2535 );
2536 }
2537 out
2538}
2539
2540fn combined_status_line(env: &Value, counts: &CombinedCounts) -> String {
2541 let mut out = String::new();
2542 if counts.check > 0 {
2543 let _ = write!(
2544 out,
2545 ":warning: **{}** code {}",
2546 counts.check,
2547 if counts.check == 1 { "issue" } else { "issues" },
2548 );
2549 } else {
2550 out.push_str(":white_check_mark: No code issues");
2551 }
2552 out.push_str(" \u{b7} ");
2553 if counts.dupes > 0 {
2554 let _ = write!(
2555 out,
2556 ":warning: **{}** clone {}",
2557 counts.dupes,
2558 if counts.dupes == 1 { "group" } else { "groups" },
2559 );
2560 } else {
2561 out.push_str(":white_check_mark: No duplication");
2562 }
2563 out.push_str(" \u{b7} ");
2564 let health = counts.health();
2565 if health > 0 {
2566 let _ = write!(
2567 out,
2568 ":warning: **{health}** health {}",
2569 if health == 1 { "finding" } else { "findings" },
2570 );
2571 } else {
2572 out.push_str(":white_check_mark: No blocking health findings");
2573 }
2574 if counts.prod_advisory > 0 {
2575 let _ = write!(
2576 out,
2577 " \u{b7} :information_source: **{}** coverage advisory finding{}",
2578 counts.prod_advisory,
2579 if counts.prod_advisory == 1 { "" } else { "s" },
2580 );
2581 }
2582 if counts.hot_paths > 0 {
2583 let _ = write!(
2584 out,
2585 " \u{b7} :eyes: **{}** {}",
2586 counts.hot_paths,
2587 hot_path_label(env, counts.hot_paths),
2588 );
2589 }
2590 out.push_str("\n\n");
2591 out
2592}
2593
2594fn combined_check_breakdown(env: &Value, counts: &CombinedCounts) -> String {
2595 if counts.check == 0 {
2596 return String::new();
2597 }
2598 let check = env.get("check").cloned().unwrap_or(Value::Null);
2599 format!(
2600 "<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",
2601 counts.check,
2602 dead_code_category_table(&check),
2603 )
2604}
2605
2606fn combined_dupes_breakdown(env: &Value, counts: &CombinedCounts, links: &LinkContext) -> String {
2607 if counts.dupes == 0 {
2608 return String::new();
2609 }
2610 let dupes = env.get("dupes").cloned().unwrap_or(Value::Null);
2611 let stats = dupes.get("stats").cloned().unwrap_or(Value::Null);
2612 let groups = sorted_clone_groups(&dupes);
2613 let files_with_clones = u(&stats, "files_with_clones") as usize;
2614 let rows = groups
2615 .iter()
2616 .take(5)
2617 .map(|group| {
2618 let locations = arr(group, "instances")
2619 .map(|instance| {
2620 file_link(
2621 links,
2622 s(instance, "file"),
2623 &num(instance, "start_line"),
2624 &num(instance, "end_line"),
2625 )
2626 })
2627 .collect::<Vec<_>>()
2628 .join("<br>");
2629 format!(
2630 "| {locations} | {} | {} |",
2631 num(group, "line_count"),
2632 num(group, "token_count"),
2633 )
2634 })
2635 .collect::<Vec<_>>()
2636 .join("\n");
2637 let tail = if counts.dupes > 5 {
2638 format!("\n\n*\u{2026} and {} more groups.*", counts.dupes - 5)
2639 } else {
2640 String::new()
2641 };
2642 format!(
2643 "<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",
2644 counts.dupes,
2645 if counts.dupes == 1 { "group" } else { "groups" },
2646 num(&stats, "duplicated_lines"),
2647 pct(f_or_zero(&stats, "duplication_percentage")),
2648 if files_with_clones == 1 {
2649 "file"
2650 } else {
2651 "files"
2652 },
2653 )
2654}
2655
2656fn combined_complexity_breakdown(env: &Value, counts: &CombinedCounts) -> String {
2657 if counts.complex == 0 {
2658 return String::new();
2659 }
2660 let health = env.get("health").cloned().unwrap_or(Value::Null);
2661 let summary = health.get("summary").cloned().unwrap_or(Value::Null);
2662 let findings = ranked_health_findings(&health);
2663 let show_crap = summary
2664 .get("max_crap_threshold")
2665 .is_some_and(|threshold| !threshold.is_null())
2666 || findings
2667 .iter()
2668 .any(|finding| finding.get("crap").is_some_and(|crap| !crap.is_null()));
2669 let cyc_t = threshold_or(&summary, "max_cyclomatic_threshold", "default");
2670 let cog_t = threshold_or(&summary, "max_cognitive_threshold", "default");
2671 let crap_t = threshold_or(&summary, "max_crap_threshold", "default");
2672 let crap_header = if show_crap {
2673 format!(" | [CRAP]({HEALTH_DOCS}#crap-score)")
2674 } else {
2675 String::new()
2676 };
2677 let crap_separator = if show_crap { "|-----:" } else { "" };
2678 let rows = findings
2679 .iter()
2680 .take(5)
2681 .map(|it| {
2682 let crap_column = if show_crap {
2683 format!(" | {}", crap_cell(it))
2684 } else {
2685 String::new()
2686 };
2687 format!(
2688 "| {} | {} | {} | {}{} | {}{}{crap_column} | {} |",
2689 markdown_table_code_span(&format!(
2690 "{}:{}",
2691 last_three_segments(s(it, "path")),
2692 num(it, "line")
2693 )),
2694 code_cell(it, "name"),
2695 markdown_table_text(str_or(it, "severity", "moderate")),
2696 num(it, "cyclomatic"),
2697 exceeded_marker(it, &["cyclomatic", "both", "all"]),
2698 num(it, "cognitive"),
2699 exceeded_marker(it, &["cognitive", "both", "all"]),
2700 num(it, "line_count"),
2701 )
2702 })
2703 .collect::<Vec<_>>()
2704 .join("\n");
2705 let crap_footer = if show_crap {
2706 format!(", CRAP >= {crap_t}")
2707 } else {
2708 String::new()
2709 };
2710 format!(
2711 "<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",
2712 counts.complex,
2713 if counts.complex == 1 {
2714 "function"
2715 } else {
2716 "functions"
2717 },
2718 threshold_or(&summary, "files_analyzed", "unknown"),
2719 threshold_or(&summary, "functions_analyzed", "unknown"),
2720 )
2721}
2722
2723fn combined_runtime_breakdown(env: &Value, counts: &CombinedCounts) -> String {
2724 let prod_total = counts.prod_failing + counts.prod_advisory;
2725 if prod_total == 0 && counts.hot_paths == 0 {
2726 return String::new();
2727 }
2728 let runtime = env
2729 .get("health")
2730 .and_then(|health| health.get("runtime_coverage"))
2731 .cloned()
2732 .unwrap_or(Value::Null);
2733 let hot_suffix = if counts.hot_paths > 0 {
2734 format!(
2735 ", {} {}",
2736 counts.hot_paths,
2737 hot_path_label(env, counts.hot_paths)
2738 )
2739 } else {
2740 String::new()
2741 };
2742 let mut out = format!(
2743 "<details>\n<summary><strong><a href=\"{HEALTH_DOCS}#runtime-coverage\">Runtime coverage</a> ({prod_total} finding{}{hot_suffix})</strong></summary>\n\n",
2744 if prod_total == 1 { "" } else { "s" },
2745 );
2746 if prod_total > 0 {
2747 out.push_str("| File | Function | Verdict | Invocations | Confidence |\n|:-----|:---------|:--------|------------:|:-----------|\n");
2748 out.push_str(
2749 &arr(&runtime, "findings")
2750 .take(5)
2751 .map(|it| {
2752 let invocations = it
2753 .get("invocations")
2754 .filter(|value| !value.is_null())
2755 .map_or_else(|| "-".to_owned(), fmt_num);
2756 format!(
2757 "| {} | {} | {} | {invocations} | {} |",
2758 markdown_table_code_span(&format!(
2759 "{}:{}",
2760 last_three_segments(s(it, "path")),
2761 num(it, "line")
2762 )),
2763 code_cell(it, "function"),
2764 code_cell(it, "verdict"),
2765 markdown_table_text(s(it, "confidence")),
2766 )
2767 })
2768 .collect::<Vec<_>>()
2769 .join("\n"),
2770 );
2771 if counts.hot_paths > 0 {
2772 out.push_str("\n\n");
2773 }
2774 }
2775 if counts.hot_paths > 0 {
2776 out.push_str("| File | Function | Invocations | Percentile |\n|:-----|:---------|------------:|-----------:|\n");
2777 out.push_str(
2778 &arr(&runtime, "hot_paths")
2779 .take(5)
2780 .map(|it| {
2781 format!(
2782 "| {} | {} | {} | {} |",
2783 markdown_table_code_span(&format!(
2784 "{}:{}",
2785 last_three_segments(s(it, "path")),
2786 num(it, "line")
2787 )),
2788 code_cell(it, "function"),
2789 num(it, "invocations"),
2790 num(it, "percentile"),
2791 )
2792 })
2793 .collect::<Vec<_>>()
2794 .join("\n"),
2795 );
2796 out.push_str("\n\n");
2797 }
2798 out.push_str("</details>\n\n");
2799 out
2800}
2801
2802fn combined_vitals(env: &Value) -> String {
2803 let health = env.get("health").cloned().unwrap_or(Value::Null);
2804 let vitals = health.get("vital_signs").cloned().unwrap_or(Value::Null);
2805 let has_vitals = vitals.as_object().is_some_and(|vitals| !vitals.is_empty());
2806 if !has_vitals {
2807 return String::new();
2808 }
2809 let scores: Vec<f64> = arr(&health, "file_scores")
2810 .filter_map(|score| opt_f(score, "maintainability_index"))
2811 .collect();
2812 let scoped_maintainability = if scores.is_empty() {
2813 None
2814 } else {
2815 let avg = scores.iter().sum::<f64>() / scores.len() as f64;
2816 Some((avg * 10.0).round() / 10.0)
2817 };
2818 let mut out = format!(
2819 "#### [Codebase health]({HEALTH_DOCS})\n\n| Metric | Value |\n|:-------|------:|\n"
2820 );
2821 let maintainability = opt_f(&vitals, "maintainability_avg");
2822 if let Some(avg) = maintainability {
2823 let _ = writeln!(
2824 out,
2825 "| [Maintainability]({HEALTH_DOCS}#maintainability-index-mi) | **{}** / 100 |",
2826 pct(avg),
2827 );
2828 }
2829 if let Some(scoped) = scoped_maintainability {
2830 let rounded_avg = (maintainability.unwrap_or_default() * 10.0).round() / 10.0;
2831 if (scoped - rounded_avg).abs() > f64::EPSILON {
2832 let _ = writeln!(
2833 out,
2834 "| [Maintainability]({HEALTH_DOCS}#maintainability-index-mi) (changed files) | **{}** / 100 |",
2835 fmt_num(&serde_json::json!(scoped)),
2836 );
2837 }
2838 }
2839 if let Some(avg_cyclomatic) = opt_f(&vitals, "avg_cyclomatic") {
2840 let _ = writeln!(
2841 out,
2842 "| [Avg complexity]({HEALTH_DOCS}#cyclomatic-complexity) | {} |",
2843 pct(avg_cyclomatic),
2844 );
2845 }
2846 out.push('\n');
2847 out
2848}
2849
2850fn combined_tips(env: &Value) -> String {
2851 let check = env.get("check").cloned().unwrap_or(Value::Null);
2852 let fixable = arr(&check, "unused_exports").count()
2853 + arr(&check, "unused_dependencies").count()
2854 + arr(&check, "unused_enum_members").count();
2855 if fixable == 0 {
2856 return String::new();
2857 }
2858 let mut out = String::from("> [!TIP]\n> Run `fallow fix --dry-run` to preview auto-fixes.\n");
2859 if arr(&check, "unused_exports").count() > 0 {
2860 let _ = writeln!(
2861 out,
2862 "> Add [`/** @public */`]({SUPPRESSION_DOCS}) above exports to preserve them."
2863 );
2864 }
2865 out
2866}
2867
2868#[must_use]
2870fn render_combined_summary(env: &Value, links: &LinkContext) -> String {
2871 let counts = combined_counts(env);
2872 let header = health_score_header(&env.get("health").cloned().unwrap_or(Value::Null));
2873 if counts.total() == 0 {
2874 return format!("{header}{}", combined_zero_case(env, &counts));
2875 }
2876 let pointer = if counts.check > 0 || counts.dupes > 0 || counts.health() > 0 {
2877 "See inline review comments for per-finding details.\n\n"
2878 } else {
2879 ""
2880 };
2881 format!(
2882 "{header}# \u{1F33F} Fallow\n\n> [!WARNING]\n> **Review needed**\n\n{}{pointer}{}{}{}{}{}{}",
2883 combined_status_line(env, &counts),
2884 combined_check_breakdown(env, &counts),
2885 combined_dupes_breakdown(env, &counts, links),
2886 combined_complexity_breakdown(env, &counts),
2887 combined_runtime_breakdown(env, &counts),
2888 combined_vitals(env),
2889 combined_tips(env),
2890 )
2891}