Skip to main content

fallow_cli/report/
markdown.rs

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