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 rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
954
955 let mut out = String::new();
956
957 if report.clone_groups.is_empty() {
958 out.push_str("## Fallow: no code duplication found\n");
959 return out;
960 }
961
962 let stats = &report.stats;
963 let _ = write!(
964 out,
965 "## Fallow: {} clone group{} found ({:.1}% duplication)\n\n",
966 stats.clone_groups,
967 plural(stats.clone_groups),
968 stats.duplication_percentage,
969 );
970
971 out.push_str("### Duplicates\n\n");
972 for (i, group) in report.clone_groups.iter().enumerate() {
973 let instance_count = group.instances.len();
974 let _ = write!(
975 out,
976 "**Clone group {}** ({} lines, {instance_count} instance{})\n\n",
977 i + 1,
978 group.line_count,
979 plural(instance_count)
980 );
981 for instance in &group.instances {
982 let relative = rel(&instance.file);
983 let _ = writeln!(
984 out,
985 "- `{relative}:{}-{}`",
986 instance.start_line, instance.end_line
987 );
988 }
989 out.push('\n');
990 }
991
992 if !report.clone_families.is_empty() {
993 out.push_str("### Clone Families\n\n");
994 for (i, family) in report.clone_families.iter().enumerate() {
995 let file_names: Vec<_> = family.files.iter().map(|f| rel(f)).collect();
996 let _ = write!(
997 out,
998 "**Family {}** ({} group{}, {} lines across {})\n\n",
999 i + 1,
1000 family.groups.len(),
1001 plural(family.groups.len()),
1002 family.total_duplicated_lines,
1003 file_names
1004 .iter()
1005 .map(|s| format!("`{s}`"))
1006 .collect::<Vec<_>>()
1007 .join(", "),
1008 );
1009 for suggestion in &family.suggestions {
1010 let savings = if suggestion.estimated_savings > 0 {
1011 format!(" (~{} lines saved)", suggestion.estimated_savings)
1012 } else {
1013 String::new()
1014 };
1015 let _ = writeln!(out, "- {}{savings}", suggestion.description);
1016 }
1017 out.push('\n');
1018 }
1019 }
1020
1021 let _ = writeln!(
1022 out,
1023 "**Summary:** {} duplicated lines ({:.1}%) across {} file{}",
1024 stats.duplicated_lines,
1025 stats.duplication_percentage,
1026 stats.files_with_clones,
1027 plural(stats.files_with_clones),
1028 );
1029
1030 out
1031}
1032
1033pub(super) fn print_health_markdown(report: &crate::health_types::HealthReport, root: &Path) {
1034 outln!("{}", build_health_markdown(report, root));
1035}
1036
1037#[must_use]
1039pub fn build_health_markdown(report: &crate::health_types::HealthReport, root: &Path) -> String {
1040 let mut out = String::new();
1041
1042 if let Some(ref hs) = report.health_score {
1043 let _ = writeln!(out, "## Health Score: {:.0} ({})\n", hs.score, hs.grade);
1044 }
1045
1046 write_trend_section(&mut out, report);
1047 write_vital_signs_section(&mut out, report);
1048
1049 if report.findings.is_empty()
1050 && report.file_scores.is_empty()
1051 && report.coverage_gaps.is_none()
1052 && report.hotspots.is_empty()
1053 && report.targets.is_empty()
1054 && report.runtime_coverage.is_none()
1055 && report.coverage_intelligence.is_none()
1056 && report.threshold_overrides.is_empty()
1057 && report.css_analytics.is_none()
1058 {
1059 if report.vital_signs.is_none() {
1060 let _ = write!(
1061 out,
1062 "## Fallow: no functions exceed complexity thresholds\n\n\
1063 **{}** functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {:.1})\n",
1064 report.summary.functions_analyzed,
1065 report.summary.max_cyclomatic_threshold,
1066 report.summary.max_cognitive_threshold,
1067 report.summary.max_crap_threshold,
1068 );
1069 }
1070 return out;
1071 }
1072
1073 write_findings_section(&mut out, report, root);
1074 write_threshold_overrides_section(&mut out, report, root);
1075 write_runtime_coverage_section(&mut out, report, root);
1076 write_coverage_intelligence_section(&mut out, report, root);
1077 write_coverage_gaps_section(&mut out, report, root);
1078 write_file_scores_section(&mut out, report, root);
1079 write_hotspots_section(&mut out, report, root);
1080 write_targets_section(&mut out, report, root);
1081 write_css_analytics_section(&mut out, report);
1082 write_metric_legend(&mut out, report);
1083
1084 out
1085}
1086
1087fn write_css_analytics_section(out: &mut String, report: &crate::health_types::HealthReport) {
1091 let Some(ref css) = report.css_analytics else {
1092 return;
1093 };
1094 let s = &css.summary;
1095 if !out.is_empty() && !out.ends_with("\n\n") {
1096 out.push('\n');
1097 }
1098 out.push_str("## CSS Health\n\n");
1099 let important_pct = if s.total_declarations > 0 {
1100 f64::from(s.important_declarations) / f64::from(s.total_declarations) * 100.0
1101 } else {
1102 0.0
1103 };
1104 let _ = writeln!(
1105 out,
1106 "- Stylesheets: {} | Rules: {} | !important: {important_pct:.1}% | Empty rules: {} | Max nesting: {}",
1107 s.files_analyzed, s.total_rules, s.empty_rules, s.max_nesting_depth,
1108 );
1109 let _ = writeln!(
1110 out,
1111 "- Value sprawl: {} colors | {} font sizes | {} z-index | {} shadows | {} radii | {} line-heights",
1112 s.unique_colors,
1113 s.unique_font_sizes,
1114 s.unique_z_indexes,
1115 s.unique_box_shadows,
1116 s.unique_border_radii,
1117 s.unique_line_heights,
1118 );
1119 let _ = writeln!(
1120 out,
1121 "- 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",
1122 s.keyframes_unreferenced,
1123 s.keyframes_undefined,
1124 s.duplicate_declaration_blocks,
1125 s.scoped_unused_classes,
1126 s.tailwind_arbitrary_values,
1127 s.unused_property_registrations,
1128 s.unused_layers,
1129 s.unresolved_class_references,
1130 s.unreferenced_css_classes,
1131 s.unused_font_faces,
1132 s.unused_theme_tokens,
1133 );
1134 write_css_candidate_details(out, css);
1135 out.push('\n');
1136}
1137
1138fn write_css_candidate_details(out: &mut String, css: &crate::health_types::CssAnalyticsReport) {
1139 write_css_keyframe_details(out, css);
1140 write_css_tailwind_details(out, css);
1141 write_css_class_candidate_details(out, css);
1142 write_css_font_candidate_details(out, css);
1143 write_css_font_size_mix_details(out, css);
1144}
1145
1146fn write_css_keyframe_details(out: &mut String, css: &crate::health_types::CssAnalyticsReport) {
1147 if !css.undefined_keyframes.is_empty() {
1148 let named: Vec<String> = css
1149 .undefined_keyframes
1150 .iter()
1151 .take(5)
1152 .map(|kf| format!("`{}` ({})", kf.name, kf.path))
1153 .collect();
1154 let _ = writeln!(
1155 out,
1156 "- Undefined @keyframes (candidates; likely typo or CSS-in-JS): {}",
1157 named.join(", "),
1158 );
1159 }
1160}
1161
1162fn write_css_tailwind_details(out: &mut String, css: &crate::health_types::CssAnalyticsReport) {
1163 if !css.tailwind_arbitrary_values.is_empty() {
1164 let named: Vec<String> = css
1165 .tailwind_arbitrary_values
1166 .iter()
1167 .take(5)
1168 .map(|a| format!("`{}` ({}x)", a.value, a.count))
1169 .collect();
1170 let _ = writeln!(out, "- Top Tailwind arbitrary values: {}", named.join(", "));
1171 }
1172}
1173
1174fn write_css_class_candidate_details(
1175 out: &mut String,
1176 css: &crate::health_types::CssAnalyticsReport,
1177) {
1178 if !css.unresolved_class_references.is_empty() {
1179 let named: Vec<String> = css
1180 .unresolved_class_references
1181 .iter()
1182 .take(5)
1183 .map(|u| {
1184 format!(
1185 "`{}` -> `{}` ({}:{})",
1186 u.class, u.suggestion, u.path, u.line
1187 )
1188 })
1189 .collect();
1190 let _ = writeln!(
1191 out,
1192 "- Likely class typos (candidates; verify, may be CSS-in-JS or external): {}",
1193 named.join(", "),
1194 );
1195 }
1196 if !css.unreferenced_css_classes.is_empty() {
1197 let named: Vec<String> = css
1198 .unreferenced_css_classes
1199 .iter()
1200 .take(5)
1201 .map(|u| format!("`.{}` ({}:{})", u.class, u.path, u.line))
1202 .collect();
1203 let _ = writeln!(
1204 out,
1205 "- Unreferenced global classes (candidates; verify no email / server / CMS / Markdown applies them): {}",
1206 named.join(", "),
1207 );
1208 }
1209}
1210
1211fn write_css_font_candidate_details(
1212 out: &mut String,
1213 css: &crate::health_types::CssAnalyticsReport,
1214) {
1215 if !css.unused_font_faces.is_empty() {
1216 let named: Vec<String> = css
1217 .unused_font_faces
1218 .iter()
1219 .take(5)
1220 .map(|u| format!("`{}` ({})", u.family, u.path))
1221 .collect();
1222 let _ = writeln!(
1223 out,
1224 "- Unused @font-face (dead web-font; candidates, may be set from JS/inline): {}",
1225 named.join(", "),
1226 );
1227 }
1228 if !css.unused_theme_tokens.is_empty() {
1229 let named: Vec<String> = css
1230 .unused_theme_tokens
1231 .iter()
1232 .take(5)
1233 .map(|u| format!("`{}` ({}:{})", u.token, u.path, u.line))
1234 .collect();
1235 let _ = writeln!(
1236 out,
1237 "- Unused @theme tokens (dead Tailwind v4 design tokens; candidates, may be consumed by a plugin or downstream repo): {}",
1238 named.join(", "),
1239 );
1240 }
1241}
1242
1243fn write_css_font_size_mix_details(
1244 out: &mut String,
1245 css: &crate::health_types::CssAnalyticsReport,
1246) {
1247 if let Some(mix) = &css.font_size_unit_mix {
1248 let breakdown: Vec<String> = mix
1249 .notations
1250 .iter()
1251 .map(|n| format!("{} {}", n.count, n.notation))
1252 .collect();
1253 let _ = writeln!(
1254 out,
1255 "- Font sizes mix {} units (candidate, standardize unless intentional): {}",
1256 mix.notations.len(),
1257 breakdown.join(", "),
1258 );
1259 }
1260}
1261
1262fn write_coverage_intelligence_section(
1263 out: &mut String,
1264 report: &crate::health_types::HealthReport,
1265 root: &Path,
1266) {
1267 let Some(ref intelligence) = report.coverage_intelligence else {
1268 return;
1269 };
1270 if !out.is_empty() && !out.ends_with("\n\n") {
1271 out.push('\n');
1272 }
1273 let _ = writeln!(
1274 out,
1275 "## Coverage Intelligence\n\n- Verdict: {}\n- Findings: {}\n- Ambiguous matches skipped: {}\n",
1276 intelligence.verdict,
1277 intelligence.summary.findings,
1278 intelligence.summary.skipped_ambiguous_matches,
1279 );
1280 if intelligence.findings.is_empty() {
1281 if intelligence.summary.skipped_ambiguous_matches > 0 {
1282 let match_phrase = if intelligence.summary.skipped_ambiguous_matches == 1 {
1283 "evidence match was"
1284 } else {
1285 "evidence matches were"
1286 };
1287 let _ = writeln!(
1288 out,
1289 "No actionable findings were emitted because {} ambiguous {match_phrase} skipped.\n",
1290 intelligence.summary.skipped_ambiguous_matches,
1291 );
1292 }
1293 return;
1294 }
1295 out.push_str("| ID | Path | Identity | Verdict | Recommendation | Confidence | Signals |\n");
1296 out.push_str("|:---|:-----|:---------|:--------|:---------------|:-----------|:--------|\n");
1297 for finding in &intelligence.findings {
1298 let path = escape_backticks(&normalize_uri(
1299 &relative_path(&finding.path, root).display().to_string(),
1300 ));
1301 let identity = finding
1302 .identity
1303 .as_deref()
1304 .map_or_else(|| "-".to_owned(), escape_backticks);
1305 let signals = finding
1306 .signals
1307 .iter()
1308 .map(ToString::to_string)
1309 .collect::<Vec<_>>()
1310 .join(", ");
1311 let _ = writeln!(
1312 out,
1313 "| `{}` | `{}`:{} | `{}` | {} | {} | {} | {} |",
1314 escape_backticks(&finding.id),
1315 path,
1316 finding.line,
1317 identity,
1318 finding.verdict,
1319 finding.recommendation,
1320 finding.confidence,
1321 signals,
1322 );
1323 }
1324 out.push('\n');
1325}
1326
1327fn write_runtime_coverage_section(
1328 out: &mut String,
1329 report: &crate::health_types::HealthReport,
1330 root: &Path,
1331) {
1332 let Some(ref production) = report.runtime_coverage else {
1333 return;
1334 };
1335 if !out.is_empty() && !out.ends_with("\n\n") {
1336 out.push('\n');
1337 }
1338 let _ = writeln!(
1339 out,
1340 "## 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",
1341 production.verdict,
1342 production.summary.functions_tracked,
1343 production.summary.functions_hit,
1344 production.summary.functions_unhit,
1345 production.summary.functions_untracked,
1346 production.summary.coverage_percent,
1347 production.summary.trace_count,
1348 production.summary.period_days,
1349 production.summary.deployments_seen,
1350 );
1351 if let Some(watermark) = production.watermark {
1352 let _ = writeln!(out, "- Watermark: {watermark}\n");
1353 }
1354 if let Some(ref quality) = production.summary.capture_quality
1355 && quality.lazy_parse_warning
1356 {
1357 let window = super::human::health::format_window(quality.window_seconds);
1358 let _ = writeln!(
1359 out,
1360 "- Capture quality: short window ({} from {} instance(s), {:.1}% of functions untracked); lazy-parsed scripts may not appear.\n",
1361 window, quality.instances_observed, quality.untracked_ratio_percent,
1362 );
1363 }
1364 let rel = |p: &Path| {
1365 escape_backticks(&normalize_uri(
1366 &relative_path(p, root).display().to_string(),
1367 ))
1368 };
1369 if !production.findings.is_empty() {
1370 out.push_str("| ID | Path | Function | Verdict | Invocations | Confidence |\n");
1371 out.push_str("|:---|:-----|:---------|:--------|------------:|:-----------|\n");
1372 for finding in &production.findings {
1373 let invocations = finding
1374 .invocations
1375 .map_or_else(|| "-".to_owned(), |hits| hits.to_string());
1376 let _ = writeln!(
1377 out,
1378 "| `{}` | `{}`:{} | `{}` | {} | {} | {} |",
1379 escape_backticks(&finding.id),
1380 rel(&finding.path),
1381 finding.line,
1382 escape_backticks(&finding.function),
1383 finding.verdict,
1384 invocations,
1385 finding.confidence,
1386 );
1387 }
1388 out.push('\n');
1389 }
1390 if !production.hot_paths.is_empty() {
1391 out.push_str("| ID | Hot path | Function | Invocations | Percentile |\n");
1392 out.push_str("|:---|:---------|:---------|------------:|-----------:|\n");
1393 for entry in &production.hot_paths {
1394 let _ = writeln!(
1395 out,
1396 "| `{}` | `{}`:{} | `{}` | {} | {} |",
1397 escape_backticks(&entry.id),
1398 rel(&entry.path),
1399 entry.line,
1400 escape_backticks(&entry.function),
1401 entry.invocations,
1402 entry.percentile,
1403 );
1404 }
1405 out.push('\n');
1406 }
1407}
1408
1409fn write_trend_section(out: &mut String, report: &crate::health_types::HealthReport) {
1411 let Some(ref trend) = report.health_trend else {
1412 return;
1413 };
1414 let sha_str = trend
1415 .compared_to
1416 .git_sha
1417 .as_deref()
1418 .map_or(String::new(), |sha| format!(" ({sha})"));
1419 let _ = writeln!(
1420 out,
1421 "## Trend (vs {}{})\n",
1422 trend
1423 .compared_to
1424 .timestamp
1425 .get(..10)
1426 .unwrap_or(&trend.compared_to.timestamp),
1427 sha_str,
1428 );
1429 out.push_str("| Metric | Previous | Current | Delta | Direction |\n");
1430 out.push_str("|:-------|:---------|:--------|:------|:----------|\n");
1431 for m in &trend.metrics {
1432 let fmt_val = |v: f64| -> String {
1433 if m.unit == "%" {
1434 format!("{v:.1}%")
1435 } else if (v - v.round()).abs() < 0.05 {
1436 format!("{v:.0}")
1437 } else {
1438 format!("{v:.1}")
1439 }
1440 };
1441 let prev = fmt_val(m.previous);
1442 let cur = fmt_val(m.current);
1443 let delta = if m.unit == "%" {
1444 format!("{:+.1}%", m.delta)
1445 } else if (m.delta - m.delta.round()).abs() < 0.05 {
1446 format!("{:+.0}", m.delta)
1447 } else {
1448 format!("{:+.1}", m.delta)
1449 };
1450 let _ = writeln!(
1451 out,
1452 "| {} | {} | {} | {} | {} {} |",
1453 m.label,
1454 prev,
1455 cur,
1456 delta,
1457 m.direction.arrow(),
1458 m.direction.label(),
1459 );
1460 }
1461 let md_sha = trend
1462 .compared_to
1463 .git_sha
1464 .as_deref()
1465 .map_or(String::new(), |sha| format!(" ({sha})"));
1466 let _ = writeln!(
1467 out,
1468 "\n*vs {}{} · {} {} available*\n",
1469 trend
1470 .compared_to
1471 .timestamp
1472 .get(..10)
1473 .unwrap_or(&trend.compared_to.timestamp),
1474 md_sha,
1475 trend.snapshots_loaded,
1476 if trend.snapshots_loaded == 1 {
1477 "snapshot"
1478 } else {
1479 "snapshots"
1480 },
1481 );
1482}
1483
1484fn write_vital_signs_section(out: &mut String, report: &crate::health_types::HealthReport) {
1486 let Some(ref vs) = report.vital_signs else {
1487 return;
1488 };
1489 out.push_str("## Vital Signs\n\n");
1490 out.push_str("| Metric | Value |\n");
1491 out.push_str("|:-------|------:|\n");
1492 if vs.total_loc > 0 {
1493 let _ = writeln!(out, "| Total LOC | {} |", vs.total_loc);
1494 }
1495 let _ = writeln!(out, "| Avg Cyclomatic | {:.1} |", vs.avg_cyclomatic);
1496 let _ = writeln!(out, "| P90 Cyclomatic | {} |", vs.p90_cyclomatic);
1497 if let Some(v) = vs.dead_file_pct {
1498 let _ = writeln!(out, "| Dead Files | {v:.1}% |");
1499 }
1500 if let Some(v) = vs.dead_export_pct {
1501 let _ = writeln!(out, "| Dead Exports | {v:.1}% |");
1502 }
1503 if let Some(v) = vs.maintainability_avg {
1504 let _ = writeln!(out, "| Maintainability (avg) | {v:.1} |");
1505 }
1506 if let Some(v) = vs.hotspot_count {
1507 let label = report.hotspot_summary.as_ref().map_or_else(
1508 || "Hotspots".to_string(),
1509 |summary| format!("Hotspots (since {})", summary.since),
1510 );
1511 let _ = writeln!(out, "| {label} | {v} |");
1512 }
1513 if let Some(v) = vs.circular_dep_count {
1514 let _ = writeln!(out, "| Circular Deps | {v} |");
1515 }
1516 if let Some(v) = vs.unused_dep_count {
1517 let _ = writeln!(out, "| Unused Deps | {v} |");
1518 }
1519 out.push('\n');
1520}
1521
1522fn write_findings_section(
1524 out: &mut String,
1525 report: &crate::health_types::HealthReport,
1526 root: &Path,
1527) {
1528 if report.findings.is_empty() {
1529 return;
1530 }
1531
1532 let rel = |p: &Path| {
1533 escape_backticks(&normalize_uri(
1534 &relative_path(p, root).display().to_string(),
1535 ))
1536 };
1537
1538 let count = report.summary.functions_above_threshold;
1539 let shown = report.findings.len();
1540 let has_synthetic = report
1541 .findings
1542 .iter()
1543 .any(|finding| matches!(finding.name.as_str(), "<template>" | "<component>"));
1544 let subject = if has_synthetic {
1545 "high complexity finding"
1546 } else {
1547 "high complexity function"
1548 };
1549 if shown < count {
1550 let _ = write!(
1551 out,
1552 "## Fallow: {count} {subject}{} ({shown} shown)\n\n",
1553 plural(count),
1554 );
1555 } else {
1556 let _ = write!(out, "## Fallow: {count} {subject}{}\n\n", plural(count));
1557 }
1558
1559 let name_header = if has_synthetic { "Entry" } else { "Function" };
1560 let _ = writeln!(
1561 out,
1562 "| File | {name_header} | Severity | Cyclomatic | Cognitive | CRAP | Lines |"
1563 );
1564 out.push_str("|:-----|:---------|:---------|:-----------|:----------|:-----|:------|\n");
1565
1566 for finding in &report.findings {
1567 let file_str = rel(&finding.path);
1568 let thresholds = finding.effective_thresholds.unwrap_or(
1569 crate::health_types::HealthEffectiveThresholds {
1570 max_cyclomatic: report.summary.max_cyclomatic_threshold,
1571 max_cognitive: report.summary.max_cognitive_threshold,
1572 max_crap: report.summary.max_crap_threshold,
1573 },
1574 );
1575 let cyc_marker = if finding.cyclomatic > thresholds.max_cyclomatic {
1576 " **!**"
1577 } else {
1578 ""
1579 };
1580 let cog_marker = if finding.cognitive > thresholds.max_cognitive {
1581 " **!**"
1582 } else {
1583 ""
1584 };
1585 let severity_label = match finding.severity {
1586 crate::health_types::FindingSeverity::Critical => "critical",
1587 crate::health_types::FindingSeverity::High => "high",
1588 crate::health_types::FindingSeverity::Moderate => "moderate",
1589 };
1590 let crap_cell = match finding.crap {
1591 Some(crap) => {
1592 let marker = if crap >= thresholds.max_crap {
1593 " **!**"
1594 } else {
1595 ""
1596 };
1597 format!("{crap:.1}{marker}")
1598 }
1599 None => "-".to_string(),
1600 };
1601 let _ = writeln!(
1602 out,
1603 "| `{file_str}:{line}` | `{name}` | {severity_label} | {cyc}{cyc_marker} | {cog}{cog_marker} | {crap_cell} | {lines} |",
1604 line = finding.line,
1605 name = escape_backticks(display_complexity_entry_name(&finding.name).as_ref()),
1606 cyc = finding.cyclomatic,
1607 cog = finding.cognitive,
1608 lines = finding.line_count,
1609 );
1610 }
1611
1612 let s = &report.summary;
1613 let _ = write!(
1614 out,
1615 "\n**{files}** files, **{funcs}** functions analyzed \
1616 (thresholds: cyclomatic > {cyc}, cognitive > {cog}, CRAP >= {crap:.1})\n",
1617 files = s.files_analyzed,
1618 funcs = s.functions_analyzed,
1619 cyc = s.max_cyclomatic_threshold,
1620 cog = s.max_cognitive_threshold,
1621 crap = s.max_crap_threshold,
1622 );
1623}
1624
1625fn write_threshold_overrides_section(
1626 out: &mut String,
1627 report: &crate::health_types::HealthReport,
1628 root: &Path,
1629) {
1630 if report.threshold_overrides.is_empty() {
1631 return;
1632 }
1633 if !out.is_empty() && !out.ends_with("\n\n") {
1634 out.push('\n');
1635 }
1636 out.push_str("## Health Threshold Overrides\n\n");
1637 out.push_str("| Override | Status | Target | Metrics |\n");
1638 out.push_str("|---------:|:-------|:-------|:--------|\n");
1639 for entry in &report.threshold_overrides {
1640 let status = match entry.status {
1641 crate::health_types::ThresholdOverrideStatus::Active => "active",
1642 crate::health_types::ThresholdOverrideStatus::Stale => "stale",
1643 crate::health_types::ThresholdOverrideStatus::NoMatch => "no_match",
1644 };
1645 let target = entry.path.as_ref().map_or_else(
1646 || "<no matching file or function>".to_string(),
1647 |path| {
1648 let display = escape_backticks(&normalize_uri(
1649 &relative_path(path, root).display().to_string(),
1650 ));
1651 entry.function.as_ref().map_or_else(
1652 || display.clone(),
1653 |name| format!("{display}:{}", escape_backticks(name)),
1654 )
1655 },
1656 );
1657 let metrics = entry.metrics.map_or_else(
1658 || "-".to_string(),
1659 |metrics| {
1660 let crap = metrics
1661 .crap
1662 .map_or(String::new(), |value| format!(", CRAP {value:.1}"));
1663 format!(
1664 "cyclomatic {}, cognitive {}{}",
1665 metrics.cyclomatic, metrics.cognitive, crap
1666 )
1667 },
1668 );
1669 let _ = writeln!(
1670 out,
1671 "| {} | {} | `{}` | {} |",
1672 entry.override_index, status, target, metrics
1673 );
1674 }
1675 out.push('\n');
1676}
1677
1678fn write_file_scores_section(
1680 out: &mut String,
1681 report: &crate::health_types::HealthReport,
1682 root: &Path,
1683) {
1684 if report.file_scores.is_empty() {
1685 return;
1686 }
1687
1688 let rel = |p: &Path| {
1689 escape_backticks(&normalize_uri(
1690 &relative_path(p, root).display().to_string(),
1691 ))
1692 };
1693
1694 out.push('\n');
1695 let _ = writeln!(
1696 out,
1697 "### File Health Scores ({} files)\n",
1698 report.file_scores.len(),
1699 );
1700 out.push_str("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density | Risk |\n");
1701 out.push_str("|:-----|:---------------|:-------|:--------|:----------|:--------|:-----|\n");
1702
1703 for score in &report.file_scores {
1704 let file_str = rel(&score.path);
1705 let _ = writeln!(
1706 out,
1707 "| `{file_str}` | {mi:.1} | {fi} | {fan_out} | {dead:.0}% | {density:.2} | {crap:.1} |",
1708 mi = score.maintainability_index,
1709 fi = score.fan_in,
1710 fan_out = score.fan_out,
1711 dead = score.dead_code_ratio * 100.0,
1712 density = score.complexity_density,
1713 crap = score.crap_max,
1714 );
1715 }
1716
1717 if let Some(avg) = report.summary.average_maintainability {
1718 let _ = write!(out, "\n**Average maintainability index:** {avg:.1}/100\n");
1719 }
1720}
1721
1722fn write_coverage_gaps_section(
1723 out: &mut String,
1724 report: &crate::health_types::HealthReport,
1725 root: &Path,
1726) {
1727 let Some(ref gaps) = report.coverage_gaps else {
1728 return;
1729 };
1730
1731 out.push('\n');
1732 let _ = writeln!(out, "### Coverage Gaps\n");
1733 let _ = writeln!(
1734 out,
1735 "*{} untested files · {} untested exports · {:.1}% file coverage*\n",
1736 gaps.summary.untested_files, gaps.summary.untested_exports, gaps.summary.file_coverage_pct,
1737 );
1738
1739 if gaps.files.is_empty() && gaps.exports.is_empty() {
1740 out.push_str("_No coverage gaps found in scope._\n");
1741 return;
1742 }
1743
1744 if !gaps.files.is_empty() {
1745 out.push_str("#### Files\n");
1746 for item in &gaps.files {
1747 let file_str = escape_backticks(&normalize_uri(
1748 &relative_path(&item.file.path, root).display().to_string(),
1749 ));
1750 let _ = writeln!(
1751 out,
1752 "- `{file_str}` ({count} value export{})",
1753 if item.file.value_export_count == 1 {
1754 ""
1755 } else {
1756 "s"
1757 },
1758 count = item.file.value_export_count,
1759 );
1760 }
1761 out.push('\n');
1762 }
1763
1764 if !gaps.exports.is_empty() {
1765 out.push_str("#### Exports\n");
1766 for item in &gaps.exports {
1767 let file_str = escape_backticks(&normalize_uri(
1768 &relative_path(&item.export.path, root).display().to_string(),
1769 ));
1770 let _ = writeln!(
1771 out,
1772 "- `{file_str}`:{} `{}`",
1773 item.export.line, item.export.export_name
1774 );
1775 }
1776 }
1777}
1778
1779fn ownership_md_cells(
1784 ownership: Option<&crate::health_types::OwnershipMetrics>,
1785) -> (String, String, String, String) {
1786 let Some(o) = ownership else {
1787 let dash = "\u{2013}".to_string();
1788 return (dash.clone(), dash.clone(), dash.clone(), dash);
1789 };
1790 let bus = o.bus_factor.to_string();
1791 let top = format!(
1792 "`{}` ({:.0}%)",
1793 o.top_contributor.identifier,
1794 o.top_contributor.share * 100.0,
1795 );
1796 let owner = o
1797 .declared_owner
1798 .as_deref()
1799 .map_or_else(|| "\u{2013}".to_string(), str::to_string);
1800 let mut notes: Vec<&str> = Vec::new();
1801 if o.unowned == Some(true) {
1802 notes.push("**unowned**");
1803 }
1804 if o.ownership_state == crate::health_types::OwnershipState::DeclaredInactive {
1805 notes.push("declared owner inactive");
1806 }
1807 if o.drift {
1808 notes.push("drift");
1809 }
1810 let notes_str = if notes.is_empty() {
1811 "\u{2013}".to_string()
1812 } else {
1813 notes.join(", ")
1814 };
1815 (bus, top, owner, notes_str)
1816}
1817
1818fn write_hotspots_section(
1819 out: &mut String,
1820 report: &crate::health_types::HealthReport,
1821 root: &Path,
1822) {
1823 if report.hotspots.is_empty() {
1824 return;
1825 }
1826
1827 let rel = |p: &Path| {
1828 escape_backticks(&normalize_uri(
1829 &relative_path(p, root).display().to_string(),
1830 ))
1831 };
1832
1833 out.push('\n');
1834 let header = report.hotspot_summary.as_ref().map_or_else(
1835 || format!("### Hotspots ({} files)\n", report.hotspots.len()),
1836 |summary| {
1837 format!(
1838 "### Hotspots ({} files, since {})\n",
1839 report.hotspots.len(),
1840 summary.since,
1841 )
1842 },
1843 );
1844 let _ = writeln!(out, "{header}");
1845 let any_ownership = report.hotspots.iter().any(|e| e.ownership.is_some());
1846 if any_ownership {
1847 out.push_str(
1848 "| File | Score | Commits | Churn | Density | Fan-in | Trend | Bus | Top | Owner | Notes |\n"
1849 );
1850 out.push_str(
1851 "|:-----|:------|:--------|:------|:--------|:-------|:------|:----|:----|:------|:------|\n"
1852 );
1853 } else {
1854 out.push_str("| File | Score | Commits | Churn | Density | Fan-in | Trend |\n");
1855 out.push_str("|:-----|:------|:--------|:------|:--------|:-------|:------|\n");
1856 }
1857
1858 for entry in &report.hotspots {
1859 let file_str = rel(&entry.path);
1860 if any_ownership {
1861 let (bus, top, owner, notes) = ownership_md_cells(entry.ownership.as_ref());
1862 let _ = writeln!(
1863 out,
1864 "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} | {bus} | {top} | {owner} | {notes} |",
1865 score = entry.score,
1866 commits = entry.commits,
1867 churn = entry.lines_added + entry.lines_deleted,
1868 density = entry.complexity_density,
1869 fi = entry.fan_in,
1870 trend = entry.trend,
1871 );
1872 } else {
1873 let _ = writeln!(
1874 out,
1875 "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} |",
1876 score = entry.score,
1877 commits = entry.commits,
1878 churn = entry.lines_added + entry.lines_deleted,
1879 density = entry.complexity_density,
1880 fi = entry.fan_in,
1881 trend = entry.trend,
1882 );
1883 }
1884 }
1885
1886 if let Some(ref summary) = report.hotspot_summary
1887 && summary.files_excluded > 0
1888 {
1889 let _ = write!(
1890 out,
1891 "\n*{} file{} excluded (< {} commits)*\n",
1892 summary.files_excluded,
1893 plural(summary.files_excluded),
1894 summary.min_commits,
1895 );
1896 }
1897}
1898
1899fn write_targets_section(
1901 out: &mut String,
1902 report: &crate::health_types::HealthReport,
1903 root: &Path,
1904) {
1905 if report.targets.is_empty() {
1906 return;
1907 }
1908 let _ = write!(
1909 out,
1910 "\n### Refactoring Targets ({})\n\n",
1911 report.targets.len()
1912 );
1913 out.push_str("| Efficiency | Category | Effort / Confidence | File | Recommendation |\n");
1914 out.push_str("|:-----------|:---------|:--------------------|:-----|:---------------|\n");
1915 for target in &report.targets {
1916 let file_str = normalize_uri(&relative_path(&target.path, root).display().to_string());
1917 let category = target.category.label();
1918 let effort = target.effort.label();
1919 let confidence = target.confidence.label();
1920 let _ = writeln!(
1921 out,
1922 "| {:.1} | {category} | {effort} / {confidence} | `{file_str}` | {} |",
1923 target.efficiency, target.recommendation,
1924 );
1925 }
1926}
1927
1928fn write_metric_legend(out: &mut String, report: &crate::health_types::HealthReport) {
1930 let has_scores = !report.file_scores.is_empty();
1931 let has_coverage = report.coverage_gaps.is_some();
1932 let has_hotspots = !report.hotspots.is_empty();
1933 let has_targets = !report.targets.is_empty();
1934 if !has_scores && !has_coverage && !has_hotspots && !has_targets {
1935 return;
1936 }
1937 out.push_str("\n---\n\n<details><summary>Metric definitions</summary>\n\n");
1938 if has_scores {
1939 out.push_str("- **MI**: Maintainability Index (0\u{2013}100, higher is better)\n");
1940 out.push_str("- **Order**: risk-aware triage order using the larger of low-MI concern and CRAP risk\n");
1941 out.push_str("- **Fan-in**: files that import this file (blast radius)\n");
1942 out.push_str("- **Fan-out**: files this file imports (coupling)\n");
1943 out.push_str("- **Dead Code**: % of value exports with zero references\n");
1944 out.push_str("- **Density**: cyclomatic complexity / lines of code\n");
1945 out.push_str(
1946 "- **Risk**: max CRAP score for the file; low <15, moderate 15-30, high >=30\n",
1947 );
1948 }
1949 if has_coverage {
1950 out.push_str(
1951 "- **File coverage**: runtime files also reachable from a discovered test root\n",
1952 );
1953 out.push_str("- **Untested export**: export with no reference chain from any test-reachable module\n");
1954 }
1955 if has_hotspots {
1956 out.push_str("- **Score**: churn \u{00d7} complexity (0\u{2013}100, higher = riskier)\n");
1957 out.push_str("- **Commits**: commits in the analysis window\n");
1958 out.push_str("- **Churn**: total lines added + deleted\n");
1959 out.push_str("- **Trend**: accelerating / stable / cooling\n");
1960 }
1961 if has_targets {
1962 out.push_str(
1963 "- **Efficiency**: priority / effort (higher = better quick-win value, default sort)\n",
1964 );
1965 out.push_str("- **Category**: recommendation type (churn+complexity, high impact, dead code, complexity, coupling, circular dep)\n");
1966 out.push_str("- **Effort**: estimated effort (low / medium / high) based on file size, function count, and fan-in\n");
1967 out.push_str("- **Confidence**: recommendation reliability (high = deterministic analysis, medium = heuristic, low = git-dependent)\n");
1968 }
1969 out.push_str(
1970 "\n[Full metric reference](https://docs.fallow.tools/explanations/metrics)\n\n</details>\n",
1971 );
1972}
1973
1974#[cfg(test)]
1975mod tests {
1976 use super::*;
1977 use crate::report::test_helpers::sample_results;
1978 use fallow_core::duplicates::{
1979 CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
1980 RefactoringKind, RefactoringSuggestion,
1981 };
1982 use fallow_core::results::*;
1983 use std::path::PathBuf;
1984
1985 #[test]
1986 fn markdown_empty_results_no_issues() {
1987 let root = PathBuf::from("/project");
1988 let results = AnalysisResults::default();
1989 let md = build_markdown(&results, &root);
1990 assert_eq!(md, "## Fallow: no issues found\n");
1991 }
1992
1993 #[test]
1994 fn markdown_contains_header_with_count() {
1995 let root = PathBuf::from("/project");
1996 let results = sample_results(&root);
1997 let md = build_markdown(&results, &root);
1998 assert!(md.starts_with(&format!(
1999 "## Fallow: {} issues found\n",
2000 results.total_issues()
2001 )));
2002 }
2003
2004 #[test]
2005 fn markdown_contains_all_sections() {
2006 let root = PathBuf::from("/project");
2007 let results = sample_results(&root);
2008 let md = build_markdown(&results, &root);
2009
2010 assert!(md.contains("### Unused files (1)"));
2011 assert!(md.contains("### Unused exports (1)"));
2012 assert!(md.contains("### Unused type exports (1)"));
2013 assert!(md.contains("### Unused dependencies (1)"));
2014 assert!(md.contains("### Unused devDependencies (1)"));
2015 assert!(md.contains("### Unused enum members (1)"));
2016 assert!(md.contains("### Unused class members (1)"));
2017 assert!(md.contains("### Unresolved imports (1)"));
2018 assert!(md.contains("### Unlisted dependencies (1)"));
2019 assert!(md.contains("### Duplicate exports (1)"));
2020 assert!(md.contains("### Type-only dependencies"));
2021 assert!(md.contains("### Test-only production dependencies"));
2022 assert!(md.contains("### Circular dependencies (1)"));
2023 }
2024
2025 #[test]
2026 fn markdown_unused_file_format() {
2027 let root = PathBuf::from("/project");
2028 let mut results = AnalysisResults::default();
2029 results
2030 .unused_files
2031 .push(UnusedFileFinding::with_actions(UnusedFile {
2032 path: root.join("src/dead.ts"),
2033 }));
2034 let md = build_markdown(&results, &root);
2035 assert!(md.contains("- `src/dead.ts`"));
2036 }
2037
2038 #[test]
2039 fn markdown_unused_export_grouped_by_file() {
2040 let root = PathBuf::from("/project");
2041 let mut results = AnalysisResults::default();
2042 results
2043 .unused_exports
2044 .push(UnusedExportFinding::with_actions(UnusedExport {
2045 path: root.join("src/utils.ts"),
2046 export_name: "helperFn".to_string(),
2047 is_type_only: false,
2048 line: 10,
2049 col: 4,
2050 span_start: 120,
2051 is_re_export: false,
2052 }));
2053 let md = build_markdown(&results, &root);
2054 assert!(md.contains("- `src/utils.ts`"));
2055 assert!(md.contains(":10 `helperFn`"));
2056 }
2057
2058 #[test]
2059 fn markdown_re_export_tagged() {
2060 let root = PathBuf::from("/project");
2061 let mut results = AnalysisResults::default();
2062 results
2063 .unused_exports
2064 .push(UnusedExportFinding::with_actions(UnusedExport {
2065 path: root.join("src/index.ts"),
2066 export_name: "reExported".to_string(),
2067 is_type_only: false,
2068 line: 1,
2069 col: 0,
2070 span_start: 0,
2071 is_re_export: true,
2072 }));
2073 let md = build_markdown(&results, &root);
2074 assert!(md.contains("(re-export)"));
2075 }
2076
2077 #[test]
2078 fn markdown_unused_dep_format() {
2079 let root = PathBuf::from("/project");
2080 let mut results = AnalysisResults::default();
2081 results
2082 .unused_dependencies
2083 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2084 package_name: "lodash".to_string(),
2085 location: DependencyLocation::Dependencies,
2086 path: root.join("package.json"),
2087 line: 5,
2088 used_in_workspaces: Vec::new(),
2089 }));
2090 let md = build_markdown(&results, &root);
2091 assert!(md.contains("- `lodash`"));
2092 }
2093
2094 #[test]
2095 fn markdown_circular_dep_format() {
2096 let root = PathBuf::from("/project");
2097 let mut results = AnalysisResults::default();
2098 results
2099 .circular_dependencies
2100 .push(CircularDependencyFinding::with_actions(
2101 CircularDependency {
2102 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2103 length: 2,
2104 line: 3,
2105 col: 0,
2106 edges: Vec::new(),
2107 is_cross_package: false,
2108 },
2109 ));
2110 let md = build_markdown(&results, &root);
2111 assert!(md.contains("`src/a.ts`"));
2112 assert!(md.contains("`src/b.ts`"));
2113 assert!(md.contains("\u{2192}"));
2114 }
2115
2116 #[test]
2117 fn markdown_strips_root_prefix() {
2118 let root = PathBuf::from("/project");
2119 let mut results = AnalysisResults::default();
2120 results
2121 .unused_files
2122 .push(UnusedFileFinding::with_actions(UnusedFile {
2123 path: PathBuf::from("/project/src/deep/nested/file.ts"),
2124 }));
2125 let md = build_markdown(&results, &root);
2126 assert!(md.contains("`src/deep/nested/file.ts`"));
2127 assert!(!md.contains("/project/"));
2128 }
2129
2130 #[test]
2131 fn markdown_single_issue_no_plural() {
2132 let root = PathBuf::from("/project");
2133 let mut results = AnalysisResults::default();
2134 results
2135 .unused_files
2136 .push(UnusedFileFinding::with_actions(UnusedFile {
2137 path: root.join("src/dead.ts"),
2138 }));
2139 let md = build_markdown(&results, &root);
2140 assert!(md.starts_with("## Fallow: 1 issue found\n"));
2141 }
2142
2143 #[test]
2144 fn markdown_type_only_dep_format() {
2145 let root = PathBuf::from("/project");
2146 let mut results = AnalysisResults::default();
2147 results
2148 .type_only_dependencies
2149 .push(TypeOnlyDependencyFinding::with_actions(
2150 TypeOnlyDependency {
2151 package_name: "zod".to_string(),
2152 path: root.join("package.json"),
2153 line: 8,
2154 },
2155 ));
2156 let md = build_markdown(&results, &root);
2157 assert!(md.contains("### Type-only dependencies"));
2158 assert!(md.contains("- `zod`"));
2159 }
2160
2161 #[test]
2162 fn markdown_escapes_backticks_in_export_names() {
2163 let root = PathBuf::from("/project");
2164 let mut results = AnalysisResults::default();
2165 results
2166 .unused_exports
2167 .push(UnusedExportFinding::with_actions(UnusedExport {
2168 path: root.join("src/utils.ts"),
2169 export_name: "foo`bar".to_string(),
2170 is_type_only: false,
2171 line: 1,
2172 col: 0,
2173 span_start: 0,
2174 is_re_export: false,
2175 }));
2176 let md = build_markdown(&results, &root);
2177 assert!(md.contains("foo\\`bar"));
2178 assert!(!md.contains("foo`bar`"));
2179 }
2180
2181 #[test]
2182 fn markdown_escapes_backticks_in_package_names() {
2183 let root = PathBuf::from("/project");
2184 let mut results = AnalysisResults::default();
2185 results
2186 .unused_dependencies
2187 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2188 package_name: "pkg`name".to_string(),
2189 location: DependencyLocation::Dependencies,
2190 path: root.join("package.json"),
2191 line: 5,
2192 used_in_workspaces: Vec::new(),
2193 }));
2194 let md = build_markdown(&results, &root);
2195 assert!(md.contains("pkg\\`name"));
2196 }
2197
2198 #[test]
2199 fn duplication_markdown_empty() {
2200 let report = DuplicationReport::default();
2201 let root = PathBuf::from("/project");
2202 let md = build_duplication_markdown(&report, &root);
2203 assert_eq!(md, "## Fallow: no code duplication found\n");
2204 }
2205
2206 #[test]
2207 fn duplication_markdown_contains_groups() {
2208 let root = PathBuf::from("/project");
2209 let report = DuplicationReport {
2210 clone_groups: vec![CloneGroup {
2211 instances: vec![
2212 CloneInstance {
2213 file: root.join("src/a.ts"),
2214 start_line: 1,
2215 end_line: 10,
2216 start_col: 0,
2217 end_col: 0,
2218 fragment: String::new(),
2219 },
2220 CloneInstance {
2221 file: root.join("src/b.ts"),
2222 start_line: 5,
2223 end_line: 14,
2224 start_col: 0,
2225 end_col: 0,
2226 fragment: String::new(),
2227 },
2228 ],
2229 token_count: 50,
2230 line_count: 10,
2231 }],
2232 clone_families: vec![],
2233 mirrored_directories: vec![],
2234 stats: DuplicationStats {
2235 total_files: 10,
2236 files_with_clones: 2,
2237 total_lines: 500,
2238 duplicated_lines: 20,
2239 total_tokens: 2500,
2240 duplicated_tokens: 100,
2241 clone_groups: 1,
2242 clone_instances: 2,
2243 duplication_percentage: 4.0,
2244 clone_groups_below_min_occurrences: 0,
2245 },
2246 };
2247 let md = build_duplication_markdown(&report, &root);
2248 assert!(md.contains("**Clone group 1**"));
2249 assert!(md.contains("`src/a.ts:1-10`"));
2250 assert!(md.contains("`src/b.ts:5-14`"));
2251 assert!(md.contains("4.0% duplication"));
2252 }
2253
2254 #[test]
2255 fn duplication_markdown_contains_families() {
2256 let root = PathBuf::from("/project");
2257 let report = DuplicationReport {
2258 clone_groups: vec![CloneGroup {
2259 instances: vec![CloneInstance {
2260 file: root.join("src/a.ts"),
2261 start_line: 1,
2262 end_line: 5,
2263 start_col: 0,
2264 end_col: 0,
2265 fragment: String::new(),
2266 }],
2267 token_count: 30,
2268 line_count: 5,
2269 }],
2270 clone_families: vec![CloneFamily {
2271 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2272 groups: vec![],
2273 total_duplicated_lines: 20,
2274 total_duplicated_tokens: 100,
2275 suggestions: vec![RefactoringSuggestion {
2276 kind: RefactoringKind::ExtractFunction,
2277 description: "Extract shared utility function".to_string(),
2278 estimated_savings: 15,
2279 }],
2280 }],
2281 mirrored_directories: vec![],
2282 stats: DuplicationStats {
2283 clone_groups: 1,
2284 clone_instances: 1,
2285 duplication_percentage: 2.0,
2286 ..Default::default()
2287 },
2288 };
2289 let md = build_duplication_markdown(&report, &root);
2290 assert!(md.contains("### Clone Families"));
2291 assert!(md.contains("**Family 1**"));
2292 assert!(md.contains("Extract shared utility function"));
2293 assert!(md.contains("~15 lines saved"));
2294 }
2295
2296 #[test]
2297 fn health_markdown_empty_no_findings() {
2298 let root = PathBuf::from("/project");
2299 let report = crate::health_types::HealthReport {
2300 summary: crate::health_types::HealthSummary {
2301 files_analyzed: 10,
2302 functions_analyzed: 50,
2303 ..Default::default()
2304 },
2305 ..Default::default()
2306 };
2307 let md = build_health_markdown(&report, &root);
2308 assert!(md.contains("no functions exceed complexity thresholds"));
2309 assert!(md.contains("**50** functions analyzed"));
2310 }
2311
2312 #[test]
2313 fn health_markdown_table_format() {
2314 let root = PathBuf::from("/project");
2315 let report = crate::health_types::HealthReport {
2316 findings: vec![
2317 crate::health_types::ComplexityViolation {
2318 path: root.join("src/utils.ts"),
2319 name: "parseExpression".to_string(),
2320 line: 42,
2321 col: 0,
2322 cyclomatic: 25,
2323 cognitive: 30,
2324 line_count: 80,
2325 param_count: 0,
2326 react_hook_count: 0,
2327 react_jsx_max_depth: 0,
2328 react_prop_count: 0,
2329 react_hook_profile: None,
2330 exceeded: crate::health_types::ExceededThreshold::Both,
2331 severity: crate::health_types::FindingSeverity::High,
2332 crap: None,
2333 coverage_pct: None,
2334 coverage_tier: None,
2335 coverage_source: None,
2336 inherited_from: None,
2337 component_rollup: None,
2338 contributions: Vec::new(),
2339 effective_thresholds: None,
2340 threshold_source: None,
2341 }
2342 .into(),
2343 ],
2344 summary: crate::health_types::HealthSummary {
2345 files_analyzed: 10,
2346 functions_analyzed: 50,
2347 functions_above_threshold: 1,
2348 ..Default::default()
2349 },
2350 ..Default::default()
2351 };
2352 let md = build_health_markdown(&report, &root);
2353 assert!(md.contains("## Fallow: 1 high complexity function\n"));
2354 assert!(md.contains("| File | Function |"));
2355 assert!(md.contains("`src/utils.ts:42`"));
2356 assert!(md.contains("`parseExpression`"));
2357 assert!(md.contains("25 **!**"));
2358 assert!(md.contains("30 **!**"));
2359 assert!(md.contains("| 80 |"));
2360 assert!(md.contains("| - |"));
2361 }
2362
2363 #[test]
2364 fn health_markdown_labels_template_complexity_entries() {
2365 let root = PathBuf::from("/project");
2366 let report = crate::health_types::HealthReport {
2367 findings: vec![
2368 crate::health_types::ComplexityViolation {
2369 path: root.join("src/Card.vue"),
2370 name: "<template>".to_string(),
2371 line: 1,
2372 col: 0,
2373 cyclomatic: 8,
2374 cognitive: 12,
2375 line_count: 40,
2376 param_count: 0,
2377 react_hook_count: 0,
2378 react_jsx_max_depth: 0,
2379 react_prop_count: 0,
2380 react_hook_profile: None,
2381 exceeded: crate::health_types::ExceededThreshold::Cognitive,
2382 severity: crate::health_types::FindingSeverity::Moderate,
2383 crap: None,
2384 coverage_pct: None,
2385 coverage_tier: None,
2386 coverage_source: None,
2387 inherited_from: None,
2388 component_rollup: None,
2389 contributions: Vec::new(),
2390 effective_thresholds: None,
2391 threshold_source: None,
2392 }
2393 .into(),
2394 ],
2395 summary: crate::health_types::HealthSummary {
2396 files_analyzed: 1,
2397 functions_analyzed: 1,
2398 functions_above_threshold: 1,
2399 ..Default::default()
2400 },
2401 ..Default::default()
2402 };
2403 let md = build_health_markdown(&report, &root);
2404 assert!(md.contains("## Fallow: 1 high complexity finding\n"));
2405 assert!(md.contains("| File | Entry |"));
2406 assert!(md.contains("`<template> (template complexity)`"));
2407 }
2408
2409 #[test]
2410 fn health_markdown_includes_coverage_intelligence_and_ambiguity_summary() {
2411 use crate::health_types::{
2412 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2413 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2414 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2415 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2416 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2417 HealthReport, HealthSummary,
2418 };
2419
2420 let root = PathBuf::from("/project");
2421 let mut report = HealthReport {
2422 summary: HealthSummary {
2423 files_analyzed: 10,
2424 functions_analyzed: 50,
2425 ..Default::default()
2426 },
2427 coverage_intelligence: Some(CoverageIntelligenceReport {
2428 schema_version: CoverageIntelligenceSchemaVersion::V1,
2429 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2430 summary: CoverageIntelligenceSummary {
2431 findings: 1,
2432 high_confidence_deletes: 1,
2433 ..Default::default()
2434 },
2435 findings: vec![CoverageIntelligenceFinding {
2436 id: "fallow:coverage-intel:abc123".to_owned(),
2437 path: root.join("src/dead.ts"),
2438 identity: Some("deadPath".to_owned()),
2439 line: 9,
2440 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2441 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2442 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2443 confidence: CoverageIntelligenceConfidence::High,
2444 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2445 evidence: CoverageIntelligenceEvidence {
2446 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2447 ..Default::default()
2448 },
2449 actions: vec![CoverageIntelligenceAction {
2450 kind: "delete-after-confirming-owner".to_owned(),
2451 description: "Confirm ownership".to_owned(),
2452 auto_fixable: false,
2453 }],
2454 }],
2455 }),
2456 ..Default::default()
2457 };
2458
2459 let md = build_health_markdown(&report, &root);
2460 assert!(md.contains("## Coverage Intelligence"));
2461 assert!(md.contains("fallow:coverage-intel:abc123"));
2462 assert!(md.contains("delete-after-confirming-owner"));
2463 assert!(md.contains("runtime_cold"));
2464
2465 report.coverage_intelligence = Some(CoverageIntelligenceReport {
2466 schema_version: CoverageIntelligenceSchemaVersion::V1,
2467 verdict: CoverageIntelligenceVerdict::Clean,
2468 summary: CoverageIntelligenceSummary {
2469 skipped_ambiguous_matches: 2,
2470 ..Default::default()
2471 },
2472 findings: vec![],
2473 });
2474 let md = build_health_markdown(&report, &root);
2475 assert!(md.contains("2 ambiguous evidence matches were skipped"));
2476 assert!(!md.contains("| ID | Path |"));
2477 }
2478
2479 #[test]
2480 fn health_markdown_crap_column_shows_score_and_marker() {
2481 let root = PathBuf::from("/project");
2482 let report = crate::health_types::HealthReport {
2483 findings: vec![
2484 crate::health_types::ComplexityViolation {
2485 path: root.join("src/risky.ts"),
2486 name: "branchy".to_string(),
2487 line: 1,
2488 col: 0,
2489 cyclomatic: 67,
2490 cognitive: 10,
2491 line_count: 80,
2492 param_count: 1,
2493 react_hook_count: 0,
2494 react_jsx_max_depth: 0,
2495 react_prop_count: 0,
2496 react_hook_profile: None,
2497 exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
2498 severity: crate::health_types::FindingSeverity::Critical,
2499 crap: Some(182.0),
2500 coverage_pct: None,
2501 coverage_tier: None,
2502 coverage_source: None,
2503 inherited_from: None,
2504 component_rollup: None,
2505 contributions: Vec::new(),
2506 effective_thresholds: None,
2507 threshold_source: None,
2508 }
2509 .into(),
2510 ],
2511 summary: crate::health_types::HealthSummary {
2512 files_analyzed: 1,
2513 functions_analyzed: 1,
2514 functions_above_threshold: 1,
2515 ..Default::default()
2516 },
2517 ..Default::default()
2518 };
2519 let md = build_health_markdown(&report, &root);
2520 assert!(
2521 md.contains("| CRAP |"),
2522 "markdown table should have CRAP column header: {md}"
2523 );
2524 assert!(
2525 md.contains("182.0 **!**"),
2526 "CRAP value should be rendered with a threshold marker: {md}"
2527 );
2528 assert!(
2529 md.contains("CRAP >="),
2530 "trailing summary line should reference the CRAP threshold: {md}"
2531 );
2532 }
2533
2534 #[test]
2535 fn health_markdown_no_marker_when_below_threshold() {
2536 let root = PathBuf::from("/project");
2537 let report = crate::health_types::HealthReport {
2538 findings: vec![
2539 crate::health_types::ComplexityViolation {
2540 path: root.join("src/utils.ts"),
2541 name: "helper".to_string(),
2542 line: 10,
2543 col: 0,
2544 cyclomatic: 15,
2545 cognitive: 20,
2546 line_count: 30,
2547 param_count: 0,
2548 react_hook_count: 0,
2549 react_jsx_max_depth: 0,
2550 react_prop_count: 0,
2551 react_hook_profile: None,
2552 exceeded: crate::health_types::ExceededThreshold::Cognitive,
2553 severity: crate::health_types::FindingSeverity::High,
2554 crap: None,
2555 coverage_pct: None,
2556 coverage_tier: None,
2557 coverage_source: None,
2558 inherited_from: None,
2559 component_rollup: None,
2560 contributions: Vec::new(),
2561 effective_thresholds: None,
2562 threshold_source: None,
2563 }
2564 .into(),
2565 ],
2566 summary: crate::health_types::HealthSummary {
2567 files_analyzed: 5,
2568 functions_analyzed: 20,
2569 functions_above_threshold: 1,
2570 ..Default::default()
2571 },
2572 ..Default::default()
2573 };
2574 let md = build_health_markdown(&report, &root);
2575 assert!(md.contains("| 15 |"));
2576 assert!(md.contains("20 **!**"));
2577 }
2578
2579 #[test]
2580 fn health_markdown_with_targets() {
2581 use crate::health_types::*;
2582
2583 let root = PathBuf::from("/project");
2584 let report = HealthReport {
2585 summary: HealthSummary {
2586 files_analyzed: 10,
2587 functions_analyzed: 50,
2588 ..Default::default()
2589 },
2590 targets: vec![
2591 RefactoringTarget {
2592 path: PathBuf::from("/project/src/complex.ts"),
2593 priority: 82.5,
2594 efficiency: 27.5,
2595 recommendation: "Split high-impact file".into(),
2596 category: RecommendationCategory::SplitHighImpact,
2597 effort: crate::health_types::EffortEstimate::High,
2598 confidence: crate::health_types::Confidence::Medium,
2599 factors: vec![ContributingFactor {
2600 metric: "fan_in",
2601 value: 25.0,
2602 threshold: 10.0,
2603 detail: "25 files depend on this".into(),
2604 }],
2605 evidence: None,
2606 }
2607 .into(),
2608 RefactoringTarget {
2609 path: PathBuf::from("/project/src/legacy.ts"),
2610 priority: 45.0,
2611 efficiency: 45.0,
2612 recommendation: "Remove 5 unused exports".into(),
2613 category: RecommendationCategory::RemoveDeadCode,
2614 effort: crate::health_types::EffortEstimate::Low,
2615 confidence: crate::health_types::Confidence::High,
2616 factors: vec![],
2617 evidence: None,
2618 }
2619 .into(),
2620 ],
2621 ..Default::default()
2622 };
2623 let md = build_health_markdown(&report, &root);
2624
2625 assert!(
2626 md.contains("Refactoring Targets"),
2627 "should contain targets heading"
2628 );
2629 assert!(
2630 md.contains("src/complex.ts"),
2631 "should contain target file path"
2632 );
2633 assert!(md.contains("27.5"), "should contain efficiency score");
2634 assert!(
2635 md.contains("Split high-impact file"),
2636 "should contain recommendation"
2637 );
2638 assert!(md.contains("src/legacy.ts"), "should contain second target");
2639 }
2640
2641 #[test]
2642 fn health_markdown_with_coverage_gaps() {
2643 use crate::health_types::*;
2644
2645 let root = PathBuf::from("/project");
2646 let report = HealthReport {
2647 summary: HealthSummary {
2648 files_analyzed: 10,
2649 functions_analyzed: 50,
2650 ..Default::default()
2651 },
2652 coverage_gaps: Some(CoverageGaps {
2653 summary: CoverageGapSummary {
2654 runtime_files: 2,
2655 covered_files: 0,
2656 file_coverage_pct: 0.0,
2657 untested_files: 1,
2658 untested_exports: 1,
2659 },
2660 files: vec![UntestedFileFinding::with_actions(
2661 UntestedFile {
2662 path: root.join("src/app.ts"),
2663 value_export_count: 2,
2664 },
2665 &root,
2666 )],
2667 exports: vec![UntestedExportFinding::with_actions(
2668 UntestedExport {
2669 path: root.join("src/app.ts"),
2670 export_name: "loader".into(),
2671 line: 12,
2672 col: 4,
2673 },
2674 &root,
2675 )],
2676 }),
2677 ..Default::default()
2678 };
2679
2680 let md = build_health_markdown(&report, &root);
2681 assert!(md.contains("### Coverage Gaps"));
2682 assert!(md.contains("*1 untested files"));
2683 assert!(md.contains("`src/app.ts` (2 value exports)"));
2684 assert!(md.contains("`src/app.ts`:12 `loader`"));
2685 }
2686
2687 #[test]
2688 fn markdown_dep_in_workspace_shows_package_label() {
2689 let root = PathBuf::from("/project");
2690 let mut results = AnalysisResults::default();
2691 results
2692 .unused_dependencies
2693 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2694 package_name: "lodash".to_string(),
2695 location: DependencyLocation::Dependencies,
2696 path: root.join("packages/core/package.json"),
2697 line: 5,
2698 used_in_workspaces: Vec::new(),
2699 }));
2700 let md = build_markdown(&results, &root);
2701 assert!(md.contains("(packages/core/package.json)"));
2702 }
2703
2704 #[test]
2705 fn markdown_dep_at_root_no_extra_label() {
2706 let root = PathBuf::from("/project");
2707 let mut results = AnalysisResults::default();
2708 results
2709 .unused_dependencies
2710 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2711 package_name: "lodash".to_string(),
2712 location: DependencyLocation::Dependencies,
2713 path: root.join("package.json"),
2714 line: 5,
2715 used_in_workspaces: Vec::new(),
2716 }));
2717 let md = build_markdown(&results, &root);
2718 assert!(md.contains("- `lodash`"));
2719 assert!(!md.contains("(package.json)"));
2720 }
2721
2722 #[test]
2723 fn markdown_root_dep_with_cross_workspace_context_uses_context_label() {
2724 let root = PathBuf::from("/project");
2725 let mut results = AnalysisResults::default();
2726 results
2727 .unused_dependencies
2728 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2729 package_name: "lodash-es".to_string(),
2730 location: DependencyLocation::Dependencies,
2731 path: root.join("package.json"),
2732 line: 5,
2733 used_in_workspaces: vec![root.join("packages/consumer")],
2734 }));
2735 let md = build_markdown(&results, &root);
2736 assert!(md.contains("- `lodash-es` (imported in packages/consumer)"));
2737 assert!(!md.contains("(package.json; imported in packages/consumer)"));
2738 }
2739
2740 #[test]
2741 fn markdown_exports_grouped_by_file() {
2742 let root = PathBuf::from("/project");
2743 let mut results = AnalysisResults::default();
2744 results
2745 .unused_exports
2746 .push(UnusedExportFinding::with_actions(UnusedExport {
2747 path: root.join("src/utils.ts"),
2748 export_name: "alpha".to_string(),
2749 is_type_only: false,
2750 line: 5,
2751 col: 0,
2752 span_start: 0,
2753 is_re_export: false,
2754 }));
2755 results
2756 .unused_exports
2757 .push(UnusedExportFinding::with_actions(UnusedExport {
2758 path: root.join("src/utils.ts"),
2759 export_name: "beta".to_string(),
2760 is_type_only: false,
2761 line: 10,
2762 col: 0,
2763 span_start: 0,
2764 is_re_export: false,
2765 }));
2766 results
2767 .unused_exports
2768 .push(UnusedExportFinding::with_actions(UnusedExport {
2769 path: root.join("src/other.ts"),
2770 export_name: "gamma".to_string(),
2771 is_type_only: false,
2772 line: 1,
2773 col: 0,
2774 span_start: 0,
2775 is_re_export: false,
2776 }));
2777 let md = build_markdown(&results, &root);
2778 let utils_count = md.matches("- `src/utils.ts`").count();
2779 assert_eq!(utils_count, 1, "file header should appear once per file");
2780 assert!(md.contains(":5 `alpha`"));
2781 assert!(md.contains(":10 `beta`"));
2782 }
2783
2784 #[test]
2785 fn markdown_multiple_issues_plural() {
2786 let root = PathBuf::from("/project");
2787 let mut results = AnalysisResults::default();
2788 results
2789 .unused_files
2790 .push(UnusedFileFinding::with_actions(UnusedFile {
2791 path: root.join("src/a.ts"),
2792 }));
2793 results
2794 .unused_files
2795 .push(UnusedFileFinding::with_actions(UnusedFile {
2796 path: root.join("src/b.ts"),
2797 }));
2798 let md = build_markdown(&results, &root);
2799 assert!(md.starts_with("## Fallow: 2 issues found\n"));
2800 }
2801
2802 #[test]
2803 fn duplication_markdown_zero_savings_no_suffix() {
2804 let root = PathBuf::from("/project");
2805 let report = DuplicationReport {
2806 clone_groups: vec![CloneGroup {
2807 instances: vec![CloneInstance {
2808 file: root.join("src/a.ts"),
2809 start_line: 1,
2810 end_line: 5,
2811 start_col: 0,
2812 end_col: 0,
2813 fragment: String::new(),
2814 }],
2815 token_count: 30,
2816 line_count: 5,
2817 }],
2818 clone_families: vec![CloneFamily {
2819 files: vec![root.join("src/a.ts")],
2820 groups: vec![],
2821 total_duplicated_lines: 5,
2822 total_duplicated_tokens: 30,
2823 suggestions: vec![RefactoringSuggestion {
2824 kind: RefactoringKind::ExtractFunction,
2825 description: "Extract function".to_string(),
2826 estimated_savings: 0,
2827 }],
2828 }],
2829 mirrored_directories: vec![],
2830 stats: DuplicationStats {
2831 clone_groups: 1,
2832 clone_instances: 1,
2833 duplication_percentage: 1.0,
2834 ..Default::default()
2835 },
2836 };
2837 let md = build_duplication_markdown(&report, &root);
2838 assert!(md.contains("Extract function"));
2839 assert!(!md.contains("lines saved"));
2840 }
2841
2842 #[test]
2843 fn health_markdown_vital_signs_table() {
2844 let root = PathBuf::from("/project");
2845 let report = crate::health_types::HealthReport {
2846 summary: crate::health_types::HealthSummary {
2847 files_analyzed: 10,
2848 functions_analyzed: 50,
2849 ..Default::default()
2850 },
2851 vital_signs: Some(crate::health_types::VitalSigns {
2852 avg_cyclomatic: 3.5,
2853 p90_cyclomatic: 12,
2854 dead_file_pct: Some(5.0),
2855 dead_export_pct: Some(10.2),
2856 duplication_pct: None,
2857 maintainability_avg: Some(72.3),
2858 hotspot_count: Some(3),
2859 circular_dep_count: Some(1),
2860 unused_dep_count: Some(2),
2861 counts: None,
2862 unit_size_profile: None,
2863 unit_interfacing_profile: None,
2864 p95_fan_in: None,
2865 coupling_high_pct: None,
2866 total_loc: 15_200,
2867 ..Default::default()
2868 }),
2869 hotspot_summary: Some(crate::health_types::HotspotSummary {
2870 since: "6 months".to_string(),
2871 min_commits: 3,
2872 files_analyzed: 50,
2873 files_excluded: 0,
2874 shallow_clone: false,
2875 }),
2876 ..Default::default()
2877 };
2878 let md = build_health_markdown(&report, &root);
2879 assert!(md.contains("## Vital Signs"));
2880 assert!(md.contains("| Metric | Value |"));
2881 assert!(md.contains("| Total LOC | 15200 |"));
2882 assert!(md.contains("| Avg Cyclomatic | 3.5 |"));
2883 assert!(md.contains("| P90 Cyclomatic | 12 |"));
2884 assert!(md.contains("| Dead Files | 5.0% |"));
2885 assert!(md.contains("| Dead Exports | 10.2% |"));
2886 assert!(md.contains("| Maintainability (avg) | 72.3 |"));
2887 assert!(md.contains("| Hotspots (since 6 months) | 3 |"));
2888 assert!(md.contains("| Circular Deps | 1 |"));
2889 assert!(md.contains("| Unused Deps | 2 |"));
2890 }
2891
2892 #[test]
2893 fn health_markdown_hotspots_without_summary_omits_window() {
2894 let root = PathBuf::from("/project");
2895 let report = crate::health_types::HealthReport {
2896 vital_signs: Some(crate::health_types::VitalSigns {
2897 avg_cyclomatic: 2.0,
2898 p90_cyclomatic: 5,
2899 hotspot_count: Some(0),
2900 total_loc: 1_000,
2901 ..Default::default()
2902 }),
2903 hotspot_summary: None,
2904 ..Default::default()
2905 };
2906 let md = build_health_markdown(&report, &root);
2907 assert!(md.contains("| Hotspots | 0 |"));
2908 assert!(!md.contains("Hotspots (since"));
2909 }
2910
2911 #[test]
2912 fn health_markdown_file_scores_table() {
2913 let root = PathBuf::from("/project");
2914 let report = crate::health_types::HealthReport {
2915 findings: vec![
2916 crate::health_types::ComplexityViolation {
2917 path: root.join("src/dummy.ts"),
2918 name: "fn".to_string(),
2919 line: 1,
2920 col: 0,
2921 cyclomatic: 25,
2922 cognitive: 20,
2923 line_count: 50,
2924 param_count: 0,
2925 react_hook_count: 0,
2926 react_jsx_max_depth: 0,
2927 react_prop_count: 0,
2928 react_hook_profile: None,
2929 exceeded: crate::health_types::ExceededThreshold::Both,
2930 severity: crate::health_types::FindingSeverity::High,
2931 crap: None,
2932 coverage_pct: None,
2933 coverage_tier: None,
2934 coverage_source: None,
2935 inherited_from: None,
2936 component_rollup: None,
2937 contributions: Vec::new(),
2938 effective_thresholds: None,
2939 threshold_source: None,
2940 }
2941 .into(),
2942 ],
2943 summary: crate::health_types::HealthSummary {
2944 files_analyzed: 5,
2945 functions_analyzed: 10,
2946 functions_above_threshold: 1,
2947 files_scored: Some(1),
2948 average_maintainability: Some(65.0),
2949 ..Default::default()
2950 },
2951 file_scores: vec![crate::health_types::FileHealthScore {
2952 path: root.join("src/utils.ts"),
2953 fan_in: 5,
2954 fan_out: 3,
2955 dead_code_ratio: 0.25,
2956 complexity_density: 0.8,
2957 maintainability_index: 72.5,
2958 total_cyclomatic: 40,
2959 total_cognitive: 30,
2960 function_count: 10,
2961 lines: 200,
2962 crap_max: 0.0,
2963 crap_above_threshold: 0,
2964 }],
2965 ..Default::default()
2966 };
2967 let md = build_health_markdown(&report, &root);
2968 assert!(md.contains("### File Health Scores (1 files)"));
2969 assert!(md.contains("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density |"));
2970 assert!(md.contains("| `src/utils.ts` | 72.5 | 5 | 3 | 25% | 0.80 |"));
2971 assert!(md.contains("**Average maintainability index:** 65.0/100"));
2972 }
2973
2974 #[test]
2975 fn health_markdown_hotspots_table() {
2976 let root = PathBuf::from("/project");
2977 let report = crate::health_types::HealthReport {
2978 findings: vec![
2979 crate::health_types::ComplexityViolation {
2980 path: root.join("src/dummy.ts"),
2981 name: "fn".to_string(),
2982 line: 1,
2983 col: 0,
2984 cyclomatic: 25,
2985 cognitive: 20,
2986 line_count: 50,
2987 param_count: 0,
2988 react_hook_count: 0,
2989 react_jsx_max_depth: 0,
2990 react_prop_count: 0,
2991 react_hook_profile: None,
2992 exceeded: crate::health_types::ExceededThreshold::Both,
2993 severity: crate::health_types::FindingSeverity::High,
2994 crap: None,
2995 coverage_pct: None,
2996 coverage_tier: None,
2997 coverage_source: None,
2998 inherited_from: None,
2999 component_rollup: None,
3000 contributions: Vec::new(),
3001 effective_thresholds: None,
3002 threshold_source: None,
3003 }
3004 .into(),
3005 ],
3006 summary: crate::health_types::HealthSummary {
3007 files_analyzed: 5,
3008 functions_analyzed: 10,
3009 functions_above_threshold: 1,
3010 ..Default::default()
3011 },
3012 hotspots: vec![
3013 crate::health_types::HotspotEntry {
3014 path: root.join("src/hot.ts"),
3015 score: 85.0,
3016 commits: 42,
3017 weighted_commits: 35.0,
3018 lines_added: 500,
3019 lines_deleted: 200,
3020 complexity_density: 1.2,
3021 fan_in: 10,
3022 trend: fallow_core::churn::ChurnTrend::Accelerating,
3023 ownership: None,
3024 is_test_path: false,
3025 }
3026 .into(),
3027 ],
3028 hotspot_summary: Some(crate::health_types::HotspotSummary {
3029 since: "6 months".to_string(),
3030 min_commits: 3,
3031 files_analyzed: 50,
3032 files_excluded: 5,
3033 shallow_clone: false,
3034 }),
3035 ..Default::default()
3036 };
3037 let md = build_health_markdown(&report, &root);
3038 assert!(md.contains("### Hotspots (1 files, since 6 months)"));
3039 assert!(md.contains("| `src/hot.ts` | 85.0 | 42 | 700 | 1.20 | 10 | accelerating |"));
3040 assert!(md.contains("*5 files excluded (< 3 commits)*"));
3041 }
3042
3043 #[test]
3044 fn health_markdown_metric_legend_with_scores() {
3045 let root = PathBuf::from("/project");
3046 let report = crate::health_types::HealthReport {
3047 findings: vec![
3048 crate::health_types::ComplexityViolation {
3049 path: root.join("src/x.ts"),
3050 name: "f".to_string(),
3051 line: 1,
3052 col: 0,
3053 cyclomatic: 25,
3054 cognitive: 20,
3055 line_count: 10,
3056 param_count: 0,
3057 react_hook_count: 0,
3058 react_jsx_max_depth: 0,
3059 react_prop_count: 0,
3060 react_hook_profile: None,
3061 exceeded: crate::health_types::ExceededThreshold::Both,
3062 severity: crate::health_types::FindingSeverity::High,
3063 crap: None,
3064 coverage_pct: None,
3065 coverage_tier: None,
3066 coverage_source: None,
3067 inherited_from: None,
3068 component_rollup: None,
3069 contributions: Vec::new(),
3070 effective_thresholds: None,
3071 threshold_source: None,
3072 }
3073 .into(),
3074 ],
3075 summary: crate::health_types::HealthSummary {
3076 files_analyzed: 1,
3077 functions_analyzed: 1,
3078 functions_above_threshold: 1,
3079 files_scored: Some(1),
3080 average_maintainability: Some(70.0),
3081 ..Default::default()
3082 },
3083 file_scores: vec![crate::health_types::FileHealthScore {
3084 path: root.join("src/x.ts"),
3085 fan_in: 1,
3086 fan_out: 1,
3087 dead_code_ratio: 0.0,
3088 complexity_density: 0.5,
3089 maintainability_index: 80.0,
3090 total_cyclomatic: 10,
3091 total_cognitive: 8,
3092 function_count: 2,
3093 lines: 50,
3094 crap_max: 0.0,
3095 crap_above_threshold: 0,
3096 }],
3097 ..Default::default()
3098 };
3099 let md = build_health_markdown(&report, &root);
3100 assert!(md.contains("<details><summary>Metric definitions</summary>"));
3101 assert!(md.contains("**MI**: Maintainability Index"));
3102 assert!(md.contains("**Fan-in**"));
3103 assert!(md.contains("Full metric reference"));
3104 }
3105
3106 #[test]
3107 fn health_markdown_truncated_findings_shown_count() {
3108 let root = PathBuf::from("/project");
3109 let report = crate::health_types::HealthReport {
3110 findings: vec![
3111 crate::health_types::ComplexityViolation {
3112 path: root.join("src/x.ts"),
3113 name: "f".to_string(),
3114 line: 1,
3115 col: 0,
3116 cyclomatic: 25,
3117 cognitive: 20,
3118 line_count: 10,
3119 param_count: 0,
3120 react_hook_count: 0,
3121 react_jsx_max_depth: 0,
3122 react_prop_count: 0,
3123 react_hook_profile: None,
3124 exceeded: crate::health_types::ExceededThreshold::Both,
3125 severity: crate::health_types::FindingSeverity::High,
3126 crap: None,
3127 coverage_pct: None,
3128 coverage_tier: None,
3129 coverage_source: None,
3130 inherited_from: None,
3131 component_rollup: None,
3132 contributions: Vec::new(),
3133 effective_thresholds: None,
3134 threshold_source: None,
3135 }
3136 .into(),
3137 ],
3138 summary: crate::health_types::HealthSummary {
3139 files_analyzed: 10,
3140 functions_analyzed: 50,
3141 functions_above_threshold: 5, ..Default::default()
3143 },
3144 ..Default::default()
3145 };
3146 let md = build_health_markdown(&report, &root);
3147 assert!(md.contains("5 high complexity functions (1 shown)"));
3148 }
3149
3150 #[test]
3151 fn escape_backticks_handles_multiple() {
3152 assert_eq!(escape_backticks("a`b`c"), "a\\`b\\`c");
3153 }
3154
3155 #[test]
3156 fn escape_backticks_no_backticks_unchanged() {
3157 assert_eq!(escape_backticks("hello"), "hello");
3158 }
3159
3160 #[test]
3161 fn markdown_unresolved_import_grouped_by_file() {
3162 let root = PathBuf::from("/project");
3163 let mut results = AnalysisResults::default();
3164 results
3165 .unresolved_imports
3166 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
3167 path: root.join("src/app.ts"),
3168 specifier: "./missing".to_string(),
3169 line: 3,
3170 col: 0,
3171 specifier_col: 0,
3172 }));
3173 let md = build_markdown(&results, &root);
3174 assert!(md.contains("### Unresolved imports (1)"));
3175 assert!(md.contains("- `src/app.ts`"));
3176 assert!(md.contains(":3 `./missing`"));
3177 }
3178
3179 #[test]
3180 fn markdown_unused_optional_dep() {
3181 let root = PathBuf::from("/project");
3182 let mut results = AnalysisResults::default();
3183 results
3184 .unused_optional_dependencies
3185 .push(UnusedOptionalDependencyFinding::with_actions(
3186 UnusedDependency {
3187 package_name: "fsevents".to_string(),
3188 location: DependencyLocation::OptionalDependencies,
3189 path: root.join("package.json"),
3190 line: 12,
3191 used_in_workspaces: Vec::new(),
3192 },
3193 ));
3194 let md = build_markdown(&results, &root);
3195 assert!(md.contains("### Unused optionalDependencies (1)"));
3196 assert!(md.contains("- `fsevents`"));
3197 }
3198
3199 #[test]
3200 fn health_markdown_hotspots_no_excluded_message() {
3201 let root = PathBuf::from("/project");
3202 let report = crate::health_types::HealthReport {
3203 findings: vec![
3204 crate::health_types::ComplexityViolation {
3205 path: root.join("src/x.ts"),
3206 name: "f".to_string(),
3207 line: 1,
3208 col: 0,
3209 cyclomatic: 25,
3210 cognitive: 20,
3211 line_count: 10,
3212 param_count: 0,
3213 react_hook_count: 0,
3214 react_jsx_max_depth: 0,
3215 react_prop_count: 0,
3216 react_hook_profile: None,
3217 exceeded: crate::health_types::ExceededThreshold::Both,
3218 severity: crate::health_types::FindingSeverity::High,
3219 crap: None,
3220 coverage_pct: None,
3221 coverage_tier: None,
3222 coverage_source: None,
3223 inherited_from: None,
3224 component_rollup: None,
3225 contributions: Vec::new(),
3226 effective_thresholds: None,
3227 threshold_source: None,
3228 }
3229 .into(),
3230 ],
3231 summary: crate::health_types::HealthSummary {
3232 files_analyzed: 5,
3233 functions_analyzed: 10,
3234 functions_above_threshold: 1,
3235 ..Default::default()
3236 },
3237 hotspots: vec![
3238 crate::health_types::HotspotEntry {
3239 path: root.join("src/hot.ts"),
3240 score: 50.0,
3241 commits: 10,
3242 weighted_commits: 8.0,
3243 lines_added: 100,
3244 lines_deleted: 50,
3245 complexity_density: 0.5,
3246 fan_in: 3,
3247 trend: fallow_core::churn::ChurnTrend::Stable,
3248 ownership: None,
3249 is_test_path: false,
3250 }
3251 .into(),
3252 ],
3253 hotspot_summary: Some(crate::health_types::HotspotSummary {
3254 since: "6 months".to_string(),
3255 min_commits: 3,
3256 files_analyzed: 50,
3257 files_excluded: 0,
3258 shallow_clone: false,
3259 }),
3260 ..Default::default()
3261 };
3262 let md = build_health_markdown(&report, &root);
3263 assert!(!md.contains("files excluded"));
3264 }
3265
3266 #[test]
3267 fn duplication_markdown_single_group_no_plural() {
3268 let root = PathBuf::from("/project");
3269 let report = DuplicationReport {
3270 clone_groups: vec![CloneGroup {
3271 instances: vec![CloneInstance {
3272 file: root.join("src/a.ts"),
3273 start_line: 1,
3274 end_line: 5,
3275 start_col: 0,
3276 end_col: 0,
3277 fragment: String::new(),
3278 }],
3279 token_count: 30,
3280 line_count: 5,
3281 }],
3282 clone_families: vec![],
3283 mirrored_directories: vec![],
3284 stats: DuplicationStats {
3285 clone_groups: 1,
3286 clone_instances: 1,
3287 duplication_percentage: 2.0,
3288 ..Default::default()
3289 },
3290 };
3291 let md = build_duplication_markdown(&report, &root);
3292 assert!(md.contains("1 clone group found"));
3293 assert!(!md.contains("1 clone groups found"));
3294 }
3295}