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