1use crate::report::sink::{out, outln};
2use std::borrow::Cow;
3use std::fmt::Write;
4use std::path::Path;
5
6use fallow_core::duplicates::DuplicationReport;
7use fallow_core::results::{
8 AnalysisResults, UnresolvedCatalogReferenceFinding, UnusedCatalogEntryFinding,
9 UnusedClassMemberFinding, UnusedDependencyOverrideFinding, UnusedEnumMemberFinding,
10 UnusedExport, UnusedExportFinding, UnusedMember, UnusedStoreMemberFinding, UnusedTypeFinding,
11};
12
13use super::grouping::ResultGroup;
14use super::{normalize_uri, plural, relative_path};
15
16fn escape_backticks(s: &str) -> String {
18 s.replace('`', "\\`")
19}
20
21fn display_complexity_entry_name(name: &str) -> Cow<'_, str> {
22 match name {
23 "<template>" => Cow::Borrowed("<template> (template complexity)"),
24 "<component>" => Cow::Borrowed("<component> (component rollup)"),
25 _ => Cow::Borrowed(name),
26 }
27}
28
29pub(super) fn print_markdown(results: &AnalysisResults, root: &Path) {
30 outln!("{}", build_markdown(results, root));
31}
32
33pub fn build_markdown(results: &AnalysisResults, root: &Path) -> String {
35 let total = results.total_issues();
36 let mut out = String::new();
37
38 if total == 0 {
39 out.push_str("## Fallow: no issues found\n");
40 return out;
41 }
42
43 let _ = write!(out, "## Fallow: {total} issue{} found\n\n", plural(total));
44
45 push_markdown_primary_sections(&mut out, results, root);
46 push_markdown_import_sections(&mut out, results, root);
47 push_markdown_dependency_detail_sections(&mut out, results, root);
48 push_markdown_graph_sections(&mut out, results, &|path| {
49 markdown_relative_path(path, root)
50 });
51 push_markdown_catalog_sections(&mut out, results, &|path| {
52 markdown_relative_path(path, root)
53 });
54
55 out
56}
57
58fn markdown_relative_path(path: &Path, root: &Path) -> String {
59 escape_backticks(&normalize_uri(
60 &relative_path(path, root).display().to_string(),
61 ))
62}
63
64fn push_markdown_primary_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
65 markdown_section(out, &results.unused_files, "Unused files", |file| {
66 vec![format!(
67 "- `{}`",
68 markdown_relative_path(&file.file.path, root)
69 )]
70 });
71
72 markdown_grouped_section(
73 out,
74 &results.unused_exports,
75 "Unused exports",
76 root,
77 |e| e.export.path.as_path(),
78 |e: &UnusedExportFinding| format_export(&e.export),
79 );
80
81 markdown_grouped_section(
82 out,
83 &results.unused_types,
84 "Unused type exports",
85 root,
86 |e| e.export.path.as_path(),
87 |e: &UnusedTypeFinding| format_export(&e.export),
88 );
89
90 markdown_grouped_section(
91 out,
92 &results.private_type_leaks,
93 "Private type leaks",
94 root,
95 |e| e.leak.path.as_path(),
96 format_private_type_leak,
97 );
98
99 push_markdown_dependency_sections(out, results, root);
100 push_markdown_member_sections(out, results, root);
101}
102
103fn push_markdown_import_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
104 markdown_grouped_section(
105 out,
106 &results.unresolved_imports,
107 "Unresolved imports",
108 root,
109 |i| i.import.path.as_path(),
110 |i| {
111 format!(
112 ":{} `{}`",
113 i.import.line,
114 escape_backticks(&i.import.specifier)
115 )
116 },
117 );
118
119 markdown_section(
120 out,
121 &results.unlisted_dependencies,
122 "Unlisted dependencies",
123 |dep| vec![format!("- `{}`", escape_backticks(&dep.dep.package_name))],
124 );
125
126 markdown_section(
127 out,
128 &results.duplicate_exports,
129 "Duplicate exports",
130 |dup| {
131 let locations: Vec<String> = dup
132 .export
133 .locations
134 .iter()
135 .map(|loc| format!("`{}`", markdown_relative_path(&loc.path, root)))
136 .collect();
137 vec![format!(
138 "- `{}` in {}",
139 escape_backticks(&dup.export.export_name),
140 locations.join(", ")
141 )]
142 },
143 );
144}
145
146fn push_markdown_dependency_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
147 markdown_section(
148 out,
149 &results.unused_dependencies,
150 "Unused dependencies",
151 |dep| {
152 format_dependency(
153 &dep.dep.package_name,
154 &dep.dep.path,
155 &dep.dep.used_in_workspaces,
156 root,
157 )
158 },
159 );
160 markdown_section(
161 out,
162 &results.unused_dev_dependencies,
163 "Unused devDependencies",
164 |dep| {
165 format_dependency(
166 &dep.dep.package_name,
167 &dep.dep.path,
168 &dep.dep.used_in_workspaces,
169 root,
170 )
171 },
172 );
173 markdown_section(
174 out,
175 &results.unused_optional_dependencies,
176 "Unused optionalDependencies",
177 |dep| {
178 format_dependency(
179 &dep.dep.package_name,
180 &dep.dep.path,
181 &dep.dep.used_in_workspaces,
182 root,
183 )
184 },
185 );
186}
187
188fn push_markdown_member_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
189 markdown_grouped_section(
190 out,
191 &results.unused_enum_members,
192 "Unused enum members",
193 root,
194 |m| m.member.path.as_path(),
195 |m: &UnusedEnumMemberFinding| format_member(&m.member),
196 );
197 markdown_grouped_section(
198 out,
199 &results.unused_class_members,
200 "Unused class members",
201 root,
202 |m| m.member.path.as_path(),
203 |m: &UnusedClassMemberFinding| format_member(&m.member),
204 );
205 markdown_grouped_section(
206 out,
207 &results.unused_store_members,
208 "Unused store members",
209 root,
210 |m| m.member.path.as_path(),
211 |m: &UnusedStoreMemberFinding| format_member(&m.member),
212 );
213}
214
215fn push_markdown_dependency_detail_sections(
216 out: &mut String,
217 results: &AnalysisResults,
218 root: &Path,
219) {
220 markdown_section(
221 out,
222 &results.type_only_dependencies,
223 "Type-only dependencies (consider moving to devDependencies)",
224 |dep| format_dependency(&dep.dep.package_name, &dep.dep.path, &[], root),
225 );
226 markdown_section(
227 out,
228 &results.test_only_dependencies,
229 "Test-only production dependencies (consider moving to devDependencies)",
230 |dep| format_dependency(&dep.dep.package_name, &dep.dep.path, &[], root),
231 );
232}
233
234fn push_markdown_graph_sections(
235 out: &mut String,
236 results: &AnalysisResults,
237 rel: &dyn Fn(&Path) -> String,
238) {
239 push_markdown_structure_sections(out, results, rel);
240 push_markdown_framework_sections(out, results, rel);
241 push_markdown_component_sections(out, results, rel);
242 push_markdown_suppression_sections(out, results, rel);
243}
244
245fn push_markdown_structure_sections(
246 out: &mut String,
247 results: &AnalysisResults,
248 rel: &dyn Fn(&Path) -> String,
249) {
250 markdown_section(
251 out,
252 &results.circular_dependencies,
253 "Circular dependencies",
254 |cycle| format_markdown_circular_dependency(cycle, rel),
255 );
256 markdown_section(
257 out,
258 &results.re_export_cycles,
259 "Re-export cycles",
260 |cycle| format_markdown_re_export_cycle(cycle, rel),
261 );
262 markdown_section(
263 out,
264 &results.boundary_violations,
265 "Boundary violations",
266 |v| format_markdown_boundary_violation(v, rel),
267 );
268 markdown_section(
269 out,
270 &results.boundary_coverage_violations,
271 "Boundary coverage",
272 |v| format_markdown_boundary_coverage(v, rel),
273 );
274 markdown_section(
275 out,
276 &results.boundary_call_violations,
277 "Boundary calls",
278 |v| format_markdown_boundary_call(v, rel),
279 );
280 markdown_section(out, &results.policy_violations, "Policy violations", |v| {
281 format_markdown_policy_violation(v, rel)
282 });
283}
284
285fn push_markdown_framework_sections(
286 out: &mut String,
287 results: &AnalysisResults,
288 rel: &dyn Fn(&Path) -> String,
289) {
290 markdown_section(
291 out,
292 &results.invalid_client_exports,
293 "Invalid client exports",
294 |e| format_markdown_invalid_client_export(e, rel),
295 );
296 markdown_section(
297 out,
298 &results.mixed_client_server_barrels,
299 "Mixed client/server barrels",
300 |b| format_markdown_mixed_client_server_barrel(b, rel),
301 );
302 markdown_section(
303 out,
304 &results.misplaced_directives,
305 "Misplaced directives",
306 |d| format_markdown_misplaced_directive(d, rel),
307 );
308 markdown_section(out, &results.route_collisions, "Route collisions", |c| {
309 format_markdown_route_collision(c, rel)
310 });
311 markdown_section(
312 out,
313 &results.dynamic_segment_name_conflicts,
314 "Dynamic segment conflicts",
315 |c| format_markdown_dynamic_segment_name_conflict(c, rel),
316 );
317 markdown_section(
318 out,
319 &results.unprovided_injects,
320 "Unprovided injects",
321 |i| format_markdown_unprovided_inject(i, rel),
322 );
323}
324
325fn push_markdown_component_sections(
326 out: &mut String,
327 results: &AnalysisResults,
328 rel: &dyn Fn(&Path) -> String,
329) {
330 markdown_section(
331 out,
332 &results.unrendered_components,
333 "Unrendered components",
334 |c| format_markdown_unrendered_component(c, rel),
335 );
336 markdown_section(
337 out,
338 &results.unused_component_props,
339 "Unused component props",
340 |p| format_markdown_unused_component_prop(p, rel),
341 );
342 markdown_section(
343 out,
344 &results.unused_component_emits,
345 "Unused component emits",
346 |e| format_markdown_unused_component_emit(e, rel),
347 );
348 markdown_section(
349 out,
350 &results.unused_component_inputs,
351 "Unused component inputs",
352 |i| format_markdown_unused_component_input(i, rel),
353 );
354 markdown_section(
355 out,
356 &results.unused_component_outputs,
357 "Unused component outputs",
358 |o| format_markdown_unused_component_output(o, rel),
359 );
360 markdown_section(
361 out,
362 &results.unused_svelte_events,
363 "Unused Svelte events",
364 |e| format_markdown_unused_svelte_event(e, rel),
365 );
366 markdown_section(
367 out,
368 &results.unused_server_actions,
369 "Unused server actions",
370 |a| format_markdown_unused_server_action(a, rel),
371 );
372 markdown_section(
373 out,
374 &results.unused_load_data_keys,
375 "Unused load data keys",
376 |k| format_markdown_unused_load_data_key(k, rel),
377 );
378}
379
380fn push_markdown_suppression_sections(
381 out: &mut String,
382 results: &AnalysisResults,
383 rel: &dyn Fn(&Path) -> String,
384) {
385 markdown_section(
386 out,
387 &results.stale_suppressions,
388 "Stale suppressions",
389 |s| {
390 vec![format!(
391 "- `{}`:{} `{}` ({})",
392 rel(&s.path),
393 s.line,
394 escape_backticks(&s.description()),
395 escape_backticks(&s.explanation()),
396 )]
397 },
398 );
399}
400
401fn format_markdown_circular_dependency(
402 cycle: &fallow_core::results::CircularDependencyFinding,
403 rel: &dyn Fn(&Path) -> String,
404) -> Vec<String> {
405 let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
406 let mut display_chain = chain.clone();
407 if let Some(first) = chain.first() {
408 display_chain.push(first.clone());
409 }
410 let cross_pkg_tag = if cycle.cycle.is_cross_package {
411 " *(cross-package)*"
412 } else {
413 ""
414 };
415 vec![format!(
416 "- {}{}",
417 display_chain
418 .iter()
419 .map(|s| format!("`{s}`"))
420 .collect::<Vec<_>>()
421 .join(" \u{2192} "),
422 cross_pkg_tag
423 )]
424}
425
426fn format_markdown_re_export_cycle(
427 cycle: &fallow_core::results::ReExportCycleFinding,
428 rel: &dyn Fn(&Path) -> String,
429) -> Vec<String> {
430 let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
431 let kind_tag = match cycle.cycle.kind {
432 fallow_core::results::ReExportCycleKind::SelfLoop => " *(self-loop)*",
433 fallow_core::results::ReExportCycleKind::MultiNode => "",
434 };
435 vec![format!(
436 "- {}{}",
437 chain
438 .iter()
439 .map(|s| format!("`{s}`"))
440 .collect::<Vec<_>>()
441 .join(" <-> "),
442 kind_tag
443 )]
444}
445
446fn format_markdown_boundary_violation(
447 v: &fallow_core::results::BoundaryViolationFinding,
448 rel: &dyn Fn(&Path) -> String,
449) -> Vec<String> {
450 vec![format!(
451 "- `{}`:{} \u{2192} `{}` ({} \u{2192} {})",
452 rel(&v.violation.from_path),
453 v.violation.line,
454 rel(&v.violation.to_path),
455 v.violation.from_zone,
456 v.violation.to_zone,
457 )]
458}
459
460fn format_markdown_boundary_coverage(
461 v: &fallow_core::results::BoundaryCoverageViolationFinding,
462 rel: &dyn Fn(&Path) -> String,
463) -> Vec<String> {
464 vec![format!(
465 "- `{}`:{} no matching boundary zone",
466 rel(&v.violation.path),
467 v.violation.line,
468 )]
469}
470
471fn format_markdown_boundary_call(
472 v: &fallow_core::results::BoundaryCallViolationFinding,
473 rel: &dyn Fn(&Path) -> String,
474) -> Vec<String> {
475 vec![format!(
476 "- `{}`:{} `{}` forbidden in zone `{}` (pattern `{}`)",
477 rel(&v.violation.path),
478 v.violation.line,
479 v.violation.callee,
480 v.violation.zone,
481 v.violation.pattern,
482 )]
483}
484
485fn format_markdown_policy_violation(
486 v: &fallow_core::results::PolicyViolationFinding,
487 rel: &dyn Fn(&Path) -> String,
488) -> Vec<String> {
489 vec![format!(
490 "- `{}`:{} `{}` banned by `{}/{}`{}",
491 rel(&v.violation.path),
492 v.violation.line,
493 v.violation.matched,
494 v.violation.pack,
495 v.violation.rule_id,
496 v.violation
497 .message
498 .as_deref()
499 .map(|m| format!(" ({m})"))
500 .unwrap_or_default(),
501 )]
502}
503
504fn format_markdown_invalid_client_export(
505 e: &fallow_core::results::InvalidClientExportFinding,
506 rel: &dyn Fn(&Path) -> String,
507) -> Vec<String> {
508 vec![format!(
509 "- `{}`:{} `{}` (from `\"{}\"`)",
510 rel(&e.export.path),
511 e.export.line,
512 e.export.export_name,
513 e.export.directive,
514 )]
515}
516
517fn format_markdown_mixed_client_server_barrel(
518 b: &fallow_core::results::MixedClientServerBarrelFinding,
519 rel: &dyn Fn(&Path) -> String,
520) -> Vec<String> {
521 vec![format!(
522 "- `{}`:{} re-exports client `{}` and server-only `{}`",
523 rel(&b.barrel.path),
524 b.barrel.line,
525 b.barrel.client_origin,
526 b.barrel.server_origin,
527 )]
528}
529
530fn format_markdown_misplaced_directive(
531 d: &fallow_core::results::MisplacedDirectiveFinding,
532 rel: &dyn Fn(&Path) -> String,
533) -> Vec<String> {
534 vec![format!(
535 "- `{}`:{} `\"{}\"` is not in the leading position and is ignored",
536 rel(&d.directive_site.path),
537 d.directive_site.line,
538 d.directive_site.directive,
539 )]
540}
541
542fn format_markdown_unprovided_inject(
543 i: &fallow_core::results::UnprovidedInjectFinding,
544 rel: &dyn Fn(&Path) -> String,
545) -> Vec<String> {
546 vec![format!(
547 "- `{}`:{} `{}` has no matching provide(`{}`) in this project; at runtime it returns undefined",
548 rel(&i.inject.path),
549 i.inject.line,
550 escape_backticks(&i.inject.key_name),
551 escape_backticks(&i.inject.key_name),
552 )]
553}
554
555fn format_markdown_unrendered_component(
556 c: &fallow_core::results::UnrenderedComponentFinding,
557 rel: &dyn Fn(&Path) -> String,
558) -> Vec<String> {
559 vec![format!(
560 "- `{}`:{} `{}` is reachable but rendered nowhere in this project (render it somewhere or remove it)",
561 rel(&c.component.path),
562 c.component.line,
563 escape_backticks(&c.component.component_name),
564 )]
565}
566
567fn format_markdown_unused_component_prop(
568 p: &fallow_core::results::UnusedComponentPropFinding,
569 rel: &dyn Fn(&Path) -> String,
570) -> Vec<String> {
571 vec![format!(
572 "- `{}`:{} `{}` is declared but referenced nowhere in this component (remove it or use it)",
573 rel(&p.prop.path),
574 p.prop.line,
575 escape_backticks(&p.prop.prop_name),
576 )]
577}
578
579fn format_markdown_unused_component_emit(
580 e: &fallow_core::results::UnusedComponentEmitFinding,
581 rel: &dyn Fn(&Path) -> String,
582) -> Vec<String> {
583 vec![format!(
584 "- `{}`:{} `{}` is declared but emitted nowhere in this component (remove it or emit it)",
585 rel(&e.emit.path),
586 e.emit.line,
587 escape_backticks(&e.emit.emit_name),
588 )]
589}
590
591fn format_markdown_unused_svelte_event(
592 e: &fallow_core::results::UnusedSvelteEventFinding,
593 rel: &dyn Fn(&Path) -> String,
594) -> Vec<String> {
595 vec![format!(
596 "- `{}`:{} `{}` is dispatched but listened to nowhere in the project (remove it or listen for it)",
597 rel(&e.event.path),
598 e.event.line,
599 escape_backticks(&e.event.event_name),
600 )]
601}
602
603fn format_markdown_unused_component_input(
604 i: &fallow_core::results::UnusedComponentInputFinding,
605 rel: &dyn Fn(&Path) -> String,
606) -> Vec<String> {
607 vec![format!(
608 "- `{}`:{} `{}` is declared but referenced nowhere in this component (remove it or use it)",
609 rel(&i.input.path),
610 i.input.line,
611 escape_backticks(&i.input.input_name),
612 )]
613}
614
615fn format_markdown_unused_component_output(
616 o: &fallow_core::results::UnusedComponentOutputFinding,
617 rel: &dyn Fn(&Path) -> String,
618) -> Vec<String> {
619 vec![format!(
620 "- `{}`:{} `{}` is declared but emitted nowhere in this component (remove it or emit it)",
621 rel(&o.output.path),
622 o.output.line,
623 escape_backticks(&o.output.output_name),
624 )]
625}
626
627fn format_markdown_unused_server_action(
628 a: &fallow_core::results::UnusedServerActionFinding,
629 rel: &dyn Fn(&Path) -> String,
630) -> Vec<String> {
631 vec![format!(
632 "- `{}`:{} `{}` is exported from a \"use server\" file but no code in this project references it",
633 rel(&a.action.path),
634 a.action.line,
635 escape_backticks(&a.action.action_name),
636 )]
637}
638
639fn format_markdown_unused_load_data_key(
640 k: &fallow_core::results::UnusedLoadDataKeyFinding,
641 rel: &dyn Fn(&Path) -> String,
642) -> Vec<String> {
643 vec![format!(
644 "- `{}`:{} `{}` is returned from load() but no consumer reads it",
645 rel(&k.key.path),
646 k.key.line,
647 escape_backticks(&k.key.key_name),
648 )]
649}
650
651fn format_markdown_route_collision(
652 c: &fallow_core::results::RouteCollisionFinding,
653 rel: &dyn Fn(&Path) -> String,
654) -> Vec<String> {
655 vec![format!(
656 "- `{}` resolves to `{}` (shared with {} other route file(s))",
657 rel(&c.collision.path),
658 c.collision.url,
659 c.collision.conflicting_paths.len(),
660 )]
661}
662
663fn format_markdown_dynamic_segment_name_conflict(
664 c: &fallow_core::results::DynamicSegmentNameConflictFinding,
665 rel: &dyn Fn(&Path) -> String,
666) -> Vec<String> {
667 vec![format!(
668 "- `{}` crashes at runtime: different slug names ({}) at the same dynamic path `{}`; \
669 `next build` passes but the route fails on its first request (rename to one consistent slug)",
670 rel(&c.conflict.path),
671 c.conflict.conflicting_segments.join(" vs "),
672 c.conflict.position,
673 )]
674}
675
676fn push_markdown_catalog_sections(
677 out: &mut String,
678 results: &AnalysisResults,
679 rel: &dyn Fn(&Path) -> String,
680) {
681 markdown_section(
682 out,
683 &results.unused_catalog_entries,
684 "Unused catalog entries",
685 |entry| format_unused_catalog_entry(entry, rel),
686 );
687 markdown_section(
688 out,
689 &results.empty_catalog_groups,
690 "Empty catalog groups",
691 |group| {
692 vec![format!(
693 "- `{}` `{}`:{}",
694 escape_backticks(&group.group.catalog_name),
695 rel(&group.group.path),
696 group.group.line,
697 )]
698 },
699 );
700 markdown_section(
701 out,
702 &results.unresolved_catalog_references,
703 "Unresolved catalog references",
704 |finding| format_unresolved_catalog_reference(finding, rel),
705 );
706 markdown_section(
707 out,
708 &results.unused_dependency_overrides,
709 "Unused dependency overrides",
710 |finding| format_unused_dependency_override(finding, rel),
711 );
712 markdown_section(
713 out,
714 &results.misconfigured_dependency_overrides,
715 "Misconfigured dependency overrides",
716 |finding| {
717 vec![format!(
718 "- `{}` -> `{}` (`{}`) `{}`:{} ({})",
719 escape_backticks(&finding.entry.raw_key),
720 escape_backticks(&finding.entry.raw_value),
721 finding.entry.source.as_label(),
722 rel(&finding.entry.path),
723 finding.entry.line,
724 finding.entry.reason.describe(),
725 )]
726 },
727 );
728}
729
730fn format_unused_catalog_entry(
731 entry: &UnusedCatalogEntryFinding,
732 rel: &dyn Fn(&Path) -> String,
733) -> Vec<String> {
734 let mut row = format!(
735 "- `{}` (`{}`) `{}`:{}",
736 escape_backticks(&entry.entry.entry_name),
737 escape_backticks(&entry.entry.catalog_name),
738 rel(&entry.entry.path),
739 entry.entry.line,
740 );
741 if !entry.entry.hardcoded_consumers.is_empty() {
742 let consumers = entry
743 .entry
744 .hardcoded_consumers
745 .iter()
746 .map(|p| format!("`{}`", rel(p)))
747 .collect::<Vec<_>>()
748 .join(", ");
749 let _ = write!(row, " (hardcoded in {consumers})");
750 }
751 vec![row]
752}
753
754fn format_unresolved_catalog_reference(
755 finding: &UnresolvedCatalogReferenceFinding,
756 rel: &dyn Fn(&Path) -> String,
757) -> Vec<String> {
758 let mut row = format!(
759 "- `{}` (`{}`) `{}`:{}",
760 escape_backticks(&finding.reference.entry_name),
761 escape_backticks(&finding.reference.catalog_name),
762 rel(&finding.reference.path),
763 finding.reference.line,
764 );
765 if !finding.reference.available_in_catalogs.is_empty() {
766 let alts = finding
767 .reference
768 .available_in_catalogs
769 .iter()
770 .map(|c| format!("`{}`", escape_backticks(c)))
771 .collect::<Vec<_>>()
772 .join(", ");
773 let _ = write!(row, " (available in: {alts})");
774 }
775 vec![row]
776}
777
778fn format_unused_dependency_override(
779 finding: &UnusedDependencyOverrideFinding,
780 rel: &dyn Fn(&Path) -> String,
781) -> Vec<String> {
782 let mut row = format!(
783 "- `{}` -> `{}` (`{}`) `{}`:{}",
784 escape_backticks(&finding.entry.raw_key),
785 escape_backticks(&finding.entry.version_range),
786 finding.entry.source.as_label(),
787 rel(&finding.entry.path),
788 finding.entry.line,
789 );
790 if let Some(hint) = &finding.entry.hint {
791 let _ = write!(row, " (hint: {})", escape_backticks(hint));
792 }
793 vec![row]
794}
795
796pub(super) fn print_grouped_markdown(groups: &[ResultGroup], root: &Path) {
798 let total: usize = groups.iter().map(|g| g.results.total_issues()).sum();
799
800 if total == 0 {
801 outln!("## Fallow: no issues found");
802 return;
803 }
804
805 outln!(
806 "## Fallow: {total} issue{} found (grouped)\n",
807 plural(total)
808 );
809
810 for group in groups {
811 let count = group.results.total_issues();
812 if count == 0 {
813 continue;
814 }
815 outln!(
816 "## {} ({count} issue{})\n",
817 escape_backticks(&group.key),
818 plural(count)
819 );
820 if let Some(ref owners) = group.owners
821 && !owners.is_empty()
822 {
823 let joined = owners
824 .iter()
825 .map(|o| escape_backticks(o))
826 .collect::<Vec<_>>()
827 .join(" ");
828 outln!("Owners: {joined}\n");
829 }
830 let body = build_markdown(&group.results, root);
831 let sections = body
832 .strip_prefix("## Fallow: no issues found\n")
833 .or_else(|| body.find("\n\n").map(|pos| &body[pos + 2..]))
834 .unwrap_or(&body);
835 out!("{sections}");
836 }
837}
838
839fn format_export(e: &UnusedExport) -> String {
840 let re = if e.is_re_export { " (re-export)" } else { "" };
841 format!(":{} `{}`{re}", e.line, escape_backticks(&e.export_name))
842}
843
844fn format_private_type_leak(
845 entry: &fallow_types::output_dead_code::PrivateTypeLeakFinding,
846) -> String {
847 let e = &entry.leak;
848 format!(
849 ":{} `{}` references private type `{}`",
850 e.line,
851 escape_backticks(&e.export_name),
852 escape_backticks(&e.type_name)
853 )
854}
855
856fn format_member(m: &UnusedMember) -> String {
857 format!(
858 ":{} `{}.{}`",
859 m.line,
860 escape_backticks(&m.parent_name),
861 escape_backticks(&m.member_name)
862 )
863}
864
865fn format_dependency(
866 dep_name: &str,
867 pkg_path: &Path,
868 used_in_workspaces: &[std::path::PathBuf],
869 root: &Path,
870) -> Vec<String> {
871 let name = escape_backticks(dep_name);
872 let pkg_label = relative_path(pkg_path, root).display().to_string();
873 let workspace_context = if used_in_workspaces.is_empty() {
874 String::new()
875 } else {
876 let workspaces = used_in_workspaces
877 .iter()
878 .map(|path| escape_backticks(&relative_path(path, root).display().to_string()))
879 .collect::<Vec<_>>()
880 .join(", ");
881 format!("; imported in {workspaces}")
882 };
883 if pkg_label == "package.json" && workspace_context.is_empty() {
884 vec![format!("- `{name}`")]
885 } else {
886 let label = if pkg_label == "package.json" {
887 workspace_context.trim_start_matches("; ").to_string()
888 } else {
889 format!("{}{workspace_context}", escape_backticks(&pkg_label))
890 };
891 vec![format!("- `{name}` ({label})")]
892 }
893}
894
895fn markdown_section<T>(
897 out: &mut String,
898 items: &[T],
899 title: &str,
900 format_lines: impl Fn(&T) -> Vec<String>,
901) {
902 if items.is_empty() {
903 return;
904 }
905 let _ = write!(out, "### {title} ({})\n\n", items.len());
906 for item in items {
907 for line in format_lines(item) {
908 out.push_str(&line);
909 out.push('\n');
910 }
911 }
912 out.push('\n');
913}
914
915fn markdown_grouped_section<'a, T>(
917 out: &mut String,
918 items: &'a [T],
919 title: &str,
920 root: &Path,
921 get_path: impl Fn(&'a T) -> &'a Path,
922 format_detail: impl Fn(&T) -> String,
923) {
924 if items.is_empty() {
925 return;
926 }
927 let _ = write!(out, "### {title} ({})\n\n", items.len());
928
929 let mut indices: Vec<usize> = (0..items.len()).collect();
930 indices.sort_by(|&a, &b| get_path(&items[a]).cmp(get_path(&items[b])));
931
932 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
933 let mut last_file = String::new();
934 for &i in &indices {
935 let item = &items[i];
936 let file_str = rel(get_path(item));
937 if file_str != last_file {
938 let _ = writeln!(out, "- `{file_str}`");
939 last_file = file_str;
940 }
941 let _ = writeln!(out, " - {}", format_detail(item));
942 }
943 out.push('\n');
944}
945
946pub(super) fn print_duplication_markdown(report: &DuplicationReport, root: &Path) {
947 outln!("{}", build_duplication_markdown(report, root));
948}
949
950#[must_use]
952pub fn build_duplication_markdown(report: &DuplicationReport, root: &Path) -> String {
953 let mut out = String::new();
954
955 if report.clone_groups.is_empty() {
956 out.push_str("## Fallow: no code duplication found\n");
957 return out;
958 }
959
960 let stats = &report.stats;
961 let _ = write!(
962 out,
963 "## Fallow: {} clone group{} found ({:.1}% duplication)\n\n",
964 stats.clone_groups,
965 plural(stats.clone_groups),
966 stats.duplication_percentage,
967 );
968
969 write_duplication_groups(&mut out, report, root);
970 write_duplication_families(&mut out, report, root);
971
972 let _ = writeln!(
973 out,
974 "**Summary:** {} duplicated lines ({:.1}%) across {} file{}",
975 stats.duplicated_lines,
976 stats.duplication_percentage,
977 stats.files_with_clones,
978 plural(stats.files_with_clones),
979 );
980
981 out
982}
983
984fn write_duplication_groups(out: &mut String, report: &DuplicationReport, root: &Path) {
986 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
987 out.push_str("### Duplicates\n\n");
988 for (i, group) in report.clone_groups.iter().enumerate() {
989 let instance_count = group.instances.len();
990 let _ = write!(
991 out,
992 "**Clone group {}** ({} lines, {instance_count} instance{})\n\n",
993 i + 1,
994 group.line_count,
995 plural(instance_count)
996 );
997 for instance in &group.instances {
998 let relative = rel(&instance.file);
999 let _ = writeln!(
1000 out,
1001 "- `{relative}:{}-{}`",
1002 instance.start_line, instance.end_line
1003 );
1004 }
1005 out.push('\n');
1006 }
1007}
1008
1009fn write_duplication_families(out: &mut String, report: &DuplicationReport, root: &Path) {
1011 if report.clone_families.is_empty() {
1012 return;
1013 }
1014 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
1015 out.push_str("### Clone Families\n\n");
1016 for (i, family) in report.clone_families.iter().enumerate() {
1017 let file_names: Vec<_> = family.files.iter().map(|f| rel(f)).collect();
1018 let _ = write!(
1019 out,
1020 "**Family {}** ({} group{}, {} lines across {})\n\n",
1021 i + 1,
1022 family.groups.len(),
1023 plural(family.groups.len()),
1024 family.total_duplicated_lines,
1025 file_names
1026 .iter()
1027 .map(|s| format!("`{s}`"))
1028 .collect::<Vec<_>>()
1029 .join(", "),
1030 );
1031 for suggestion in &family.suggestions {
1032 let savings = if suggestion.estimated_savings > 0 {
1033 format!(" (~{} lines saved)", suggestion.estimated_savings)
1034 } else {
1035 String::new()
1036 };
1037 let _ = writeln!(out, "- {}{savings}", suggestion.description);
1038 }
1039 out.push('\n');
1040 }
1041}
1042
1043pub(super) fn print_health_markdown(report: &crate::health_types::HealthReport, root: &Path) {
1044 outln!("{}", build_health_markdown(report, root));
1045}
1046
1047#[must_use]
1049pub fn build_health_markdown(report: &crate::health_types::HealthReport, root: &Path) -> String {
1050 let mut out = String::new();
1051
1052 if let Some(ref hs) = report.health_score {
1053 let _ = writeln!(out, "## Health Score: {:.0} ({})\n", hs.score, hs.grade);
1054 }
1055
1056 write_trend_section(&mut out, report);
1057 write_vital_signs_section(&mut out, report);
1058
1059 if report.findings.is_empty()
1060 && report.file_scores.is_empty()
1061 && report.coverage_gaps.is_none()
1062 && report.hotspots.is_empty()
1063 && report.targets.is_empty()
1064 && report.runtime_coverage.is_none()
1065 && report.coverage_intelligence.is_none()
1066 && report.threshold_overrides.is_empty()
1067 && report.css_analytics.is_none()
1068 {
1069 if report.vital_signs.is_none() {
1070 let _ = write!(
1071 out,
1072 "## Fallow: no functions exceed complexity thresholds\n\n\
1073 **{}** functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {:.1})\n",
1074 report.summary.functions_analyzed,
1075 report.summary.max_cyclomatic_threshold,
1076 report.summary.max_cognitive_threshold,
1077 report.summary.max_crap_threshold,
1078 );
1079 }
1080 return out;
1081 }
1082
1083 write_findings_section(&mut out, report, root);
1084 write_threshold_overrides_section(&mut out, report, root);
1085 write_runtime_coverage_section(&mut out, report, root);
1086 write_coverage_intelligence_section(&mut out, report, root);
1087 write_coverage_gaps_section(&mut out, report, root);
1088 write_file_scores_section(&mut out, report, root);
1089 write_hotspots_section(&mut out, report, root);
1090 write_targets_section(&mut out, report, root);
1091 write_css_analytics_section(&mut out, report);
1092 write_metric_legend(&mut out, report);
1093
1094 out
1095}
1096
1097fn write_css_analytics_section(out: &mut String, report: &crate::health_types::HealthReport) {
1101 let Some(ref css) = report.css_analytics else {
1102 return;
1103 };
1104 let s = &css.summary;
1105 if !out.is_empty() && !out.ends_with("\n\n") {
1106 out.push('\n');
1107 }
1108 out.push_str("## CSS Health\n\n");
1109 let important_pct = if s.total_declarations > 0 {
1110 f64::from(s.important_declarations) / f64::from(s.total_declarations) * 100.0
1111 } else {
1112 0.0
1113 };
1114 let _ = writeln!(
1115 out,
1116 "- Stylesheets: {} | Rules: {} | !important: {important_pct:.1}% | Empty rules: {} | Max nesting: {}",
1117 s.files_analyzed, s.total_rules, s.empty_rules, s.max_nesting_depth,
1118 );
1119 let _ = writeln!(
1120 out,
1121 "- Value sprawl: {} colors | {} font sizes | {} z-index | {} shadows | {} radii | {} line-heights",
1122 s.unique_colors,
1123 s.unique_font_sizes,
1124 s.unique_z_indexes,
1125 s.unique_box_shadows,
1126 s.unique_border_radii,
1127 s.unique_line_heights,
1128 );
1129 let _ = writeln!(
1130 out,
1131 "- 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",
1132 s.keyframes_unreferenced,
1133 s.keyframes_undefined,
1134 s.duplicate_declaration_blocks,
1135 s.scoped_unused_classes,
1136 s.tailwind_arbitrary_values,
1137 s.unused_property_registrations,
1138 s.unused_layers,
1139 s.unresolved_class_references,
1140 s.unreferenced_css_classes,
1141 s.unused_font_faces,
1142 s.unused_theme_tokens,
1143 );
1144 write_css_candidate_details(out, css);
1145 out.push('\n');
1146}
1147
1148fn write_css_candidate_details(out: &mut String, css: &crate::health_types::CssAnalyticsReport) {
1149 write_css_keyframe_details(out, css);
1150 write_css_tailwind_details(out, css);
1151 write_css_class_candidate_details(out, css);
1152 write_css_font_candidate_details(out, css);
1153 write_css_font_size_mix_details(out, css);
1154}
1155
1156fn write_css_keyframe_details(out: &mut String, css: &crate::health_types::CssAnalyticsReport) {
1157 if !css.undefined_keyframes.is_empty() {
1158 let named: Vec<String> = css
1159 .undefined_keyframes
1160 .iter()
1161 .take(5)
1162 .map(|kf| format!("`{}` ({})", kf.name, kf.path))
1163 .collect();
1164 let _ = writeln!(
1165 out,
1166 "- Undefined @keyframes (candidates; likely typo or CSS-in-JS): {}",
1167 named.join(", "),
1168 );
1169 }
1170}
1171
1172fn write_css_tailwind_details(out: &mut String, css: &crate::health_types::CssAnalyticsReport) {
1173 if !css.tailwind_arbitrary_values.is_empty() {
1174 let named: Vec<String> = css
1175 .tailwind_arbitrary_values
1176 .iter()
1177 .take(5)
1178 .map(|a| format!("`{}` ({}x)", a.value, a.count))
1179 .collect();
1180 let _ = writeln!(out, "- Top Tailwind arbitrary values: {}", named.join(", "));
1181 }
1182}
1183
1184fn write_css_class_candidate_details(
1185 out: &mut String,
1186 css: &crate::health_types::CssAnalyticsReport,
1187) {
1188 if !css.unresolved_class_references.is_empty() {
1189 let named: Vec<String> = css
1190 .unresolved_class_references
1191 .iter()
1192 .take(5)
1193 .map(|u| {
1194 format!(
1195 "`{}` -> `{}` ({}:{})",
1196 u.class, u.suggestion, u.path, u.line
1197 )
1198 })
1199 .collect();
1200 let _ = writeln!(
1201 out,
1202 "- Likely class typos (candidates; verify, may be CSS-in-JS or external): {}",
1203 named.join(", "),
1204 );
1205 }
1206 if !css.unreferenced_css_classes.is_empty() {
1207 let named: Vec<String> = css
1208 .unreferenced_css_classes
1209 .iter()
1210 .take(5)
1211 .map(|u| format!("`.{}` ({}:{})", u.class, u.path, u.line))
1212 .collect();
1213 let _ = writeln!(
1214 out,
1215 "- Unreferenced global classes (candidates; verify no email / server / CMS / Markdown applies them): {}",
1216 named.join(", "),
1217 );
1218 }
1219}
1220
1221fn write_css_font_candidate_details(
1222 out: &mut String,
1223 css: &crate::health_types::CssAnalyticsReport,
1224) {
1225 if !css.unused_font_faces.is_empty() {
1226 let named: Vec<String> = css
1227 .unused_font_faces
1228 .iter()
1229 .take(5)
1230 .map(|u| format!("`{}` ({})", u.family, u.path))
1231 .collect();
1232 let _ = writeln!(
1233 out,
1234 "- Unused @font-face (dead web-font; candidates, may be set from JS/inline): {}",
1235 named.join(", "),
1236 );
1237 }
1238 if !css.unused_theme_tokens.is_empty() {
1239 let named: Vec<String> = css
1240 .unused_theme_tokens
1241 .iter()
1242 .take(5)
1243 .map(|u| format!("`{}` ({}:{})", u.token, u.path, u.line))
1244 .collect();
1245 let _ = writeln!(
1246 out,
1247 "- Unused @theme tokens (dead Tailwind v4 design tokens; candidates, may be consumed by a plugin or downstream repo): {}",
1248 named.join(", "),
1249 );
1250 }
1251}
1252
1253fn write_css_font_size_mix_details(
1254 out: &mut String,
1255 css: &crate::health_types::CssAnalyticsReport,
1256) {
1257 if let Some(mix) = &css.font_size_unit_mix {
1258 let breakdown: Vec<String> = mix
1259 .notations
1260 .iter()
1261 .map(|n| format!("{} {}", n.count, n.notation))
1262 .collect();
1263 let _ = writeln!(
1264 out,
1265 "- Font sizes mix {} units (candidate, standardize unless intentional): {}",
1266 mix.notations.len(),
1267 breakdown.join(", "),
1268 );
1269 }
1270}
1271
1272fn write_coverage_intelligence_section(
1273 out: &mut String,
1274 report: &crate::health_types::HealthReport,
1275 root: &Path,
1276) {
1277 let Some(ref intelligence) = report.coverage_intelligence else {
1278 return;
1279 };
1280 if !out.is_empty() && !out.ends_with("\n\n") {
1281 out.push('\n');
1282 }
1283 let _ = writeln!(
1284 out,
1285 "## Coverage Intelligence\n\n- Verdict: {}\n- Findings: {}\n- Ambiguous matches skipped: {}\n",
1286 intelligence.verdict,
1287 intelligence.summary.findings,
1288 intelligence.summary.skipped_ambiguous_matches,
1289 );
1290 if intelligence.findings.is_empty() {
1291 if intelligence.summary.skipped_ambiguous_matches > 0 {
1292 let match_phrase = if intelligence.summary.skipped_ambiguous_matches == 1 {
1293 "evidence match was"
1294 } else {
1295 "evidence matches were"
1296 };
1297 let _ = writeln!(
1298 out,
1299 "No actionable findings were emitted because {} ambiguous {match_phrase} skipped.\n",
1300 intelligence.summary.skipped_ambiguous_matches,
1301 );
1302 }
1303 return;
1304 }
1305 out.push_str("| ID | Path | Identity | Verdict | Recommendation | Confidence | Signals |\n");
1306 out.push_str("|:---|:-----|:---------|:--------|:---------------|:-----------|:--------|\n");
1307 for finding in &intelligence.findings {
1308 write_coverage_intelligence_row(out, finding, root);
1309 }
1310 out.push('\n');
1311}
1312
1313fn write_coverage_intelligence_row(
1315 out: &mut String,
1316 finding: &crate::health_types::CoverageIntelligenceFinding,
1317 root: &Path,
1318) {
1319 let path = escape_backticks(&normalize_uri(
1320 &relative_path(&finding.path, root).display().to_string(),
1321 ));
1322 let identity = finding
1323 .identity
1324 .as_deref()
1325 .map_or_else(|| "-".to_owned(), escape_backticks);
1326 let signals = finding
1327 .signals
1328 .iter()
1329 .map(ToString::to_string)
1330 .collect::<Vec<_>>()
1331 .join(", ");
1332 let _ = writeln!(
1333 out,
1334 "| `{}` | `{}`:{} | `{}` | {} | {} | {} | {} |",
1335 escape_backticks(&finding.id),
1336 path,
1337 finding.line,
1338 identity,
1339 finding.verdict,
1340 finding.recommendation,
1341 finding.confidence,
1342 signals,
1343 );
1344}
1345
1346fn write_runtime_coverage_section(
1347 out: &mut String,
1348 report: &crate::health_types::HealthReport,
1349 root: &Path,
1350) {
1351 let Some(ref production) = report.runtime_coverage else {
1352 return;
1353 };
1354 if !out.is_empty() && !out.ends_with("\n\n") {
1355 out.push('\n');
1356 }
1357 write_runtime_coverage_summary(out, production);
1358 write_runtime_coverage_findings(out, production, root);
1359 write_runtime_coverage_hot_paths(out, production, root);
1360}
1361
1362fn write_runtime_coverage_summary(
1364 out: &mut String,
1365 production: &crate::health_types::RuntimeCoverageReport,
1366) {
1367 let _ = writeln!(
1368 out,
1369 "## 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",
1370 production.verdict,
1371 production.summary.functions_tracked,
1372 production.summary.functions_hit,
1373 production.summary.functions_unhit,
1374 production.summary.functions_untracked,
1375 production.summary.coverage_percent,
1376 production.summary.trace_count,
1377 production.summary.period_days,
1378 production.summary.deployments_seen,
1379 );
1380 if let Some(watermark) = production.watermark {
1381 let _ = writeln!(out, "- Watermark: {watermark}\n");
1382 }
1383 if let Some(ref quality) = production.summary.capture_quality
1384 && quality.lazy_parse_warning
1385 {
1386 let window = super::human::health::format_window(quality.window_seconds);
1387 let _ = writeln!(
1388 out,
1389 "- Capture quality: short window ({} from {} instance(s), {:.1}% of functions untracked); lazy-parsed scripts may not appear.\n",
1390 window, quality.instances_observed, quality.untracked_ratio_percent,
1391 );
1392 }
1393}
1394
1395fn write_runtime_coverage_findings(
1397 out: &mut String,
1398 production: &crate::health_types::RuntimeCoverageReport,
1399 root: &Path,
1400) {
1401 if production.findings.is_empty() {
1402 return;
1403 }
1404 out.push_str("| ID | Path | Function | Verdict | Invocations | Confidence |\n");
1405 out.push_str("|:---|:-----|:---------|:--------|------------:|:-----------|\n");
1406 for finding in &production.findings {
1407 let invocations = finding
1408 .invocations
1409 .map_or_else(|| "-".to_owned(), |hits| hits.to_string());
1410 let _ = writeln!(
1411 out,
1412 "| `{}` | `{}`:{} | `{}` | {} | {} | {} |",
1413 escape_backticks(&finding.id),
1414 escape_backticks(&normalize_uri(
1415 &relative_path(&finding.path, root).display().to_string(),
1416 )),
1417 finding.line,
1418 escape_backticks(&finding.function),
1419 finding.verdict,
1420 invocations,
1421 finding.confidence,
1422 );
1423 }
1424 out.push('\n');
1425}
1426
1427fn write_runtime_coverage_hot_paths(
1429 out: &mut String,
1430 production: &crate::health_types::RuntimeCoverageReport,
1431 root: &Path,
1432) {
1433 if production.hot_paths.is_empty() {
1434 return;
1435 }
1436 out.push_str("| ID | Hot path | Function | Invocations | Percentile |\n");
1437 out.push_str("|:---|:---------|:---------|------------:|-----------:|\n");
1438 for entry in &production.hot_paths {
1439 let _ = writeln!(
1440 out,
1441 "| `{}` | `{}`:{} | `{}` | {} | {} |",
1442 escape_backticks(&entry.id),
1443 escape_backticks(&normalize_uri(
1444 &relative_path(&entry.path, root).display().to_string(),
1445 )),
1446 entry.line,
1447 escape_backticks(&entry.function),
1448 entry.invocations,
1449 entry.percentile,
1450 );
1451 }
1452 out.push('\n');
1453}
1454
1455fn write_trend_section(out: &mut String, report: &crate::health_types::HealthReport) {
1457 let Some(ref trend) = report.health_trend else {
1458 return;
1459 };
1460 let sha_str = trend
1461 .compared_to
1462 .git_sha
1463 .as_deref()
1464 .map_or(String::new(), |sha| format!(" ({sha})"));
1465 let _ = writeln!(
1466 out,
1467 "## Trend (vs {}{})\n",
1468 trend
1469 .compared_to
1470 .timestamp
1471 .get(..10)
1472 .unwrap_or(&trend.compared_to.timestamp),
1473 sha_str,
1474 );
1475 out.push_str("| Metric | Previous | Current | Delta | Direction |\n");
1476 out.push_str("|:-------|:---------|:--------|:------|:----------|\n");
1477 for m in &trend.metrics {
1478 write_trend_metric_row(out, m);
1479 }
1480 let md_sha = trend
1481 .compared_to
1482 .git_sha
1483 .as_deref()
1484 .map_or(String::new(), |sha| format!(" ({sha})"));
1485 let _ = writeln!(
1486 out,
1487 "\n*vs {}{} · {} {} available*\n",
1488 trend
1489 .compared_to
1490 .timestamp
1491 .get(..10)
1492 .unwrap_or(&trend.compared_to.timestamp),
1493 md_sha,
1494 trend.snapshots_loaded,
1495 if trend.snapshots_loaded == 1 {
1496 "snapshot"
1497 } else {
1498 "snapshots"
1499 },
1500 );
1501}
1502
1503fn write_trend_metric_row(out: &mut String, m: &crate::health_types::TrendMetric) {
1505 let fmt_val = |v: f64| -> String {
1506 if m.unit == "%" {
1507 format!("{v:.1}%")
1508 } else if (v - v.round()).abs() < 0.05 {
1509 format!("{v:.0}")
1510 } else {
1511 format!("{v:.1}")
1512 }
1513 };
1514 let prev = fmt_val(m.previous);
1515 let cur = fmt_val(m.current);
1516 let delta = if m.unit == "%" {
1517 format!("{:+.1}%", m.delta)
1518 } else if (m.delta - m.delta.round()).abs() < 0.05 {
1519 format!("{:+.0}", m.delta)
1520 } else {
1521 format!("{:+.1}", m.delta)
1522 };
1523 let _ = writeln!(
1524 out,
1525 "| {} | {} | {} | {} | {} {} |",
1526 m.label,
1527 prev,
1528 cur,
1529 delta,
1530 m.direction.arrow(),
1531 m.direction.label(),
1532 );
1533}
1534
1535fn write_vital_signs_section(out: &mut String, report: &crate::health_types::HealthReport) {
1537 let Some(ref vs) = report.vital_signs else {
1538 return;
1539 };
1540 out.push_str("## Vital Signs\n\n");
1541 out.push_str("| Metric | Value |\n");
1542 out.push_str("|:-------|------:|\n");
1543 if vs.total_loc > 0 {
1544 let _ = writeln!(out, "| Total LOC | {} |", vs.total_loc);
1545 }
1546 let _ = writeln!(out, "| Avg Cyclomatic | {:.1} |", vs.avg_cyclomatic);
1547 let _ = writeln!(out, "| P90 Cyclomatic | {} |", vs.p90_cyclomatic);
1548 if let Some(v) = vs.dead_file_pct {
1549 let _ = writeln!(out, "| Dead Files | {v:.1}% |");
1550 }
1551 if let Some(v) = vs.dead_export_pct {
1552 let _ = writeln!(out, "| Dead Exports | {v:.1}% |");
1553 }
1554 if let Some(v) = vs.maintainability_avg {
1555 let _ = writeln!(out, "| Maintainability (avg) | {v:.1} |");
1556 }
1557 if let Some(v) = vs.hotspot_count {
1558 let label = report.hotspot_summary.as_ref().map_or_else(
1559 || "Hotspots".to_string(),
1560 |summary| format!("Hotspots (since {})", summary.since),
1561 );
1562 let _ = writeln!(out, "| {label} | {v} |");
1563 }
1564 if let Some(v) = vs.circular_dep_count {
1565 let _ = writeln!(out, "| Circular Deps | {v} |");
1566 }
1567 if let Some(v) = vs.unused_dep_count {
1568 let _ = writeln!(out, "| Unused Deps | {v} |");
1569 }
1570 out.push('\n');
1571}
1572
1573fn write_findings_section(
1575 out: &mut String,
1576 report: &crate::health_types::HealthReport,
1577 root: &Path,
1578) {
1579 if report.findings.is_empty() {
1580 return;
1581 }
1582
1583 let has_synthetic = report
1584 .findings
1585 .iter()
1586 .any(|finding| matches!(finding.name.as_str(), "<template>" | "<component>"));
1587 write_findings_heading(out, report, has_synthetic);
1588 write_findings_table_header(out, has_synthetic);
1589
1590 for finding in &report.findings {
1591 write_findings_row(out, finding, report, root);
1592 }
1593
1594 let s = &report.summary;
1595 let _ = write!(
1596 out,
1597 "\n**{files}** files, **{funcs}** functions analyzed \
1598 (thresholds: cyclomatic > {cyc}, cognitive > {cog}, CRAP >= {crap:.1})\n",
1599 files = s.files_analyzed,
1600 funcs = s.functions_analyzed,
1601 cyc = s.max_cyclomatic_threshold,
1602 cog = s.max_cognitive_threshold,
1603 crap = s.max_crap_threshold,
1604 );
1605}
1606
1607fn write_findings_heading(
1609 out: &mut String,
1610 report: &crate::health_types::HealthReport,
1611 has_synthetic: bool,
1612) {
1613 let count = report.summary.functions_above_threshold;
1614 let shown = report.findings.len();
1615 let subject = if has_synthetic {
1616 "high complexity finding"
1617 } else {
1618 "high complexity function"
1619 };
1620 if shown < count {
1621 let _ = write!(
1622 out,
1623 "## Fallow: {count} {subject}{} ({shown} shown)\n\n",
1624 plural(count),
1625 );
1626 } else {
1627 let _ = write!(out, "## Fallow: {count} {subject}{}\n\n", plural(count));
1628 }
1629}
1630
1631fn write_findings_table_header(out: &mut String, has_synthetic: bool) {
1633 let name_header = if has_synthetic { "Entry" } else { "Function" };
1634 let _ = writeln!(
1635 out,
1636 "| File | {name_header} | Severity | Cyclomatic | Cognitive | CRAP | Lines |"
1637 );
1638 out.push_str("|:-----|:---------|:---------|:-----------|:----------|:-----|:------|\n");
1639}
1640
1641fn write_findings_row(
1643 out: &mut String,
1644 finding: &crate::health_types::HealthFinding,
1645 report: &crate::health_types::HealthReport,
1646 root: &Path,
1647) {
1648 let file_str = escape_backticks(&normalize_uri(
1649 &relative_path(&finding.path, root).display().to_string(),
1650 ));
1651 let thresholds =
1652 finding
1653 .effective_thresholds
1654 .unwrap_or(crate::health_types::HealthEffectiveThresholds {
1655 max_cyclomatic: report.summary.max_cyclomatic_threshold,
1656 max_cognitive: report.summary.max_cognitive_threshold,
1657 max_crap: report.summary.max_crap_threshold,
1658 });
1659 let cyc_marker = if finding.cyclomatic > thresholds.max_cyclomatic {
1660 " **!**"
1661 } else {
1662 ""
1663 };
1664 let cog_marker = if finding.cognitive > thresholds.max_cognitive {
1665 " **!**"
1666 } else {
1667 ""
1668 };
1669 let severity_label = match finding.severity {
1670 crate::health_types::FindingSeverity::Critical => "critical",
1671 crate::health_types::FindingSeverity::High => "high",
1672 crate::health_types::FindingSeverity::Moderate => "moderate",
1673 };
1674 let crap_cell = match finding.crap {
1675 Some(crap) => {
1676 let marker = if crap >= thresholds.max_crap {
1677 " **!**"
1678 } else {
1679 ""
1680 };
1681 format!("{crap:.1}{marker}")
1682 }
1683 None => "-".to_string(),
1684 };
1685 let _ = writeln!(
1686 out,
1687 "| `{file_str}:{line}` | `{name}` | {severity_label} | {cyc}{cyc_marker} | {cog}{cog_marker} | {crap_cell} | {lines} |",
1688 line = finding.line,
1689 name = escape_backticks(display_complexity_entry_name(&finding.name).as_ref()),
1690 cyc = finding.cyclomatic,
1691 cog = finding.cognitive,
1692 lines = finding.line_count,
1693 );
1694}
1695
1696fn write_threshold_overrides_section(
1697 out: &mut String,
1698 report: &crate::health_types::HealthReport,
1699 root: &Path,
1700) {
1701 if report.threshold_overrides.is_empty() {
1702 return;
1703 }
1704 if !out.is_empty() && !out.ends_with("\n\n") {
1705 out.push('\n');
1706 }
1707 out.push_str("## Health Threshold Overrides\n\n");
1708 out.push_str("| Override | Status | Target | Metrics |\n");
1709 out.push_str("|---------:|:-------|:-------|:--------|\n");
1710 for entry in &report.threshold_overrides {
1711 let status = match entry.status {
1712 crate::health_types::ThresholdOverrideStatus::Active => "active",
1713 crate::health_types::ThresholdOverrideStatus::Stale => "stale",
1714 crate::health_types::ThresholdOverrideStatus::NoMatch => "no_match",
1715 };
1716 let target = entry.path.as_ref().map_or_else(
1717 || "<no matching file or function>".to_string(),
1718 |path| {
1719 let display = escape_backticks(&normalize_uri(
1720 &relative_path(path, root).display().to_string(),
1721 ));
1722 entry.function.as_ref().map_or_else(
1723 || display.clone(),
1724 |name| format!("{display}:{}", escape_backticks(name)),
1725 )
1726 },
1727 );
1728 let metrics = entry.metrics.map_or_else(
1729 || "-".to_string(),
1730 |metrics| {
1731 let crap = metrics
1732 .crap
1733 .map_or(String::new(), |value| format!(", CRAP {value:.1}"));
1734 format!(
1735 "cyclomatic {}, cognitive {}{}",
1736 metrics.cyclomatic, metrics.cognitive, crap
1737 )
1738 },
1739 );
1740 let _ = writeln!(
1741 out,
1742 "| {} | {} | `{}` | {} |",
1743 entry.override_index, status, target, metrics
1744 );
1745 }
1746 out.push('\n');
1747}
1748
1749fn write_file_scores_section(
1751 out: &mut String,
1752 report: &crate::health_types::HealthReport,
1753 root: &Path,
1754) {
1755 if report.file_scores.is_empty() {
1756 return;
1757 }
1758
1759 let rel = |p: &Path| {
1760 escape_backticks(&normalize_uri(
1761 &relative_path(p, root).display().to_string(),
1762 ))
1763 };
1764
1765 out.push('\n');
1766 let _ = writeln!(
1767 out,
1768 "### File Health Scores ({} files)\n",
1769 report.file_scores.len(),
1770 );
1771 out.push_str("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density | Risk |\n");
1772 out.push_str("|:-----|:---------------|:-------|:--------|:----------|:--------|:-----|\n");
1773
1774 for score in &report.file_scores {
1775 let file_str = rel(&score.path);
1776 let _ = writeln!(
1777 out,
1778 "| `{file_str}` | {mi:.1} | {fi} | {fan_out} | {dead:.0}% | {density:.2} | {crap:.1} |",
1779 mi = score.maintainability_index,
1780 fi = score.fan_in,
1781 fan_out = score.fan_out,
1782 dead = score.dead_code_ratio * 100.0,
1783 density = score.complexity_density,
1784 crap = score.crap_max,
1785 );
1786 }
1787
1788 if let Some(avg) = report.summary.average_maintainability {
1789 let _ = write!(out, "\n**Average maintainability index:** {avg:.1}/100\n");
1790 }
1791}
1792
1793fn write_coverage_gaps_section(
1794 out: &mut String,
1795 report: &crate::health_types::HealthReport,
1796 root: &Path,
1797) {
1798 let Some(ref gaps) = report.coverage_gaps else {
1799 return;
1800 };
1801
1802 out.push('\n');
1803 let _ = writeln!(out, "### Coverage Gaps\n");
1804 let _ = writeln!(
1805 out,
1806 "*{} untested files · {} untested exports · {:.1}% file coverage*\n",
1807 gaps.summary.untested_files, gaps.summary.untested_exports, gaps.summary.file_coverage_pct,
1808 );
1809
1810 if gaps.files.is_empty() && gaps.exports.is_empty() {
1811 out.push_str("_No coverage gaps found in scope._\n");
1812 return;
1813 }
1814
1815 if !gaps.files.is_empty() {
1816 out.push_str("#### Files\n");
1817 for item in &gaps.files {
1818 let file_str = escape_backticks(&normalize_uri(
1819 &relative_path(&item.file.path, root).display().to_string(),
1820 ));
1821 let _ = writeln!(
1822 out,
1823 "- `{file_str}` ({count} value export{})",
1824 if item.file.value_export_count == 1 {
1825 ""
1826 } else {
1827 "s"
1828 },
1829 count = item.file.value_export_count,
1830 );
1831 }
1832 out.push('\n');
1833 }
1834
1835 if !gaps.exports.is_empty() {
1836 out.push_str("#### Exports\n");
1837 for item in &gaps.exports {
1838 let file_str = escape_backticks(&normalize_uri(
1839 &relative_path(&item.export.path, root).display().to_string(),
1840 ));
1841 let _ = writeln!(
1842 out,
1843 "- `{file_str}`:{} `{}`",
1844 item.export.line, item.export.export_name
1845 );
1846 }
1847 }
1848}
1849
1850fn ownership_md_cells(
1855 ownership: Option<&crate::health_types::OwnershipMetrics>,
1856) -> (String, String, String, String) {
1857 let Some(o) = ownership else {
1858 let dash = "\u{2013}".to_string();
1859 return (dash.clone(), dash.clone(), dash.clone(), dash);
1860 };
1861 let bus = o.bus_factor.to_string();
1862 let top = format!(
1863 "`{}` ({:.0}%)",
1864 o.top_contributor.identifier,
1865 o.top_contributor.share * 100.0,
1866 );
1867 let owner = o
1868 .declared_owner
1869 .as_deref()
1870 .map_or_else(|| "\u{2013}".to_string(), str::to_string);
1871 let mut notes: Vec<&str> = Vec::new();
1872 if o.unowned == Some(true) {
1873 notes.push("**unowned**");
1874 }
1875 if o.ownership_state == crate::health_types::OwnershipState::DeclaredInactive {
1876 notes.push("declared owner inactive");
1877 }
1878 if o.drift {
1879 notes.push("drift");
1880 }
1881 let notes_str = if notes.is_empty() {
1882 "\u{2013}".to_string()
1883 } else {
1884 notes.join(", ")
1885 };
1886 (bus, top, owner, notes_str)
1887}
1888
1889fn write_hotspots_section(
1890 out: &mut String,
1891 report: &crate::health_types::HealthReport,
1892 root: &Path,
1893) {
1894 if report.hotspots.is_empty() {
1895 return;
1896 }
1897
1898 out.push('\n');
1899 let header = report.hotspot_summary.as_ref().map_or_else(
1900 || format!("### Hotspots ({} files)\n", report.hotspots.len()),
1901 |summary| {
1902 format!(
1903 "### Hotspots ({} files, since {})\n",
1904 report.hotspots.len(),
1905 summary.since,
1906 )
1907 },
1908 );
1909 let _ = writeln!(out, "{header}");
1910 let any_ownership = report.hotspots.iter().any(|e| e.ownership.is_some());
1911 write_hotspots_table_header(out, any_ownership);
1912
1913 for entry in &report.hotspots {
1914 write_hotspots_row(out, entry, any_ownership, root);
1915 }
1916
1917 if let Some(ref summary) = report.hotspot_summary
1918 && summary.files_excluded > 0
1919 {
1920 let _ = write!(
1921 out,
1922 "\n*{} file{} excluded (< {} commits)*\n",
1923 summary.files_excluded,
1924 plural(summary.files_excluded),
1925 summary.min_commits,
1926 );
1927 }
1928}
1929
1930fn write_hotspots_table_header(out: &mut String, any_ownership: bool) {
1932 if any_ownership {
1933 out.push_str(
1934 "| File | Score | Commits | Churn | Density | Fan-in | Trend | Bus | Top | Owner | Notes |\n"
1935 );
1936 out.push_str(
1937 "|:-----|:------|:--------|:------|:--------|:-------|:------|:----|:----|:------|:------|\n"
1938 );
1939 } else {
1940 out.push_str("| File | Score | Commits | Churn | Density | Fan-in | Trend |\n");
1941 out.push_str("|:-----|:------|:--------|:------|:--------|:-------|:------|\n");
1942 }
1943}
1944
1945fn write_hotspots_row(
1947 out: &mut String,
1948 entry: &crate::health_types::HotspotFinding,
1949 any_ownership: bool,
1950 root: &Path,
1951) {
1952 let file_str = escape_backticks(&normalize_uri(
1953 &relative_path(&entry.path, root).display().to_string(),
1954 ));
1955 if any_ownership {
1956 let (bus, top, owner, notes) = ownership_md_cells(entry.ownership.as_ref());
1957 let _ = writeln!(
1958 out,
1959 "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} | {bus} | {top} | {owner} | {notes} |",
1960 score = entry.score,
1961 commits = entry.commits,
1962 churn = entry.lines_added + entry.lines_deleted,
1963 density = entry.complexity_density,
1964 fi = entry.fan_in,
1965 trend = entry.trend,
1966 );
1967 } else {
1968 let _ = writeln!(
1969 out,
1970 "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} |",
1971 score = entry.score,
1972 commits = entry.commits,
1973 churn = entry.lines_added + entry.lines_deleted,
1974 density = entry.complexity_density,
1975 fi = entry.fan_in,
1976 trend = entry.trend,
1977 );
1978 }
1979}
1980
1981fn write_targets_section(
1983 out: &mut String,
1984 report: &crate::health_types::HealthReport,
1985 root: &Path,
1986) {
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: &crate::health_types::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}
2055
2056#[cfg(test)]
2057mod tests {
2058 use super::*;
2059 use crate::report::test_helpers::sample_results;
2060 use fallow_core::duplicates::{
2061 CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
2062 RefactoringKind, RefactoringSuggestion,
2063 };
2064 use fallow_core::results::*;
2065 use std::path::PathBuf;
2066
2067 #[test]
2068 fn markdown_empty_results_no_issues() {
2069 let root = PathBuf::from("/project");
2070 let results = AnalysisResults::default();
2071 let md = build_markdown(&results, &root);
2072 assert_eq!(md, "## Fallow: no issues found\n");
2073 }
2074
2075 #[test]
2076 fn markdown_contains_header_with_count() {
2077 let root = PathBuf::from("/project");
2078 let results = sample_results(&root);
2079 let md = build_markdown(&results, &root);
2080 assert!(md.starts_with(&format!(
2081 "## Fallow: {} issues found\n",
2082 results.total_issues()
2083 )));
2084 }
2085
2086 #[test]
2087 fn markdown_contains_all_sections() {
2088 let root = PathBuf::from("/project");
2089 let results = sample_results(&root);
2090 let md = build_markdown(&results, &root);
2091
2092 assert!(md.contains("### Unused files (1)"));
2093 assert!(md.contains("### Unused exports (1)"));
2094 assert!(md.contains("### Unused type exports (1)"));
2095 assert!(md.contains("### Unused dependencies (1)"));
2096 assert!(md.contains("### Unused devDependencies (1)"));
2097 assert!(md.contains("### Unused enum members (1)"));
2098 assert!(md.contains("### Unused class members (1)"));
2099 assert!(md.contains("### Unresolved imports (1)"));
2100 assert!(md.contains("### Unlisted dependencies (1)"));
2101 assert!(md.contains("### Duplicate exports (1)"));
2102 assert!(md.contains("### Type-only dependencies"));
2103 assert!(md.contains("### Test-only production dependencies"));
2104 assert!(md.contains("### Circular dependencies (1)"));
2105 }
2106
2107 #[test]
2108 fn markdown_unused_file_format() {
2109 let root = PathBuf::from("/project");
2110 let mut results = AnalysisResults::default();
2111 results
2112 .unused_files
2113 .push(UnusedFileFinding::with_actions(UnusedFile {
2114 path: root.join("src/dead.ts"),
2115 }));
2116 let md = build_markdown(&results, &root);
2117 assert!(md.contains("- `src/dead.ts`"));
2118 }
2119
2120 #[test]
2121 fn markdown_unused_export_grouped_by_file() {
2122 let root = PathBuf::from("/project");
2123 let mut results = AnalysisResults::default();
2124 results
2125 .unused_exports
2126 .push(UnusedExportFinding::with_actions(UnusedExport {
2127 path: root.join("src/utils.ts"),
2128 export_name: "helperFn".to_string(),
2129 is_type_only: false,
2130 line: 10,
2131 col: 4,
2132 span_start: 120,
2133 is_re_export: false,
2134 }));
2135 let md = build_markdown(&results, &root);
2136 assert!(md.contains("- `src/utils.ts`"));
2137 assert!(md.contains(":10 `helperFn`"));
2138 }
2139
2140 #[test]
2141 fn markdown_re_export_tagged() {
2142 let root = PathBuf::from("/project");
2143 let mut results = AnalysisResults::default();
2144 results
2145 .unused_exports
2146 .push(UnusedExportFinding::with_actions(UnusedExport {
2147 path: root.join("src/index.ts"),
2148 export_name: "reExported".to_string(),
2149 is_type_only: false,
2150 line: 1,
2151 col: 0,
2152 span_start: 0,
2153 is_re_export: true,
2154 }));
2155 let md = build_markdown(&results, &root);
2156 assert!(md.contains("(re-export)"));
2157 }
2158
2159 #[test]
2160 fn markdown_unused_dep_format() {
2161 let root = PathBuf::from("/project");
2162 let mut results = AnalysisResults::default();
2163 results
2164 .unused_dependencies
2165 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2166 package_name: "lodash".to_string(),
2167 location: DependencyLocation::Dependencies,
2168 path: root.join("package.json"),
2169 line: 5,
2170 used_in_workspaces: Vec::new(),
2171 }));
2172 let md = build_markdown(&results, &root);
2173 assert!(md.contains("- `lodash`"));
2174 }
2175
2176 #[test]
2177 fn markdown_circular_dep_format() {
2178 let root = PathBuf::from("/project");
2179 let mut results = AnalysisResults::default();
2180 results
2181 .circular_dependencies
2182 .push(CircularDependencyFinding::with_actions(
2183 CircularDependency {
2184 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2185 length: 2,
2186 line: 3,
2187 col: 0,
2188 edges: Vec::new(),
2189 is_cross_package: false,
2190 },
2191 ));
2192 let md = build_markdown(&results, &root);
2193 assert!(md.contains("`src/a.ts`"));
2194 assert!(md.contains("`src/b.ts`"));
2195 assert!(md.contains("\u{2192}"));
2196 }
2197
2198 #[test]
2199 fn markdown_strips_root_prefix() {
2200 let root = PathBuf::from("/project");
2201 let mut results = AnalysisResults::default();
2202 results
2203 .unused_files
2204 .push(UnusedFileFinding::with_actions(UnusedFile {
2205 path: PathBuf::from("/project/src/deep/nested/file.ts"),
2206 }));
2207 let md = build_markdown(&results, &root);
2208 assert!(md.contains("`src/deep/nested/file.ts`"));
2209 assert!(!md.contains("/project/"));
2210 }
2211
2212 #[test]
2213 fn markdown_single_issue_no_plural() {
2214 let root = PathBuf::from("/project");
2215 let mut results = AnalysisResults::default();
2216 results
2217 .unused_files
2218 .push(UnusedFileFinding::with_actions(UnusedFile {
2219 path: root.join("src/dead.ts"),
2220 }));
2221 let md = build_markdown(&results, &root);
2222 assert!(md.starts_with("## Fallow: 1 issue found\n"));
2223 }
2224
2225 #[test]
2226 fn markdown_type_only_dep_format() {
2227 let root = PathBuf::from("/project");
2228 let mut results = AnalysisResults::default();
2229 results
2230 .type_only_dependencies
2231 .push(TypeOnlyDependencyFinding::with_actions(
2232 TypeOnlyDependency {
2233 package_name: "zod".to_string(),
2234 path: root.join("package.json"),
2235 line: 8,
2236 },
2237 ));
2238 let md = build_markdown(&results, &root);
2239 assert!(md.contains("### Type-only dependencies"));
2240 assert!(md.contains("- `zod`"));
2241 }
2242
2243 #[test]
2244 fn markdown_escapes_backticks_in_export_names() {
2245 let root = PathBuf::from("/project");
2246 let mut results = AnalysisResults::default();
2247 results
2248 .unused_exports
2249 .push(UnusedExportFinding::with_actions(UnusedExport {
2250 path: root.join("src/utils.ts"),
2251 export_name: "foo`bar".to_string(),
2252 is_type_only: false,
2253 line: 1,
2254 col: 0,
2255 span_start: 0,
2256 is_re_export: false,
2257 }));
2258 let md = build_markdown(&results, &root);
2259 assert!(md.contains("foo\\`bar"));
2260 assert!(!md.contains("foo`bar`"));
2261 }
2262
2263 #[test]
2264 fn markdown_escapes_backticks_in_package_names() {
2265 let root = PathBuf::from("/project");
2266 let mut results = AnalysisResults::default();
2267 results
2268 .unused_dependencies
2269 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2270 package_name: "pkg`name".to_string(),
2271 location: DependencyLocation::Dependencies,
2272 path: root.join("package.json"),
2273 line: 5,
2274 used_in_workspaces: Vec::new(),
2275 }));
2276 let md = build_markdown(&results, &root);
2277 assert!(md.contains("pkg\\`name"));
2278 }
2279
2280 #[test]
2281 fn duplication_markdown_empty() {
2282 let report = DuplicationReport::default();
2283 let root = PathBuf::from("/project");
2284 let md = build_duplication_markdown(&report, &root);
2285 assert_eq!(md, "## Fallow: no code duplication found\n");
2286 }
2287
2288 #[test]
2289 fn duplication_markdown_contains_groups() {
2290 let root = PathBuf::from("/project");
2291 let report = DuplicationReport {
2292 clone_groups: vec![CloneGroup {
2293 instances: vec![
2294 CloneInstance {
2295 file: root.join("src/a.ts"),
2296 start_line: 1,
2297 end_line: 10,
2298 start_col: 0,
2299 end_col: 0,
2300 fragment: String::new(),
2301 },
2302 CloneInstance {
2303 file: root.join("src/b.ts"),
2304 start_line: 5,
2305 end_line: 14,
2306 start_col: 0,
2307 end_col: 0,
2308 fragment: String::new(),
2309 },
2310 ],
2311 token_count: 50,
2312 line_count: 10,
2313 }],
2314 clone_families: vec![],
2315 mirrored_directories: vec![],
2316 stats: DuplicationStats {
2317 total_files: 10,
2318 files_with_clones: 2,
2319 total_lines: 500,
2320 duplicated_lines: 20,
2321 total_tokens: 2500,
2322 duplicated_tokens: 100,
2323 clone_groups: 1,
2324 clone_instances: 2,
2325 duplication_percentage: 4.0,
2326 clone_groups_below_min_occurrences: 0,
2327 },
2328 };
2329 let md = build_duplication_markdown(&report, &root);
2330 assert!(md.contains("**Clone group 1**"));
2331 assert!(md.contains("`src/a.ts:1-10`"));
2332 assert!(md.contains("`src/b.ts:5-14`"));
2333 assert!(md.contains("4.0% duplication"));
2334 }
2335
2336 #[test]
2337 fn duplication_markdown_contains_families() {
2338 let root = PathBuf::from("/project");
2339 let report = DuplicationReport {
2340 clone_groups: vec![CloneGroup {
2341 instances: vec![CloneInstance {
2342 file: root.join("src/a.ts"),
2343 start_line: 1,
2344 end_line: 5,
2345 start_col: 0,
2346 end_col: 0,
2347 fragment: String::new(),
2348 }],
2349 token_count: 30,
2350 line_count: 5,
2351 }],
2352 clone_families: vec![CloneFamily {
2353 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2354 groups: vec![],
2355 total_duplicated_lines: 20,
2356 total_duplicated_tokens: 100,
2357 suggestions: vec![RefactoringSuggestion {
2358 kind: RefactoringKind::ExtractFunction,
2359 description: "Extract shared utility function".to_string(),
2360 estimated_savings: 15,
2361 }],
2362 }],
2363 mirrored_directories: vec![],
2364 stats: DuplicationStats {
2365 clone_groups: 1,
2366 clone_instances: 1,
2367 duplication_percentage: 2.0,
2368 ..Default::default()
2369 },
2370 };
2371 let md = build_duplication_markdown(&report, &root);
2372 assert!(md.contains("### Clone Families"));
2373 assert!(md.contains("**Family 1**"));
2374 assert!(md.contains("Extract shared utility function"));
2375 assert!(md.contains("~15 lines saved"));
2376 }
2377
2378 #[test]
2379 fn health_markdown_empty_no_findings() {
2380 let root = PathBuf::from("/project");
2381 let report = crate::health_types::HealthReport {
2382 summary: crate::health_types::HealthSummary {
2383 files_analyzed: 10,
2384 functions_analyzed: 50,
2385 ..Default::default()
2386 },
2387 ..Default::default()
2388 };
2389 let md = build_health_markdown(&report, &root);
2390 assert!(md.contains("no functions exceed complexity thresholds"));
2391 assert!(md.contains("**50** functions analyzed"));
2392 }
2393
2394 #[test]
2395 fn health_markdown_table_format() {
2396 let root = PathBuf::from("/project");
2397 let report = crate::health_types::HealthReport {
2398 findings: vec![
2399 crate::health_types::ComplexityViolation {
2400 path: root.join("src/utils.ts"),
2401 name: "parseExpression".to_string(),
2402 line: 42,
2403 col: 0,
2404 cyclomatic: 25,
2405 cognitive: 30,
2406 line_count: 80,
2407 param_count: 0,
2408 react_hook_count: 0,
2409 react_jsx_max_depth: 0,
2410 react_prop_count: 0,
2411 react_hook_profile: None,
2412 exceeded: crate::health_types::ExceededThreshold::Both,
2413 severity: crate::health_types::FindingSeverity::High,
2414 crap: None,
2415 coverage_pct: None,
2416 coverage_tier: None,
2417 coverage_source: None,
2418 inherited_from: None,
2419 component_rollup: None,
2420 contributions: Vec::new(),
2421 effective_thresholds: None,
2422 threshold_source: None,
2423 }
2424 .into(),
2425 ],
2426 summary: crate::health_types::HealthSummary {
2427 files_analyzed: 10,
2428 functions_analyzed: 50,
2429 functions_above_threshold: 1,
2430 ..Default::default()
2431 },
2432 ..Default::default()
2433 };
2434 let md = build_health_markdown(&report, &root);
2435 assert!(md.contains("## Fallow: 1 high complexity function\n"));
2436 assert!(md.contains("| File | Function |"));
2437 assert!(md.contains("`src/utils.ts:42`"));
2438 assert!(md.contains("`parseExpression`"));
2439 assert!(md.contains("25 **!**"));
2440 assert!(md.contains("30 **!**"));
2441 assert!(md.contains("| 80 |"));
2442 assert!(md.contains("| - |"));
2443 }
2444
2445 #[test]
2446 fn health_markdown_labels_template_complexity_entries() {
2447 let root = PathBuf::from("/project");
2448 let report = crate::health_types::HealthReport {
2449 findings: vec![
2450 crate::health_types::ComplexityViolation {
2451 path: root.join("src/Card.vue"),
2452 name: "<template>".to_string(),
2453 line: 1,
2454 col: 0,
2455 cyclomatic: 8,
2456 cognitive: 12,
2457 line_count: 40,
2458 param_count: 0,
2459 react_hook_count: 0,
2460 react_jsx_max_depth: 0,
2461 react_prop_count: 0,
2462 react_hook_profile: None,
2463 exceeded: crate::health_types::ExceededThreshold::Cognitive,
2464 severity: crate::health_types::FindingSeverity::Moderate,
2465 crap: None,
2466 coverage_pct: None,
2467 coverage_tier: None,
2468 coverage_source: None,
2469 inherited_from: None,
2470 component_rollup: None,
2471 contributions: Vec::new(),
2472 effective_thresholds: None,
2473 threshold_source: None,
2474 }
2475 .into(),
2476 ],
2477 summary: crate::health_types::HealthSummary {
2478 files_analyzed: 1,
2479 functions_analyzed: 1,
2480 functions_above_threshold: 1,
2481 ..Default::default()
2482 },
2483 ..Default::default()
2484 };
2485 let md = build_health_markdown(&report, &root);
2486 assert!(md.contains("## Fallow: 1 high complexity finding\n"));
2487 assert!(md.contains("| File | Entry |"));
2488 assert!(md.contains("`<template> (template complexity)`"));
2489 }
2490
2491 #[test]
2492 fn health_markdown_includes_coverage_intelligence_and_ambiguity_summary() {
2493 use crate::health_types::{
2494 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2495 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2496 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2497 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2498 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2499 HealthReport, HealthSummary,
2500 };
2501
2502 let root = PathBuf::from("/project");
2503 let mut report = HealthReport {
2504 summary: HealthSummary {
2505 files_analyzed: 10,
2506 functions_analyzed: 50,
2507 ..Default::default()
2508 },
2509 coverage_intelligence: Some(CoverageIntelligenceReport {
2510 schema_version: CoverageIntelligenceSchemaVersion::V1,
2511 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2512 summary: CoverageIntelligenceSummary {
2513 findings: 1,
2514 high_confidence_deletes: 1,
2515 ..Default::default()
2516 },
2517 findings: vec![CoverageIntelligenceFinding {
2518 id: "fallow:coverage-intel:abc123".to_owned(),
2519 path: root.join("src/dead.ts"),
2520 identity: Some("deadPath".to_owned()),
2521 line: 9,
2522 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2523 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2524 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2525 confidence: CoverageIntelligenceConfidence::High,
2526 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2527 evidence: CoverageIntelligenceEvidence {
2528 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2529 ..Default::default()
2530 },
2531 actions: vec![CoverageIntelligenceAction {
2532 kind: "delete-after-confirming-owner".to_owned(),
2533 description: "Confirm ownership".to_owned(),
2534 auto_fixable: false,
2535 }],
2536 }],
2537 }),
2538 ..Default::default()
2539 };
2540
2541 let md = build_health_markdown(&report, &root);
2542 assert!(md.contains("## Coverage Intelligence"));
2543 assert!(md.contains("fallow:coverage-intel:abc123"));
2544 assert!(md.contains("delete-after-confirming-owner"));
2545 assert!(md.contains("runtime_cold"));
2546
2547 report.coverage_intelligence = Some(CoverageIntelligenceReport {
2548 schema_version: CoverageIntelligenceSchemaVersion::V1,
2549 verdict: CoverageIntelligenceVerdict::Clean,
2550 summary: CoverageIntelligenceSummary {
2551 skipped_ambiguous_matches: 2,
2552 ..Default::default()
2553 },
2554 findings: vec![],
2555 });
2556 let md = build_health_markdown(&report, &root);
2557 assert!(md.contains("2 ambiguous evidence matches were skipped"));
2558 assert!(!md.contains("| ID | Path |"));
2559 }
2560
2561 #[test]
2562 fn health_markdown_crap_column_shows_score_and_marker() {
2563 let root = PathBuf::from("/project");
2564 let report = crate::health_types::HealthReport {
2565 findings: vec![
2566 crate::health_types::ComplexityViolation {
2567 path: root.join("src/risky.ts"),
2568 name: "branchy".to_string(),
2569 line: 1,
2570 col: 0,
2571 cyclomatic: 67,
2572 cognitive: 10,
2573 line_count: 80,
2574 param_count: 1,
2575 react_hook_count: 0,
2576 react_jsx_max_depth: 0,
2577 react_prop_count: 0,
2578 react_hook_profile: None,
2579 exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
2580 severity: crate::health_types::FindingSeverity::Critical,
2581 crap: Some(182.0),
2582 coverage_pct: None,
2583 coverage_tier: None,
2584 coverage_source: None,
2585 inherited_from: None,
2586 component_rollup: None,
2587 contributions: Vec::new(),
2588 effective_thresholds: None,
2589 threshold_source: None,
2590 }
2591 .into(),
2592 ],
2593 summary: crate::health_types::HealthSummary {
2594 files_analyzed: 1,
2595 functions_analyzed: 1,
2596 functions_above_threshold: 1,
2597 ..Default::default()
2598 },
2599 ..Default::default()
2600 };
2601 let md = build_health_markdown(&report, &root);
2602 assert!(
2603 md.contains("| CRAP |"),
2604 "markdown table should have CRAP column header: {md}"
2605 );
2606 assert!(
2607 md.contains("182.0 **!**"),
2608 "CRAP value should be rendered with a threshold marker: {md}"
2609 );
2610 assert!(
2611 md.contains("CRAP >="),
2612 "trailing summary line should reference the CRAP threshold: {md}"
2613 );
2614 }
2615
2616 #[test]
2617 fn health_markdown_no_marker_when_below_threshold() {
2618 let root = PathBuf::from("/project");
2619 let report = crate::health_types::HealthReport {
2620 findings: vec![
2621 crate::health_types::ComplexityViolation {
2622 path: root.join("src/utils.ts"),
2623 name: "helper".to_string(),
2624 line: 10,
2625 col: 0,
2626 cyclomatic: 15,
2627 cognitive: 20,
2628 line_count: 30,
2629 param_count: 0,
2630 react_hook_count: 0,
2631 react_jsx_max_depth: 0,
2632 react_prop_count: 0,
2633 react_hook_profile: None,
2634 exceeded: crate::health_types::ExceededThreshold::Cognitive,
2635 severity: crate::health_types::FindingSeverity::High,
2636 crap: None,
2637 coverage_pct: None,
2638 coverage_tier: None,
2639 coverage_source: None,
2640 inherited_from: None,
2641 component_rollup: None,
2642 contributions: Vec::new(),
2643 effective_thresholds: None,
2644 threshold_source: None,
2645 }
2646 .into(),
2647 ],
2648 summary: crate::health_types::HealthSummary {
2649 files_analyzed: 5,
2650 functions_analyzed: 20,
2651 functions_above_threshold: 1,
2652 ..Default::default()
2653 },
2654 ..Default::default()
2655 };
2656 let md = build_health_markdown(&report, &root);
2657 assert!(md.contains("| 15 |"));
2658 assert!(md.contains("20 **!**"));
2659 }
2660
2661 #[test]
2662 fn health_markdown_with_targets() {
2663 use crate::health_types::*;
2664
2665 let root = PathBuf::from("/project");
2666 let report = HealthReport {
2667 summary: HealthSummary {
2668 files_analyzed: 10,
2669 functions_analyzed: 50,
2670 ..Default::default()
2671 },
2672 targets: vec![
2673 RefactoringTarget {
2674 path: PathBuf::from("/project/src/complex.ts"),
2675 priority: 82.5,
2676 efficiency: 27.5,
2677 recommendation: "Split high-impact file".into(),
2678 category: RecommendationCategory::SplitHighImpact,
2679 effort: crate::health_types::EffortEstimate::High,
2680 confidence: crate::health_types::Confidence::Medium,
2681 factors: vec![ContributingFactor {
2682 metric: "fan_in",
2683 value: 25.0,
2684 threshold: 10.0,
2685 detail: "25 files depend on this".into(),
2686 }],
2687 evidence: None,
2688 }
2689 .into(),
2690 RefactoringTarget {
2691 path: PathBuf::from("/project/src/legacy.ts"),
2692 priority: 45.0,
2693 efficiency: 45.0,
2694 recommendation: "Remove 5 unused exports".into(),
2695 category: RecommendationCategory::RemoveDeadCode,
2696 effort: crate::health_types::EffortEstimate::Low,
2697 confidence: crate::health_types::Confidence::High,
2698 factors: vec![],
2699 evidence: None,
2700 }
2701 .into(),
2702 ],
2703 ..Default::default()
2704 };
2705 let md = build_health_markdown(&report, &root);
2706
2707 assert!(
2708 md.contains("Refactoring Targets"),
2709 "should contain targets heading"
2710 );
2711 assert!(
2712 md.contains("src/complex.ts"),
2713 "should contain target file path"
2714 );
2715 assert!(md.contains("27.5"), "should contain efficiency score");
2716 assert!(
2717 md.contains("Split high-impact file"),
2718 "should contain recommendation"
2719 );
2720 assert!(md.contains("src/legacy.ts"), "should contain second target");
2721 }
2722
2723 #[test]
2724 fn health_markdown_with_coverage_gaps() {
2725 use crate::health_types::*;
2726
2727 let root = PathBuf::from("/project");
2728 let report = HealthReport {
2729 summary: HealthSummary {
2730 files_analyzed: 10,
2731 functions_analyzed: 50,
2732 ..Default::default()
2733 },
2734 coverage_gaps: Some(CoverageGaps {
2735 summary: CoverageGapSummary {
2736 runtime_files: 2,
2737 covered_files: 0,
2738 file_coverage_pct: 0.0,
2739 untested_files: 1,
2740 untested_exports: 1,
2741 },
2742 files: vec![UntestedFileFinding::with_actions(
2743 UntestedFile {
2744 path: root.join("src/app.ts"),
2745 value_export_count: 2,
2746 },
2747 &root,
2748 )],
2749 exports: vec![UntestedExportFinding::with_actions(
2750 UntestedExport {
2751 path: root.join("src/app.ts"),
2752 export_name: "loader".into(),
2753 line: 12,
2754 col: 4,
2755 },
2756 &root,
2757 )],
2758 }),
2759 ..Default::default()
2760 };
2761
2762 let md = build_health_markdown(&report, &root);
2763 assert!(md.contains("### Coverage Gaps"));
2764 assert!(md.contains("*1 untested files"));
2765 assert!(md.contains("`src/app.ts` (2 value exports)"));
2766 assert!(md.contains("`src/app.ts`:12 `loader`"));
2767 }
2768
2769 #[test]
2770 fn markdown_dep_in_workspace_shows_package_label() {
2771 let root = PathBuf::from("/project");
2772 let mut results = AnalysisResults::default();
2773 results
2774 .unused_dependencies
2775 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2776 package_name: "lodash".to_string(),
2777 location: DependencyLocation::Dependencies,
2778 path: root.join("packages/core/package.json"),
2779 line: 5,
2780 used_in_workspaces: Vec::new(),
2781 }));
2782 let md = build_markdown(&results, &root);
2783 assert!(md.contains("(packages/core/package.json)"));
2784 }
2785
2786 #[test]
2787 fn markdown_dep_at_root_no_extra_label() {
2788 let root = PathBuf::from("/project");
2789 let mut results = AnalysisResults::default();
2790 results
2791 .unused_dependencies
2792 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2793 package_name: "lodash".to_string(),
2794 location: DependencyLocation::Dependencies,
2795 path: root.join("package.json"),
2796 line: 5,
2797 used_in_workspaces: Vec::new(),
2798 }));
2799 let md = build_markdown(&results, &root);
2800 assert!(md.contains("- `lodash`"));
2801 assert!(!md.contains("(package.json)"));
2802 }
2803
2804 #[test]
2805 fn markdown_root_dep_with_cross_workspace_context_uses_context_label() {
2806 let root = PathBuf::from("/project");
2807 let mut results = AnalysisResults::default();
2808 results
2809 .unused_dependencies
2810 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2811 package_name: "lodash-es".to_string(),
2812 location: DependencyLocation::Dependencies,
2813 path: root.join("package.json"),
2814 line: 5,
2815 used_in_workspaces: vec![root.join("packages/consumer")],
2816 }));
2817 let md = build_markdown(&results, &root);
2818 assert!(md.contains("- `lodash-es` (imported in packages/consumer)"));
2819 assert!(!md.contains("(package.json; imported in packages/consumer)"));
2820 }
2821
2822 #[test]
2823 fn markdown_exports_grouped_by_file() {
2824 let root = PathBuf::from("/project");
2825 let mut results = AnalysisResults::default();
2826 results
2827 .unused_exports
2828 .push(UnusedExportFinding::with_actions(UnusedExport {
2829 path: root.join("src/utils.ts"),
2830 export_name: "alpha".to_string(),
2831 is_type_only: false,
2832 line: 5,
2833 col: 0,
2834 span_start: 0,
2835 is_re_export: false,
2836 }));
2837 results
2838 .unused_exports
2839 .push(UnusedExportFinding::with_actions(UnusedExport {
2840 path: root.join("src/utils.ts"),
2841 export_name: "beta".to_string(),
2842 is_type_only: false,
2843 line: 10,
2844 col: 0,
2845 span_start: 0,
2846 is_re_export: false,
2847 }));
2848 results
2849 .unused_exports
2850 .push(UnusedExportFinding::with_actions(UnusedExport {
2851 path: root.join("src/other.ts"),
2852 export_name: "gamma".to_string(),
2853 is_type_only: false,
2854 line: 1,
2855 col: 0,
2856 span_start: 0,
2857 is_re_export: false,
2858 }));
2859 let md = build_markdown(&results, &root);
2860 let utils_count = md.matches("- `src/utils.ts`").count();
2861 assert_eq!(utils_count, 1, "file header should appear once per file");
2862 assert!(md.contains(":5 `alpha`"));
2863 assert!(md.contains(":10 `beta`"));
2864 }
2865
2866 #[test]
2867 fn markdown_multiple_issues_plural() {
2868 let root = PathBuf::from("/project");
2869 let mut results = AnalysisResults::default();
2870 results
2871 .unused_files
2872 .push(UnusedFileFinding::with_actions(UnusedFile {
2873 path: root.join("src/a.ts"),
2874 }));
2875 results
2876 .unused_files
2877 .push(UnusedFileFinding::with_actions(UnusedFile {
2878 path: root.join("src/b.ts"),
2879 }));
2880 let md = build_markdown(&results, &root);
2881 assert!(md.starts_with("## Fallow: 2 issues found\n"));
2882 }
2883
2884 #[test]
2885 fn duplication_markdown_zero_savings_no_suffix() {
2886 let root = PathBuf::from("/project");
2887 let report = DuplicationReport {
2888 clone_groups: vec![CloneGroup {
2889 instances: vec![CloneInstance {
2890 file: root.join("src/a.ts"),
2891 start_line: 1,
2892 end_line: 5,
2893 start_col: 0,
2894 end_col: 0,
2895 fragment: String::new(),
2896 }],
2897 token_count: 30,
2898 line_count: 5,
2899 }],
2900 clone_families: vec![CloneFamily {
2901 files: vec![root.join("src/a.ts")],
2902 groups: vec![],
2903 total_duplicated_lines: 5,
2904 total_duplicated_tokens: 30,
2905 suggestions: vec![RefactoringSuggestion {
2906 kind: RefactoringKind::ExtractFunction,
2907 description: "Extract function".to_string(),
2908 estimated_savings: 0,
2909 }],
2910 }],
2911 mirrored_directories: vec![],
2912 stats: DuplicationStats {
2913 clone_groups: 1,
2914 clone_instances: 1,
2915 duplication_percentage: 1.0,
2916 ..Default::default()
2917 },
2918 };
2919 let md = build_duplication_markdown(&report, &root);
2920 assert!(md.contains("Extract function"));
2921 assert!(!md.contains("lines saved"));
2922 }
2923
2924 #[test]
2925 fn health_markdown_vital_signs_table() {
2926 let root = PathBuf::from("/project");
2927 let report = crate::health_types::HealthReport {
2928 summary: crate::health_types::HealthSummary {
2929 files_analyzed: 10,
2930 functions_analyzed: 50,
2931 ..Default::default()
2932 },
2933 vital_signs: Some(crate::health_types::VitalSigns {
2934 avg_cyclomatic: 3.5,
2935 p90_cyclomatic: 12,
2936 dead_file_pct: Some(5.0),
2937 dead_export_pct: Some(10.2),
2938 duplication_pct: None,
2939 maintainability_avg: Some(72.3),
2940 hotspot_count: Some(3),
2941 circular_dep_count: Some(1),
2942 unused_dep_count: Some(2),
2943 counts: None,
2944 unit_size_profile: None,
2945 unit_interfacing_profile: None,
2946 p95_fan_in: None,
2947 coupling_high_pct: None,
2948 total_loc: 15_200,
2949 ..Default::default()
2950 }),
2951 hotspot_summary: Some(crate::health_types::HotspotSummary {
2952 since: "6 months".to_string(),
2953 min_commits: 3,
2954 files_analyzed: 50,
2955 files_excluded: 0,
2956 shallow_clone: false,
2957 }),
2958 ..Default::default()
2959 };
2960 let md = build_health_markdown(&report, &root);
2961 assert!(md.contains("## Vital Signs"));
2962 assert!(md.contains("| Metric | Value |"));
2963 assert!(md.contains("| Total LOC | 15200 |"));
2964 assert!(md.contains("| Avg Cyclomatic | 3.5 |"));
2965 assert!(md.contains("| P90 Cyclomatic | 12 |"));
2966 assert!(md.contains("| Dead Files | 5.0% |"));
2967 assert!(md.contains("| Dead Exports | 10.2% |"));
2968 assert!(md.contains("| Maintainability (avg) | 72.3 |"));
2969 assert!(md.contains("| Hotspots (since 6 months) | 3 |"));
2970 assert!(md.contains("| Circular Deps | 1 |"));
2971 assert!(md.contains("| Unused Deps | 2 |"));
2972 }
2973
2974 #[test]
2975 fn health_markdown_hotspots_without_summary_omits_window() {
2976 let root = PathBuf::from("/project");
2977 let report = crate::health_types::HealthReport {
2978 vital_signs: Some(crate::health_types::VitalSigns {
2979 avg_cyclomatic: 2.0,
2980 p90_cyclomatic: 5,
2981 hotspot_count: Some(0),
2982 total_loc: 1_000,
2983 ..Default::default()
2984 }),
2985 hotspot_summary: None,
2986 ..Default::default()
2987 };
2988 let md = build_health_markdown(&report, &root);
2989 assert!(md.contains("| Hotspots | 0 |"));
2990 assert!(!md.contains("Hotspots (since"));
2991 }
2992
2993 #[test]
2994 fn health_markdown_file_scores_table() {
2995 let root = PathBuf::from("/project");
2996 let report = crate::health_types::HealthReport {
2997 findings: vec![
2998 crate::health_types::ComplexityViolation {
2999 path: root.join("src/dummy.ts"),
3000 name: "fn".to_string(),
3001 line: 1,
3002 col: 0,
3003 cyclomatic: 25,
3004 cognitive: 20,
3005 line_count: 50,
3006 param_count: 0,
3007 react_hook_count: 0,
3008 react_jsx_max_depth: 0,
3009 react_prop_count: 0,
3010 react_hook_profile: None,
3011 exceeded: crate::health_types::ExceededThreshold::Both,
3012 severity: crate::health_types::FindingSeverity::High,
3013 crap: None,
3014 coverage_pct: None,
3015 coverage_tier: None,
3016 coverage_source: None,
3017 inherited_from: None,
3018 component_rollup: None,
3019 contributions: Vec::new(),
3020 effective_thresholds: None,
3021 threshold_source: None,
3022 }
3023 .into(),
3024 ],
3025 summary: crate::health_types::HealthSummary {
3026 files_analyzed: 5,
3027 functions_analyzed: 10,
3028 functions_above_threshold: 1,
3029 files_scored: Some(1),
3030 average_maintainability: Some(65.0),
3031 ..Default::default()
3032 },
3033 file_scores: vec![crate::health_types::FileHealthScore {
3034 path: root.join("src/utils.ts"),
3035 fan_in: 5,
3036 fan_out: 3,
3037 dead_code_ratio: 0.25,
3038 complexity_density: 0.8,
3039 maintainability_index: 72.5,
3040 total_cyclomatic: 40,
3041 total_cognitive: 30,
3042 function_count: 10,
3043 lines: 200,
3044 crap_max: 0.0,
3045 crap_above_threshold: 0,
3046 }],
3047 ..Default::default()
3048 };
3049 let md = build_health_markdown(&report, &root);
3050 assert!(md.contains("### File Health Scores (1 files)"));
3051 assert!(md.contains("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density |"));
3052 assert!(md.contains("| `src/utils.ts` | 72.5 | 5 | 3 | 25% | 0.80 |"));
3053 assert!(md.contains("**Average maintainability index:** 65.0/100"));
3054 }
3055
3056 #[test]
3057 fn health_markdown_hotspots_table() {
3058 let root = PathBuf::from("/project");
3059 let report = crate::health_types::HealthReport {
3060 findings: vec![
3061 crate::health_types::ComplexityViolation {
3062 path: root.join("src/dummy.ts"),
3063 name: "fn".to_string(),
3064 line: 1,
3065 col: 0,
3066 cyclomatic: 25,
3067 cognitive: 20,
3068 line_count: 50,
3069 param_count: 0,
3070 react_hook_count: 0,
3071 react_jsx_max_depth: 0,
3072 react_prop_count: 0,
3073 react_hook_profile: None,
3074 exceeded: crate::health_types::ExceededThreshold::Both,
3075 severity: crate::health_types::FindingSeverity::High,
3076 crap: None,
3077 coverage_pct: None,
3078 coverage_tier: None,
3079 coverage_source: None,
3080 inherited_from: None,
3081 component_rollup: None,
3082 contributions: Vec::new(),
3083 effective_thresholds: None,
3084 threshold_source: None,
3085 }
3086 .into(),
3087 ],
3088 summary: crate::health_types::HealthSummary {
3089 files_analyzed: 5,
3090 functions_analyzed: 10,
3091 functions_above_threshold: 1,
3092 ..Default::default()
3093 },
3094 hotspots: vec![
3095 crate::health_types::HotspotEntry {
3096 path: root.join("src/hot.ts"),
3097 score: 85.0,
3098 commits: 42,
3099 weighted_commits: 35.0,
3100 lines_added: 500,
3101 lines_deleted: 200,
3102 complexity_density: 1.2,
3103 fan_in: 10,
3104 trend: fallow_core::churn::ChurnTrend::Accelerating,
3105 ownership: None,
3106 is_test_path: false,
3107 }
3108 .into(),
3109 ],
3110 hotspot_summary: Some(crate::health_types::HotspotSummary {
3111 since: "6 months".to_string(),
3112 min_commits: 3,
3113 files_analyzed: 50,
3114 files_excluded: 5,
3115 shallow_clone: false,
3116 }),
3117 ..Default::default()
3118 };
3119 let md = build_health_markdown(&report, &root);
3120 assert!(md.contains("### Hotspots (1 files, since 6 months)"));
3121 assert!(md.contains("| `src/hot.ts` | 85.0 | 42 | 700 | 1.20 | 10 | accelerating |"));
3122 assert!(md.contains("*5 files excluded (< 3 commits)*"));
3123 }
3124
3125 #[test]
3126 fn health_markdown_metric_legend_with_scores() {
3127 let root = PathBuf::from("/project");
3128 let report = crate::health_types::HealthReport {
3129 findings: vec![
3130 crate::health_types::ComplexityViolation {
3131 path: root.join("src/x.ts"),
3132 name: "f".to_string(),
3133 line: 1,
3134 col: 0,
3135 cyclomatic: 25,
3136 cognitive: 20,
3137 line_count: 10,
3138 param_count: 0,
3139 react_hook_count: 0,
3140 react_jsx_max_depth: 0,
3141 react_prop_count: 0,
3142 react_hook_profile: None,
3143 exceeded: crate::health_types::ExceededThreshold::Both,
3144 severity: crate::health_types::FindingSeverity::High,
3145 crap: None,
3146 coverage_pct: None,
3147 coverage_tier: None,
3148 coverage_source: None,
3149 inherited_from: None,
3150 component_rollup: None,
3151 contributions: Vec::new(),
3152 effective_thresholds: None,
3153 threshold_source: None,
3154 }
3155 .into(),
3156 ],
3157 summary: crate::health_types::HealthSummary {
3158 files_analyzed: 1,
3159 functions_analyzed: 1,
3160 functions_above_threshold: 1,
3161 files_scored: Some(1),
3162 average_maintainability: Some(70.0),
3163 ..Default::default()
3164 },
3165 file_scores: vec![crate::health_types::FileHealthScore {
3166 path: root.join("src/x.ts"),
3167 fan_in: 1,
3168 fan_out: 1,
3169 dead_code_ratio: 0.0,
3170 complexity_density: 0.5,
3171 maintainability_index: 80.0,
3172 total_cyclomatic: 10,
3173 total_cognitive: 8,
3174 function_count: 2,
3175 lines: 50,
3176 crap_max: 0.0,
3177 crap_above_threshold: 0,
3178 }],
3179 ..Default::default()
3180 };
3181 let md = build_health_markdown(&report, &root);
3182 assert!(md.contains("<details><summary>Metric definitions</summary>"));
3183 assert!(md.contains("**MI**: Maintainability Index"));
3184 assert!(md.contains("**Fan-in**"));
3185 assert!(md.contains("Full metric reference"));
3186 }
3187
3188 #[test]
3189 fn health_markdown_truncated_findings_shown_count() {
3190 let root = PathBuf::from("/project");
3191 let report = crate::health_types::HealthReport {
3192 findings: vec![
3193 crate::health_types::ComplexityViolation {
3194 path: root.join("src/x.ts"),
3195 name: "f".to_string(),
3196 line: 1,
3197 col: 0,
3198 cyclomatic: 25,
3199 cognitive: 20,
3200 line_count: 10,
3201 param_count: 0,
3202 react_hook_count: 0,
3203 react_jsx_max_depth: 0,
3204 react_prop_count: 0,
3205 react_hook_profile: None,
3206 exceeded: crate::health_types::ExceededThreshold::Both,
3207 severity: crate::health_types::FindingSeverity::High,
3208 crap: None,
3209 coverage_pct: None,
3210 coverage_tier: None,
3211 coverage_source: None,
3212 inherited_from: None,
3213 component_rollup: None,
3214 contributions: Vec::new(),
3215 effective_thresholds: None,
3216 threshold_source: None,
3217 }
3218 .into(),
3219 ],
3220 summary: crate::health_types::HealthSummary {
3221 files_analyzed: 10,
3222 functions_analyzed: 50,
3223 functions_above_threshold: 5, ..Default::default()
3225 },
3226 ..Default::default()
3227 };
3228 let md = build_health_markdown(&report, &root);
3229 assert!(md.contains("5 high complexity functions (1 shown)"));
3230 }
3231
3232 #[test]
3233 fn escape_backticks_handles_multiple() {
3234 assert_eq!(escape_backticks("a`b`c"), "a\\`b\\`c");
3235 }
3236
3237 #[test]
3238 fn escape_backticks_no_backticks_unchanged() {
3239 assert_eq!(escape_backticks("hello"), "hello");
3240 }
3241
3242 #[test]
3243 fn markdown_unresolved_import_grouped_by_file() {
3244 let root = PathBuf::from("/project");
3245 let mut results = AnalysisResults::default();
3246 results
3247 .unresolved_imports
3248 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
3249 path: root.join("src/app.ts"),
3250 specifier: "./missing".to_string(),
3251 line: 3,
3252 col: 0,
3253 specifier_col: 0,
3254 }));
3255 let md = build_markdown(&results, &root);
3256 assert!(md.contains("### Unresolved imports (1)"));
3257 assert!(md.contains("- `src/app.ts`"));
3258 assert!(md.contains(":3 `./missing`"));
3259 }
3260
3261 #[test]
3262 fn markdown_unused_optional_dep() {
3263 let root = PathBuf::from("/project");
3264 let mut results = AnalysisResults::default();
3265 results
3266 .unused_optional_dependencies
3267 .push(UnusedOptionalDependencyFinding::with_actions(
3268 UnusedDependency {
3269 package_name: "fsevents".to_string(),
3270 location: DependencyLocation::OptionalDependencies,
3271 path: root.join("package.json"),
3272 line: 12,
3273 used_in_workspaces: Vec::new(),
3274 },
3275 ));
3276 let md = build_markdown(&results, &root);
3277 assert!(md.contains("### Unused optionalDependencies (1)"));
3278 assert!(md.contains("- `fsevents`"));
3279 }
3280
3281 #[test]
3282 fn health_markdown_hotspots_no_excluded_message() {
3283 let root = PathBuf::from("/project");
3284 let report = crate::health_types::HealthReport {
3285 findings: vec![
3286 crate::health_types::ComplexityViolation {
3287 path: root.join("src/x.ts"),
3288 name: "f".to_string(),
3289 line: 1,
3290 col: 0,
3291 cyclomatic: 25,
3292 cognitive: 20,
3293 line_count: 10,
3294 param_count: 0,
3295 react_hook_count: 0,
3296 react_jsx_max_depth: 0,
3297 react_prop_count: 0,
3298 react_hook_profile: None,
3299 exceeded: crate::health_types::ExceededThreshold::Both,
3300 severity: crate::health_types::FindingSeverity::High,
3301 crap: None,
3302 coverage_pct: None,
3303 coverage_tier: None,
3304 coverage_source: None,
3305 inherited_from: None,
3306 component_rollup: None,
3307 contributions: Vec::new(),
3308 effective_thresholds: None,
3309 threshold_source: None,
3310 }
3311 .into(),
3312 ],
3313 summary: crate::health_types::HealthSummary {
3314 files_analyzed: 5,
3315 functions_analyzed: 10,
3316 functions_above_threshold: 1,
3317 ..Default::default()
3318 },
3319 hotspots: vec![
3320 crate::health_types::HotspotEntry {
3321 path: root.join("src/hot.ts"),
3322 score: 50.0,
3323 commits: 10,
3324 weighted_commits: 8.0,
3325 lines_added: 100,
3326 lines_deleted: 50,
3327 complexity_density: 0.5,
3328 fan_in: 3,
3329 trend: fallow_core::churn::ChurnTrend::Stable,
3330 ownership: None,
3331 is_test_path: false,
3332 }
3333 .into(),
3334 ],
3335 hotspot_summary: Some(crate::health_types::HotspotSummary {
3336 since: "6 months".to_string(),
3337 min_commits: 3,
3338 files_analyzed: 50,
3339 files_excluded: 0,
3340 shallow_clone: false,
3341 }),
3342 ..Default::default()
3343 };
3344 let md = build_health_markdown(&report, &root);
3345 assert!(!md.contains("files excluded"));
3346 }
3347
3348 #[test]
3349 fn duplication_markdown_single_group_no_plural() {
3350 let root = PathBuf::from("/project");
3351 let report = DuplicationReport {
3352 clone_groups: vec![CloneGroup {
3353 instances: vec![CloneInstance {
3354 file: root.join("src/a.ts"),
3355 start_line: 1,
3356 end_line: 5,
3357 start_col: 0,
3358 end_col: 0,
3359 fragment: String::new(),
3360 }],
3361 token_count: 30,
3362 line_count: 5,
3363 }],
3364 clone_families: vec![],
3365 mirrored_directories: vec![],
3366 stats: DuplicationStats {
3367 clone_groups: 1,
3368 clone_instances: 1,
3369 duplication_percentage: 2.0,
3370 ..Default::default()
3371 },
3372 };
3373 let md = build_duplication_markdown(&report, &root);
3374 assert!(md.contains("1 clone group found"));
3375 assert!(!md.contains("1 clone groups found"));
3376 }
3377}