1use std::borrow::Cow;
2use std::fmt::Write;
3use std::path::Path;
4
5use fallow_engine::duplicates::DuplicationReport;
6use fallow_types::output_dead_code::*;
7use fallow_types::results::{AnalysisResults, UnusedExport, UnusedMember};
8
9use fallow_output::normalize_uri;
10
11use crate::ResultGroup;
12
13fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
14 path.strip_prefix(root).unwrap_or(path)
15}
16
17fn plural(count: usize) -> &'static str {
18 if count == 1 { "" } else { "s" }
19}
20
21fn format_window(seconds: u64) -> String {
22 if seconds < 60 {
23 return format!("{seconds} s");
24 }
25 let minutes = seconds / 60;
26 if minutes < 120 {
27 return format!("{minutes} min");
28 }
29 let hours = minutes / 60;
30 if hours < 48 {
31 format!("{hours} h")
32 } else {
33 format!("{} d", hours / 24)
34 }
35}
36
37fn escape_backticks(s: &str) -> String {
39 s.replace('`', "\\`")
40}
41
42fn display_complexity_entry_name(name: &str) -> Cow<'_, str> {
43 match name {
44 "<template>" => Cow::Borrowed("<template> (template complexity)"),
45 "<component>" => Cow::Borrowed("<component> (component rollup)"),
46 _ => Cow::Borrowed(name),
47 }
48}
49
50pub fn build_markdown(results: &AnalysisResults, root: &Path) -> String {
52 let total = results.total_issues();
53 let mut out = String::new();
54
55 if total == 0 {
56 out.push_str("## Fallow: no issues found\n");
57 return out;
58 }
59
60 let _ = write!(out, "## Fallow: {total} issue{} found\n\n", plural(total));
61
62 push_markdown_primary_sections(&mut out, results, root);
63 push_markdown_import_sections(&mut out, results, root);
64 push_markdown_dependency_detail_sections(&mut out, results, root);
65 push_markdown_graph_sections(&mut out, results, &|path| {
66 markdown_relative_path(path, root)
67 });
68 push_markdown_catalog_sections(&mut out, results, &|path| {
69 markdown_relative_path(path, root)
70 });
71
72 out
73}
74
75fn markdown_relative_path(path: &Path, root: &Path) -> String {
76 escape_backticks(&normalize_uri(
77 &relative_path(path, root).display().to_string(),
78 ))
79}
80
81fn push_markdown_primary_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
82 markdown_section(out, &results.unused_files, "Unused files", |file| {
83 vec![format!(
84 "- `{}`",
85 markdown_relative_path(&file.file.path, root)
86 )]
87 });
88
89 markdown_grouped_section(
90 out,
91 &results.unused_exports,
92 "Unused exports",
93 root,
94 |e| e.export.path.as_path(),
95 |e: &UnusedExportFinding| format_export(&e.export),
96 );
97
98 markdown_grouped_section(
99 out,
100 &results.unused_types,
101 "Unused type exports",
102 root,
103 |e| e.export.path.as_path(),
104 |e: &UnusedTypeFinding| format_export(&e.export),
105 );
106
107 markdown_grouped_section(
108 out,
109 &results.private_type_leaks,
110 "Private type leaks",
111 root,
112 |e| e.leak.path.as_path(),
113 format_private_type_leak,
114 );
115
116 push_markdown_dependency_sections(out, results, root);
117 push_markdown_member_sections(out, results, root);
118}
119
120fn push_markdown_import_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
121 markdown_grouped_section(
122 out,
123 &results.unresolved_imports,
124 "Unresolved imports",
125 root,
126 |i| i.import.path.as_path(),
127 |i| {
128 format!(
129 ":{} `{}`",
130 i.import.line,
131 escape_backticks(&i.import.specifier)
132 )
133 },
134 );
135
136 markdown_section(
137 out,
138 &results.unlisted_dependencies,
139 "Unlisted dependencies",
140 |dep| vec![format!("- `{}`", escape_backticks(&dep.dep.package_name))],
141 );
142
143 markdown_section(
144 out,
145 &results.duplicate_exports,
146 "Duplicate exports",
147 |dup| {
148 let locations: Vec<String> = dup
149 .export
150 .locations
151 .iter()
152 .map(|loc| format!("`{}`", markdown_relative_path(&loc.path, root)))
153 .collect();
154 vec![format!(
155 "- `{}` in {}",
156 escape_backticks(&dup.export.export_name),
157 locations.join(", ")
158 )]
159 },
160 );
161}
162
163fn push_markdown_dependency_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
164 markdown_section(
165 out,
166 &results.unused_dependencies,
167 "Unused dependencies",
168 |dep| {
169 format_dependency(
170 &dep.dep.package_name,
171 &dep.dep.path,
172 &dep.dep.used_in_workspaces,
173 root,
174 )
175 },
176 );
177 markdown_section(
178 out,
179 &results.unused_dev_dependencies,
180 "Unused devDependencies",
181 |dep| {
182 format_dependency(
183 &dep.dep.package_name,
184 &dep.dep.path,
185 &dep.dep.used_in_workspaces,
186 root,
187 )
188 },
189 );
190 markdown_section(
191 out,
192 &results.unused_optional_dependencies,
193 "Unused optionalDependencies",
194 |dep| {
195 format_dependency(
196 &dep.dep.package_name,
197 &dep.dep.path,
198 &dep.dep.used_in_workspaces,
199 root,
200 )
201 },
202 );
203}
204
205fn push_markdown_member_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
206 markdown_grouped_section(
207 out,
208 &results.unused_enum_members,
209 "Unused enum members",
210 root,
211 |m| m.member.path.as_path(),
212 |m: &UnusedEnumMemberFinding| format_member(&m.member),
213 );
214 markdown_grouped_section(
215 out,
216 &results.unused_class_members,
217 "Unused class members",
218 root,
219 |m| m.member.path.as_path(),
220 |m: &UnusedClassMemberFinding| format_member(&m.member),
221 );
222 markdown_grouped_section(
223 out,
224 &results.unused_store_members,
225 "Unused store members",
226 root,
227 |m| m.member.path.as_path(),
228 |m: &UnusedStoreMemberFinding| format_member(&m.member),
229 );
230}
231
232fn push_markdown_dependency_detail_sections(
233 out: &mut String,
234 results: &AnalysisResults,
235 root: &Path,
236) {
237 markdown_section(
238 out,
239 &results.type_only_dependencies,
240 "Type-only dependencies (consider moving to devDependencies)",
241 |dep| format_dependency(&dep.dep.package_name, &dep.dep.path, &[], root),
242 );
243 markdown_section(
244 out,
245 &results.test_only_dependencies,
246 "Test-only production dependencies (consider moving to devDependencies)",
247 |dep| format_dependency(&dep.dep.package_name, &dep.dep.path, &[], root),
248 );
249}
250
251fn push_markdown_graph_sections(
252 out: &mut String,
253 results: &AnalysisResults,
254 rel: &dyn Fn(&Path) -> String,
255) {
256 push_markdown_structure_sections(out, results, rel);
257 push_markdown_framework_sections(out, results, rel);
258 push_markdown_component_sections(out, results, rel);
259 push_markdown_suppression_sections(out, results, rel);
260}
261
262fn push_markdown_structure_sections(
263 out: &mut String,
264 results: &AnalysisResults,
265 rel: &dyn Fn(&Path) -> String,
266) {
267 markdown_section(
268 out,
269 &results.circular_dependencies,
270 "Circular dependencies",
271 |cycle| format_markdown_circular_dependency(cycle, rel),
272 );
273 markdown_section(
274 out,
275 &results.re_export_cycles,
276 "Re-export cycles",
277 |cycle| format_markdown_re_export_cycle(cycle, rel),
278 );
279 markdown_section(
280 out,
281 &results.boundary_violations,
282 "Boundary violations",
283 |v| format_markdown_boundary_violation(v, rel),
284 );
285 markdown_section(
286 out,
287 &results.boundary_coverage_violations,
288 "Boundary coverage",
289 |v| format_markdown_boundary_coverage(v, rel),
290 );
291 markdown_section(
292 out,
293 &results.boundary_call_violations,
294 "Boundary calls",
295 |v| format_markdown_boundary_call(v, rel),
296 );
297 markdown_section(out, &results.policy_violations, "Policy violations", |v| {
298 format_markdown_policy_violation(v, rel)
299 });
300}
301
302fn push_markdown_framework_sections(
303 out: &mut String,
304 results: &AnalysisResults,
305 rel: &dyn Fn(&Path) -> String,
306) {
307 markdown_section(
308 out,
309 &results.invalid_client_exports,
310 "Invalid client exports",
311 |e| format_markdown_invalid_client_export(e, rel),
312 );
313 markdown_section(
314 out,
315 &results.mixed_client_server_barrels,
316 "Mixed client/server barrels",
317 |b| format_markdown_mixed_client_server_barrel(b, rel),
318 );
319 markdown_section(
320 out,
321 &results.misplaced_directives,
322 "Misplaced directives",
323 |d| format_markdown_misplaced_directive(d, rel),
324 );
325 markdown_section(out, &results.route_collisions, "Route collisions", |c| {
326 format_markdown_route_collision(c, rel)
327 });
328 markdown_section(
329 out,
330 &results.dynamic_segment_name_conflicts,
331 "Dynamic segment conflicts",
332 |c| format_markdown_dynamic_segment_name_conflict(c, rel),
333 );
334 markdown_section(
335 out,
336 &results.unprovided_injects,
337 "Unprovided injects",
338 |i| format_markdown_unprovided_inject(i, rel),
339 );
340}
341
342fn push_markdown_component_sections(
343 out: &mut String,
344 results: &AnalysisResults,
345 rel: &dyn Fn(&Path) -> String,
346) {
347 markdown_section(
348 out,
349 &results.unrendered_components,
350 "Unrendered components",
351 |c| format_markdown_unrendered_component(c, rel),
352 );
353 markdown_section(
354 out,
355 &results.unused_component_props,
356 "Unused component props",
357 |p| format_markdown_unused_component_prop(p, rel),
358 );
359 markdown_section(
360 out,
361 &results.unused_component_emits,
362 "Unused component emits",
363 |e| format_markdown_unused_component_emit(e, rel),
364 );
365 markdown_section(
366 out,
367 &results.unused_component_inputs,
368 "Unused component inputs",
369 |i| format_markdown_unused_component_input(i, rel),
370 );
371 markdown_section(
372 out,
373 &results.unused_component_outputs,
374 "Unused component outputs",
375 |o| format_markdown_unused_component_output(o, rel),
376 );
377 markdown_section(
378 out,
379 &results.unused_svelte_events,
380 "Unused Svelte events",
381 |e| format_markdown_unused_svelte_event(e, rel),
382 );
383 markdown_section(
384 out,
385 &results.unused_server_actions,
386 "Unused server actions",
387 |a| format_markdown_unused_server_action(a, rel),
388 );
389 markdown_section(
390 out,
391 &results.unused_load_data_keys,
392 "Unused load data keys",
393 |k| format_markdown_unused_load_data_key(k, rel),
394 );
395}
396
397fn push_markdown_suppression_sections(
398 out: &mut String,
399 results: &AnalysisResults,
400 rel: &dyn Fn(&Path) -> String,
401) {
402 markdown_section(
403 out,
404 &results.stale_suppressions,
405 "Stale suppressions",
406 |s| {
407 vec![format!(
408 "- `{}`:{} `{}` ({})",
409 rel(&s.path),
410 s.line,
411 escape_backticks(&s.description()),
412 escape_backticks(&s.explanation()),
413 )]
414 },
415 );
416}
417
418fn format_markdown_circular_dependency(
419 cycle: &fallow_types::output_dead_code::CircularDependencyFinding,
420 rel: &dyn Fn(&Path) -> String,
421) -> Vec<String> {
422 let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
423 let mut display_chain = chain.clone();
424 if let Some(first) = chain.first() {
425 display_chain.push(first.clone());
426 }
427 let cross_pkg_tag = if cycle.cycle.is_cross_package {
428 " *(cross-package)*"
429 } else {
430 ""
431 };
432 vec![format!(
433 "- {}{}",
434 display_chain
435 .iter()
436 .map(|s| format!("`{s}`"))
437 .collect::<Vec<_>>()
438 .join(" \u{2192} "),
439 cross_pkg_tag
440 )]
441}
442
443fn format_markdown_re_export_cycle(
444 cycle: &fallow_types::output_dead_code::ReExportCycleFinding,
445 rel: &dyn Fn(&Path) -> String,
446) -> Vec<String> {
447 let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
448 let kind_tag = match cycle.cycle.kind {
449 fallow_types::results::ReExportCycleKind::SelfLoop => " *(self-loop)*",
450 fallow_types::results::ReExportCycleKind::MultiNode => "",
451 };
452 vec![format!(
453 "- {}{}",
454 chain
455 .iter()
456 .map(|s| format!("`{s}`"))
457 .collect::<Vec<_>>()
458 .join(" <-> "),
459 kind_tag
460 )]
461}
462
463fn format_markdown_boundary_violation(
464 v: &fallow_types::output_dead_code::BoundaryViolationFinding,
465 rel: &dyn Fn(&Path) -> String,
466) -> Vec<String> {
467 vec![format!(
468 "- `{}`:{} \u{2192} `{}` ({} \u{2192} {})",
469 rel(&v.violation.from_path),
470 v.violation.line,
471 rel(&v.violation.to_path),
472 v.violation.from_zone,
473 v.violation.to_zone,
474 )]
475}
476
477fn format_markdown_boundary_coverage(
478 v: &fallow_types::output_dead_code::BoundaryCoverageViolationFinding,
479 rel: &dyn Fn(&Path) -> String,
480) -> Vec<String> {
481 vec![format!(
482 "- `{}`:{} no matching boundary zone",
483 rel(&v.violation.path),
484 v.violation.line,
485 )]
486}
487
488fn format_markdown_boundary_call(
489 v: &fallow_types::output_dead_code::BoundaryCallViolationFinding,
490 rel: &dyn Fn(&Path) -> String,
491) -> Vec<String> {
492 vec![format!(
493 "- `{}`:{} `{}` forbidden in zone `{}` (pattern `{}`)",
494 rel(&v.violation.path),
495 v.violation.line,
496 v.violation.callee,
497 v.violation.zone,
498 v.violation.pattern,
499 )]
500}
501
502fn format_markdown_policy_violation(
503 v: &fallow_types::output_dead_code::PolicyViolationFinding,
504 rel: &dyn Fn(&Path) -> String,
505) -> Vec<String> {
506 vec![format!(
507 "- `{}`:{} `{}` banned by `{}/{}`{}",
508 rel(&v.violation.path),
509 v.violation.line,
510 v.violation.matched,
511 v.violation.pack,
512 v.violation.rule_id,
513 v.violation
514 .message
515 .as_deref()
516 .map(|m| format!(" ({m})"))
517 .unwrap_or_default(),
518 )]
519}
520
521fn format_markdown_invalid_client_export(
522 e: &fallow_types::output_dead_code::InvalidClientExportFinding,
523 rel: &dyn Fn(&Path) -> String,
524) -> Vec<String> {
525 vec![format!(
526 "- `{}`:{} `{}` (from `\"{}\"`)",
527 rel(&e.export.path),
528 e.export.line,
529 e.export.export_name,
530 e.export.directive,
531 )]
532}
533
534fn format_markdown_mixed_client_server_barrel(
535 b: &fallow_types::output_dead_code::MixedClientServerBarrelFinding,
536 rel: &dyn Fn(&Path) -> String,
537) -> Vec<String> {
538 vec![format!(
539 "- `{}`:{} re-exports client `{}` and server-only `{}`",
540 rel(&b.barrel.path),
541 b.barrel.line,
542 b.barrel.client_origin,
543 b.barrel.server_origin,
544 )]
545}
546
547fn format_markdown_misplaced_directive(
548 d: &fallow_types::output_dead_code::MisplacedDirectiveFinding,
549 rel: &dyn Fn(&Path) -> String,
550) -> Vec<String> {
551 vec![format!(
552 "- `{}`:{} `\"{}\"` is not in the leading position and is ignored",
553 rel(&d.directive_site.path),
554 d.directive_site.line,
555 d.directive_site.directive,
556 )]
557}
558
559fn format_markdown_unprovided_inject(
560 i: &fallow_types::output_dead_code::UnprovidedInjectFinding,
561 rel: &dyn Fn(&Path) -> String,
562) -> Vec<String> {
563 vec![format!(
564 "- `{}`:{} `{}` has no matching provide(`{}`) in this project; at runtime it returns undefined",
565 rel(&i.inject.path),
566 i.inject.line,
567 escape_backticks(&i.inject.key_name),
568 escape_backticks(&i.inject.key_name),
569 )]
570}
571
572fn format_markdown_unrendered_component(
573 c: &fallow_types::output_dead_code::UnrenderedComponentFinding,
574 rel: &dyn Fn(&Path) -> String,
575) -> Vec<String> {
576 if c.component.framework == "lit" {
580 return vec![format!(
581 "- `{}`:{} `<{}>` is a registered custom element but rendered in no template (render it or remove it)",
582 rel(&c.component.path),
583 c.component.line,
584 escape_backticks(&c.component.component_name),
585 )];
586 }
587 vec![format!(
588 "- `{}`:{} `{}` is reachable but rendered nowhere in this project (render it somewhere or remove it)",
589 rel(&c.component.path),
590 c.component.line,
591 escape_backticks(&c.component.component_name),
592 )]
593}
594
595fn format_markdown_unused_component_prop(
596 p: &fallow_types::output_dead_code::UnusedComponentPropFinding,
597 rel: &dyn Fn(&Path) -> String,
598) -> Vec<String> {
599 vec![format!(
600 "- `{}`:{} `{}` is declared but referenced nowhere in this component (remove it or use it)",
601 rel(&p.prop.path),
602 p.prop.line,
603 escape_backticks(&p.prop.prop_name),
604 )]
605}
606
607fn format_markdown_unused_component_emit(
608 e: &fallow_types::output_dead_code::UnusedComponentEmitFinding,
609 rel: &dyn Fn(&Path) -> String,
610) -> Vec<String> {
611 vec![format!(
612 "- `{}`:{} `{}` is declared but emitted nowhere in this component (remove it or emit it)",
613 rel(&e.emit.path),
614 e.emit.line,
615 escape_backticks(&e.emit.emit_name),
616 )]
617}
618
619fn format_markdown_unused_svelte_event(
620 e: &fallow_types::output_dead_code::UnusedSvelteEventFinding,
621 rel: &dyn Fn(&Path) -> String,
622) -> Vec<String> {
623 vec![format!(
624 "- `{}`:{} `{}` is dispatched but listened to nowhere in the project (remove it or listen for it)",
625 rel(&e.event.path),
626 e.event.line,
627 escape_backticks(&e.event.event_name),
628 )]
629}
630
631fn format_markdown_unused_component_input(
632 i: &fallow_types::output_dead_code::UnusedComponentInputFinding,
633 rel: &dyn Fn(&Path) -> String,
634) -> Vec<String> {
635 vec![format!(
636 "- `{}`:{} `{}` is declared but referenced nowhere in this component (remove it or use it)",
637 rel(&i.input.path),
638 i.input.line,
639 escape_backticks(&i.input.input_name),
640 )]
641}
642
643fn format_markdown_unused_component_output(
644 o: &fallow_types::output_dead_code::UnusedComponentOutputFinding,
645 rel: &dyn Fn(&Path) -> String,
646) -> Vec<String> {
647 vec![format!(
648 "- `{}`:{} `{}` is declared but emitted nowhere in this component (remove it or emit it)",
649 rel(&o.output.path),
650 o.output.line,
651 escape_backticks(&o.output.output_name),
652 )]
653}
654
655fn format_markdown_unused_server_action(
656 a: &fallow_types::output_dead_code::UnusedServerActionFinding,
657 rel: &dyn Fn(&Path) -> String,
658) -> Vec<String> {
659 vec![format!(
660 "- `{}`:{} `{}` is exported from a \"use server\" file but no code in this project references it",
661 rel(&a.action.path),
662 a.action.line,
663 escape_backticks(&a.action.action_name),
664 )]
665}
666
667fn format_markdown_unused_load_data_key(
668 k: &fallow_types::output_dead_code::UnusedLoadDataKeyFinding,
669 rel: &dyn Fn(&Path) -> String,
670) -> Vec<String> {
671 vec![format!(
672 "- `{}`:{} `{}` is returned from load() but no consumer reads it",
673 rel(&k.key.path),
674 k.key.line,
675 escape_backticks(&k.key.key_name),
676 )]
677}
678
679fn format_markdown_route_collision(
680 c: &fallow_types::output_dead_code::RouteCollisionFinding,
681 rel: &dyn Fn(&Path) -> String,
682) -> Vec<String> {
683 vec![format!(
684 "- `{}` resolves to `{}` (shared with {} other route file(s))",
685 rel(&c.collision.path),
686 c.collision.url,
687 c.collision.conflicting_paths.len(),
688 )]
689}
690
691fn format_markdown_dynamic_segment_name_conflict(
692 c: &fallow_types::output_dead_code::DynamicSegmentNameConflictFinding,
693 rel: &dyn Fn(&Path) -> String,
694) -> Vec<String> {
695 vec![format!(
696 "- `{}` crashes at runtime: different slug names ({}) at the same dynamic path `{}`; \
697 `next build` passes but the route fails on its first request (rename to one consistent slug)",
698 rel(&c.conflict.path),
699 c.conflict.conflicting_segments.join(" vs "),
700 c.conflict.position,
701 )]
702}
703
704fn push_markdown_catalog_sections(
705 out: &mut String,
706 results: &AnalysisResults,
707 rel: &dyn Fn(&Path) -> String,
708) {
709 markdown_section(
710 out,
711 &results.unused_catalog_entries,
712 "Unused catalog entries",
713 |entry| format_unused_catalog_entry(entry, rel),
714 );
715 markdown_section(
716 out,
717 &results.empty_catalog_groups,
718 "Empty catalog groups",
719 |group| {
720 vec![format!(
721 "- `{}` `{}`:{}",
722 escape_backticks(&group.group.catalog_name),
723 rel(&group.group.path),
724 group.group.line,
725 )]
726 },
727 );
728 markdown_section(
729 out,
730 &results.unresolved_catalog_references,
731 "Unresolved catalog references",
732 |finding| format_unresolved_catalog_reference(finding, rel),
733 );
734 markdown_section(
735 out,
736 &results.unused_dependency_overrides,
737 "Unused dependency overrides",
738 |finding| format_unused_dependency_override(finding, rel),
739 );
740 markdown_section(
741 out,
742 &results.misconfigured_dependency_overrides,
743 "Misconfigured dependency overrides",
744 |finding| {
745 vec![format!(
746 "- `{}` -> `{}` (`{}`) `{}`:{} ({})",
747 escape_backticks(&finding.entry.raw_key),
748 escape_backticks(&finding.entry.raw_value),
749 finding.entry.source.as_label(),
750 rel(&finding.entry.path),
751 finding.entry.line,
752 finding.entry.reason.describe(),
753 )]
754 },
755 );
756}
757
758fn format_unused_catalog_entry(
759 entry: &UnusedCatalogEntryFinding,
760 rel: &dyn Fn(&Path) -> String,
761) -> Vec<String> {
762 let mut row = format!(
763 "- `{}` (`{}`) `{}`:{}",
764 escape_backticks(&entry.entry.entry_name),
765 escape_backticks(&entry.entry.catalog_name),
766 rel(&entry.entry.path),
767 entry.entry.line,
768 );
769 if !entry.entry.hardcoded_consumers.is_empty() {
770 let consumers = entry
771 .entry
772 .hardcoded_consumers
773 .iter()
774 .map(|p| format!("`{}`", rel(p)))
775 .collect::<Vec<_>>()
776 .join(", ");
777 let _ = write!(row, " (hardcoded in {consumers})");
778 }
779 vec![row]
780}
781
782fn format_unresolved_catalog_reference(
783 finding: &UnresolvedCatalogReferenceFinding,
784 rel: &dyn Fn(&Path) -> String,
785) -> Vec<String> {
786 let mut row = format!(
787 "- `{}` (`{}`) `{}`:{}",
788 escape_backticks(&finding.reference.entry_name),
789 escape_backticks(&finding.reference.catalog_name),
790 rel(&finding.reference.path),
791 finding.reference.line,
792 );
793 if !finding.reference.available_in_catalogs.is_empty() {
794 let alts = finding
795 .reference
796 .available_in_catalogs
797 .iter()
798 .map(|c| format!("`{}`", escape_backticks(c)))
799 .collect::<Vec<_>>()
800 .join(", ");
801 let _ = write!(row, " (available in: {alts})");
802 }
803 vec![row]
804}
805
806fn format_unused_dependency_override(
807 finding: &UnusedDependencyOverrideFinding,
808 rel: &dyn Fn(&Path) -> String,
809) -> Vec<String> {
810 let mut row = format!(
811 "- `{}` -> `{}` (`{}`) `{}`:{}",
812 escape_backticks(&finding.entry.raw_key),
813 escape_backticks(&finding.entry.version_range),
814 finding.entry.source.as_label(),
815 rel(&finding.entry.path),
816 finding.entry.line,
817 );
818 if let Some(hint) = &finding.entry.hint {
819 let _ = write!(row, " (hint: {})", escape_backticks(hint));
820 }
821 vec![row]
822}
823
824#[must_use]
826pub fn build_grouped_markdown(groups: &[ResultGroup], root: &Path) -> String {
827 let total: usize = groups.iter().map(|g| g.results.total_issues()).sum();
828 let mut out = String::new();
829
830 if total == 0 {
831 out.push_str("## Fallow: no issues found\n");
832 return out;
833 }
834
835 let _ = writeln!(
836 out,
837 "## Fallow: {total} issue{} found (grouped)\n",
838 plural(total)
839 );
840
841 for group in groups {
842 let count = group.results.total_issues();
843 if count == 0 {
844 continue;
845 }
846 let _ = writeln!(
847 out,
848 "## {} ({count} issue{})\n",
849 escape_backticks(&group.key),
850 plural(count)
851 );
852 if let Some(ref owners) = group.owners
853 && !owners.is_empty()
854 {
855 let joined = owners
856 .iter()
857 .map(|owner| escape_backticks(owner))
858 .collect::<Vec<_>>()
859 .join(" ");
860 let _ = writeln!(out, "Owners: {joined}\n");
861 }
862 let body = build_markdown(&group.results, root);
863 let sections = body
864 .strip_prefix("## Fallow: no issues found\n")
865 .or_else(|| body.find("\n\n").map(|pos| &body[pos + 2..]))
866 .unwrap_or(&body);
867 out.push_str(sections);
868 }
869
870 out
871}
872
873fn format_export(e: &UnusedExport) -> String {
874 let re = if e.is_re_export { " (re-export)" } else { "" };
875 format!(":{} `{}`{re}", e.line, escape_backticks(&e.export_name))
876}
877
878fn format_private_type_leak(
879 entry: &fallow_types::output_dead_code::PrivateTypeLeakFinding,
880) -> String {
881 let e = &entry.leak;
882 format!(
883 ":{} `{}` references private type `{}`",
884 e.line,
885 escape_backticks(&e.export_name),
886 escape_backticks(&e.type_name)
887 )
888}
889
890fn format_member(m: &UnusedMember) -> String {
891 format!(
892 ":{} `{}.{}`",
893 m.line,
894 escape_backticks(&m.parent_name),
895 escape_backticks(&m.member_name)
896 )
897}
898
899fn format_dependency(
900 dep_name: &str,
901 pkg_path: &Path,
902 used_in_workspaces: &[std::path::PathBuf],
903 root: &Path,
904) -> Vec<String> {
905 let name = escape_backticks(dep_name);
906 let pkg_label = relative_path(pkg_path, root).display().to_string();
907 let workspace_context = if used_in_workspaces.is_empty() {
908 String::new()
909 } else {
910 let workspaces = used_in_workspaces
911 .iter()
912 .map(|path| escape_backticks(&relative_path(path, root).display().to_string()))
913 .collect::<Vec<_>>()
914 .join(", ");
915 format!("; imported in {workspaces}")
916 };
917 if pkg_label == "package.json" && workspace_context.is_empty() {
918 vec![format!("- `{name}`")]
919 } else {
920 let label = if pkg_label == "package.json" {
921 workspace_context.trim_start_matches("; ").to_string()
922 } else {
923 format!("{}{workspace_context}", escape_backticks(&pkg_label))
924 };
925 vec![format!("- `{name}` ({label})")]
926 }
927}
928
929fn markdown_section<T>(
931 out: &mut String,
932 items: &[T],
933 title: &str,
934 format_lines: impl Fn(&T) -> Vec<String>,
935) {
936 if items.is_empty() {
937 return;
938 }
939 let _ = write!(out, "### {title} ({})\n\n", items.len());
940 for item in items {
941 for line in format_lines(item) {
942 out.push_str(&line);
943 out.push('\n');
944 }
945 }
946 out.push('\n');
947}
948
949fn markdown_grouped_section<'a, T>(
950 out: &mut String,
951 items: &'a [T],
952 title: &str,
953 root: &Path,
954 get_path: impl Fn(&'a T) -> &'a Path,
955 format_detail: impl Fn(&T) -> String,
956) {
957 if items.is_empty() {
958 return;
959 }
960 let _ = write!(out, "### {title} ({})\n\n", items.len());
961
962 let mut indices: Vec<usize> = (0..items.len()).collect();
963 indices.sort_by(|&a, &b| get_path(&items[a]).cmp(get_path(&items[b])));
964
965 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
966 let mut last_file = String::new();
967 for &i in &indices {
968 let item = &items[i];
969 let file_str = rel(get_path(item));
970 if file_str != last_file {
971 let _ = writeln!(out, "- `{file_str}`");
972 last_file = file_str;
973 }
974 let _ = writeln!(out, " - {}", format_detail(item));
975 }
976 out.push('\n');
977}
978
979#[must_use]
981pub fn build_duplication_markdown(report: &DuplicationReport, root: &Path) -> String {
982 let mut out = String::new();
983
984 if report.clone_groups.is_empty() {
985 out.push_str("## Fallow: no code duplication found\n");
986 return out;
987 }
988
989 let stats = &report.stats;
990 let _ = write!(
991 out,
992 "## Fallow: {} clone group{} found ({:.1}% duplication)\n\n",
993 stats.clone_groups,
994 plural(stats.clone_groups),
995 stats.duplication_percentage,
996 );
997
998 write_duplication_groups(&mut out, report, root);
999 write_duplication_families(&mut out, report, root);
1000
1001 let _ = writeln!(
1002 out,
1003 "**Summary:** {} duplicated lines ({:.1}%) across {} file{}",
1004 stats.duplicated_lines,
1005 stats.duplication_percentage,
1006 stats.files_with_clones,
1007 plural(stats.files_with_clones),
1008 );
1009
1010 out
1011}
1012
1013fn write_duplication_groups(out: &mut String, report: &DuplicationReport, root: &Path) {
1015 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
1016 out.push_str("### Duplicates\n\n");
1017 for (i, group) in report.clone_groups.iter().enumerate() {
1018 let instance_count = group.instances.len();
1019 let _ = write!(
1020 out,
1021 "**Clone group {}** ({} lines, {instance_count} instance{})\n\n",
1022 i + 1,
1023 group.line_count,
1024 plural(instance_count)
1025 );
1026 for instance in &group.instances {
1027 let relative = rel(&instance.file);
1028 let _ = writeln!(
1029 out,
1030 "- `{relative}:{}-{}`",
1031 instance.start_line, instance.end_line
1032 );
1033 }
1034 out.push('\n');
1035 }
1036}
1037
1038fn write_duplication_families(out: &mut String, report: &DuplicationReport, root: &Path) {
1040 if report.clone_families.is_empty() {
1041 return;
1042 }
1043 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
1044 out.push_str("### Clone Families\n\n");
1045 for (i, family) in report.clone_families.iter().enumerate() {
1046 let file_names: Vec<_> = family.files.iter().map(|f| rel(f)).collect();
1047 let _ = write!(
1048 out,
1049 "**Family {}** ({} group{}, {} lines across {})\n\n",
1050 i + 1,
1051 family.groups.len(),
1052 plural(family.groups.len()),
1053 family.total_duplicated_lines,
1054 file_names
1055 .iter()
1056 .map(|s| format!("`{s}`"))
1057 .collect::<Vec<_>>()
1058 .join(", "),
1059 );
1060 for suggestion in &family.suggestions {
1061 let savings = if suggestion.estimated_savings > 0 {
1062 format!(" (~{} lines saved)", suggestion.estimated_savings)
1063 } else {
1064 String::new()
1065 };
1066 let _ = writeln!(out, "- {}{savings}", suggestion.description);
1067 }
1068 out.push('\n');
1069 }
1070}
1071
1072#[must_use]
1074pub fn build_health_markdown(report: &fallow_output::HealthReport, root: &Path) -> String {
1075 let mut out = String::new();
1076
1077 if let Some(ref hs) = report.health_score {
1078 let _ = writeln!(out, "## Health Score: {:.0} ({})\n", hs.score, hs.grade);
1079 }
1080
1081 write_trend_section(&mut out, report);
1082 write_vital_signs_section(&mut out, report);
1083
1084 if report.findings.is_empty()
1085 && report.file_scores.is_empty()
1086 && report.coverage_gaps.is_none()
1087 && report.hotspots.is_empty()
1088 && report.targets.is_empty()
1089 && report.runtime_coverage.is_none()
1090 && report.coverage_intelligence.is_none()
1091 && report.threshold_overrides.is_empty()
1092 && report.css_analytics.is_none()
1093 {
1094 if report.vital_signs.is_none() {
1095 let _ = write!(
1096 out,
1097 "## Fallow: no functions exceed complexity thresholds\n\n\
1098 **{}** functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {:.1})\n",
1099 report.summary.functions_analyzed,
1100 report.summary.max_cyclomatic_threshold,
1101 report.summary.max_cognitive_threshold,
1102 report.summary.max_crap_threshold,
1103 );
1104 }
1105 return out;
1106 }
1107
1108 write_findings_section(&mut out, report, root);
1109 write_threshold_overrides_section(&mut out, report, root);
1110 write_runtime_coverage_section(&mut out, report, root);
1111 write_coverage_intelligence_section(&mut out, report, root);
1112 write_coverage_gaps_section(&mut out, report, root);
1113 write_file_scores_section(&mut out, report, root);
1114 write_hotspots_section(&mut out, report, root);
1115 write_targets_section(&mut out, report, root);
1116 write_css_analytics_section(&mut out, report);
1117 write_metric_legend(&mut out, report);
1118
1119 out
1120}
1121
1122fn write_css_analytics_section(out: &mut String, report: &fallow_output::HealthReport) {
1126 let Some(ref css) = report.css_analytics else {
1127 return;
1128 };
1129 let s = &css.summary;
1130 if !out.is_empty() && !out.ends_with("\n\n") {
1131 out.push('\n');
1132 }
1133 out.push_str("## CSS Health\n\n");
1134 let important_pct = if s.total_declarations > 0 {
1135 f64::from(s.important_declarations) / f64::from(s.total_declarations) * 100.0
1136 } else {
1137 0.0
1138 };
1139 let _ = writeln!(
1140 out,
1141 "- Stylesheets: {} | Rules: {} | !important: {important_pct:.1}% | Empty rules: {} | Max nesting: {}",
1142 s.files_analyzed, s.total_rules, s.empty_rules, s.max_nesting_depth,
1143 );
1144 let _ = writeln!(
1145 out,
1146 "- Value sprawl: {} colors | {} font sizes | {} z-index | {} shadows | {} radii | {} line-heights",
1147 s.unique_colors,
1148 s.unique_font_sizes,
1149 s.unique_z_indexes,
1150 s.unique_box_shadows,
1151 s.unique_border_radii,
1152 s.unique_line_heights,
1153 );
1154 let _ = writeln!(
1155 out,
1156 "- Candidates: {} unreferenced + {} undefined @keyframes | {} duplicate blocks | {} scoped-unused classes | {} Tailwind arbitrary values | {} unused @property | {} unused @layer | {} likely class typos | {} unreferenced classes | {} unused @font-face | {} unused @theme tokens",
1157 s.keyframes_unreferenced,
1158 s.keyframes_undefined,
1159 s.duplicate_declaration_blocks,
1160 s.scoped_unused_classes,
1161 s.tailwind_arbitrary_values,
1162 s.unused_property_registrations,
1163 s.unused_layers,
1164 s.unresolved_class_references,
1165 s.unreferenced_css_classes,
1166 s.unused_font_faces,
1167 s.unused_theme_tokens,
1168 );
1169 write_css_candidate_details(out, css);
1170 out.push('\n');
1171}
1172
1173fn write_css_candidate_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
1174 write_css_keyframe_details(out, css);
1175 write_css_tailwind_details(out, css);
1176 write_css_class_candidate_details(out, css);
1177 write_css_font_candidate_details(out, css);
1178 write_css_font_size_mix_details(out, css);
1179}
1180
1181fn write_css_keyframe_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
1182 if !css.undefined_keyframes.is_empty() {
1183 let named: Vec<String> = css
1184 .undefined_keyframes
1185 .iter()
1186 .take(5)
1187 .map(|kf| format!("`{}` ({})", kf.name, kf.path))
1188 .collect();
1189 let _ = writeln!(
1190 out,
1191 "- Undefined @keyframes (candidates; likely typo or CSS-in-JS): {}",
1192 named.join(", "),
1193 );
1194 }
1195}
1196
1197fn write_css_tailwind_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
1198 if !css.tailwind_arbitrary_values.is_empty() {
1199 let named: Vec<String> = css
1200 .tailwind_arbitrary_values
1201 .iter()
1202 .take(5)
1203 .map(|a| format!("`{}` ({}x)", a.value, a.count))
1204 .collect();
1205 let _ = writeln!(out, "- Top Tailwind arbitrary values: {}", named.join(", "));
1206 }
1207}
1208
1209fn write_css_class_candidate_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
1210 if !css.unresolved_class_references.is_empty() {
1211 let named: Vec<String> = css
1212 .unresolved_class_references
1213 .iter()
1214 .take(5)
1215 .map(|u| {
1216 format!(
1217 "`{}` -> `{}` ({}:{})",
1218 u.class, u.suggestion, u.path, u.line
1219 )
1220 })
1221 .collect();
1222 let _ = writeln!(
1223 out,
1224 "- Likely class typos (candidates; verify, may be CSS-in-JS or external): {}",
1225 named.join(", "),
1226 );
1227 }
1228 if !css.unreferenced_css_classes.is_empty() {
1229 let named: Vec<String> = css
1230 .unreferenced_css_classes
1231 .iter()
1232 .take(5)
1233 .map(|u| format!("`.{}` ({}:{})", u.class, u.path, u.line))
1234 .collect();
1235 let _ = writeln!(
1236 out,
1237 "- Unreferenced global classes (candidates; verify no email / server / CMS / Markdown applies them): {}",
1238 named.join(", "),
1239 );
1240 }
1241}
1242
1243fn write_css_font_candidate_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
1244 if !css.unused_font_faces.is_empty() {
1245 let named: Vec<String> = css
1246 .unused_font_faces
1247 .iter()
1248 .take(5)
1249 .map(|u| format!("`{}` ({})", u.family, u.path))
1250 .collect();
1251 let _ = writeln!(
1252 out,
1253 "- Unused @font-face (dead web-font; candidates, may be set from JS/inline): {}",
1254 named.join(", "),
1255 );
1256 }
1257 if !css.unused_theme_tokens.is_empty() {
1258 let named: Vec<String> = css
1259 .unused_theme_tokens
1260 .iter()
1261 .take(5)
1262 .map(|u| format!("`{}` ({}:{})", u.token, u.path, u.line))
1263 .collect();
1264 let _ = writeln!(
1265 out,
1266 "- Unused @theme tokens (dead Tailwind v4 design tokens; candidates, may be consumed by a plugin or downstream repo): {}",
1267 named.join(", "),
1268 );
1269 }
1270}
1271
1272fn write_css_font_size_mix_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
1273 if let Some(mix) = &css.font_size_unit_mix {
1274 let breakdown: Vec<String> = mix
1275 .notations
1276 .iter()
1277 .map(|n| format!("{} {}", n.count, n.notation))
1278 .collect();
1279 let _ = writeln!(
1280 out,
1281 "- Font sizes mix {} units (candidate, standardize unless intentional): {}",
1282 mix.notations.len(),
1283 breakdown.join(", "),
1284 );
1285 }
1286}
1287
1288fn write_coverage_intelligence_section(
1289 out: &mut String,
1290 report: &fallow_output::HealthReport,
1291 root: &Path,
1292) {
1293 let Some(ref intelligence) = report.coverage_intelligence else {
1294 return;
1295 };
1296 if !out.is_empty() && !out.ends_with("\n\n") {
1297 out.push('\n');
1298 }
1299 let _ = writeln!(
1300 out,
1301 "## Coverage Intelligence\n\n- Verdict: {}\n- Findings: {}\n- Ambiguous matches skipped: {}\n",
1302 intelligence.verdict,
1303 intelligence.summary.findings,
1304 intelligence.summary.skipped_ambiguous_matches,
1305 );
1306 if intelligence.findings.is_empty() {
1307 if intelligence.summary.skipped_ambiguous_matches > 0 {
1308 let match_phrase = if intelligence.summary.skipped_ambiguous_matches == 1 {
1309 "evidence match was"
1310 } else {
1311 "evidence matches were"
1312 };
1313 let _ = writeln!(
1314 out,
1315 "No actionable findings were emitted because {} ambiguous {match_phrase} skipped.\n",
1316 intelligence.summary.skipped_ambiguous_matches,
1317 );
1318 }
1319 return;
1320 }
1321 out.push_str("| ID | Path | Identity | Verdict | Recommendation | Confidence | Signals |\n");
1322 out.push_str("|:---|:-----|:---------|:--------|:---------------|:-----------|:--------|\n");
1323 for finding in &intelligence.findings {
1324 write_coverage_intelligence_row(out, finding, root);
1325 }
1326 out.push('\n');
1327}
1328
1329fn write_coverage_intelligence_row(
1331 out: &mut String,
1332 finding: &fallow_output::CoverageIntelligenceFinding,
1333 root: &Path,
1334) {
1335 let path = escape_backticks(&normalize_uri(
1336 &relative_path(&finding.path, root).display().to_string(),
1337 ));
1338 let identity = finding
1339 .identity
1340 .as_deref()
1341 .map_or_else(|| "-".to_owned(), escape_backticks);
1342 let signals = finding
1343 .signals
1344 .iter()
1345 .map(ToString::to_string)
1346 .collect::<Vec<_>>()
1347 .join(", ");
1348 let _ = writeln!(
1349 out,
1350 "| `{}` | `{}`:{} | `{}` | {} | {} | {} | {} |",
1351 escape_backticks(&finding.id),
1352 path,
1353 finding.line,
1354 identity,
1355 finding.verdict,
1356 finding.recommendation,
1357 finding.confidence,
1358 signals,
1359 );
1360}
1361
1362fn write_runtime_coverage_section(
1363 out: &mut String,
1364 report: &fallow_output::HealthReport,
1365 root: &Path,
1366) {
1367 let Some(ref production) = report.runtime_coverage else {
1368 return;
1369 };
1370 if !out.is_empty() && !out.ends_with("\n\n") {
1371 out.push('\n');
1372 }
1373 write_runtime_coverage_summary(out, production);
1374 write_runtime_coverage_findings(out, production, root);
1375 write_runtime_coverage_hot_paths(out, production, root);
1376}
1377
1378fn write_runtime_coverage_summary(
1380 out: &mut String,
1381 production: &fallow_output::RuntimeCoverageReport,
1382) {
1383 let _ = writeln!(
1384 out,
1385 "## Runtime Coverage\n\n- Verdict: {}\n- Functions tracked: {}\n- Hit: {}\n- Unhit: {}\n- Untracked: {}\n- Coverage: {:.1}%\n- Traces observed: {}\n- Period: {} day(s), {} deployment(s)\n",
1386 production.verdict,
1387 production.summary.functions_tracked,
1388 production.summary.functions_hit,
1389 production.summary.functions_unhit,
1390 production.summary.functions_untracked,
1391 production.summary.coverage_percent,
1392 production.summary.trace_count,
1393 production.summary.period_days,
1394 production.summary.deployments_seen,
1395 );
1396 if let Some(watermark) = production.watermark {
1397 let _ = writeln!(out, "- Watermark: {watermark}\n");
1398 }
1399 if let Some(ref quality) = production.summary.capture_quality
1400 && quality.lazy_parse_warning
1401 {
1402 let window = format_window(quality.window_seconds);
1403 let _ = writeln!(
1404 out,
1405 "- Capture quality: short window ({} from {} instance(s), {:.1}% of functions untracked); lazy-parsed scripts may not appear.\n",
1406 window, quality.instances_observed, quality.untracked_ratio_percent,
1407 );
1408 }
1409}
1410
1411fn write_runtime_coverage_findings(
1413 out: &mut String,
1414 production: &fallow_output::RuntimeCoverageReport,
1415 root: &Path,
1416) {
1417 if production.findings.is_empty() {
1418 return;
1419 }
1420 out.push_str("| ID | Path | Function | Verdict | Invocations | Confidence |\n");
1421 out.push_str("|:---|:-----|:---------|:--------|------------:|:-----------|\n");
1422 for finding in &production.findings {
1423 let invocations = finding
1424 .invocations
1425 .map_or_else(|| "-".to_owned(), |hits| hits.to_string());
1426 let _ = writeln!(
1427 out,
1428 "| `{}` | `{}`:{} | `{}` | {} | {} | {} |",
1429 escape_backticks(&finding.id),
1430 escape_backticks(&normalize_uri(
1431 &relative_path(&finding.path, root).display().to_string(),
1432 )),
1433 finding.line,
1434 escape_backticks(&finding.function),
1435 finding.verdict,
1436 invocations,
1437 finding.confidence,
1438 );
1439 }
1440 out.push('\n');
1441}
1442
1443fn write_runtime_coverage_hot_paths(
1445 out: &mut String,
1446 production: &fallow_output::RuntimeCoverageReport,
1447 root: &Path,
1448) {
1449 if production.hot_paths.is_empty() {
1450 return;
1451 }
1452 out.push_str("| ID | Hot path | Function | Invocations | Percentile |\n");
1453 out.push_str("|:---|:---------|:---------|------------:|-----------:|\n");
1454 for entry in &production.hot_paths {
1455 let _ = writeln!(
1456 out,
1457 "| `{}` | `{}`:{} | `{}` | {} | {} |",
1458 escape_backticks(&entry.id),
1459 escape_backticks(&normalize_uri(
1460 &relative_path(&entry.path, root).display().to_string(),
1461 )),
1462 entry.line,
1463 escape_backticks(&entry.function),
1464 entry.invocations,
1465 entry.percentile,
1466 );
1467 }
1468 out.push('\n');
1469}
1470
1471fn write_trend_section(out: &mut String, report: &fallow_output::HealthReport) {
1473 let Some(ref trend) = report.health_trend else {
1474 return;
1475 };
1476 let sha_str = trend
1477 .compared_to
1478 .git_sha
1479 .as_deref()
1480 .map_or(String::new(), |sha| format!(" ({sha})"));
1481 let _ = writeln!(
1482 out,
1483 "## Trend (vs {}{})\n",
1484 trend
1485 .compared_to
1486 .timestamp
1487 .get(..10)
1488 .unwrap_or(&trend.compared_to.timestamp),
1489 sha_str,
1490 );
1491 out.push_str("| Metric | Previous | Current | Delta | Direction |\n");
1492 out.push_str("|:-------|:---------|:--------|:------|:----------|\n");
1493 for m in &trend.metrics {
1494 write_trend_metric_row(out, m);
1495 }
1496 let md_sha = trend
1497 .compared_to
1498 .git_sha
1499 .as_deref()
1500 .map_or(String::new(), |sha| format!(" ({sha})"));
1501 let _ = writeln!(
1502 out,
1503 "\n*vs {}{} · {} {} available*\n",
1504 trend
1505 .compared_to
1506 .timestamp
1507 .get(..10)
1508 .unwrap_or(&trend.compared_to.timestamp),
1509 md_sha,
1510 trend.snapshots_loaded,
1511 if trend.snapshots_loaded == 1 {
1512 "snapshot"
1513 } else {
1514 "snapshots"
1515 },
1516 );
1517}
1518
1519fn write_trend_metric_row(out: &mut String, m: &fallow_output::TrendMetric) {
1521 let fmt_val = |v: f64| -> String {
1522 if m.unit == "%" {
1523 format!("{v:.1}%")
1524 } else if (v - v.round()).abs() < 0.05 {
1525 format!("{v:.0}")
1526 } else {
1527 format!("{v:.1}")
1528 }
1529 };
1530 let prev = fmt_val(m.previous);
1531 let cur = fmt_val(m.current);
1532 let delta = if m.unit == "%" {
1533 format!("{:+.1}%", m.delta)
1534 } else if (m.delta - m.delta.round()).abs() < 0.05 {
1535 format!("{:+.0}", m.delta)
1536 } else {
1537 format!("{:+.1}", m.delta)
1538 };
1539 let _ = writeln!(
1540 out,
1541 "| {} | {} | {} | {} | {} {} |",
1542 m.label,
1543 prev,
1544 cur,
1545 delta,
1546 m.direction.arrow(),
1547 m.direction.label(),
1548 );
1549}
1550
1551fn write_vital_signs_section(out: &mut String, report: &fallow_output::HealthReport) {
1553 let Some(ref vs) = report.vital_signs else {
1554 return;
1555 };
1556 out.push_str("## Vital Signs\n\n");
1557 out.push_str("| Metric | Value |\n");
1558 out.push_str("|:-------|------:|\n");
1559 if vs.total_loc > 0 {
1560 let _ = writeln!(out, "| Total LOC | {} |", vs.total_loc);
1561 }
1562 let _ = writeln!(out, "| Avg Cyclomatic | {:.1} |", vs.avg_cyclomatic);
1563 let _ = writeln!(out, "| P90 Cyclomatic | {} |", vs.p90_cyclomatic);
1564 if let Some(v) = vs.dead_file_pct {
1565 let _ = writeln!(out, "| Dead Files | {v:.1}% |");
1566 }
1567 if let Some(v) = vs.dead_export_pct {
1568 let _ = writeln!(out, "| Dead Exports | {v:.1}% |");
1569 }
1570 if let Some(v) = vs.maintainability_avg {
1571 let _ = writeln!(out, "| Maintainability (avg) | {v:.1} |");
1572 }
1573 if let Some(v) = vs.hotspot_count {
1574 let label = report.hotspot_summary.as_ref().map_or_else(
1575 || "Hotspots".to_string(),
1576 |summary| format!("Hotspots (since {})", summary.since),
1577 );
1578 let _ = writeln!(out, "| {label} | {v} |");
1579 }
1580 if let Some(v) = vs.circular_dep_count {
1581 let _ = writeln!(out, "| Circular Deps | {v} |");
1582 }
1583 if let Some(v) = vs.unused_dep_count {
1584 let _ = writeln!(out, "| Unused Deps | {v} |");
1585 }
1586 out.push('\n');
1587}
1588
1589fn write_findings_section(out: &mut String, report: &fallow_output::HealthReport, root: &Path) {
1591 if report.findings.is_empty() {
1592 return;
1593 }
1594
1595 let has_synthetic = report
1596 .findings
1597 .iter()
1598 .any(|finding| matches!(finding.name.as_str(), "<template>" | "<component>"));
1599 write_findings_heading(out, report, has_synthetic);
1600 write_findings_table_header(out, has_synthetic);
1601
1602 for finding in &report.findings {
1603 write_findings_row(out, finding, report, root);
1604 }
1605
1606 let s = &report.summary;
1607 let _ = write!(
1608 out,
1609 "\n**{files}** files, **{funcs}** functions analyzed \
1610 (thresholds: cyclomatic > {cyc}, cognitive > {cog}, CRAP >= {crap:.1})\n",
1611 files = s.files_analyzed,
1612 funcs = s.functions_analyzed,
1613 cyc = s.max_cyclomatic_threshold,
1614 cog = s.max_cognitive_threshold,
1615 crap = s.max_crap_threshold,
1616 );
1617}
1618
1619fn write_findings_heading(
1621 out: &mut String,
1622 report: &fallow_output::HealthReport,
1623 has_synthetic: bool,
1624) {
1625 let count = report.summary.functions_above_threshold;
1626 let shown = report.findings.len();
1627 let subject = if has_synthetic {
1628 "high complexity finding"
1629 } else {
1630 "high complexity function"
1631 };
1632 if shown < count {
1633 let _ = write!(
1634 out,
1635 "## Fallow: {count} {subject}{} ({shown} shown)\n\n",
1636 plural(count),
1637 );
1638 } else {
1639 let _ = write!(out, "## Fallow: {count} {subject}{}\n\n", plural(count));
1640 }
1641}
1642
1643fn write_findings_table_header(out: &mut String, has_synthetic: bool) {
1645 let name_header = if has_synthetic { "Entry" } else { "Function" };
1646 let _ = writeln!(
1647 out,
1648 "| File | {name_header} | Severity | Cyclomatic | Cognitive | CRAP | Lines |"
1649 );
1650 out.push_str("|:-----|:---------|:---------|:-----------|:----------|:-----|:------|\n");
1651}
1652
1653fn write_findings_row(
1655 out: &mut String,
1656 finding: &fallow_output::HealthFinding,
1657 report: &fallow_output::HealthReport,
1658 root: &Path,
1659) {
1660 let file_str = escape_backticks(&normalize_uri(
1661 &relative_path(&finding.path, root).display().to_string(),
1662 ));
1663 let thresholds =
1664 finding
1665 .effective_thresholds
1666 .unwrap_or(fallow_output::HealthEffectiveThresholds {
1667 max_cyclomatic: report.summary.max_cyclomatic_threshold,
1668 max_cognitive: report.summary.max_cognitive_threshold,
1669 max_crap: report.summary.max_crap_threshold,
1670 });
1671 let cyc_marker = if finding.cyclomatic > thresholds.max_cyclomatic {
1672 " **!**"
1673 } else {
1674 ""
1675 };
1676 let cog_marker = if finding.cognitive > thresholds.max_cognitive {
1677 " **!**"
1678 } else {
1679 ""
1680 };
1681 let severity_label = match finding.severity {
1682 fallow_output::FindingSeverity::Critical => "critical",
1683 fallow_output::FindingSeverity::High => "high",
1684 fallow_output::FindingSeverity::Moderate => "moderate",
1685 };
1686 let crap_cell = match finding.crap {
1687 Some(crap) => {
1688 let marker = if crap >= thresholds.max_crap {
1689 " **!**"
1690 } else {
1691 ""
1692 };
1693 format!("{crap:.1}{marker}")
1694 }
1695 None => "-".to_string(),
1696 };
1697 let _ = writeln!(
1698 out,
1699 "| `{file_str}:{line}` | `{name}` | {severity_label} | {cyc}{cyc_marker} | {cog}{cog_marker} | {crap_cell} | {lines} |",
1700 line = finding.line,
1701 name = escape_backticks(display_complexity_entry_name(&finding.name).as_ref()),
1702 cyc = finding.cyclomatic,
1703 cog = finding.cognitive,
1704 lines = finding.line_count,
1705 );
1706}
1707
1708fn write_threshold_overrides_section(
1709 out: &mut String,
1710 report: &fallow_output::HealthReport,
1711 root: &Path,
1712) {
1713 if report.threshold_overrides.is_empty() {
1714 return;
1715 }
1716 if !out.is_empty() && !out.ends_with("\n\n") {
1717 out.push('\n');
1718 }
1719 out.push_str("## Health Threshold Overrides\n\n");
1720 out.push_str("| Override | Status | Target | Metrics |\n");
1721 out.push_str("|---------:|:-------|:-------|:--------|\n");
1722 for entry in &report.threshold_overrides {
1723 let status = match entry.status {
1724 fallow_output::ThresholdOverrideStatus::Active => "active",
1725 fallow_output::ThresholdOverrideStatus::Stale => "stale",
1726 fallow_output::ThresholdOverrideStatus::NoMatch => "no_match",
1727 };
1728 let target = entry.path.as_ref().map_or_else(
1729 || "<no matching file or function>".to_string(),
1730 |path| {
1731 let display = escape_backticks(&normalize_uri(
1732 &relative_path(path, root).display().to_string(),
1733 ));
1734 entry.function.as_ref().map_or_else(
1735 || display.clone(),
1736 |name| format!("{display}:{}", escape_backticks(name)),
1737 )
1738 },
1739 );
1740 let metrics = entry.metrics.map_or_else(
1741 || "-".to_string(),
1742 |metrics| {
1743 let crap = metrics
1744 .crap
1745 .map_or(String::new(), |value| format!(", CRAP {value:.1}"));
1746 format!(
1747 "cyclomatic {}, cognitive {}{}",
1748 metrics.cyclomatic, metrics.cognitive, crap
1749 )
1750 },
1751 );
1752 let _ = writeln!(
1753 out,
1754 "| {} | {} | `{}` | {} |",
1755 entry.override_index, status, target, metrics
1756 );
1757 }
1758 out.push('\n');
1759}
1760
1761fn write_file_scores_section(out: &mut String, report: &fallow_output::HealthReport, root: &Path) {
1763 if report.file_scores.is_empty() {
1764 return;
1765 }
1766
1767 let rel = |p: &Path| {
1768 escape_backticks(&normalize_uri(
1769 &relative_path(p, root).display().to_string(),
1770 ))
1771 };
1772
1773 out.push('\n');
1774 let _ = writeln!(
1775 out,
1776 "### File Health Scores ({} files)\n",
1777 report.file_scores.len(),
1778 );
1779 out.push_str("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density | Risk |\n");
1780 out.push_str("|:-----|:---------------|:-------|:--------|:----------|:--------|:-----|\n");
1781
1782 for score in &report.file_scores {
1783 let file_str = rel(&score.path);
1784 let _ = writeln!(
1785 out,
1786 "| `{file_str}` | {mi:.1} | {fi} | {fan_out} | {dead:.0}% | {density:.2} | {crap:.1} |",
1787 mi = score.maintainability_index,
1788 fi = score.fan_in,
1789 fan_out = score.fan_out,
1790 dead = score.dead_code_ratio * 100.0,
1791 density = score.complexity_density,
1792 crap = score.crap_max,
1793 );
1794 }
1795
1796 if let Some(avg) = report.summary.average_maintainability {
1797 let _ = write!(out, "\n**Average maintainability index:** {avg:.1}/100\n");
1798 }
1799}
1800
1801fn write_coverage_gaps_section(
1802 out: &mut String,
1803 report: &fallow_output::HealthReport,
1804 root: &Path,
1805) {
1806 let Some(ref gaps) = report.coverage_gaps else {
1807 return;
1808 };
1809
1810 out.push('\n');
1811 let _ = writeln!(out, "### Coverage Gaps\n");
1812 let _ = writeln!(
1813 out,
1814 "*{} untested files · {} untested exports · {:.1}% file coverage*\n",
1815 gaps.summary.untested_files, gaps.summary.untested_exports, gaps.summary.file_coverage_pct,
1816 );
1817
1818 if gaps.files.is_empty() && gaps.exports.is_empty() {
1819 out.push_str("_No coverage gaps found in scope._\n");
1820 return;
1821 }
1822
1823 if !gaps.files.is_empty() {
1824 out.push_str("#### Files\n");
1825 for item in &gaps.files {
1826 let file_str = escape_backticks(&normalize_uri(
1827 &relative_path(&item.file.path, root).display().to_string(),
1828 ));
1829 let _ = writeln!(
1830 out,
1831 "- `{file_str}` ({count} value export{})",
1832 if item.file.value_export_count == 1 {
1833 ""
1834 } else {
1835 "s"
1836 },
1837 count = item.file.value_export_count,
1838 );
1839 }
1840 out.push('\n');
1841 }
1842
1843 if !gaps.exports.is_empty() {
1844 out.push_str("#### Exports\n");
1845 for item in &gaps.exports {
1846 let file_str = escape_backticks(&normalize_uri(
1847 &relative_path(&item.export.path, root).display().to_string(),
1848 ));
1849 let _ = writeln!(
1850 out,
1851 "- `{file_str}`:{} `{}`",
1852 item.export.line, item.export.export_name
1853 );
1854 }
1855 }
1856}
1857
1858fn ownership_md_cells(
1863 ownership: Option<&fallow_output::OwnershipMetrics>,
1864) -> (String, String, String, String) {
1865 let Some(o) = ownership else {
1866 let dash = "\u{2013}".to_string();
1867 return (dash.clone(), dash.clone(), dash.clone(), dash);
1868 };
1869 let bus = o.bus_factor.to_string();
1870 let top = format!(
1871 "`{}` ({:.0}%)",
1872 o.top_contributor.identifier,
1873 o.top_contributor.share * 100.0,
1874 );
1875 let owner = o
1876 .declared_owner
1877 .as_deref()
1878 .map_or_else(|| "\u{2013}".to_string(), str::to_string);
1879 let mut notes: Vec<&str> = Vec::new();
1880 if o.unowned == Some(true) {
1881 notes.push("**unowned**");
1882 }
1883 if o.ownership_state == fallow_output::OwnershipState::DeclaredInactive {
1884 notes.push("declared owner inactive");
1885 }
1886 if o.drift {
1887 notes.push("drift");
1888 }
1889 let notes_str = if notes.is_empty() {
1890 "\u{2013}".to_string()
1891 } else {
1892 notes.join(", ")
1893 };
1894 (bus, top, owner, notes_str)
1895}
1896
1897fn write_hotspots_section(out: &mut String, report: &fallow_output::HealthReport, root: &Path) {
1898 if report.hotspots.is_empty() {
1899 return;
1900 }
1901
1902 out.push('\n');
1903 let header = report.hotspot_summary.as_ref().map_or_else(
1904 || format!("### Hotspots ({} files)\n", report.hotspots.len()),
1905 |summary| {
1906 format!(
1907 "### Hotspots ({} files, since {})\n",
1908 report.hotspots.len(),
1909 summary.since,
1910 )
1911 },
1912 );
1913 let _ = writeln!(out, "{header}");
1914 let any_ownership = report.hotspots.iter().any(|e| e.ownership.is_some());
1915 write_hotspots_table_header(out, any_ownership);
1916
1917 for entry in &report.hotspots {
1918 write_hotspots_row(out, entry, any_ownership, root);
1919 }
1920
1921 if let Some(ref summary) = report.hotspot_summary
1922 && summary.files_excluded > 0
1923 {
1924 let _ = write!(
1925 out,
1926 "\n*{} file{} excluded (< {} commits)*\n",
1927 summary.files_excluded,
1928 plural(summary.files_excluded),
1929 summary.min_commits,
1930 );
1931 }
1932}
1933
1934fn write_hotspots_table_header(out: &mut String, any_ownership: bool) {
1936 if any_ownership {
1937 out.push_str(
1938 "| File | Score | Commits | Churn | Density | Fan-in | Trend | Bus | Top | Owner | Notes |\n"
1939 );
1940 out.push_str(
1941 "|:-----|:------|:--------|:------|:--------|:-------|:------|:----|:----|:------|:------|\n"
1942 );
1943 } else {
1944 out.push_str("| File | Score | Commits | Churn | Density | Fan-in | Trend |\n");
1945 out.push_str("|:-----|:------|:--------|:------|:--------|:-------|:------|\n");
1946 }
1947}
1948
1949fn write_hotspots_row(
1951 out: &mut String,
1952 entry: &fallow_output::HotspotFinding,
1953 any_ownership: bool,
1954 root: &Path,
1955) {
1956 let file_str = escape_backticks(&normalize_uri(
1957 &relative_path(&entry.path, root).display().to_string(),
1958 ));
1959 if any_ownership {
1960 let (bus, top, owner, notes) = ownership_md_cells(entry.ownership.as_ref());
1961 let _ = writeln!(
1962 out,
1963 "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} | {bus} | {top} | {owner} | {notes} |",
1964 score = entry.score,
1965 commits = entry.commits,
1966 churn = entry.lines_added + entry.lines_deleted,
1967 density = entry.complexity_density,
1968 fi = entry.fan_in,
1969 trend = entry.trend,
1970 );
1971 } else {
1972 let _ = writeln!(
1973 out,
1974 "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} |",
1975 score = entry.score,
1976 commits = entry.commits,
1977 churn = entry.lines_added + entry.lines_deleted,
1978 density = entry.complexity_density,
1979 fi = entry.fan_in,
1980 trend = entry.trend,
1981 );
1982 }
1983}
1984
1985fn write_targets_section(out: &mut String, report: &fallow_output::HealthReport, root: &Path) {
1987 if report.targets.is_empty() {
1988 return;
1989 }
1990 let _ = write!(
1991 out,
1992 "\n### Refactoring Targets ({})\n\n",
1993 report.targets.len()
1994 );
1995 out.push_str("| Efficiency | Category | Effort / Confidence | File | Recommendation |\n");
1996 out.push_str("|:-----------|:---------|:--------------------|:-----|:---------------|\n");
1997 for target in &report.targets {
1998 let file_str = normalize_uri(&relative_path(&target.path, root).display().to_string());
1999 let category = target.category.label();
2000 let effort = target.effort.label();
2001 let confidence = target.confidence.label();
2002 let _ = writeln!(
2003 out,
2004 "| {:.1} | {category} | {effort} / {confidence} | `{file_str}` | {} |",
2005 target.efficiency, target.recommendation,
2006 );
2007 }
2008}
2009
2010fn write_metric_legend(out: &mut String, report: &fallow_output::HealthReport) {
2012 let has_scores = !report.file_scores.is_empty();
2013 let has_coverage = report.coverage_gaps.is_some();
2014 let has_hotspots = !report.hotspots.is_empty();
2015 let has_targets = !report.targets.is_empty();
2016 if !has_scores && !has_coverage && !has_hotspots && !has_targets {
2017 return;
2018 }
2019 out.push_str("\n---\n\n<details><summary>Metric definitions</summary>\n\n");
2020 if has_scores {
2021 out.push_str("- **MI**: Maintainability Index (0\u{2013}100, higher is better)\n");
2022 out.push_str("- **Order**: risk-aware triage order using the larger of low-MI concern and CRAP risk\n");
2023 out.push_str("- **Fan-in**: files that import this file (blast radius)\n");
2024 out.push_str("- **Fan-out**: files this file imports (coupling)\n");
2025 out.push_str("- **Dead Code**: % of value exports with zero references\n");
2026 out.push_str("- **Density**: cyclomatic complexity / lines of code\n");
2027 out.push_str(
2028 "- **Risk**: max CRAP score for the file; low <15, moderate 15-30, high >=30\n",
2029 );
2030 }
2031 if has_coverage {
2032 out.push_str(
2033 "- **File coverage**: runtime files also reachable from a discovered test root\n",
2034 );
2035 out.push_str("- **Untested export**: export with no reference chain from any test-reachable module\n");
2036 }
2037 if has_hotspots {
2038 out.push_str("- **Score**: churn \u{00d7} complexity (0\u{2013}100, higher = riskier)\n");
2039 out.push_str("- **Commits**: commits in the analysis window\n");
2040 out.push_str("- **Churn**: total lines added + deleted\n");
2041 out.push_str("- **Trend**: accelerating / stable / cooling\n");
2042 }
2043 if has_targets {
2044 out.push_str(
2045 "- **Efficiency**: priority / effort (higher = better quick-win value, default sort)\n",
2046 );
2047 out.push_str("- **Category**: recommendation type (churn+complexity, high impact, dead code, complexity, coupling, circular dep)\n");
2048 out.push_str("- **Effort**: estimated effort (low / medium / high) based on file size, function count, and fan-in\n");
2049 out.push_str("- **Confidence**: recommendation reliability (high = deterministic analysis, medium = heuristic, low = git-dependent)\n");
2050 }
2051 out.push_str(
2052 "\n[Full metric reference](https://docs.fallow.tools/explanations/metrics)\n\n</details>\n",
2053 );
2054}