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