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