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, 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 rel = |p: &Path| {
27 escape_backticks(&normalize_uri(
28 &relative_path(p, root).display().to_string(),
29 ))
30 };
31
32 let total = results.total_issues();
33 let mut out = String::new();
34
35 if total == 0 {
36 out.push_str("## Fallow: no issues found\n");
37 return out;
38 }
39
40 let _ = write!(out, "## Fallow: {total} issue{} found\n\n", plural(total));
41
42 markdown_section(&mut out, &results.unused_files, "Unused files", |file| {
43 vec![format!("- `{}`", rel(&file.file.path))]
44 });
45
46 markdown_grouped_section(
47 &mut out,
48 &results.unused_exports,
49 "Unused exports",
50 root,
51 |e| e.export.path.as_path(),
52 |e: &UnusedExportFinding| format_export(&e.export),
53 );
54
55 markdown_grouped_section(
56 &mut out,
57 &results.unused_types,
58 "Unused type exports",
59 root,
60 |e| e.export.path.as_path(),
61 |e: &UnusedTypeFinding| format_export(&e.export),
62 );
63
64 markdown_grouped_section(
65 &mut out,
66 &results.private_type_leaks,
67 "Private type leaks",
68 root,
69 |e| e.leak.path.as_path(),
70 format_private_type_leak,
71 );
72
73 push_markdown_dependency_sections(&mut out, results, root);
74 push_markdown_member_sections(&mut out, results, root);
75
76 markdown_grouped_section(
77 &mut out,
78 &results.unresolved_imports,
79 "Unresolved imports",
80 root,
81 |i| i.import.path.as_path(),
82 |i| {
83 format!(
84 ":{} `{}`",
85 i.import.line,
86 escape_backticks(&i.import.specifier)
87 )
88 },
89 );
90
91 markdown_section(
92 &mut out,
93 &results.unlisted_dependencies,
94 "Unlisted dependencies",
95 |dep| vec![format!("- `{}`", escape_backticks(&dep.dep.package_name))],
96 );
97
98 markdown_section(
99 &mut out,
100 &results.duplicate_exports,
101 "Duplicate exports",
102 |dup| {
103 let locations: Vec<String> = dup
104 .export
105 .locations
106 .iter()
107 .map(|loc| format!("`{}`", rel(&loc.path)))
108 .collect();
109 vec![format!(
110 "- `{}` in {}",
111 escape_backticks(&dup.export.export_name),
112 locations.join(", ")
113 )]
114 },
115 );
116
117 push_markdown_dependency_detail_sections(&mut out, results, root);
118 push_markdown_graph_sections(&mut out, results, &rel);
119 push_markdown_catalog_sections(&mut out, results, &rel);
120
121 out
122}
123
124fn push_markdown_dependency_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
125 markdown_section(
126 out,
127 &results.unused_dependencies,
128 "Unused dependencies",
129 |dep| {
130 format_dependency(
131 &dep.dep.package_name,
132 &dep.dep.path,
133 &dep.dep.used_in_workspaces,
134 root,
135 )
136 },
137 );
138 markdown_section(
139 out,
140 &results.unused_dev_dependencies,
141 "Unused devDependencies",
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_optional_dependencies,
154 "Unused optionalDependencies",
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}
165
166fn push_markdown_member_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
167 markdown_grouped_section(
168 out,
169 &results.unused_enum_members,
170 "Unused enum members",
171 root,
172 |m| m.member.path.as_path(),
173 |m: &UnusedEnumMemberFinding| format_member(&m.member),
174 );
175 markdown_grouped_section(
176 out,
177 &results.unused_class_members,
178 "Unused class members",
179 root,
180 |m| m.member.path.as_path(),
181 |m: &UnusedClassMemberFinding| format_member(&m.member),
182 );
183}
184
185fn push_markdown_dependency_detail_sections(
186 out: &mut String,
187 results: &AnalysisResults,
188 root: &Path,
189) {
190 markdown_section(
191 out,
192 &results.type_only_dependencies,
193 "Type-only dependencies (consider moving to devDependencies)",
194 |dep| format_dependency(&dep.dep.package_name, &dep.dep.path, &[], root),
195 );
196 markdown_section(
197 out,
198 &results.test_only_dependencies,
199 "Test-only production dependencies (consider moving to devDependencies)",
200 |dep| format_dependency(&dep.dep.package_name, &dep.dep.path, &[], root),
201 );
202}
203
204fn push_markdown_graph_sections(
205 out: &mut String,
206 results: &AnalysisResults,
207 rel: &dyn Fn(&Path) -> String,
208) {
209 markdown_section(
210 out,
211 &results.circular_dependencies,
212 "Circular dependencies",
213 |cycle| {
214 let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
215 let mut display_chain = chain.clone();
216 if let Some(first) = chain.first() {
217 display_chain.push(first.clone());
218 }
219 let cross_pkg_tag = if cycle.cycle.is_cross_package {
220 " *(cross-package)*"
221 } else {
222 ""
223 };
224 vec![format!(
225 "- {}{}",
226 display_chain
227 .iter()
228 .map(|s| format!("`{s}`"))
229 .collect::<Vec<_>>()
230 .join(" \u{2192} "),
231 cross_pkg_tag
232 )]
233 },
234 );
235 markdown_section(
236 out,
237 &results.re_export_cycles,
238 "Re-export cycles",
239 |cycle| {
240 let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
241 let kind_tag = match cycle.cycle.kind {
242 fallow_core::results::ReExportCycleKind::SelfLoop => " *(self-loop)*",
243 fallow_core::results::ReExportCycleKind::MultiNode => "",
244 };
245 vec![format!(
246 "- {}{}",
247 chain
248 .iter()
249 .map(|s| format!("`{s}`"))
250 .collect::<Vec<_>>()
251 .join(" <-> "),
252 kind_tag
253 )]
254 },
255 );
256 markdown_section(
257 out,
258 &results.boundary_violations,
259 "Boundary violations",
260 |v| {
261 vec![format!(
262 "- `{}`:{} \u{2192} `{}` ({} \u{2192} {})",
263 rel(&v.violation.from_path),
264 v.violation.line,
265 rel(&v.violation.to_path),
266 v.violation.from_zone,
267 v.violation.to_zone,
268 )]
269 },
270 );
271 markdown_section(
272 out,
273 &results.stale_suppressions,
274 "Stale suppressions",
275 |s| {
276 vec![format!(
277 "- `{}`:{} `{}` ({})",
278 rel(&s.path),
279 s.line,
280 escape_backticks(&s.description()),
281 escape_backticks(&s.explanation()),
282 )]
283 },
284 );
285}
286
287fn push_markdown_catalog_sections(
288 out: &mut String,
289 results: &AnalysisResults,
290 rel: &dyn Fn(&Path) -> String,
291) {
292 markdown_section(
293 out,
294 &results.unused_catalog_entries,
295 "Unused catalog entries",
296 |entry| format_unused_catalog_entry(entry, rel),
297 );
298 markdown_section(
299 out,
300 &results.empty_catalog_groups,
301 "Empty catalog groups",
302 |group| {
303 vec![format!(
304 "- `{}` `{}`:{}",
305 escape_backticks(&group.group.catalog_name),
306 rel(&group.group.path),
307 group.group.line,
308 )]
309 },
310 );
311 markdown_section(
312 out,
313 &results.unresolved_catalog_references,
314 "Unresolved catalog references",
315 |finding| format_unresolved_catalog_reference(finding, rel),
316 );
317 markdown_section(
318 out,
319 &results.unused_dependency_overrides,
320 "Unused dependency overrides",
321 |finding| format_unused_dependency_override(finding, rel),
322 );
323 markdown_section(
324 out,
325 &results.misconfigured_dependency_overrides,
326 "Misconfigured dependency overrides",
327 |finding| {
328 vec![format!(
329 "- `{}` -> `{}` (`{}`) `{}`:{} ({})",
330 escape_backticks(&finding.entry.raw_key),
331 escape_backticks(&finding.entry.raw_value),
332 finding.entry.source.as_label(),
333 rel(&finding.entry.path),
334 finding.entry.line,
335 finding.entry.reason.describe(),
336 )]
337 },
338 );
339}
340
341fn format_unused_catalog_entry(
342 entry: &UnusedCatalogEntryFinding,
343 rel: &dyn Fn(&Path) -> String,
344) -> Vec<String> {
345 let mut row = format!(
346 "- `{}` (`{}`) `{}`:{}",
347 escape_backticks(&entry.entry.entry_name),
348 escape_backticks(&entry.entry.catalog_name),
349 rel(&entry.entry.path),
350 entry.entry.line,
351 );
352 if !entry.entry.hardcoded_consumers.is_empty() {
353 let consumers = entry
354 .entry
355 .hardcoded_consumers
356 .iter()
357 .map(|p| format!("`{}`", rel(p)))
358 .collect::<Vec<_>>()
359 .join(", ");
360 let _ = write!(row, " (hardcoded in {consumers})");
361 }
362 vec![row]
363}
364
365fn format_unresolved_catalog_reference(
366 finding: &UnresolvedCatalogReferenceFinding,
367 rel: &dyn Fn(&Path) -> String,
368) -> Vec<String> {
369 let mut row = format!(
370 "- `{}` (`{}`) `{}`:{}",
371 escape_backticks(&finding.reference.entry_name),
372 escape_backticks(&finding.reference.catalog_name),
373 rel(&finding.reference.path),
374 finding.reference.line,
375 );
376 if !finding.reference.available_in_catalogs.is_empty() {
377 let alts = finding
378 .reference
379 .available_in_catalogs
380 .iter()
381 .map(|c| format!("`{}`", escape_backticks(c)))
382 .collect::<Vec<_>>()
383 .join(", ");
384 let _ = write!(row, " (available in: {alts})");
385 }
386 vec![row]
387}
388
389fn format_unused_dependency_override(
390 finding: &UnusedDependencyOverrideFinding,
391 rel: &dyn Fn(&Path) -> String,
392) -> Vec<String> {
393 let mut row = format!(
394 "- `{}` -> `{}` (`{}`) `{}`:{}",
395 escape_backticks(&finding.entry.raw_key),
396 escape_backticks(&finding.entry.version_range),
397 finding.entry.source.as_label(),
398 rel(&finding.entry.path),
399 finding.entry.line,
400 );
401 if let Some(hint) = &finding.entry.hint {
402 let _ = write!(row, " (hint: {})", escape_backticks(hint));
403 }
404 vec![row]
405}
406
407pub(super) fn print_grouped_markdown(groups: &[ResultGroup], root: &Path) {
409 let total: usize = groups.iter().map(|g| g.results.total_issues()).sum();
410
411 if total == 0 {
412 outln!("## Fallow: no issues found");
413 return;
414 }
415
416 outln!(
417 "## Fallow: {total} issue{} found (grouped)\n",
418 plural(total)
419 );
420
421 for group in groups {
422 let count = group.results.total_issues();
423 if count == 0 {
424 continue;
425 }
426 outln!(
427 "## {} ({count} issue{})\n",
428 escape_backticks(&group.key),
429 plural(count)
430 );
431 if let Some(ref owners) = group.owners
432 && !owners.is_empty()
433 {
434 let joined = owners
435 .iter()
436 .map(|o| escape_backticks(o))
437 .collect::<Vec<_>>()
438 .join(" ");
439 outln!("Owners: {joined}\n");
440 }
441 let body = build_markdown(&group.results, root);
442 let sections = body
443 .strip_prefix("## Fallow: no issues found\n")
444 .or_else(|| body.find("\n\n").map(|pos| &body[pos + 2..]))
445 .unwrap_or(&body);
446 out!("{sections}");
447 }
448}
449
450fn format_export(e: &UnusedExport) -> String {
451 let re = if e.is_re_export { " (re-export)" } else { "" };
452 format!(":{} `{}`{re}", e.line, escape_backticks(&e.export_name))
453}
454
455fn format_private_type_leak(
456 entry: &fallow_types::output_dead_code::PrivateTypeLeakFinding,
457) -> String {
458 let e = &entry.leak;
459 format!(
460 ":{} `{}` references private type `{}`",
461 e.line,
462 escape_backticks(&e.export_name),
463 escape_backticks(&e.type_name)
464 )
465}
466
467fn format_member(m: &UnusedMember) -> String {
468 format!(
469 ":{} `{}.{}`",
470 m.line,
471 escape_backticks(&m.parent_name),
472 escape_backticks(&m.member_name)
473 )
474}
475
476fn format_dependency(
477 dep_name: &str,
478 pkg_path: &Path,
479 used_in_workspaces: &[std::path::PathBuf],
480 root: &Path,
481) -> Vec<String> {
482 let name = escape_backticks(dep_name);
483 let pkg_label = relative_path(pkg_path, root).display().to_string();
484 let workspace_context = if used_in_workspaces.is_empty() {
485 String::new()
486 } else {
487 let workspaces = used_in_workspaces
488 .iter()
489 .map(|path| escape_backticks(&relative_path(path, root).display().to_string()))
490 .collect::<Vec<_>>()
491 .join(", ");
492 format!("; imported in {workspaces}")
493 };
494 if pkg_label == "package.json" && workspace_context.is_empty() {
495 vec![format!("- `{name}`")]
496 } else {
497 let label = if pkg_label == "package.json" {
498 workspace_context.trim_start_matches("; ").to_string()
499 } else {
500 format!("{}{workspace_context}", escape_backticks(&pkg_label))
501 };
502 vec![format!("- `{name}` ({label})")]
503 }
504}
505
506fn markdown_section<T>(
508 out: &mut String,
509 items: &[T],
510 title: &str,
511 format_lines: impl Fn(&T) -> Vec<String>,
512) {
513 if items.is_empty() {
514 return;
515 }
516 let _ = write!(out, "### {title} ({})\n\n", items.len());
517 for item in items {
518 for line in format_lines(item) {
519 out.push_str(&line);
520 out.push('\n');
521 }
522 }
523 out.push('\n');
524}
525
526fn markdown_grouped_section<'a, T>(
528 out: &mut String,
529 items: &'a [T],
530 title: &str,
531 root: &Path,
532 get_path: impl Fn(&'a T) -> &'a Path,
533 format_detail: impl Fn(&T) -> String,
534) {
535 if items.is_empty() {
536 return;
537 }
538 let _ = write!(out, "### {title} ({})\n\n", items.len());
539
540 let mut indices: Vec<usize> = (0..items.len()).collect();
541 indices.sort_by(|&a, &b| get_path(&items[a]).cmp(get_path(&items[b])));
542
543 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
544 let mut last_file = String::new();
545 for &i in &indices {
546 let item = &items[i];
547 let file_str = rel(get_path(item));
548 if file_str != last_file {
549 let _ = writeln!(out, "- `{file_str}`");
550 last_file = file_str;
551 }
552 let _ = writeln!(out, " - {}", format_detail(item));
553 }
554 out.push('\n');
555}
556
557pub(super) fn print_duplication_markdown(report: &DuplicationReport, root: &Path) {
558 outln!("{}", build_duplication_markdown(report, root));
559}
560
561#[must_use]
563pub fn build_duplication_markdown(report: &DuplicationReport, root: &Path) -> String {
564 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
565
566 let mut out = String::new();
567
568 if report.clone_groups.is_empty() {
569 out.push_str("## Fallow: no code duplication found\n");
570 return out;
571 }
572
573 let stats = &report.stats;
574 let _ = write!(
575 out,
576 "## Fallow: {} clone group{} found ({:.1}% duplication)\n\n",
577 stats.clone_groups,
578 plural(stats.clone_groups),
579 stats.duplication_percentage,
580 );
581
582 out.push_str("### Duplicates\n\n");
583 for (i, group) in report.clone_groups.iter().enumerate() {
584 let instance_count = group.instances.len();
585 let _ = write!(
586 out,
587 "**Clone group {}** ({} lines, {instance_count} instance{})\n\n",
588 i + 1,
589 group.line_count,
590 plural(instance_count)
591 );
592 for instance in &group.instances {
593 let relative = rel(&instance.file);
594 let _ = writeln!(
595 out,
596 "- `{relative}:{}-{}`",
597 instance.start_line, instance.end_line
598 );
599 }
600 out.push('\n');
601 }
602
603 if !report.clone_families.is_empty() {
604 out.push_str("### Clone Families\n\n");
605 for (i, family) in report.clone_families.iter().enumerate() {
606 let file_names: Vec<_> = family.files.iter().map(|f| rel(f)).collect();
607 let _ = write!(
608 out,
609 "**Family {}** ({} group{}, {} lines across {})\n\n",
610 i + 1,
611 family.groups.len(),
612 plural(family.groups.len()),
613 family.total_duplicated_lines,
614 file_names
615 .iter()
616 .map(|s| format!("`{s}`"))
617 .collect::<Vec<_>>()
618 .join(", "),
619 );
620 for suggestion in &family.suggestions {
621 let savings = if suggestion.estimated_savings > 0 {
622 format!(" (~{} lines saved)", suggestion.estimated_savings)
623 } else {
624 String::new()
625 };
626 let _ = writeln!(out, "- {}{savings}", suggestion.description);
627 }
628 out.push('\n');
629 }
630 }
631
632 let _ = writeln!(
633 out,
634 "**Summary:** {} duplicated lines ({:.1}%) across {} file{}",
635 stats.duplicated_lines,
636 stats.duplication_percentage,
637 stats.files_with_clones,
638 plural(stats.files_with_clones),
639 );
640
641 out
642}
643
644pub(super) fn print_health_markdown(report: &crate::health_types::HealthReport, root: &Path) {
645 outln!("{}", build_health_markdown(report, root));
646}
647
648#[must_use]
650pub fn build_health_markdown(report: &crate::health_types::HealthReport, root: &Path) -> String {
651 let mut out = String::new();
652
653 if let Some(ref hs) = report.health_score {
654 let _ = writeln!(out, "## Health Score: {:.0} ({})\n", hs.score, hs.grade);
655 }
656
657 write_trend_section(&mut out, report);
658 write_vital_signs_section(&mut out, report);
659
660 if report.findings.is_empty()
661 && report.file_scores.is_empty()
662 && report.coverage_gaps.is_none()
663 && report.hotspots.is_empty()
664 && report.targets.is_empty()
665 && report.runtime_coverage.is_none()
666 && report.coverage_intelligence.is_none()
667 {
668 if report.vital_signs.is_none() {
669 let _ = write!(
670 out,
671 "## Fallow: no functions exceed complexity thresholds\n\n\
672 **{}** functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {:.1})\n",
673 report.summary.functions_analyzed,
674 report.summary.max_cyclomatic_threshold,
675 report.summary.max_cognitive_threshold,
676 report.summary.max_crap_threshold,
677 );
678 }
679 return out;
680 }
681
682 write_findings_section(&mut out, report, root);
683 write_runtime_coverage_section(&mut out, report, root);
684 write_coverage_intelligence_section(&mut out, report, root);
685 write_coverage_gaps_section(&mut out, report, root);
686 write_file_scores_section(&mut out, report, root);
687 write_hotspots_section(&mut out, report, root);
688 write_targets_section(&mut out, report, root);
689 write_metric_legend(&mut out, report);
690
691 out
692}
693
694fn write_coverage_intelligence_section(
695 out: &mut String,
696 report: &crate::health_types::HealthReport,
697 root: &Path,
698) {
699 let Some(ref intelligence) = report.coverage_intelligence else {
700 return;
701 };
702 if !out.is_empty() && !out.ends_with("\n\n") {
703 out.push('\n');
704 }
705 let _ = writeln!(
706 out,
707 "## Coverage Intelligence\n\n- Verdict: {}\n- Findings: {}\n- Ambiguous matches skipped: {}\n",
708 intelligence.verdict,
709 intelligence.summary.findings,
710 intelligence.summary.skipped_ambiguous_matches,
711 );
712 if intelligence.findings.is_empty() {
713 if intelligence.summary.skipped_ambiguous_matches > 0 {
714 let match_phrase = if intelligence.summary.skipped_ambiguous_matches == 1 {
715 "evidence match was"
716 } else {
717 "evidence matches were"
718 };
719 let _ = writeln!(
720 out,
721 "No actionable findings were emitted because {} ambiguous {match_phrase} skipped.\n",
722 intelligence.summary.skipped_ambiguous_matches,
723 );
724 }
725 return;
726 }
727 out.push_str("| ID | Path | Identity | Verdict | Recommendation | Confidence | Signals |\n");
728 out.push_str("|:---|:-----|:---------|:--------|:---------------|:-----------|:--------|\n");
729 for finding in &intelligence.findings {
730 let path = escape_backticks(&normalize_uri(
731 &relative_path(&finding.path, root).display().to_string(),
732 ));
733 let identity = finding
734 .identity
735 .as_deref()
736 .map_or_else(|| "-".to_owned(), escape_backticks);
737 let signals = finding
738 .signals
739 .iter()
740 .map(ToString::to_string)
741 .collect::<Vec<_>>()
742 .join(", ");
743 let _ = writeln!(
744 out,
745 "| `{}` | `{}`:{} | `{}` | {} | {} | {} | {} |",
746 escape_backticks(&finding.id),
747 path,
748 finding.line,
749 identity,
750 finding.verdict,
751 finding.recommendation,
752 finding.confidence,
753 signals,
754 );
755 }
756 out.push('\n');
757}
758
759fn write_runtime_coverage_section(
760 out: &mut String,
761 report: &crate::health_types::HealthReport,
762 root: &Path,
763) {
764 let Some(ref production) = report.runtime_coverage else {
765 return;
766 };
767 if !out.is_empty() && !out.ends_with("\n\n") {
768 out.push('\n');
769 }
770 let _ = writeln!(
771 out,
772 "## 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",
773 production.verdict,
774 production.summary.functions_tracked,
775 production.summary.functions_hit,
776 production.summary.functions_unhit,
777 production.summary.functions_untracked,
778 production.summary.coverage_percent,
779 production.summary.trace_count,
780 production.summary.period_days,
781 production.summary.deployments_seen,
782 );
783 if let Some(watermark) = production.watermark {
784 let _ = writeln!(out, "- Watermark: {watermark}\n");
785 }
786 if let Some(ref quality) = production.summary.capture_quality
787 && quality.lazy_parse_warning
788 {
789 let window = super::human::health::format_window(quality.window_seconds);
790 let _ = writeln!(
791 out,
792 "- Capture quality: short window ({} from {} instance(s), {:.1}% of functions untracked); lazy-parsed scripts may not appear.\n",
793 window, quality.instances_observed, quality.untracked_ratio_percent,
794 );
795 }
796 let rel = |p: &Path| {
797 escape_backticks(&normalize_uri(
798 &relative_path(p, root).display().to_string(),
799 ))
800 };
801 if !production.findings.is_empty() {
802 out.push_str("| ID | Path | Function | Verdict | Invocations | Confidence |\n");
803 out.push_str("|:---|:-----|:---------|:--------|------------:|:-----------|\n");
804 for finding in &production.findings {
805 let invocations = finding
806 .invocations
807 .map_or_else(|| "-".to_owned(), |hits| hits.to_string());
808 let _ = writeln!(
809 out,
810 "| `{}` | `{}`:{} | `{}` | {} | {} | {} |",
811 escape_backticks(&finding.id),
812 rel(&finding.path),
813 finding.line,
814 escape_backticks(&finding.function),
815 finding.verdict,
816 invocations,
817 finding.confidence,
818 );
819 }
820 out.push('\n');
821 }
822 if !production.hot_paths.is_empty() {
823 out.push_str("| ID | Hot path | Function | Invocations | Percentile |\n");
824 out.push_str("|:---|:---------|:---------|------------:|-----------:|\n");
825 for entry in &production.hot_paths {
826 let _ = writeln!(
827 out,
828 "| `{}` | `{}`:{} | `{}` | {} | {} |",
829 escape_backticks(&entry.id),
830 rel(&entry.path),
831 entry.line,
832 escape_backticks(&entry.function),
833 entry.invocations,
834 entry.percentile,
835 );
836 }
837 out.push('\n');
838 }
839}
840
841fn write_trend_section(out: &mut String, report: &crate::health_types::HealthReport) {
843 let Some(ref trend) = report.health_trend else {
844 return;
845 };
846 let sha_str = trend
847 .compared_to
848 .git_sha
849 .as_deref()
850 .map_or(String::new(), |sha| format!(" ({sha})"));
851 let _ = writeln!(
852 out,
853 "## Trend (vs {}{})\n",
854 trend
855 .compared_to
856 .timestamp
857 .get(..10)
858 .unwrap_or(&trend.compared_to.timestamp),
859 sha_str,
860 );
861 out.push_str("| Metric | Previous | Current | Delta | Direction |\n");
862 out.push_str("|:-------|:---------|:--------|:------|:----------|\n");
863 for m in &trend.metrics {
864 let fmt_val = |v: f64| -> String {
865 if m.unit == "%" {
866 format!("{v:.1}%")
867 } else if (v - v.round()).abs() < 0.05 {
868 format!("{v:.0}")
869 } else {
870 format!("{v:.1}")
871 }
872 };
873 let prev = fmt_val(m.previous);
874 let cur = fmt_val(m.current);
875 let delta = if m.unit == "%" {
876 format!("{:+.1}%", m.delta)
877 } else if (m.delta - m.delta.round()).abs() < 0.05 {
878 format!("{:+.0}", m.delta)
879 } else {
880 format!("{:+.1}", m.delta)
881 };
882 let _ = writeln!(
883 out,
884 "| {} | {} | {} | {} | {} {} |",
885 m.label,
886 prev,
887 cur,
888 delta,
889 m.direction.arrow(),
890 m.direction.label(),
891 );
892 }
893 let md_sha = trend
894 .compared_to
895 .git_sha
896 .as_deref()
897 .map_or(String::new(), |sha| format!(" ({sha})"));
898 let _ = writeln!(
899 out,
900 "\n*vs {}{} · {} {} available*\n",
901 trend
902 .compared_to
903 .timestamp
904 .get(..10)
905 .unwrap_or(&trend.compared_to.timestamp),
906 md_sha,
907 trend.snapshots_loaded,
908 if trend.snapshots_loaded == 1 {
909 "snapshot"
910 } else {
911 "snapshots"
912 },
913 );
914}
915
916fn write_vital_signs_section(out: &mut String, report: &crate::health_types::HealthReport) {
918 let Some(ref vs) = report.vital_signs else {
919 return;
920 };
921 out.push_str("## Vital Signs\n\n");
922 out.push_str("| Metric | Value |\n");
923 out.push_str("|:-------|------:|\n");
924 if vs.total_loc > 0 {
925 let _ = writeln!(out, "| Total LOC | {} |", vs.total_loc);
926 }
927 let _ = writeln!(out, "| Avg Cyclomatic | {:.1} |", vs.avg_cyclomatic);
928 let _ = writeln!(out, "| P90 Cyclomatic | {} |", vs.p90_cyclomatic);
929 if let Some(v) = vs.dead_file_pct {
930 let _ = writeln!(out, "| Dead Files | {v:.1}% |");
931 }
932 if let Some(v) = vs.dead_export_pct {
933 let _ = writeln!(out, "| Dead Exports | {v:.1}% |");
934 }
935 if let Some(v) = vs.maintainability_avg {
936 let _ = writeln!(out, "| Maintainability (avg) | {v:.1} |");
937 }
938 if let Some(v) = vs.hotspot_count {
939 let label = report.hotspot_summary.as_ref().map_or_else(
940 || "Hotspots".to_string(),
941 |summary| format!("Hotspots (since {})", summary.since),
942 );
943 let _ = writeln!(out, "| {label} | {v} |");
944 }
945 if let Some(v) = vs.circular_dep_count {
946 let _ = writeln!(out, "| Circular Deps | {v} |");
947 }
948 if let Some(v) = vs.unused_dep_count {
949 let _ = writeln!(out, "| Unused Deps | {v} |");
950 }
951 out.push('\n');
952}
953
954fn write_findings_section(
956 out: &mut String,
957 report: &crate::health_types::HealthReport,
958 root: &Path,
959) {
960 if report.findings.is_empty() {
961 return;
962 }
963
964 let rel = |p: &Path| {
965 escape_backticks(&normalize_uri(
966 &relative_path(p, root).display().to_string(),
967 ))
968 };
969
970 let count = report.summary.functions_above_threshold;
971 let shown = report.findings.len();
972 if shown < count {
973 let _ = write!(
974 out,
975 "## Fallow: {count} high complexity function{} ({shown} shown)\n\n",
976 plural(count),
977 );
978 } else {
979 let _ = write!(
980 out,
981 "## Fallow: {count} high complexity function{}\n\n",
982 plural(count),
983 );
984 }
985
986 out.push_str("| File | Function | Severity | Cyclomatic | Cognitive | CRAP | Lines |\n");
987 out.push_str("|:-----|:---------|:---------|:-----------|:----------|:-----|:------|\n");
988
989 for finding in &report.findings {
990 let file_str = rel(&finding.path);
991 let cyc_marker = if finding.cyclomatic > report.summary.max_cyclomatic_threshold {
992 " **!**"
993 } else {
994 ""
995 };
996 let cog_marker = if finding.cognitive > report.summary.max_cognitive_threshold {
997 " **!**"
998 } else {
999 ""
1000 };
1001 let severity_label = match finding.severity {
1002 crate::health_types::FindingSeverity::Critical => "critical",
1003 crate::health_types::FindingSeverity::High => "high",
1004 crate::health_types::FindingSeverity::Moderate => "moderate",
1005 };
1006 let crap_cell = match finding.crap {
1007 Some(crap) => {
1008 let marker = if crap >= report.summary.max_crap_threshold {
1009 " **!**"
1010 } else {
1011 ""
1012 };
1013 format!("{crap:.1}{marker}")
1014 }
1015 None => "-".to_string(),
1016 };
1017 let _ = writeln!(
1018 out,
1019 "| `{file_str}:{line}` | `{name}` | {severity_label} | {cyc}{cyc_marker} | {cog}{cog_marker} | {crap_cell} | {lines} |",
1020 line = finding.line,
1021 name = escape_backticks(&finding.name),
1022 cyc = finding.cyclomatic,
1023 cog = finding.cognitive,
1024 lines = finding.line_count,
1025 );
1026 }
1027
1028 let s = &report.summary;
1029 let _ = write!(
1030 out,
1031 "\n**{files}** files, **{funcs}** functions analyzed \
1032 (thresholds: cyclomatic > {cyc}, cognitive > {cog}, CRAP >= {crap:.1})\n",
1033 files = s.files_analyzed,
1034 funcs = s.functions_analyzed,
1035 cyc = s.max_cyclomatic_threshold,
1036 cog = s.max_cognitive_threshold,
1037 crap = s.max_crap_threshold,
1038 );
1039}
1040
1041fn write_file_scores_section(
1043 out: &mut String,
1044 report: &crate::health_types::HealthReport,
1045 root: &Path,
1046) {
1047 if report.file_scores.is_empty() {
1048 return;
1049 }
1050
1051 let rel = |p: &Path| {
1052 escape_backticks(&normalize_uri(
1053 &relative_path(p, root).display().to_string(),
1054 ))
1055 };
1056
1057 out.push('\n');
1058 let _ = writeln!(
1059 out,
1060 "### File Health Scores ({} files)\n",
1061 report.file_scores.len(),
1062 );
1063 out.push_str("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density | Risk |\n");
1064 out.push_str("|:-----|:---------------|:-------|:--------|:----------|:--------|:-----|\n");
1065
1066 for score in &report.file_scores {
1067 let file_str = rel(&score.path);
1068 let _ = writeln!(
1069 out,
1070 "| `{file_str}` | {mi:.1} | {fi} | {fan_out} | {dead:.0}% | {density:.2} | {crap:.1} |",
1071 mi = score.maintainability_index,
1072 fi = score.fan_in,
1073 fan_out = score.fan_out,
1074 dead = score.dead_code_ratio * 100.0,
1075 density = score.complexity_density,
1076 crap = score.crap_max,
1077 );
1078 }
1079
1080 if let Some(avg) = report.summary.average_maintainability {
1081 let _ = write!(out, "\n**Average maintainability index:** {avg:.1}/100\n");
1082 }
1083}
1084
1085fn write_coverage_gaps_section(
1086 out: &mut String,
1087 report: &crate::health_types::HealthReport,
1088 root: &Path,
1089) {
1090 let Some(ref gaps) = report.coverage_gaps else {
1091 return;
1092 };
1093
1094 out.push('\n');
1095 let _ = writeln!(out, "### Coverage Gaps\n");
1096 let _ = writeln!(
1097 out,
1098 "*{} untested files · {} untested exports · {:.1}% file coverage*\n",
1099 gaps.summary.untested_files, gaps.summary.untested_exports, gaps.summary.file_coverage_pct,
1100 );
1101
1102 if gaps.files.is_empty() && gaps.exports.is_empty() {
1103 out.push_str("_No coverage gaps found in scope._\n");
1104 return;
1105 }
1106
1107 if !gaps.files.is_empty() {
1108 out.push_str("#### Files\n");
1109 for item in &gaps.files {
1110 let file_str = escape_backticks(&normalize_uri(
1111 &relative_path(&item.file.path, root).display().to_string(),
1112 ));
1113 let _ = writeln!(
1114 out,
1115 "- `{file_str}` ({count} value export{})",
1116 if item.file.value_export_count == 1 {
1117 ""
1118 } else {
1119 "s"
1120 },
1121 count = item.file.value_export_count,
1122 );
1123 }
1124 out.push('\n');
1125 }
1126
1127 if !gaps.exports.is_empty() {
1128 out.push_str("#### Exports\n");
1129 for item in &gaps.exports {
1130 let file_str = escape_backticks(&normalize_uri(
1131 &relative_path(&item.export.path, root).display().to_string(),
1132 ));
1133 let _ = writeln!(
1134 out,
1135 "- `{file_str}`:{} `{}`",
1136 item.export.line, item.export.export_name
1137 );
1138 }
1139 }
1140}
1141
1142fn ownership_md_cells(
1147 ownership: Option<&crate::health_types::OwnershipMetrics>,
1148) -> (String, String, String, String) {
1149 let Some(o) = ownership else {
1150 let dash = "\u{2013}".to_string();
1151 return (dash.clone(), dash.clone(), dash.clone(), dash);
1152 };
1153 let bus = o.bus_factor.to_string();
1154 let top = format!(
1155 "`{}` ({:.0}%)",
1156 o.top_contributor.identifier,
1157 o.top_contributor.share * 100.0,
1158 );
1159 let owner = o
1160 .declared_owner
1161 .as_deref()
1162 .map_or_else(|| "\u{2013}".to_string(), str::to_string);
1163 let mut notes: Vec<&str> = Vec::new();
1164 if o.unowned == Some(true) {
1165 notes.push("**unowned**");
1166 }
1167 if o.ownership_state == crate::health_types::OwnershipState::DeclaredInactive {
1168 notes.push("declared owner inactive");
1169 }
1170 if o.drift {
1171 notes.push("drift");
1172 }
1173 let notes_str = if notes.is_empty() {
1174 "\u{2013}".to_string()
1175 } else {
1176 notes.join(", ")
1177 };
1178 (bus, top, owner, notes_str)
1179}
1180
1181fn write_hotspots_section(
1182 out: &mut String,
1183 report: &crate::health_types::HealthReport,
1184 root: &Path,
1185) {
1186 if report.hotspots.is_empty() {
1187 return;
1188 }
1189
1190 let rel = |p: &Path| {
1191 escape_backticks(&normalize_uri(
1192 &relative_path(p, root).display().to_string(),
1193 ))
1194 };
1195
1196 out.push('\n');
1197 let header = report.hotspot_summary.as_ref().map_or_else(
1198 || format!("### Hotspots ({} files)\n", report.hotspots.len()),
1199 |summary| {
1200 format!(
1201 "### Hotspots ({} files, since {})\n",
1202 report.hotspots.len(),
1203 summary.since,
1204 )
1205 },
1206 );
1207 let _ = writeln!(out, "{header}");
1208 let any_ownership = report.hotspots.iter().any(|e| e.ownership.is_some());
1209 if any_ownership {
1210 out.push_str(
1211 "| File | Score | Commits | Churn | Density | Fan-in | Trend | Bus | Top | Owner | Notes |\n"
1212 );
1213 out.push_str(
1214 "|:-----|:------|:--------|:------|:--------|:-------|:------|:----|:----|:------|:------|\n"
1215 );
1216 } else {
1217 out.push_str("| File | Score | Commits | Churn | Density | Fan-in | Trend |\n");
1218 out.push_str("|:-----|:------|:--------|:------|:--------|:-------|:------|\n");
1219 }
1220
1221 for entry in &report.hotspots {
1222 let file_str = rel(&entry.path);
1223 if any_ownership {
1224 let (bus, top, owner, notes) = ownership_md_cells(entry.ownership.as_ref());
1225 let _ = writeln!(
1226 out,
1227 "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} | {bus} | {top} | {owner} | {notes} |",
1228 score = entry.score,
1229 commits = entry.commits,
1230 churn = entry.lines_added + entry.lines_deleted,
1231 density = entry.complexity_density,
1232 fi = entry.fan_in,
1233 trend = entry.trend,
1234 );
1235 } else {
1236 let _ = writeln!(
1237 out,
1238 "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} |",
1239 score = entry.score,
1240 commits = entry.commits,
1241 churn = entry.lines_added + entry.lines_deleted,
1242 density = entry.complexity_density,
1243 fi = entry.fan_in,
1244 trend = entry.trend,
1245 );
1246 }
1247 }
1248
1249 if let Some(ref summary) = report.hotspot_summary
1250 && summary.files_excluded > 0
1251 {
1252 let _ = write!(
1253 out,
1254 "\n*{} file{} excluded (< {} commits)*\n",
1255 summary.files_excluded,
1256 plural(summary.files_excluded),
1257 summary.min_commits,
1258 );
1259 }
1260}
1261
1262fn write_targets_section(
1264 out: &mut String,
1265 report: &crate::health_types::HealthReport,
1266 root: &Path,
1267) {
1268 if report.targets.is_empty() {
1269 return;
1270 }
1271 let _ = write!(
1272 out,
1273 "\n### Refactoring Targets ({})\n\n",
1274 report.targets.len()
1275 );
1276 out.push_str("| Efficiency | Category | Effort / Confidence | File | Recommendation |\n");
1277 out.push_str("|:-----------|:---------|:--------------------|:-----|:---------------|\n");
1278 for target in &report.targets {
1279 let file_str = normalize_uri(&relative_path(&target.path, root).display().to_string());
1280 let category = target.category.label();
1281 let effort = target.effort.label();
1282 let confidence = target.confidence.label();
1283 let _ = writeln!(
1284 out,
1285 "| {:.1} | {category} | {effort} / {confidence} | `{file_str}` | {} |",
1286 target.efficiency, target.recommendation,
1287 );
1288 }
1289}
1290
1291fn write_metric_legend(out: &mut String, report: &crate::health_types::HealthReport) {
1293 let has_scores = !report.file_scores.is_empty();
1294 let has_coverage = report.coverage_gaps.is_some();
1295 let has_hotspots = !report.hotspots.is_empty();
1296 let has_targets = !report.targets.is_empty();
1297 if !has_scores && !has_coverage && !has_hotspots && !has_targets {
1298 return;
1299 }
1300 out.push_str("\n---\n\n<details><summary>Metric definitions</summary>\n\n");
1301 if has_scores {
1302 out.push_str("- **MI**: Maintainability Index (0\u{2013}100, higher is better)\n");
1303 out.push_str("- **Order**: risk-aware triage order using the larger of low-MI concern and CRAP risk\n");
1304 out.push_str("- **Fan-in**: files that import this file (blast radius)\n");
1305 out.push_str("- **Fan-out**: files this file imports (coupling)\n");
1306 out.push_str("- **Dead Code**: % of value exports with zero references\n");
1307 out.push_str("- **Density**: cyclomatic complexity / lines of code\n");
1308 out.push_str(
1309 "- **Risk**: max CRAP score for the file; low <15, moderate 15-30, high >=30\n",
1310 );
1311 }
1312 if has_coverage {
1313 out.push_str(
1314 "- **File coverage**: runtime files also reachable from a discovered test root\n",
1315 );
1316 out.push_str("- **Untested export**: export with no reference chain from any test-reachable module\n");
1317 }
1318 if has_hotspots {
1319 out.push_str("- **Score**: churn \u{00d7} complexity (0\u{2013}100, higher = riskier)\n");
1320 out.push_str("- **Commits**: commits in the analysis window\n");
1321 out.push_str("- **Churn**: total lines added + deleted\n");
1322 out.push_str("- **Trend**: accelerating / stable / cooling\n");
1323 }
1324 if has_targets {
1325 out.push_str(
1326 "- **Efficiency**: priority / effort (higher = better quick-win value, default sort)\n",
1327 );
1328 out.push_str("- **Category**: recommendation type (churn+complexity, high impact, dead code, complexity, coupling, circular dep)\n");
1329 out.push_str("- **Effort**: estimated effort (low / medium / high) based on file size, function count, and fan-in\n");
1330 out.push_str("- **Confidence**: recommendation reliability (high = deterministic analysis, medium = heuristic, low = git-dependent)\n");
1331 }
1332 out.push_str(
1333 "\n[Full metric reference](https://docs.fallow.tools/explanations/metrics)\n\n</details>\n",
1334 );
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339 use super::*;
1340 use crate::report::test_helpers::sample_results;
1341 use fallow_core::duplicates::{
1342 CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
1343 RefactoringKind, RefactoringSuggestion,
1344 };
1345 use fallow_core::results::*;
1346 use std::path::PathBuf;
1347
1348 #[test]
1349 fn markdown_empty_results_no_issues() {
1350 let root = PathBuf::from("/project");
1351 let results = AnalysisResults::default();
1352 let md = build_markdown(&results, &root);
1353 assert_eq!(md, "## Fallow: no issues found\n");
1354 }
1355
1356 #[test]
1357 fn markdown_contains_header_with_count() {
1358 let root = PathBuf::from("/project");
1359 let results = sample_results(&root);
1360 let md = build_markdown(&results, &root);
1361 assert!(md.starts_with(&format!(
1362 "## Fallow: {} issues found\n",
1363 results.total_issues()
1364 )));
1365 }
1366
1367 #[test]
1368 fn markdown_contains_all_sections() {
1369 let root = PathBuf::from("/project");
1370 let results = sample_results(&root);
1371 let md = build_markdown(&results, &root);
1372
1373 assert!(md.contains("### Unused files (1)"));
1374 assert!(md.contains("### Unused exports (1)"));
1375 assert!(md.contains("### Unused type exports (1)"));
1376 assert!(md.contains("### Unused dependencies (1)"));
1377 assert!(md.contains("### Unused devDependencies (1)"));
1378 assert!(md.contains("### Unused enum members (1)"));
1379 assert!(md.contains("### Unused class members (1)"));
1380 assert!(md.contains("### Unresolved imports (1)"));
1381 assert!(md.contains("### Unlisted dependencies (1)"));
1382 assert!(md.contains("### Duplicate exports (1)"));
1383 assert!(md.contains("### Type-only dependencies"));
1384 assert!(md.contains("### Test-only production dependencies"));
1385 assert!(md.contains("### Circular dependencies (1)"));
1386 }
1387
1388 #[test]
1389 fn markdown_unused_file_format() {
1390 let root = PathBuf::from("/project");
1391 let mut results = AnalysisResults::default();
1392 results
1393 .unused_files
1394 .push(UnusedFileFinding::with_actions(UnusedFile {
1395 path: root.join("src/dead.ts"),
1396 }));
1397 let md = build_markdown(&results, &root);
1398 assert!(md.contains("- `src/dead.ts`"));
1399 }
1400
1401 #[test]
1402 fn markdown_unused_export_grouped_by_file() {
1403 let root = PathBuf::from("/project");
1404 let mut results = AnalysisResults::default();
1405 results
1406 .unused_exports
1407 .push(UnusedExportFinding::with_actions(UnusedExport {
1408 path: root.join("src/utils.ts"),
1409 export_name: "helperFn".to_string(),
1410 is_type_only: false,
1411 line: 10,
1412 col: 4,
1413 span_start: 120,
1414 is_re_export: false,
1415 }));
1416 let md = build_markdown(&results, &root);
1417 assert!(md.contains("- `src/utils.ts`"));
1418 assert!(md.contains(":10 `helperFn`"));
1419 }
1420
1421 #[test]
1422 fn markdown_re_export_tagged() {
1423 let root = PathBuf::from("/project");
1424 let mut results = AnalysisResults::default();
1425 results
1426 .unused_exports
1427 .push(UnusedExportFinding::with_actions(UnusedExport {
1428 path: root.join("src/index.ts"),
1429 export_name: "reExported".to_string(),
1430 is_type_only: false,
1431 line: 1,
1432 col: 0,
1433 span_start: 0,
1434 is_re_export: true,
1435 }));
1436 let md = build_markdown(&results, &root);
1437 assert!(md.contains("(re-export)"));
1438 }
1439
1440 #[test]
1441 fn markdown_unused_dep_format() {
1442 let root = PathBuf::from("/project");
1443 let mut results = AnalysisResults::default();
1444 results
1445 .unused_dependencies
1446 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1447 package_name: "lodash".to_string(),
1448 location: DependencyLocation::Dependencies,
1449 path: root.join("package.json"),
1450 line: 5,
1451 used_in_workspaces: Vec::new(),
1452 }));
1453 let md = build_markdown(&results, &root);
1454 assert!(md.contains("- `lodash`"));
1455 }
1456
1457 #[test]
1458 fn markdown_circular_dep_format() {
1459 let root = PathBuf::from("/project");
1460 let mut results = AnalysisResults::default();
1461 results
1462 .circular_dependencies
1463 .push(CircularDependencyFinding::with_actions(
1464 CircularDependency {
1465 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1466 length: 2,
1467 line: 3,
1468 col: 0,
1469 edges: Vec::new(),
1470 is_cross_package: false,
1471 },
1472 ));
1473 let md = build_markdown(&results, &root);
1474 assert!(md.contains("`src/a.ts`"));
1475 assert!(md.contains("`src/b.ts`"));
1476 assert!(md.contains("\u{2192}"));
1477 }
1478
1479 #[test]
1480 fn markdown_strips_root_prefix() {
1481 let root = PathBuf::from("/project");
1482 let mut results = AnalysisResults::default();
1483 results
1484 .unused_files
1485 .push(UnusedFileFinding::with_actions(UnusedFile {
1486 path: PathBuf::from("/project/src/deep/nested/file.ts"),
1487 }));
1488 let md = build_markdown(&results, &root);
1489 assert!(md.contains("`src/deep/nested/file.ts`"));
1490 assert!(!md.contains("/project/"));
1491 }
1492
1493 #[test]
1494 fn markdown_single_issue_no_plural() {
1495 let root = PathBuf::from("/project");
1496 let mut results = AnalysisResults::default();
1497 results
1498 .unused_files
1499 .push(UnusedFileFinding::with_actions(UnusedFile {
1500 path: root.join("src/dead.ts"),
1501 }));
1502 let md = build_markdown(&results, &root);
1503 assert!(md.starts_with("## Fallow: 1 issue found\n"));
1504 }
1505
1506 #[test]
1507 fn markdown_type_only_dep_format() {
1508 let root = PathBuf::from("/project");
1509 let mut results = AnalysisResults::default();
1510 results
1511 .type_only_dependencies
1512 .push(TypeOnlyDependencyFinding::with_actions(
1513 TypeOnlyDependency {
1514 package_name: "zod".to_string(),
1515 path: root.join("package.json"),
1516 line: 8,
1517 },
1518 ));
1519 let md = build_markdown(&results, &root);
1520 assert!(md.contains("### Type-only dependencies"));
1521 assert!(md.contains("- `zod`"));
1522 }
1523
1524 #[test]
1525 fn markdown_escapes_backticks_in_export_names() {
1526 let root = PathBuf::from("/project");
1527 let mut results = AnalysisResults::default();
1528 results
1529 .unused_exports
1530 .push(UnusedExportFinding::with_actions(UnusedExport {
1531 path: root.join("src/utils.ts"),
1532 export_name: "foo`bar".to_string(),
1533 is_type_only: false,
1534 line: 1,
1535 col: 0,
1536 span_start: 0,
1537 is_re_export: false,
1538 }));
1539 let md = build_markdown(&results, &root);
1540 assert!(md.contains("foo\\`bar"));
1541 assert!(!md.contains("foo`bar`"));
1542 }
1543
1544 #[test]
1545 fn markdown_escapes_backticks_in_package_names() {
1546 let root = PathBuf::from("/project");
1547 let mut results = AnalysisResults::default();
1548 results
1549 .unused_dependencies
1550 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1551 package_name: "pkg`name".to_string(),
1552 location: DependencyLocation::Dependencies,
1553 path: root.join("package.json"),
1554 line: 5,
1555 used_in_workspaces: Vec::new(),
1556 }));
1557 let md = build_markdown(&results, &root);
1558 assert!(md.contains("pkg\\`name"));
1559 }
1560
1561 #[test]
1562 fn duplication_markdown_empty() {
1563 let report = DuplicationReport::default();
1564 let root = PathBuf::from("/project");
1565 let md = build_duplication_markdown(&report, &root);
1566 assert_eq!(md, "## Fallow: no code duplication found\n");
1567 }
1568
1569 #[test]
1570 fn duplication_markdown_contains_groups() {
1571 let root = PathBuf::from("/project");
1572 let report = DuplicationReport {
1573 clone_groups: vec![CloneGroup {
1574 instances: vec![
1575 CloneInstance {
1576 file: root.join("src/a.ts"),
1577 start_line: 1,
1578 end_line: 10,
1579 start_col: 0,
1580 end_col: 0,
1581 fragment: String::new(),
1582 },
1583 CloneInstance {
1584 file: root.join("src/b.ts"),
1585 start_line: 5,
1586 end_line: 14,
1587 start_col: 0,
1588 end_col: 0,
1589 fragment: String::new(),
1590 },
1591 ],
1592 token_count: 50,
1593 line_count: 10,
1594 }],
1595 clone_families: vec![],
1596 mirrored_directories: vec![],
1597 stats: DuplicationStats {
1598 total_files: 10,
1599 files_with_clones: 2,
1600 total_lines: 500,
1601 duplicated_lines: 20,
1602 total_tokens: 2500,
1603 duplicated_tokens: 100,
1604 clone_groups: 1,
1605 clone_instances: 2,
1606 duplication_percentage: 4.0,
1607 clone_groups_below_min_occurrences: 0,
1608 },
1609 };
1610 let md = build_duplication_markdown(&report, &root);
1611 assert!(md.contains("**Clone group 1**"));
1612 assert!(md.contains("`src/a.ts:1-10`"));
1613 assert!(md.contains("`src/b.ts:5-14`"));
1614 assert!(md.contains("4.0% duplication"));
1615 }
1616
1617 #[test]
1618 fn duplication_markdown_contains_families() {
1619 let root = PathBuf::from("/project");
1620 let report = DuplicationReport {
1621 clone_groups: vec![CloneGroup {
1622 instances: vec![CloneInstance {
1623 file: root.join("src/a.ts"),
1624 start_line: 1,
1625 end_line: 5,
1626 start_col: 0,
1627 end_col: 0,
1628 fragment: String::new(),
1629 }],
1630 token_count: 30,
1631 line_count: 5,
1632 }],
1633 clone_families: vec![CloneFamily {
1634 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1635 groups: vec![],
1636 total_duplicated_lines: 20,
1637 total_duplicated_tokens: 100,
1638 suggestions: vec![RefactoringSuggestion {
1639 kind: RefactoringKind::ExtractFunction,
1640 description: "Extract shared utility function".to_string(),
1641 estimated_savings: 15,
1642 }],
1643 }],
1644 mirrored_directories: vec![],
1645 stats: DuplicationStats {
1646 clone_groups: 1,
1647 clone_instances: 1,
1648 duplication_percentage: 2.0,
1649 ..Default::default()
1650 },
1651 };
1652 let md = build_duplication_markdown(&report, &root);
1653 assert!(md.contains("### Clone Families"));
1654 assert!(md.contains("**Family 1**"));
1655 assert!(md.contains("Extract shared utility function"));
1656 assert!(md.contains("~15 lines saved"));
1657 }
1658
1659 #[test]
1660 fn health_markdown_empty_no_findings() {
1661 let root = PathBuf::from("/project");
1662 let report = crate::health_types::HealthReport {
1663 summary: crate::health_types::HealthSummary {
1664 files_analyzed: 10,
1665 functions_analyzed: 50,
1666 ..Default::default()
1667 },
1668 ..Default::default()
1669 };
1670 let md = build_health_markdown(&report, &root);
1671 assert!(md.contains("no functions exceed complexity thresholds"));
1672 assert!(md.contains("**50** functions analyzed"));
1673 }
1674
1675 #[test]
1676 fn health_markdown_table_format() {
1677 let root = PathBuf::from("/project");
1678 let report = crate::health_types::HealthReport {
1679 findings: vec![
1680 crate::health_types::ComplexityViolation {
1681 path: root.join("src/utils.ts"),
1682 name: "parseExpression".to_string(),
1683 line: 42,
1684 col: 0,
1685 cyclomatic: 25,
1686 cognitive: 30,
1687 line_count: 80,
1688 param_count: 0,
1689 exceeded: crate::health_types::ExceededThreshold::Both,
1690 severity: crate::health_types::FindingSeverity::High,
1691 crap: None,
1692 coverage_pct: None,
1693 coverage_tier: None,
1694 coverage_source: None,
1695 inherited_from: None,
1696 component_rollup: None,
1697 contributions: Vec::new(),
1698 }
1699 .into(),
1700 ],
1701 summary: crate::health_types::HealthSummary {
1702 files_analyzed: 10,
1703 functions_analyzed: 50,
1704 functions_above_threshold: 1,
1705 ..Default::default()
1706 },
1707 ..Default::default()
1708 };
1709 let md = build_health_markdown(&report, &root);
1710 assert!(md.contains("## Fallow: 1 high complexity function\n"));
1711 assert!(md.contains("| File | Function |"));
1712 assert!(md.contains("`src/utils.ts:42`"));
1713 assert!(md.contains("`parseExpression`"));
1714 assert!(md.contains("25 **!**"));
1715 assert!(md.contains("30 **!**"));
1716 assert!(md.contains("| 80 |"));
1717 assert!(md.contains("| - |"));
1718 }
1719
1720 #[test]
1721 fn health_markdown_includes_coverage_intelligence_and_ambiguity_summary() {
1722 use crate::health_types::{
1723 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
1724 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
1725 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
1726 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
1727 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
1728 HealthReport, HealthSummary,
1729 };
1730
1731 let root = PathBuf::from("/project");
1732 let mut report = HealthReport {
1733 summary: HealthSummary {
1734 files_analyzed: 10,
1735 functions_analyzed: 50,
1736 ..Default::default()
1737 },
1738 coverage_intelligence: Some(CoverageIntelligenceReport {
1739 schema_version: CoverageIntelligenceSchemaVersion::V1,
1740 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
1741 summary: CoverageIntelligenceSummary {
1742 findings: 1,
1743 high_confidence_deletes: 1,
1744 ..Default::default()
1745 },
1746 findings: vec![CoverageIntelligenceFinding {
1747 id: "fallow:coverage-intel:abc123".to_owned(),
1748 path: root.join("src/dead.ts"),
1749 identity: Some("deadPath".to_owned()),
1750 line: 9,
1751 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
1752 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
1753 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
1754 confidence: CoverageIntelligenceConfidence::High,
1755 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
1756 evidence: CoverageIntelligenceEvidence {
1757 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
1758 ..Default::default()
1759 },
1760 actions: vec![CoverageIntelligenceAction {
1761 kind: "delete-after-confirming-owner".to_owned(),
1762 description: "Confirm ownership".to_owned(),
1763 auto_fixable: false,
1764 }],
1765 }],
1766 }),
1767 ..Default::default()
1768 };
1769
1770 let md = build_health_markdown(&report, &root);
1771 assert!(md.contains("## Coverage Intelligence"));
1772 assert!(md.contains("fallow:coverage-intel:abc123"));
1773 assert!(md.contains("delete-after-confirming-owner"));
1774 assert!(md.contains("runtime_cold"));
1775
1776 report.coverage_intelligence = Some(CoverageIntelligenceReport {
1777 schema_version: CoverageIntelligenceSchemaVersion::V1,
1778 verdict: CoverageIntelligenceVerdict::Clean,
1779 summary: CoverageIntelligenceSummary {
1780 skipped_ambiguous_matches: 2,
1781 ..Default::default()
1782 },
1783 findings: vec![],
1784 });
1785 let md = build_health_markdown(&report, &root);
1786 assert!(md.contains("2 ambiguous evidence matches were skipped"));
1787 assert!(!md.contains("| ID | Path |"));
1788 }
1789
1790 #[test]
1791 fn health_markdown_crap_column_shows_score_and_marker() {
1792 let root = PathBuf::from("/project");
1793 let report = crate::health_types::HealthReport {
1794 findings: vec![
1795 crate::health_types::ComplexityViolation {
1796 path: root.join("src/risky.ts"),
1797 name: "branchy".to_string(),
1798 line: 1,
1799 col: 0,
1800 cyclomatic: 67,
1801 cognitive: 10,
1802 line_count: 80,
1803 param_count: 1,
1804 exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
1805 severity: crate::health_types::FindingSeverity::Critical,
1806 crap: Some(182.0),
1807 coverage_pct: None,
1808 coverage_tier: None,
1809 coverage_source: None,
1810 inherited_from: None,
1811 component_rollup: None,
1812 contributions: Vec::new(),
1813 }
1814 .into(),
1815 ],
1816 summary: crate::health_types::HealthSummary {
1817 files_analyzed: 1,
1818 functions_analyzed: 1,
1819 functions_above_threshold: 1,
1820 ..Default::default()
1821 },
1822 ..Default::default()
1823 };
1824 let md = build_health_markdown(&report, &root);
1825 assert!(
1826 md.contains("| CRAP |"),
1827 "markdown table should have CRAP column header: {md}"
1828 );
1829 assert!(
1830 md.contains("182.0 **!**"),
1831 "CRAP value should be rendered with a threshold marker: {md}"
1832 );
1833 assert!(
1834 md.contains("CRAP >="),
1835 "trailing summary line should reference the CRAP threshold: {md}"
1836 );
1837 }
1838
1839 #[test]
1840 fn health_markdown_no_marker_when_below_threshold() {
1841 let root = PathBuf::from("/project");
1842 let report = crate::health_types::HealthReport {
1843 findings: vec![
1844 crate::health_types::ComplexityViolation {
1845 path: root.join("src/utils.ts"),
1846 name: "helper".to_string(),
1847 line: 10,
1848 col: 0,
1849 cyclomatic: 15,
1850 cognitive: 20,
1851 line_count: 30,
1852 param_count: 0,
1853 exceeded: crate::health_types::ExceededThreshold::Cognitive,
1854 severity: crate::health_types::FindingSeverity::High,
1855 crap: None,
1856 coverage_pct: None,
1857 coverage_tier: None,
1858 coverage_source: None,
1859 inherited_from: None,
1860 component_rollup: None,
1861 contributions: Vec::new(),
1862 }
1863 .into(),
1864 ],
1865 summary: crate::health_types::HealthSummary {
1866 files_analyzed: 5,
1867 functions_analyzed: 20,
1868 functions_above_threshold: 1,
1869 ..Default::default()
1870 },
1871 ..Default::default()
1872 };
1873 let md = build_health_markdown(&report, &root);
1874 assert!(md.contains("| 15 |"));
1875 assert!(md.contains("20 **!**"));
1876 }
1877
1878 #[test]
1879 fn health_markdown_with_targets() {
1880 use crate::health_types::*;
1881
1882 let root = PathBuf::from("/project");
1883 let report = HealthReport {
1884 summary: HealthSummary {
1885 files_analyzed: 10,
1886 functions_analyzed: 50,
1887 ..Default::default()
1888 },
1889 targets: vec![
1890 RefactoringTarget {
1891 path: PathBuf::from("/project/src/complex.ts"),
1892 priority: 82.5,
1893 efficiency: 27.5,
1894 recommendation: "Split high-impact file".into(),
1895 category: RecommendationCategory::SplitHighImpact,
1896 effort: crate::health_types::EffortEstimate::High,
1897 confidence: crate::health_types::Confidence::Medium,
1898 factors: vec![ContributingFactor {
1899 metric: "fan_in",
1900 value: 25.0,
1901 threshold: 10.0,
1902 detail: "25 files depend on this".into(),
1903 }],
1904 evidence: None,
1905 }
1906 .into(),
1907 RefactoringTarget {
1908 path: PathBuf::from("/project/src/legacy.ts"),
1909 priority: 45.0,
1910 efficiency: 45.0,
1911 recommendation: "Remove 5 unused exports".into(),
1912 category: RecommendationCategory::RemoveDeadCode,
1913 effort: crate::health_types::EffortEstimate::Low,
1914 confidence: crate::health_types::Confidence::High,
1915 factors: vec![],
1916 evidence: None,
1917 }
1918 .into(),
1919 ],
1920 ..Default::default()
1921 };
1922 let md = build_health_markdown(&report, &root);
1923
1924 assert!(
1925 md.contains("Refactoring Targets"),
1926 "should contain targets heading"
1927 );
1928 assert!(
1929 md.contains("src/complex.ts"),
1930 "should contain target file path"
1931 );
1932 assert!(md.contains("27.5"), "should contain efficiency score");
1933 assert!(
1934 md.contains("Split high-impact file"),
1935 "should contain recommendation"
1936 );
1937 assert!(md.contains("src/legacy.ts"), "should contain second target");
1938 }
1939
1940 #[test]
1941 fn health_markdown_with_coverage_gaps() {
1942 use crate::health_types::*;
1943
1944 let root = PathBuf::from("/project");
1945 let report = HealthReport {
1946 summary: HealthSummary {
1947 files_analyzed: 10,
1948 functions_analyzed: 50,
1949 ..Default::default()
1950 },
1951 coverage_gaps: Some(CoverageGaps {
1952 summary: CoverageGapSummary {
1953 runtime_files: 2,
1954 covered_files: 0,
1955 file_coverage_pct: 0.0,
1956 untested_files: 1,
1957 untested_exports: 1,
1958 },
1959 files: vec![UntestedFileFinding::with_actions(
1960 UntestedFile {
1961 path: root.join("src/app.ts"),
1962 value_export_count: 2,
1963 },
1964 &root,
1965 )],
1966 exports: vec![UntestedExportFinding::with_actions(
1967 UntestedExport {
1968 path: root.join("src/app.ts"),
1969 export_name: "loader".into(),
1970 line: 12,
1971 col: 4,
1972 },
1973 &root,
1974 )],
1975 }),
1976 ..Default::default()
1977 };
1978
1979 let md = build_health_markdown(&report, &root);
1980 assert!(md.contains("### Coverage Gaps"));
1981 assert!(md.contains("*1 untested files"));
1982 assert!(md.contains("`src/app.ts` (2 value exports)"));
1983 assert!(md.contains("`src/app.ts`:12 `loader`"));
1984 }
1985
1986 #[test]
1987 fn markdown_dep_in_workspace_shows_package_label() {
1988 let root = PathBuf::from("/project");
1989 let mut results = AnalysisResults::default();
1990 results
1991 .unused_dependencies
1992 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1993 package_name: "lodash".to_string(),
1994 location: DependencyLocation::Dependencies,
1995 path: root.join("packages/core/package.json"),
1996 line: 5,
1997 used_in_workspaces: Vec::new(),
1998 }));
1999 let md = build_markdown(&results, &root);
2000 assert!(md.contains("(packages/core/package.json)"));
2001 }
2002
2003 #[test]
2004 fn markdown_dep_at_root_no_extra_label() {
2005 let root = PathBuf::from("/project");
2006 let mut results = AnalysisResults::default();
2007 results
2008 .unused_dependencies
2009 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2010 package_name: "lodash".to_string(),
2011 location: DependencyLocation::Dependencies,
2012 path: root.join("package.json"),
2013 line: 5,
2014 used_in_workspaces: Vec::new(),
2015 }));
2016 let md = build_markdown(&results, &root);
2017 assert!(md.contains("- `lodash`"));
2018 assert!(!md.contains("(package.json)"));
2019 }
2020
2021 #[test]
2022 fn markdown_root_dep_with_cross_workspace_context_uses_context_label() {
2023 let root = PathBuf::from("/project");
2024 let mut results = AnalysisResults::default();
2025 results
2026 .unused_dependencies
2027 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2028 package_name: "lodash-es".to_string(),
2029 location: DependencyLocation::Dependencies,
2030 path: root.join("package.json"),
2031 line: 5,
2032 used_in_workspaces: vec![root.join("packages/consumer")],
2033 }));
2034 let md = build_markdown(&results, &root);
2035 assert!(md.contains("- `lodash-es` (imported in packages/consumer)"));
2036 assert!(!md.contains("(package.json; imported in packages/consumer)"));
2037 }
2038
2039 #[test]
2040 fn markdown_exports_grouped_by_file() {
2041 let root = PathBuf::from("/project");
2042 let mut results = AnalysisResults::default();
2043 results
2044 .unused_exports
2045 .push(UnusedExportFinding::with_actions(UnusedExport {
2046 path: root.join("src/utils.ts"),
2047 export_name: "alpha".to_string(),
2048 is_type_only: false,
2049 line: 5,
2050 col: 0,
2051 span_start: 0,
2052 is_re_export: false,
2053 }));
2054 results
2055 .unused_exports
2056 .push(UnusedExportFinding::with_actions(UnusedExport {
2057 path: root.join("src/utils.ts"),
2058 export_name: "beta".to_string(),
2059 is_type_only: false,
2060 line: 10,
2061 col: 0,
2062 span_start: 0,
2063 is_re_export: false,
2064 }));
2065 results
2066 .unused_exports
2067 .push(UnusedExportFinding::with_actions(UnusedExport {
2068 path: root.join("src/other.ts"),
2069 export_name: "gamma".to_string(),
2070 is_type_only: false,
2071 line: 1,
2072 col: 0,
2073 span_start: 0,
2074 is_re_export: false,
2075 }));
2076 let md = build_markdown(&results, &root);
2077 let utils_count = md.matches("- `src/utils.ts`").count();
2078 assert_eq!(utils_count, 1, "file header should appear once per file");
2079 assert!(md.contains(":5 `alpha`"));
2080 assert!(md.contains(":10 `beta`"));
2081 }
2082
2083 #[test]
2084 fn markdown_multiple_issues_plural() {
2085 let root = PathBuf::from("/project");
2086 let mut results = AnalysisResults::default();
2087 results
2088 .unused_files
2089 .push(UnusedFileFinding::with_actions(UnusedFile {
2090 path: root.join("src/a.ts"),
2091 }));
2092 results
2093 .unused_files
2094 .push(UnusedFileFinding::with_actions(UnusedFile {
2095 path: root.join("src/b.ts"),
2096 }));
2097 let md = build_markdown(&results, &root);
2098 assert!(md.starts_with("## Fallow: 2 issues found\n"));
2099 }
2100
2101 #[test]
2102 fn duplication_markdown_zero_savings_no_suffix() {
2103 let root = PathBuf::from("/project");
2104 let report = DuplicationReport {
2105 clone_groups: vec![CloneGroup {
2106 instances: vec![CloneInstance {
2107 file: root.join("src/a.ts"),
2108 start_line: 1,
2109 end_line: 5,
2110 start_col: 0,
2111 end_col: 0,
2112 fragment: String::new(),
2113 }],
2114 token_count: 30,
2115 line_count: 5,
2116 }],
2117 clone_families: vec![CloneFamily {
2118 files: vec![root.join("src/a.ts")],
2119 groups: vec![],
2120 total_duplicated_lines: 5,
2121 total_duplicated_tokens: 30,
2122 suggestions: vec![RefactoringSuggestion {
2123 kind: RefactoringKind::ExtractFunction,
2124 description: "Extract function".to_string(),
2125 estimated_savings: 0,
2126 }],
2127 }],
2128 mirrored_directories: vec![],
2129 stats: DuplicationStats {
2130 clone_groups: 1,
2131 clone_instances: 1,
2132 duplication_percentage: 1.0,
2133 ..Default::default()
2134 },
2135 };
2136 let md = build_duplication_markdown(&report, &root);
2137 assert!(md.contains("Extract function"));
2138 assert!(!md.contains("lines saved"));
2139 }
2140
2141 #[test]
2142 fn health_markdown_vital_signs_table() {
2143 let root = PathBuf::from("/project");
2144 let report = crate::health_types::HealthReport {
2145 summary: crate::health_types::HealthSummary {
2146 files_analyzed: 10,
2147 functions_analyzed: 50,
2148 ..Default::default()
2149 },
2150 vital_signs: Some(crate::health_types::VitalSigns {
2151 avg_cyclomatic: 3.5,
2152 p90_cyclomatic: 12,
2153 dead_file_pct: Some(5.0),
2154 dead_export_pct: Some(10.2),
2155 duplication_pct: None,
2156 maintainability_avg: Some(72.3),
2157 hotspot_count: Some(3),
2158 circular_dep_count: Some(1),
2159 unused_dep_count: Some(2),
2160 counts: None,
2161 unit_size_profile: None,
2162 unit_interfacing_profile: None,
2163 p95_fan_in: None,
2164 coupling_high_pct: None,
2165 total_loc: 15_200,
2166 ..Default::default()
2167 }),
2168 hotspot_summary: Some(crate::health_types::HotspotSummary {
2169 since: "6 months".to_string(),
2170 min_commits: 3,
2171 files_analyzed: 50,
2172 files_excluded: 0,
2173 shallow_clone: false,
2174 }),
2175 ..Default::default()
2176 };
2177 let md = build_health_markdown(&report, &root);
2178 assert!(md.contains("## Vital Signs"));
2179 assert!(md.contains("| Metric | Value |"));
2180 assert!(md.contains("| Total LOC | 15200 |"));
2181 assert!(md.contains("| Avg Cyclomatic | 3.5 |"));
2182 assert!(md.contains("| P90 Cyclomatic | 12 |"));
2183 assert!(md.contains("| Dead Files | 5.0% |"));
2184 assert!(md.contains("| Dead Exports | 10.2% |"));
2185 assert!(md.contains("| Maintainability (avg) | 72.3 |"));
2186 assert!(md.contains("| Hotspots (since 6 months) | 3 |"));
2187 assert!(md.contains("| Circular Deps | 1 |"));
2188 assert!(md.contains("| Unused Deps | 2 |"));
2189 }
2190
2191 #[test]
2192 fn health_markdown_hotspots_without_summary_omits_window() {
2193 let root = PathBuf::from("/project");
2194 let report = crate::health_types::HealthReport {
2195 vital_signs: Some(crate::health_types::VitalSigns {
2196 avg_cyclomatic: 2.0,
2197 p90_cyclomatic: 5,
2198 hotspot_count: Some(0),
2199 total_loc: 1_000,
2200 ..Default::default()
2201 }),
2202 hotspot_summary: None,
2203 ..Default::default()
2204 };
2205 let md = build_health_markdown(&report, &root);
2206 assert!(md.contains("| Hotspots | 0 |"));
2207 assert!(!md.contains("Hotspots (since"));
2208 }
2209
2210 #[test]
2211 fn health_markdown_file_scores_table() {
2212 let root = PathBuf::from("/project");
2213 let report = crate::health_types::HealthReport {
2214 findings: vec![
2215 crate::health_types::ComplexityViolation {
2216 path: root.join("src/dummy.ts"),
2217 name: "fn".to_string(),
2218 line: 1,
2219 col: 0,
2220 cyclomatic: 25,
2221 cognitive: 20,
2222 line_count: 50,
2223 param_count: 0,
2224 exceeded: crate::health_types::ExceededThreshold::Both,
2225 severity: crate::health_types::FindingSeverity::High,
2226 crap: None,
2227 coverage_pct: None,
2228 coverage_tier: None,
2229 coverage_source: None,
2230 inherited_from: None,
2231 component_rollup: None,
2232 contributions: Vec::new(),
2233 }
2234 .into(),
2235 ],
2236 summary: crate::health_types::HealthSummary {
2237 files_analyzed: 5,
2238 functions_analyzed: 10,
2239 functions_above_threshold: 1,
2240 files_scored: Some(1),
2241 average_maintainability: Some(65.0),
2242 ..Default::default()
2243 },
2244 file_scores: vec![crate::health_types::FileHealthScore {
2245 path: root.join("src/utils.ts"),
2246 fan_in: 5,
2247 fan_out: 3,
2248 dead_code_ratio: 0.25,
2249 complexity_density: 0.8,
2250 maintainability_index: 72.5,
2251 total_cyclomatic: 40,
2252 total_cognitive: 30,
2253 function_count: 10,
2254 lines: 200,
2255 crap_max: 0.0,
2256 crap_above_threshold: 0,
2257 }],
2258 ..Default::default()
2259 };
2260 let md = build_health_markdown(&report, &root);
2261 assert!(md.contains("### File Health Scores (1 files)"));
2262 assert!(md.contains("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density |"));
2263 assert!(md.contains("| `src/utils.ts` | 72.5 | 5 | 3 | 25% | 0.80 |"));
2264 assert!(md.contains("**Average maintainability index:** 65.0/100"));
2265 }
2266
2267 #[test]
2268 fn health_markdown_hotspots_table() {
2269 let root = PathBuf::from("/project");
2270 let report = crate::health_types::HealthReport {
2271 findings: vec![
2272 crate::health_types::ComplexityViolation {
2273 path: root.join("src/dummy.ts"),
2274 name: "fn".to_string(),
2275 line: 1,
2276 col: 0,
2277 cyclomatic: 25,
2278 cognitive: 20,
2279 line_count: 50,
2280 param_count: 0,
2281 exceeded: crate::health_types::ExceededThreshold::Both,
2282 severity: crate::health_types::FindingSeverity::High,
2283 crap: None,
2284 coverage_pct: None,
2285 coverage_tier: None,
2286 coverage_source: None,
2287 inherited_from: None,
2288 component_rollup: None,
2289 contributions: Vec::new(),
2290 }
2291 .into(),
2292 ],
2293 summary: crate::health_types::HealthSummary {
2294 files_analyzed: 5,
2295 functions_analyzed: 10,
2296 functions_above_threshold: 1,
2297 ..Default::default()
2298 },
2299 hotspots: vec![
2300 crate::health_types::HotspotEntry {
2301 path: root.join("src/hot.ts"),
2302 score: 85.0,
2303 commits: 42,
2304 weighted_commits: 35.0,
2305 lines_added: 500,
2306 lines_deleted: 200,
2307 complexity_density: 1.2,
2308 fan_in: 10,
2309 trend: fallow_core::churn::ChurnTrend::Accelerating,
2310 ownership: None,
2311 is_test_path: false,
2312 }
2313 .into(),
2314 ],
2315 hotspot_summary: Some(crate::health_types::HotspotSummary {
2316 since: "6 months".to_string(),
2317 min_commits: 3,
2318 files_analyzed: 50,
2319 files_excluded: 5,
2320 shallow_clone: false,
2321 }),
2322 ..Default::default()
2323 };
2324 let md = build_health_markdown(&report, &root);
2325 assert!(md.contains("### Hotspots (1 files, since 6 months)"));
2326 assert!(md.contains("| `src/hot.ts` | 85.0 | 42 | 700 | 1.20 | 10 | accelerating |"));
2327 assert!(md.contains("*5 files excluded (< 3 commits)*"));
2328 }
2329
2330 #[test]
2331 fn health_markdown_metric_legend_with_scores() {
2332 let root = PathBuf::from("/project");
2333 let report = crate::health_types::HealthReport {
2334 findings: vec![
2335 crate::health_types::ComplexityViolation {
2336 path: root.join("src/x.ts"),
2337 name: "f".to_string(),
2338 line: 1,
2339 col: 0,
2340 cyclomatic: 25,
2341 cognitive: 20,
2342 line_count: 10,
2343 param_count: 0,
2344 exceeded: crate::health_types::ExceededThreshold::Both,
2345 severity: crate::health_types::FindingSeverity::High,
2346 crap: None,
2347 coverage_pct: None,
2348 coverage_tier: None,
2349 coverage_source: None,
2350 inherited_from: None,
2351 component_rollup: None,
2352 contributions: Vec::new(),
2353 }
2354 .into(),
2355 ],
2356 summary: crate::health_types::HealthSummary {
2357 files_analyzed: 1,
2358 functions_analyzed: 1,
2359 functions_above_threshold: 1,
2360 files_scored: Some(1),
2361 average_maintainability: Some(70.0),
2362 ..Default::default()
2363 },
2364 file_scores: vec![crate::health_types::FileHealthScore {
2365 path: root.join("src/x.ts"),
2366 fan_in: 1,
2367 fan_out: 1,
2368 dead_code_ratio: 0.0,
2369 complexity_density: 0.5,
2370 maintainability_index: 80.0,
2371 total_cyclomatic: 10,
2372 total_cognitive: 8,
2373 function_count: 2,
2374 lines: 50,
2375 crap_max: 0.0,
2376 crap_above_threshold: 0,
2377 }],
2378 ..Default::default()
2379 };
2380 let md = build_health_markdown(&report, &root);
2381 assert!(md.contains("<details><summary>Metric definitions</summary>"));
2382 assert!(md.contains("**MI**: Maintainability Index"));
2383 assert!(md.contains("**Fan-in**"));
2384 assert!(md.contains("Full metric reference"));
2385 }
2386
2387 #[test]
2388 fn health_markdown_truncated_findings_shown_count() {
2389 let root = PathBuf::from("/project");
2390 let report = crate::health_types::HealthReport {
2391 findings: vec![
2392 crate::health_types::ComplexityViolation {
2393 path: root.join("src/x.ts"),
2394 name: "f".to_string(),
2395 line: 1,
2396 col: 0,
2397 cyclomatic: 25,
2398 cognitive: 20,
2399 line_count: 10,
2400 param_count: 0,
2401 exceeded: crate::health_types::ExceededThreshold::Both,
2402 severity: crate::health_types::FindingSeverity::High,
2403 crap: None,
2404 coverage_pct: None,
2405 coverage_tier: None,
2406 coverage_source: None,
2407 inherited_from: None,
2408 component_rollup: None,
2409 contributions: Vec::new(),
2410 }
2411 .into(),
2412 ],
2413 summary: crate::health_types::HealthSummary {
2414 files_analyzed: 10,
2415 functions_analyzed: 50,
2416 functions_above_threshold: 5, ..Default::default()
2418 },
2419 ..Default::default()
2420 };
2421 let md = build_health_markdown(&report, &root);
2422 assert!(md.contains("5 high complexity functions (1 shown)"));
2423 }
2424
2425 #[test]
2426 fn escape_backticks_handles_multiple() {
2427 assert_eq!(escape_backticks("a`b`c"), "a\\`b\\`c");
2428 }
2429
2430 #[test]
2431 fn escape_backticks_no_backticks_unchanged() {
2432 assert_eq!(escape_backticks("hello"), "hello");
2433 }
2434
2435 #[test]
2436 fn markdown_unresolved_import_grouped_by_file() {
2437 let root = PathBuf::from("/project");
2438 let mut results = AnalysisResults::default();
2439 results
2440 .unresolved_imports
2441 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
2442 path: root.join("src/app.ts"),
2443 specifier: "./missing".to_string(),
2444 line: 3,
2445 col: 0,
2446 specifier_col: 0,
2447 }));
2448 let md = build_markdown(&results, &root);
2449 assert!(md.contains("### Unresolved imports (1)"));
2450 assert!(md.contains("- `src/app.ts`"));
2451 assert!(md.contains(":3 `./missing`"));
2452 }
2453
2454 #[test]
2455 fn markdown_unused_optional_dep() {
2456 let root = PathBuf::from("/project");
2457 let mut results = AnalysisResults::default();
2458 results
2459 .unused_optional_dependencies
2460 .push(UnusedOptionalDependencyFinding::with_actions(
2461 UnusedDependency {
2462 package_name: "fsevents".to_string(),
2463 location: DependencyLocation::OptionalDependencies,
2464 path: root.join("package.json"),
2465 line: 12,
2466 used_in_workspaces: Vec::new(),
2467 },
2468 ));
2469 let md = build_markdown(&results, &root);
2470 assert!(md.contains("### Unused optionalDependencies (1)"));
2471 assert!(md.contains("- `fsevents`"));
2472 }
2473
2474 #[test]
2475 fn health_markdown_hotspots_no_excluded_message() {
2476 let root = PathBuf::from("/project");
2477 let report = crate::health_types::HealthReport {
2478 findings: vec![
2479 crate::health_types::ComplexityViolation {
2480 path: root.join("src/x.ts"),
2481 name: "f".to_string(),
2482 line: 1,
2483 col: 0,
2484 cyclomatic: 25,
2485 cognitive: 20,
2486 line_count: 10,
2487 param_count: 0,
2488 exceeded: crate::health_types::ExceededThreshold::Both,
2489 severity: crate::health_types::FindingSeverity::High,
2490 crap: None,
2491 coverage_pct: None,
2492 coverage_tier: None,
2493 coverage_source: None,
2494 inherited_from: None,
2495 component_rollup: None,
2496 contributions: Vec::new(),
2497 }
2498 .into(),
2499 ],
2500 summary: crate::health_types::HealthSummary {
2501 files_analyzed: 5,
2502 functions_analyzed: 10,
2503 functions_above_threshold: 1,
2504 ..Default::default()
2505 },
2506 hotspots: vec![
2507 crate::health_types::HotspotEntry {
2508 path: root.join("src/hot.ts"),
2509 score: 50.0,
2510 commits: 10,
2511 weighted_commits: 8.0,
2512 lines_added: 100,
2513 lines_deleted: 50,
2514 complexity_density: 0.5,
2515 fan_in: 3,
2516 trend: fallow_core::churn::ChurnTrend::Stable,
2517 ownership: None,
2518 is_test_path: false,
2519 }
2520 .into(),
2521 ],
2522 hotspot_summary: Some(crate::health_types::HotspotSummary {
2523 since: "6 months".to_string(),
2524 min_commits: 3,
2525 files_analyzed: 50,
2526 files_excluded: 0,
2527 shallow_clone: false,
2528 }),
2529 ..Default::default()
2530 };
2531 let md = build_health_markdown(&report, &root);
2532 assert!(!md.contains("files excluded"));
2533 }
2534
2535 #[test]
2536 fn duplication_markdown_single_group_no_plural() {
2537 let root = PathBuf::from("/project");
2538 let report = DuplicationReport {
2539 clone_groups: vec![CloneGroup {
2540 instances: vec![CloneInstance {
2541 file: root.join("src/a.ts"),
2542 start_line: 1,
2543 end_line: 5,
2544 start_col: 0,
2545 end_col: 0,
2546 fragment: String::new(),
2547 }],
2548 token_count: 30,
2549 line_count: 5,
2550 }],
2551 clone_families: vec![],
2552 mirrored_directories: vec![],
2553 stats: DuplicationStats {
2554 clone_groups: 1,
2555 clone_instances: 1,
2556 duplication_percentage: 2.0,
2557 ..Default::default()
2558 },
2559 };
2560 let md = build_duplication_markdown(&report, &root);
2561 assert!(md.contains("1 clone group found"));
2562 assert!(!md.contains("1 clone groups found"));
2563 }
2564}