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