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