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