Skip to main content

fallow_cli/report/
markdown.rs

1use crate::report::sink::{out, outln};
2use std::borrow::Cow;
3use std::fmt::Write;
4use std::path::Path;
5
6use fallow_core::duplicates::DuplicationReport;
7use fallow_core::results::{
8    AnalysisResults, UnresolvedCatalogReferenceFinding, UnusedCatalogEntryFinding,
9    UnusedClassMemberFinding, UnusedDependencyOverrideFinding, UnusedEnumMemberFinding,
10    UnusedExport, UnusedExportFinding, UnusedMember, UnusedStoreMemberFinding, UnusedTypeFinding,
11};
12
13use super::grouping::ResultGroup;
14use super::{normalize_uri, plural, relative_path};
15
16/// Escape backticks in user-controlled strings to prevent breaking markdown code spans.
17fn escape_backticks(s: &str) -> String {
18    s.replace('`', "\\`")
19}
20
21fn display_complexity_entry_name(name: &str) -> Cow<'_, str> {
22    match name {
23        "<template>" => Cow::Borrowed("<template> (template complexity)"),
24        "<component>" => Cow::Borrowed("<component> (component rollup)"),
25        _ => Cow::Borrowed(name),
26    }
27}
28
29pub(super) fn print_markdown(results: &AnalysisResults, root: &Path) {
30    outln!("{}", build_markdown(results, root));
31}
32
33/// Build markdown output for analysis results.
34pub fn build_markdown(results: &AnalysisResults, root: &Path) -> String {
35    let total = results.total_issues();
36    let mut out = String::new();
37
38    if total == 0 {
39        out.push_str("## Fallow: no issues found\n");
40        return out;
41    }
42
43    let _ = write!(out, "## Fallow: {total} issue{} found\n\n", plural(total));
44
45    push_markdown_primary_sections(&mut out, results, root);
46    push_markdown_import_sections(&mut out, results, root);
47    push_markdown_dependency_detail_sections(&mut out, results, root);
48    push_markdown_graph_sections(&mut out, results, &|path| {
49        markdown_relative_path(path, root)
50    });
51    push_markdown_catalog_sections(&mut out, results, &|path| {
52        markdown_relative_path(path, root)
53    });
54
55    out
56}
57
58fn markdown_relative_path(path: &Path, root: &Path) -> String {
59    escape_backticks(&normalize_uri(
60        &relative_path(path, root).display().to_string(),
61    ))
62}
63
64fn push_markdown_primary_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
65    markdown_section(out, &results.unused_files, "Unused files", |file| {
66        vec![format!(
67            "- `{}`",
68            markdown_relative_path(&file.file.path, root)
69        )]
70    });
71
72    markdown_grouped_section(
73        out,
74        &results.unused_exports,
75        "Unused exports",
76        root,
77        |e| e.export.path.as_path(),
78        |e: &UnusedExportFinding| format_export(&e.export),
79    );
80
81    markdown_grouped_section(
82        out,
83        &results.unused_types,
84        "Unused type exports",
85        root,
86        |e| e.export.path.as_path(),
87        |e: &UnusedTypeFinding| format_export(&e.export),
88    );
89
90    markdown_grouped_section(
91        out,
92        &results.private_type_leaks,
93        "Private type leaks",
94        root,
95        |e| e.leak.path.as_path(),
96        format_private_type_leak,
97    );
98
99    push_markdown_dependency_sections(out, results, root);
100    push_markdown_member_sections(out, results, root);
101}
102
103fn push_markdown_import_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
104    markdown_grouped_section(
105        out,
106        &results.unresolved_imports,
107        "Unresolved imports",
108        root,
109        |i| i.import.path.as_path(),
110        |i| {
111            format!(
112                ":{} `{}`",
113                i.import.line,
114                escape_backticks(&i.import.specifier)
115            )
116        },
117    );
118
119    markdown_section(
120        out,
121        &results.unlisted_dependencies,
122        "Unlisted dependencies",
123        |dep| vec![format!("- `{}`", escape_backticks(&dep.dep.package_name))],
124    );
125
126    markdown_section(
127        out,
128        &results.duplicate_exports,
129        "Duplicate exports",
130        |dup| {
131            let locations: Vec<String> = dup
132                .export
133                .locations
134                .iter()
135                .map(|loc| format!("`{}`", markdown_relative_path(&loc.path, root)))
136                .collect();
137            vec![format!(
138                "- `{}` in {}",
139                escape_backticks(&dup.export.export_name),
140                locations.join(", ")
141            )]
142        },
143    );
144}
145
146fn push_markdown_dependency_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
147    markdown_section(
148        out,
149        &results.unused_dependencies,
150        "Unused dependencies",
151        |dep| {
152            format_dependency(
153                &dep.dep.package_name,
154                &dep.dep.path,
155                &dep.dep.used_in_workspaces,
156                root,
157            )
158        },
159    );
160    markdown_section(
161        out,
162        &results.unused_dev_dependencies,
163        "Unused devDependencies",
164        |dep| {
165            format_dependency(
166                &dep.dep.package_name,
167                &dep.dep.path,
168                &dep.dep.used_in_workspaces,
169                root,
170            )
171        },
172    );
173    markdown_section(
174        out,
175        &results.unused_optional_dependencies,
176        "Unused optionalDependencies",
177        |dep| {
178            format_dependency(
179                &dep.dep.package_name,
180                &dep.dep.path,
181                &dep.dep.used_in_workspaces,
182                root,
183            )
184        },
185    );
186}
187
188fn push_markdown_member_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
189    markdown_grouped_section(
190        out,
191        &results.unused_enum_members,
192        "Unused enum members",
193        root,
194        |m| m.member.path.as_path(),
195        |m: &UnusedEnumMemberFinding| format_member(&m.member),
196    );
197    markdown_grouped_section(
198        out,
199        &results.unused_class_members,
200        "Unused class members",
201        root,
202        |m| m.member.path.as_path(),
203        |m: &UnusedClassMemberFinding| format_member(&m.member),
204    );
205    markdown_grouped_section(
206        out,
207        &results.unused_store_members,
208        "Unused store members",
209        root,
210        |m| m.member.path.as_path(),
211        |m: &UnusedStoreMemberFinding| format_member(&m.member),
212    );
213}
214
215fn push_markdown_dependency_detail_sections(
216    out: &mut String,
217    results: &AnalysisResults,
218    root: &Path,
219) {
220    markdown_section(
221        out,
222        &results.type_only_dependencies,
223        "Type-only dependencies (consider moving to devDependencies)",
224        |dep| format_dependency(&dep.dep.package_name, &dep.dep.path, &[], root),
225    );
226    markdown_section(
227        out,
228        &results.test_only_dependencies,
229        "Test-only production dependencies (consider moving to devDependencies)",
230        |dep| format_dependency(&dep.dep.package_name, &dep.dep.path, &[], root),
231    );
232}
233
234fn push_markdown_graph_sections(
235    out: &mut String,
236    results: &AnalysisResults,
237    rel: &dyn Fn(&Path) -> String,
238) {
239    push_markdown_structure_sections(out, results, rel);
240    push_markdown_framework_sections(out, results, rel);
241    push_markdown_component_sections(out, results, rel);
242    push_markdown_suppression_sections(out, results, rel);
243}
244
245fn push_markdown_structure_sections(
246    out: &mut String,
247    results: &AnalysisResults,
248    rel: &dyn Fn(&Path) -> String,
249) {
250    markdown_section(
251        out,
252        &results.circular_dependencies,
253        "Circular dependencies",
254        |cycle| format_markdown_circular_dependency(cycle, rel),
255    );
256    markdown_section(
257        out,
258        &results.re_export_cycles,
259        "Re-export cycles",
260        |cycle| format_markdown_re_export_cycle(cycle, rel),
261    );
262    markdown_section(
263        out,
264        &results.boundary_violations,
265        "Boundary violations",
266        |v| format_markdown_boundary_violation(v, rel),
267    );
268    markdown_section(
269        out,
270        &results.boundary_coverage_violations,
271        "Boundary coverage",
272        |v| format_markdown_boundary_coverage(v, rel),
273    );
274    markdown_section(
275        out,
276        &results.boundary_call_violations,
277        "Boundary calls",
278        |v| format_markdown_boundary_call(v, rel),
279    );
280    markdown_section(out, &results.policy_violations, "Policy violations", |v| {
281        format_markdown_policy_violation(v, rel)
282    });
283}
284
285fn push_markdown_framework_sections(
286    out: &mut String,
287    results: &AnalysisResults,
288    rel: &dyn Fn(&Path) -> String,
289) {
290    markdown_section(
291        out,
292        &results.invalid_client_exports,
293        "Invalid client exports",
294        |e| format_markdown_invalid_client_export(e, rel),
295    );
296    markdown_section(
297        out,
298        &results.mixed_client_server_barrels,
299        "Mixed client/server barrels",
300        |b| format_markdown_mixed_client_server_barrel(b, rel),
301    );
302    markdown_section(
303        out,
304        &results.misplaced_directives,
305        "Misplaced directives",
306        |d| format_markdown_misplaced_directive(d, rel),
307    );
308    markdown_section(out, &results.route_collisions, "Route collisions", |c| {
309        format_markdown_route_collision(c, rel)
310    });
311    markdown_section(
312        out,
313        &results.dynamic_segment_name_conflicts,
314        "Dynamic segment conflicts",
315        |c| format_markdown_dynamic_segment_name_conflict(c, rel),
316    );
317    markdown_section(
318        out,
319        &results.unprovided_injects,
320        "Unprovided injects",
321        |i| format_markdown_unprovided_inject(i, rel),
322    );
323}
324
325fn push_markdown_component_sections(
326    out: &mut String,
327    results: &AnalysisResults,
328    rel: &dyn Fn(&Path) -> String,
329) {
330    markdown_section(
331        out,
332        &results.unrendered_components,
333        "Unrendered components",
334        |c| format_markdown_unrendered_component(c, rel),
335    );
336    markdown_section(
337        out,
338        &results.unused_component_props,
339        "Unused component props",
340        |p| format_markdown_unused_component_prop(p, rel),
341    );
342    markdown_section(
343        out,
344        &results.unused_component_emits,
345        "Unused component emits",
346        |e| format_markdown_unused_component_emit(e, rel),
347    );
348    markdown_section(
349        out,
350        &results.unused_component_inputs,
351        "Unused component inputs",
352        |i| format_markdown_unused_component_input(i, rel),
353    );
354    markdown_section(
355        out,
356        &results.unused_component_outputs,
357        "Unused component outputs",
358        |o| format_markdown_unused_component_output(o, rel),
359    );
360    markdown_section(
361        out,
362        &results.unused_svelte_events,
363        "Unused Svelte events",
364        |e| format_markdown_unused_svelte_event(e, rel),
365    );
366    markdown_section(
367        out,
368        &results.unused_server_actions,
369        "Unused server actions",
370        |a| format_markdown_unused_server_action(a, rel),
371    );
372    markdown_section(
373        out,
374        &results.unused_load_data_keys,
375        "Unused load data keys",
376        |k| format_markdown_unused_load_data_key(k, rel),
377    );
378}
379
380fn push_markdown_suppression_sections(
381    out: &mut String,
382    results: &AnalysisResults,
383    rel: &dyn Fn(&Path) -> String,
384) {
385    markdown_section(
386        out,
387        &results.stale_suppressions,
388        "Stale suppressions",
389        |s| {
390            vec![format!(
391                "- `{}`:{} `{}` ({})",
392                rel(&s.path),
393                s.line,
394                escape_backticks(&s.description()),
395                escape_backticks(&s.explanation()),
396            )]
397        },
398    );
399}
400
401fn format_markdown_circular_dependency(
402    cycle: &fallow_core::results::CircularDependencyFinding,
403    rel: &dyn Fn(&Path) -> String,
404) -> Vec<String> {
405    let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
406    let mut display_chain = chain.clone();
407    if let Some(first) = chain.first() {
408        display_chain.push(first.clone());
409    }
410    let cross_pkg_tag = if cycle.cycle.is_cross_package {
411        " *(cross-package)*"
412    } else {
413        ""
414    };
415    vec![format!(
416        "- {}{}",
417        display_chain
418            .iter()
419            .map(|s| format!("`{s}`"))
420            .collect::<Vec<_>>()
421            .join(" \u{2192} "),
422        cross_pkg_tag
423    )]
424}
425
426fn format_markdown_re_export_cycle(
427    cycle: &fallow_core::results::ReExportCycleFinding,
428    rel: &dyn Fn(&Path) -> String,
429) -> Vec<String> {
430    let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
431    let kind_tag = match cycle.cycle.kind {
432        fallow_core::results::ReExportCycleKind::SelfLoop => " *(self-loop)*",
433        fallow_core::results::ReExportCycleKind::MultiNode => "",
434    };
435    vec![format!(
436        "- {}{}",
437        chain
438            .iter()
439            .map(|s| format!("`{s}`"))
440            .collect::<Vec<_>>()
441            .join(" <-> "),
442        kind_tag
443    )]
444}
445
446fn format_markdown_boundary_violation(
447    v: &fallow_core::results::BoundaryViolationFinding,
448    rel: &dyn Fn(&Path) -> String,
449) -> Vec<String> {
450    vec![format!(
451        "- `{}`:{}  \u{2192} `{}` ({} \u{2192} {})",
452        rel(&v.violation.from_path),
453        v.violation.line,
454        rel(&v.violation.to_path),
455        v.violation.from_zone,
456        v.violation.to_zone,
457    )]
458}
459
460fn format_markdown_boundary_coverage(
461    v: &fallow_core::results::BoundaryCoverageViolationFinding,
462    rel: &dyn Fn(&Path) -> String,
463) -> Vec<String> {
464    vec![format!(
465        "- `{}`:{} no matching boundary zone",
466        rel(&v.violation.path),
467        v.violation.line,
468    )]
469}
470
471fn format_markdown_boundary_call(
472    v: &fallow_core::results::BoundaryCallViolationFinding,
473    rel: &dyn Fn(&Path) -> String,
474) -> Vec<String> {
475    vec![format!(
476        "- `{}`:{} `{}` forbidden in zone `{}` (pattern `{}`)",
477        rel(&v.violation.path),
478        v.violation.line,
479        v.violation.callee,
480        v.violation.zone,
481        v.violation.pattern,
482    )]
483}
484
485fn format_markdown_policy_violation(
486    v: &fallow_core::results::PolicyViolationFinding,
487    rel: &dyn Fn(&Path) -> String,
488) -> Vec<String> {
489    vec![format!(
490        "- `{}`:{} `{}` banned by `{}/{}`{}",
491        rel(&v.violation.path),
492        v.violation.line,
493        v.violation.matched,
494        v.violation.pack,
495        v.violation.rule_id,
496        v.violation
497            .message
498            .as_deref()
499            .map(|m| format!(" ({m})"))
500            .unwrap_or_default(),
501    )]
502}
503
504fn format_markdown_invalid_client_export(
505    e: &fallow_core::results::InvalidClientExportFinding,
506    rel: &dyn Fn(&Path) -> String,
507) -> Vec<String> {
508    vec![format!(
509        "- `{}`:{} `{}` (from `\"{}\"`)",
510        rel(&e.export.path),
511        e.export.line,
512        e.export.export_name,
513        e.export.directive,
514    )]
515}
516
517fn format_markdown_mixed_client_server_barrel(
518    b: &fallow_core::results::MixedClientServerBarrelFinding,
519    rel: &dyn Fn(&Path) -> String,
520) -> Vec<String> {
521    vec![format!(
522        "- `{}`:{} re-exports client `{}` and server-only `{}`",
523        rel(&b.barrel.path),
524        b.barrel.line,
525        b.barrel.client_origin,
526        b.barrel.server_origin,
527    )]
528}
529
530fn format_markdown_misplaced_directive(
531    d: &fallow_core::results::MisplacedDirectiveFinding,
532    rel: &dyn Fn(&Path) -> String,
533) -> Vec<String> {
534    vec![format!(
535        "- `{}`:{} `\"{}\"` is not in the leading position and is ignored",
536        rel(&d.directive_site.path),
537        d.directive_site.line,
538        d.directive_site.directive,
539    )]
540}
541
542fn format_markdown_unprovided_inject(
543    i: &fallow_core::results::UnprovidedInjectFinding,
544    rel: &dyn Fn(&Path) -> String,
545) -> Vec<String> {
546    vec![format!(
547        "- `{}`:{} `{}` has no matching provide(`{}`) in this project; at runtime it returns undefined",
548        rel(&i.inject.path),
549        i.inject.line,
550        escape_backticks(&i.inject.key_name),
551        escape_backticks(&i.inject.key_name),
552    )]
553}
554
555fn format_markdown_unrendered_component(
556    c: &fallow_core::results::UnrenderedComponentFinding,
557    rel: &dyn Fn(&Path) -> String,
558) -> Vec<String> {
559    vec![format!(
560        "- `{}`:{} `{}` is reachable but rendered nowhere in this project (render it somewhere or remove it)",
561        rel(&c.component.path),
562        c.component.line,
563        escape_backticks(&c.component.component_name),
564    )]
565}
566
567fn format_markdown_unused_component_prop(
568    p: &fallow_core::results::UnusedComponentPropFinding,
569    rel: &dyn Fn(&Path) -> String,
570) -> Vec<String> {
571    vec![format!(
572        "- `{}`:{} `{}` is declared but referenced nowhere in this component (remove it or use it)",
573        rel(&p.prop.path),
574        p.prop.line,
575        escape_backticks(&p.prop.prop_name),
576    )]
577}
578
579fn format_markdown_unused_component_emit(
580    e: &fallow_core::results::UnusedComponentEmitFinding,
581    rel: &dyn Fn(&Path) -> String,
582) -> Vec<String> {
583    vec![format!(
584        "- `{}`:{} `{}` is declared but emitted nowhere in this component (remove it or emit it)",
585        rel(&e.emit.path),
586        e.emit.line,
587        escape_backticks(&e.emit.emit_name),
588    )]
589}
590
591fn format_markdown_unused_svelte_event(
592    e: &fallow_core::results::UnusedSvelteEventFinding,
593    rel: &dyn Fn(&Path) -> String,
594) -> Vec<String> {
595    vec![format!(
596        "- `{}`:{} `{}` is dispatched but listened to nowhere in the project (remove it or listen for it)",
597        rel(&e.event.path),
598        e.event.line,
599        escape_backticks(&e.event.event_name),
600    )]
601}
602
603fn format_markdown_unused_component_input(
604    i: &fallow_core::results::UnusedComponentInputFinding,
605    rel: &dyn Fn(&Path) -> String,
606) -> Vec<String> {
607    vec![format!(
608        "- `{}`:{} `{}` is declared but referenced nowhere in this component (remove it or use it)",
609        rel(&i.input.path),
610        i.input.line,
611        escape_backticks(&i.input.input_name),
612    )]
613}
614
615fn format_markdown_unused_component_output(
616    o: &fallow_core::results::UnusedComponentOutputFinding,
617    rel: &dyn Fn(&Path) -> String,
618) -> Vec<String> {
619    vec![format!(
620        "- `{}`:{} `{}` is declared but emitted nowhere in this component (remove it or emit it)",
621        rel(&o.output.path),
622        o.output.line,
623        escape_backticks(&o.output.output_name),
624    )]
625}
626
627fn format_markdown_unused_server_action(
628    a: &fallow_core::results::UnusedServerActionFinding,
629    rel: &dyn Fn(&Path) -> String,
630) -> Vec<String> {
631    vec![format!(
632        "- `{}`:{} `{}` is exported from a \"use server\" file but no code in this project references it",
633        rel(&a.action.path),
634        a.action.line,
635        escape_backticks(&a.action.action_name),
636    )]
637}
638
639fn format_markdown_unused_load_data_key(
640    k: &fallow_core::results::UnusedLoadDataKeyFinding,
641    rel: &dyn Fn(&Path) -> String,
642) -> Vec<String> {
643    vec![format!(
644        "- `{}`:{} `{}` is returned from load() but no consumer reads it",
645        rel(&k.key.path),
646        k.key.line,
647        escape_backticks(&k.key.key_name),
648    )]
649}
650
651fn format_markdown_route_collision(
652    c: &fallow_core::results::RouteCollisionFinding,
653    rel: &dyn Fn(&Path) -> String,
654) -> Vec<String> {
655    vec![format!(
656        "- `{}` resolves to `{}` (shared with {} other route file(s))",
657        rel(&c.collision.path),
658        c.collision.url,
659        c.collision.conflicting_paths.len(),
660    )]
661}
662
663fn format_markdown_dynamic_segment_name_conflict(
664    c: &fallow_core::results::DynamicSegmentNameConflictFinding,
665    rel: &dyn Fn(&Path) -> String,
666) -> Vec<String> {
667    vec![format!(
668        "- `{}` crashes at runtime: different slug names ({}) at the same dynamic path `{}`; \
669         `next build` passes but the route fails on its first request (rename to one consistent slug)",
670        rel(&c.conflict.path),
671        c.conflict.conflicting_segments.join(" vs "),
672        c.conflict.position,
673    )]
674}
675
676fn push_markdown_catalog_sections(
677    out: &mut String,
678    results: &AnalysisResults,
679    rel: &dyn Fn(&Path) -> String,
680) {
681    markdown_section(
682        out,
683        &results.unused_catalog_entries,
684        "Unused catalog entries",
685        |entry| format_unused_catalog_entry(entry, rel),
686    );
687    markdown_section(
688        out,
689        &results.empty_catalog_groups,
690        "Empty catalog groups",
691        |group| {
692            vec![format!(
693                "- `{}` `{}`:{}",
694                escape_backticks(&group.group.catalog_name),
695                rel(&group.group.path),
696                group.group.line,
697            )]
698        },
699    );
700    markdown_section(
701        out,
702        &results.unresolved_catalog_references,
703        "Unresolved catalog references",
704        |finding| format_unresolved_catalog_reference(finding, rel),
705    );
706    markdown_section(
707        out,
708        &results.unused_dependency_overrides,
709        "Unused dependency overrides",
710        |finding| format_unused_dependency_override(finding, rel),
711    );
712    markdown_section(
713        out,
714        &results.misconfigured_dependency_overrides,
715        "Misconfigured dependency overrides",
716        |finding| {
717            vec![format!(
718                "- `{}` -> `{}` (`{}`) `{}`:{} ({})",
719                escape_backticks(&finding.entry.raw_key),
720                escape_backticks(&finding.entry.raw_value),
721                finding.entry.source.as_label(),
722                rel(&finding.entry.path),
723                finding.entry.line,
724                finding.entry.reason.describe(),
725            )]
726        },
727    );
728}
729
730fn format_unused_catalog_entry(
731    entry: &UnusedCatalogEntryFinding,
732    rel: &dyn Fn(&Path) -> String,
733) -> Vec<String> {
734    let mut row = format!(
735        "- `{}` (`{}`) `{}`:{}",
736        escape_backticks(&entry.entry.entry_name),
737        escape_backticks(&entry.entry.catalog_name),
738        rel(&entry.entry.path),
739        entry.entry.line,
740    );
741    if !entry.entry.hardcoded_consumers.is_empty() {
742        let consumers = entry
743            .entry
744            .hardcoded_consumers
745            .iter()
746            .map(|p| format!("`{}`", rel(p)))
747            .collect::<Vec<_>>()
748            .join(", ");
749        let _ = write!(row, " (hardcoded in {consumers})");
750    }
751    vec![row]
752}
753
754fn format_unresolved_catalog_reference(
755    finding: &UnresolvedCatalogReferenceFinding,
756    rel: &dyn Fn(&Path) -> String,
757) -> Vec<String> {
758    let mut row = format!(
759        "- `{}` (`{}`) `{}`:{}",
760        escape_backticks(&finding.reference.entry_name),
761        escape_backticks(&finding.reference.catalog_name),
762        rel(&finding.reference.path),
763        finding.reference.line,
764    );
765    if !finding.reference.available_in_catalogs.is_empty() {
766        let alts = finding
767            .reference
768            .available_in_catalogs
769            .iter()
770            .map(|c| format!("`{}`", escape_backticks(c)))
771            .collect::<Vec<_>>()
772            .join(", ");
773        let _ = write!(row, " (available in: {alts})");
774    }
775    vec![row]
776}
777
778fn format_unused_dependency_override(
779    finding: &UnusedDependencyOverrideFinding,
780    rel: &dyn Fn(&Path) -> String,
781) -> Vec<String> {
782    let mut row = format!(
783        "- `{}` -> `{}` (`{}`) `{}`:{}",
784        escape_backticks(&finding.entry.raw_key),
785        escape_backticks(&finding.entry.version_range),
786        finding.entry.source.as_label(),
787        rel(&finding.entry.path),
788        finding.entry.line,
789    );
790    if let Some(hint) = &finding.entry.hint {
791        let _ = write!(row, " (hint: {})", escape_backticks(hint));
792    }
793    vec![row]
794}
795
796/// Print grouped markdown output: each group gets an `## owner (N issues)` heading.
797pub(super) fn print_grouped_markdown(groups: &[ResultGroup], root: &Path) {
798    let total: usize = groups.iter().map(|g| g.results.total_issues()).sum();
799
800    if total == 0 {
801        outln!("## Fallow: no issues found");
802        return;
803    }
804
805    outln!(
806        "## Fallow: {total} issue{} found (grouped)\n",
807        plural(total)
808    );
809
810    for group in groups {
811        let count = group.results.total_issues();
812        if count == 0 {
813            continue;
814        }
815        outln!(
816            "## {} ({count} issue{})\n",
817            escape_backticks(&group.key),
818            plural(count)
819        );
820        if let Some(ref owners) = group.owners
821            && !owners.is_empty()
822        {
823            let joined = owners
824                .iter()
825                .map(|o| escape_backticks(o))
826                .collect::<Vec<_>>()
827                .join(" ");
828            outln!("Owners: {joined}\n");
829        }
830        let body = build_markdown(&group.results, root);
831        let sections = body
832            .strip_prefix("## Fallow: no issues found\n")
833            .or_else(|| body.find("\n\n").map(|pos| &body[pos + 2..]))
834            .unwrap_or(&body);
835        out!("{sections}");
836    }
837}
838
839fn format_export(e: &UnusedExport) -> String {
840    let re = if e.is_re_export { " (re-export)" } else { "" };
841    format!(":{} `{}`{re}", e.line, escape_backticks(&e.export_name))
842}
843
844fn format_private_type_leak(
845    entry: &fallow_types::output_dead_code::PrivateTypeLeakFinding,
846) -> String {
847    let e = &entry.leak;
848    format!(
849        ":{} `{}` references private type `{}`",
850        e.line,
851        escape_backticks(&e.export_name),
852        escape_backticks(&e.type_name)
853    )
854}
855
856fn format_member(m: &UnusedMember) -> String {
857    format!(
858        ":{} `{}.{}`",
859        m.line,
860        escape_backticks(&m.parent_name),
861        escape_backticks(&m.member_name)
862    )
863}
864
865fn format_dependency(
866    dep_name: &str,
867    pkg_path: &Path,
868    used_in_workspaces: &[std::path::PathBuf],
869    root: &Path,
870) -> Vec<String> {
871    let name = escape_backticks(dep_name);
872    let pkg_label = relative_path(pkg_path, root).display().to_string();
873    let workspace_context = if used_in_workspaces.is_empty() {
874        String::new()
875    } else {
876        let workspaces = used_in_workspaces
877            .iter()
878            .map(|path| escape_backticks(&relative_path(path, root).display().to_string()))
879            .collect::<Vec<_>>()
880            .join(", ");
881        format!("; imported in {workspaces}")
882    };
883    if pkg_label == "package.json" && workspace_context.is_empty() {
884        vec![format!("- `{name}`")]
885    } else {
886        let label = if pkg_label == "package.json" {
887            workspace_context.trim_start_matches("; ").to_string()
888        } else {
889            format!("{}{workspace_context}", escape_backticks(&pkg_label))
890        };
891        vec![format!("- `{name}` ({label})")]
892    }
893}
894
895/// Emit a markdown section with a header and per-item lines. Skipped if empty.
896fn markdown_section<T>(
897    out: &mut String,
898    items: &[T],
899    title: &str,
900    format_lines: impl Fn(&T) -> Vec<String>,
901) {
902    if items.is_empty() {
903        return;
904    }
905    let _ = write!(out, "### {title} ({})\n\n", items.len());
906    for item in items {
907        for line in format_lines(item) {
908            out.push_str(&line);
909            out.push('\n');
910        }
911    }
912    out.push('\n');
913}
914
915/// Emit a markdown section whose items are grouped by file path.
916fn markdown_grouped_section<'a, T>(
917    out: &mut String,
918    items: &'a [T],
919    title: &str,
920    root: &Path,
921    get_path: impl Fn(&'a T) -> &'a Path,
922    format_detail: impl Fn(&T) -> String,
923) {
924    if items.is_empty() {
925        return;
926    }
927    let _ = write!(out, "### {title} ({})\n\n", items.len());
928
929    let mut indices: Vec<usize> = (0..items.len()).collect();
930    indices.sort_by(|&a, &b| get_path(&items[a]).cmp(get_path(&items[b])));
931
932    let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
933    let mut last_file = String::new();
934    for &i in &indices {
935        let item = &items[i];
936        let file_str = rel(get_path(item));
937        if file_str != last_file {
938            let _ = writeln!(out, "- `{file_str}`");
939            last_file = file_str;
940        }
941        let _ = writeln!(out, "  - {}", format_detail(item));
942    }
943    out.push('\n');
944}
945
946pub(super) fn print_duplication_markdown(report: &DuplicationReport, root: &Path) {
947    outln!("{}", build_duplication_markdown(report, root));
948}
949
950/// Build markdown output for duplication results.
951#[must_use]
952pub fn build_duplication_markdown(report: &DuplicationReport, root: &Path) -> String {
953    let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
954
955    let mut out = String::new();
956
957    if report.clone_groups.is_empty() {
958        out.push_str("## Fallow: no code duplication found\n");
959        return out;
960    }
961
962    let stats = &report.stats;
963    let _ = write!(
964        out,
965        "## Fallow: {} clone group{} found ({:.1}% duplication)\n\n",
966        stats.clone_groups,
967        plural(stats.clone_groups),
968        stats.duplication_percentage,
969    );
970
971    out.push_str("### Duplicates\n\n");
972    for (i, group) in report.clone_groups.iter().enumerate() {
973        let instance_count = group.instances.len();
974        let _ = write!(
975            out,
976            "**Clone group {}** ({} lines, {instance_count} instance{})\n\n",
977            i + 1,
978            group.line_count,
979            plural(instance_count)
980        );
981        for instance in &group.instances {
982            let relative = rel(&instance.file);
983            let _ = writeln!(
984                out,
985                "- `{relative}:{}-{}`",
986                instance.start_line, instance.end_line
987            );
988        }
989        out.push('\n');
990    }
991
992    if !report.clone_families.is_empty() {
993        out.push_str("### Clone Families\n\n");
994        for (i, family) in report.clone_families.iter().enumerate() {
995            let file_names: Vec<_> = family.files.iter().map(|f| rel(f)).collect();
996            let _ = write!(
997                out,
998                "**Family {}** ({} group{}, {} lines across {})\n\n",
999                i + 1,
1000                family.groups.len(),
1001                plural(family.groups.len()),
1002                family.total_duplicated_lines,
1003                file_names
1004                    .iter()
1005                    .map(|s| format!("`{s}`"))
1006                    .collect::<Vec<_>>()
1007                    .join(", "),
1008            );
1009            for suggestion in &family.suggestions {
1010                let savings = if suggestion.estimated_savings > 0 {
1011                    format!(" (~{} lines saved)", suggestion.estimated_savings)
1012                } else {
1013                    String::new()
1014                };
1015                let _ = writeln!(out, "- {}{savings}", suggestion.description);
1016            }
1017            out.push('\n');
1018        }
1019    }
1020
1021    let _ = writeln!(
1022        out,
1023        "**Summary:** {} duplicated lines ({:.1}%) across {} file{}",
1024        stats.duplicated_lines,
1025        stats.duplication_percentage,
1026        stats.files_with_clones,
1027        plural(stats.files_with_clones),
1028    );
1029
1030    out
1031}
1032
1033pub(super) fn print_health_markdown(report: &crate::health_types::HealthReport, root: &Path) {
1034    outln!("{}", build_health_markdown(report, root));
1035}
1036
1037/// Build markdown output for health (complexity) results.
1038#[must_use]
1039pub fn build_health_markdown(report: &crate::health_types::HealthReport, root: &Path) -> String {
1040    let mut out = String::new();
1041
1042    if let Some(ref hs) = report.health_score {
1043        let _ = writeln!(out, "## Health Score: {:.0} ({})\n", hs.score, hs.grade);
1044    }
1045
1046    write_trend_section(&mut out, report);
1047    write_vital_signs_section(&mut out, report);
1048
1049    if report.findings.is_empty()
1050        && report.file_scores.is_empty()
1051        && report.coverage_gaps.is_none()
1052        && report.hotspots.is_empty()
1053        && report.targets.is_empty()
1054        && report.runtime_coverage.is_none()
1055        && report.coverage_intelligence.is_none()
1056        && report.threshold_overrides.is_empty()
1057        && report.css_analytics.is_none()
1058    {
1059        if report.vital_signs.is_none() {
1060            let _ = write!(
1061                out,
1062                "## Fallow: no functions exceed complexity thresholds\n\n\
1063                 **{}** functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {:.1})\n",
1064                report.summary.functions_analyzed,
1065                report.summary.max_cyclomatic_threshold,
1066                report.summary.max_cognitive_threshold,
1067                report.summary.max_crap_threshold,
1068            );
1069        }
1070        return out;
1071    }
1072
1073    write_findings_section(&mut out, report, root);
1074    write_threshold_overrides_section(&mut out, report, root);
1075    write_runtime_coverage_section(&mut out, report, root);
1076    write_coverage_intelligence_section(&mut out, report, root);
1077    write_coverage_gaps_section(&mut out, report, root);
1078    write_file_scores_section(&mut out, report, root);
1079    write_hotspots_section(&mut out, report, root);
1080    write_targets_section(&mut out, report, root);
1081    write_css_analytics_section(&mut out, report);
1082    write_metric_legend(&mut out, report);
1083
1084    out
1085}
1086
1087/// Render the opt-in `## CSS Health` markdown section (present only with
1088/// `--css`): a summary of structural metrics, value sprawl, and candidate counts
1089/// plus a bounded list of the most actionable located candidates.
1090fn write_css_analytics_section(out: &mut String, report: &crate::health_types::HealthReport) {
1091    let Some(ref css) = report.css_analytics else {
1092        return;
1093    };
1094    let s = &css.summary;
1095    if !out.is_empty() && !out.ends_with("\n\n") {
1096        out.push('\n');
1097    }
1098    out.push_str("## CSS Health\n\n");
1099    let important_pct = if s.total_declarations > 0 {
1100        f64::from(s.important_declarations) / f64::from(s.total_declarations) * 100.0
1101    } else {
1102        0.0
1103    };
1104    let _ = writeln!(
1105        out,
1106        "- Stylesheets: {} | Rules: {} | !important: {important_pct:.1}% | Empty rules: {} | Max nesting: {}",
1107        s.files_analyzed, s.total_rules, s.empty_rules, s.max_nesting_depth,
1108    );
1109    let _ = writeln!(
1110        out,
1111        "- Value sprawl: {} colors | {} font sizes | {} z-index | {} shadows | {} radii | {} line-heights",
1112        s.unique_colors,
1113        s.unique_font_sizes,
1114        s.unique_z_indexes,
1115        s.unique_box_shadows,
1116        s.unique_border_radii,
1117        s.unique_line_heights,
1118    );
1119    let _ = writeln!(
1120        out,
1121        "- Candidates: {} unreferenced + {} undefined @keyframes | {} duplicate blocks | {} scoped-unused classes | {} Tailwind arbitrary values | {} unused @property | {} unused @layer | {} likely class typos | {} unreferenced classes | {} unused @font-face | {} unused @theme tokens",
1122        s.keyframes_unreferenced,
1123        s.keyframes_undefined,
1124        s.duplicate_declaration_blocks,
1125        s.scoped_unused_classes,
1126        s.tailwind_arbitrary_values,
1127        s.unused_property_registrations,
1128        s.unused_layers,
1129        s.unresolved_class_references,
1130        s.unreferenced_css_classes,
1131        s.unused_font_faces,
1132        s.unused_theme_tokens,
1133    );
1134    write_css_candidate_details(out, css);
1135    out.push('\n');
1136}
1137
1138fn write_css_candidate_details(out: &mut String, css: &crate::health_types::CssAnalyticsReport) {
1139    write_css_keyframe_details(out, css);
1140    write_css_tailwind_details(out, css);
1141    write_css_class_candidate_details(out, css);
1142    write_css_font_candidate_details(out, css);
1143    write_css_font_size_mix_details(out, css);
1144}
1145
1146fn write_css_keyframe_details(out: &mut String, css: &crate::health_types::CssAnalyticsReport) {
1147    if !css.undefined_keyframes.is_empty() {
1148        let named: Vec<String> = css
1149            .undefined_keyframes
1150            .iter()
1151            .take(5)
1152            .map(|kf| format!("`{}` ({})", kf.name, kf.path))
1153            .collect();
1154        let _ = writeln!(
1155            out,
1156            "- Undefined @keyframes (candidates; likely typo or CSS-in-JS): {}",
1157            named.join(", "),
1158        );
1159    }
1160}
1161
1162fn write_css_tailwind_details(out: &mut String, css: &crate::health_types::CssAnalyticsReport) {
1163    if !css.tailwind_arbitrary_values.is_empty() {
1164        let named: Vec<String> = css
1165            .tailwind_arbitrary_values
1166            .iter()
1167            .take(5)
1168            .map(|a| format!("`{}` ({}x)", a.value, a.count))
1169            .collect();
1170        let _ = writeln!(out, "- Top Tailwind arbitrary values: {}", named.join(", "));
1171    }
1172}
1173
1174fn write_css_class_candidate_details(
1175    out: &mut String,
1176    css: &crate::health_types::CssAnalyticsReport,
1177) {
1178    if !css.unresolved_class_references.is_empty() {
1179        let named: Vec<String> = css
1180            .unresolved_class_references
1181            .iter()
1182            .take(5)
1183            .map(|u| {
1184                format!(
1185                    "`{}` -> `{}` ({}:{})",
1186                    u.class, u.suggestion, u.path, u.line
1187                )
1188            })
1189            .collect();
1190        let _ = writeln!(
1191            out,
1192            "- Likely class typos (candidates; verify, may be CSS-in-JS or external): {}",
1193            named.join(", "),
1194        );
1195    }
1196    if !css.unreferenced_css_classes.is_empty() {
1197        let named: Vec<String> = css
1198            .unreferenced_css_classes
1199            .iter()
1200            .take(5)
1201            .map(|u| format!("`.{}` ({}:{})", u.class, u.path, u.line))
1202            .collect();
1203        let _ = writeln!(
1204            out,
1205            "- Unreferenced global classes (candidates; verify no email / server / CMS / Markdown applies them): {}",
1206            named.join(", "),
1207        );
1208    }
1209}
1210
1211fn write_css_font_candidate_details(
1212    out: &mut String,
1213    css: &crate::health_types::CssAnalyticsReport,
1214) {
1215    if !css.unused_font_faces.is_empty() {
1216        let named: Vec<String> = css
1217            .unused_font_faces
1218            .iter()
1219            .take(5)
1220            .map(|u| format!("`{}` ({})", u.family, u.path))
1221            .collect();
1222        let _ = writeln!(
1223            out,
1224            "- Unused @font-face (dead web-font; candidates, may be set from JS/inline): {}",
1225            named.join(", "),
1226        );
1227    }
1228    if !css.unused_theme_tokens.is_empty() {
1229        let named: Vec<String> = css
1230            .unused_theme_tokens
1231            .iter()
1232            .take(5)
1233            .map(|u| format!("`{}` ({}:{})", u.token, u.path, u.line))
1234            .collect();
1235        let _ = writeln!(
1236            out,
1237            "- Unused @theme tokens (dead Tailwind v4 design tokens; candidates, may be consumed by a plugin or downstream repo): {}",
1238            named.join(", "),
1239        );
1240    }
1241}
1242
1243fn write_css_font_size_mix_details(
1244    out: &mut String,
1245    css: &crate::health_types::CssAnalyticsReport,
1246) {
1247    if let Some(mix) = &css.font_size_unit_mix {
1248        let breakdown: Vec<String> = mix
1249            .notations
1250            .iter()
1251            .map(|n| format!("{} {}", n.count, n.notation))
1252            .collect();
1253        let _ = writeln!(
1254            out,
1255            "- Font sizes mix {} units (candidate, standardize unless intentional): {}",
1256            mix.notations.len(),
1257            breakdown.join(", "),
1258        );
1259    }
1260}
1261
1262fn write_coverage_intelligence_section(
1263    out: &mut String,
1264    report: &crate::health_types::HealthReport,
1265    root: &Path,
1266) {
1267    let Some(ref intelligence) = report.coverage_intelligence else {
1268        return;
1269    };
1270    if !out.is_empty() && !out.ends_with("\n\n") {
1271        out.push('\n');
1272    }
1273    let _ = writeln!(
1274        out,
1275        "## Coverage Intelligence\n\n- Verdict: {}\n- Findings: {}\n- Ambiguous matches skipped: {}\n",
1276        intelligence.verdict,
1277        intelligence.summary.findings,
1278        intelligence.summary.skipped_ambiguous_matches,
1279    );
1280    if intelligence.findings.is_empty() {
1281        if intelligence.summary.skipped_ambiguous_matches > 0 {
1282            let match_phrase = if intelligence.summary.skipped_ambiguous_matches == 1 {
1283                "evidence match was"
1284            } else {
1285                "evidence matches were"
1286            };
1287            let _ = writeln!(
1288                out,
1289                "No actionable findings were emitted because {} ambiguous {match_phrase} skipped.\n",
1290                intelligence.summary.skipped_ambiguous_matches,
1291            );
1292        }
1293        return;
1294    }
1295    out.push_str("| ID | Path | Identity | Verdict | Recommendation | Confidence | Signals |\n");
1296    out.push_str("|:---|:-----|:---------|:--------|:---------------|:-----------|:--------|\n");
1297    for finding in &intelligence.findings {
1298        let path = escape_backticks(&normalize_uri(
1299            &relative_path(&finding.path, root).display().to_string(),
1300        ));
1301        let identity = finding
1302            .identity
1303            .as_deref()
1304            .map_or_else(|| "-".to_owned(), escape_backticks);
1305        let signals = finding
1306            .signals
1307            .iter()
1308            .map(ToString::to_string)
1309            .collect::<Vec<_>>()
1310            .join(", ");
1311        let _ = writeln!(
1312            out,
1313            "| `{}` | `{}`:{} | `{}` | {} | {} | {} | {} |",
1314            escape_backticks(&finding.id),
1315            path,
1316            finding.line,
1317            identity,
1318            finding.verdict,
1319            finding.recommendation,
1320            finding.confidence,
1321            signals,
1322        );
1323    }
1324    out.push('\n');
1325}
1326
1327fn write_runtime_coverage_section(
1328    out: &mut String,
1329    report: &crate::health_types::HealthReport,
1330    root: &Path,
1331) {
1332    let Some(ref production) = report.runtime_coverage else {
1333        return;
1334    };
1335    if !out.is_empty() && !out.ends_with("\n\n") {
1336        out.push('\n');
1337    }
1338    let _ = writeln!(
1339        out,
1340        "## 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",
1341        production.verdict,
1342        production.summary.functions_tracked,
1343        production.summary.functions_hit,
1344        production.summary.functions_unhit,
1345        production.summary.functions_untracked,
1346        production.summary.coverage_percent,
1347        production.summary.trace_count,
1348        production.summary.period_days,
1349        production.summary.deployments_seen,
1350    );
1351    if let Some(watermark) = production.watermark {
1352        let _ = writeln!(out, "- Watermark: {watermark}\n");
1353    }
1354    if let Some(ref quality) = production.summary.capture_quality
1355        && quality.lazy_parse_warning
1356    {
1357        let window = super::human::health::format_window(quality.window_seconds);
1358        let _ = writeln!(
1359            out,
1360            "- Capture quality: short window ({} from {} instance(s), {:.1}% of functions untracked); lazy-parsed scripts may not appear.\n",
1361            window, quality.instances_observed, quality.untracked_ratio_percent,
1362        );
1363    }
1364    let rel = |p: &Path| {
1365        escape_backticks(&normalize_uri(
1366            &relative_path(p, root).display().to_string(),
1367        ))
1368    };
1369    if !production.findings.is_empty() {
1370        out.push_str("| ID | Path | Function | Verdict | Invocations | Confidence |\n");
1371        out.push_str("|:---|:-----|:---------|:--------|------------:|:-----------|\n");
1372        for finding in &production.findings {
1373            let invocations = finding
1374                .invocations
1375                .map_or_else(|| "-".to_owned(), |hits| hits.to_string());
1376            let _ = writeln!(
1377                out,
1378                "| `{}` | `{}`:{} | `{}` | {} | {} | {} |",
1379                escape_backticks(&finding.id),
1380                rel(&finding.path),
1381                finding.line,
1382                escape_backticks(&finding.function),
1383                finding.verdict,
1384                invocations,
1385                finding.confidence,
1386            );
1387        }
1388        out.push('\n');
1389    }
1390    if !production.hot_paths.is_empty() {
1391        out.push_str("| ID | Hot path | Function | Invocations | Percentile |\n");
1392        out.push_str("|:---|:---------|:---------|------------:|-----------:|\n");
1393        for entry in &production.hot_paths {
1394            let _ = writeln!(
1395                out,
1396                "| `{}` | `{}`:{} | `{}` | {} | {} |",
1397                escape_backticks(&entry.id),
1398                rel(&entry.path),
1399                entry.line,
1400                escape_backticks(&entry.function),
1401                entry.invocations,
1402                entry.percentile,
1403            );
1404        }
1405        out.push('\n');
1406    }
1407}
1408
1409/// Write the trend comparison table to the output.
1410fn write_trend_section(out: &mut String, report: &crate::health_types::HealthReport) {
1411    let Some(ref trend) = report.health_trend else {
1412        return;
1413    };
1414    let sha_str = trend
1415        .compared_to
1416        .git_sha
1417        .as_deref()
1418        .map_or(String::new(), |sha| format!(" ({sha})"));
1419    let _ = writeln!(
1420        out,
1421        "## Trend (vs {}{})\n",
1422        trend
1423            .compared_to
1424            .timestamp
1425            .get(..10)
1426            .unwrap_or(&trend.compared_to.timestamp),
1427        sha_str,
1428    );
1429    out.push_str("| Metric | Previous | Current | Delta | Direction |\n");
1430    out.push_str("|:-------|:---------|:--------|:------|:----------|\n");
1431    for m in &trend.metrics {
1432        let fmt_val = |v: f64| -> String {
1433            if m.unit == "%" {
1434                format!("{v:.1}%")
1435            } else if (v - v.round()).abs() < 0.05 {
1436                format!("{v:.0}")
1437            } else {
1438                format!("{v:.1}")
1439            }
1440        };
1441        let prev = fmt_val(m.previous);
1442        let cur = fmt_val(m.current);
1443        let delta = if m.unit == "%" {
1444            format!("{:+.1}%", m.delta)
1445        } else if (m.delta - m.delta.round()).abs() < 0.05 {
1446            format!("{:+.0}", m.delta)
1447        } else {
1448            format!("{:+.1}", m.delta)
1449        };
1450        let _ = writeln!(
1451            out,
1452            "| {} | {} | {} | {} | {} {} |",
1453            m.label,
1454            prev,
1455            cur,
1456            delta,
1457            m.direction.arrow(),
1458            m.direction.label(),
1459        );
1460    }
1461    let md_sha = trend
1462        .compared_to
1463        .git_sha
1464        .as_deref()
1465        .map_or(String::new(), |sha| format!(" ({sha})"));
1466    let _ = writeln!(
1467        out,
1468        "\n*vs {}{} · {} {} available*\n",
1469        trend
1470            .compared_to
1471            .timestamp
1472            .get(..10)
1473            .unwrap_or(&trend.compared_to.timestamp),
1474        md_sha,
1475        trend.snapshots_loaded,
1476        if trend.snapshots_loaded == 1 {
1477            "snapshot"
1478        } else {
1479            "snapshots"
1480        },
1481    );
1482}
1483
1484/// Write the vital signs summary table to the output.
1485fn write_vital_signs_section(out: &mut String, report: &crate::health_types::HealthReport) {
1486    let Some(ref vs) = report.vital_signs else {
1487        return;
1488    };
1489    out.push_str("## Vital Signs\n\n");
1490    out.push_str("| Metric | Value |\n");
1491    out.push_str("|:-------|------:|\n");
1492    if vs.total_loc > 0 {
1493        let _ = writeln!(out, "| Total LOC | {} |", vs.total_loc);
1494    }
1495    let _ = writeln!(out, "| Avg Cyclomatic | {:.1} |", vs.avg_cyclomatic);
1496    let _ = writeln!(out, "| P90 Cyclomatic | {} |", vs.p90_cyclomatic);
1497    if let Some(v) = vs.dead_file_pct {
1498        let _ = writeln!(out, "| Dead Files | {v:.1}% |");
1499    }
1500    if let Some(v) = vs.dead_export_pct {
1501        let _ = writeln!(out, "| Dead Exports | {v:.1}% |");
1502    }
1503    if let Some(v) = vs.maintainability_avg {
1504        let _ = writeln!(out, "| Maintainability (avg) | {v:.1} |");
1505    }
1506    if let Some(v) = vs.hotspot_count {
1507        let label = report.hotspot_summary.as_ref().map_or_else(
1508            || "Hotspots".to_string(),
1509            |summary| format!("Hotspots (since {})", summary.since),
1510        );
1511        let _ = writeln!(out, "| {label} | {v} |");
1512    }
1513    if let Some(v) = vs.circular_dep_count {
1514        let _ = writeln!(out, "| Circular Deps | {v} |");
1515    }
1516    if let Some(v) = vs.unused_dep_count {
1517        let _ = writeln!(out, "| Unused Deps | {v} |");
1518    }
1519    out.push('\n');
1520}
1521
1522/// Write the complexity findings table to the output.
1523fn write_findings_section(
1524    out: &mut String,
1525    report: &crate::health_types::HealthReport,
1526    root: &Path,
1527) {
1528    if report.findings.is_empty() {
1529        return;
1530    }
1531
1532    let rel = |p: &Path| {
1533        escape_backticks(&normalize_uri(
1534            &relative_path(p, root).display().to_string(),
1535        ))
1536    };
1537
1538    let count = report.summary.functions_above_threshold;
1539    let shown = report.findings.len();
1540    let has_synthetic = report
1541        .findings
1542        .iter()
1543        .any(|finding| matches!(finding.name.as_str(), "<template>" | "<component>"));
1544    let subject = if has_synthetic {
1545        "high complexity finding"
1546    } else {
1547        "high complexity function"
1548    };
1549    if shown < count {
1550        let _ = write!(
1551            out,
1552            "## Fallow: {count} {subject}{} ({shown} shown)\n\n",
1553            plural(count),
1554        );
1555    } else {
1556        let _ = write!(out, "## Fallow: {count} {subject}{}\n\n", plural(count));
1557    }
1558
1559    let name_header = if has_synthetic { "Entry" } else { "Function" };
1560    let _ = writeln!(
1561        out,
1562        "| File | {name_header} | Severity | Cyclomatic | Cognitive | CRAP | Lines |"
1563    );
1564    out.push_str("|:-----|:---------|:---------|:-----------|:----------|:-----|:------|\n");
1565
1566    for finding in &report.findings {
1567        let file_str = rel(&finding.path);
1568        let thresholds = finding.effective_thresholds.unwrap_or(
1569            crate::health_types::HealthEffectiveThresholds {
1570                max_cyclomatic: report.summary.max_cyclomatic_threshold,
1571                max_cognitive: report.summary.max_cognitive_threshold,
1572                max_crap: report.summary.max_crap_threshold,
1573            },
1574        );
1575        let cyc_marker = if finding.cyclomatic > thresholds.max_cyclomatic {
1576            " **!**"
1577        } else {
1578            ""
1579        };
1580        let cog_marker = if finding.cognitive > thresholds.max_cognitive {
1581            " **!**"
1582        } else {
1583            ""
1584        };
1585        let severity_label = match finding.severity {
1586            crate::health_types::FindingSeverity::Critical => "critical",
1587            crate::health_types::FindingSeverity::High => "high",
1588            crate::health_types::FindingSeverity::Moderate => "moderate",
1589        };
1590        let crap_cell = match finding.crap {
1591            Some(crap) => {
1592                let marker = if crap >= thresholds.max_crap {
1593                    " **!**"
1594                } else {
1595                    ""
1596                };
1597                format!("{crap:.1}{marker}")
1598            }
1599            None => "-".to_string(),
1600        };
1601        let _ = writeln!(
1602            out,
1603            "| `{file_str}:{line}` | `{name}` | {severity_label} | {cyc}{cyc_marker} | {cog}{cog_marker} | {crap_cell} | {lines} |",
1604            line = finding.line,
1605            name = escape_backticks(display_complexity_entry_name(&finding.name).as_ref()),
1606            cyc = finding.cyclomatic,
1607            cog = finding.cognitive,
1608            lines = finding.line_count,
1609        );
1610    }
1611
1612    let s = &report.summary;
1613    let _ = write!(
1614        out,
1615        "\n**{files}** files, **{funcs}** functions analyzed \
1616         (thresholds: cyclomatic > {cyc}, cognitive > {cog}, CRAP >= {crap:.1})\n",
1617        files = s.files_analyzed,
1618        funcs = s.functions_analyzed,
1619        cyc = s.max_cyclomatic_threshold,
1620        cog = s.max_cognitive_threshold,
1621        crap = s.max_crap_threshold,
1622    );
1623}
1624
1625fn write_threshold_overrides_section(
1626    out: &mut String,
1627    report: &crate::health_types::HealthReport,
1628    root: &Path,
1629) {
1630    if report.threshold_overrides.is_empty() {
1631        return;
1632    }
1633    if !out.is_empty() && !out.ends_with("\n\n") {
1634        out.push('\n');
1635    }
1636    out.push_str("## Health Threshold Overrides\n\n");
1637    out.push_str("| Override | Status | Target | Metrics |\n");
1638    out.push_str("|---------:|:-------|:-------|:--------|\n");
1639    for entry in &report.threshold_overrides {
1640        let status = match entry.status {
1641            crate::health_types::ThresholdOverrideStatus::Active => "active",
1642            crate::health_types::ThresholdOverrideStatus::Stale => "stale",
1643            crate::health_types::ThresholdOverrideStatus::NoMatch => "no_match",
1644        };
1645        let target = entry.path.as_ref().map_or_else(
1646            || "<no matching file or function>".to_string(),
1647            |path| {
1648                let display = escape_backticks(&normalize_uri(
1649                    &relative_path(path, root).display().to_string(),
1650                ));
1651                entry.function.as_ref().map_or_else(
1652                    || display.clone(),
1653                    |name| format!("{display}:{}", escape_backticks(name)),
1654                )
1655            },
1656        );
1657        let metrics = entry.metrics.map_or_else(
1658            || "-".to_string(),
1659            |metrics| {
1660                let crap = metrics
1661                    .crap
1662                    .map_or(String::new(), |value| format!(", CRAP {value:.1}"));
1663                format!(
1664                    "cyclomatic {}, cognitive {}{}",
1665                    metrics.cyclomatic, metrics.cognitive, crap
1666                )
1667            },
1668        );
1669        let _ = writeln!(
1670            out,
1671            "| {} | {} | `{}` | {} |",
1672            entry.override_index, status, target, metrics
1673        );
1674    }
1675    out.push('\n');
1676}
1677
1678/// Write the file health scores table to the output.
1679fn write_file_scores_section(
1680    out: &mut String,
1681    report: &crate::health_types::HealthReport,
1682    root: &Path,
1683) {
1684    if report.file_scores.is_empty() {
1685        return;
1686    }
1687
1688    let rel = |p: &Path| {
1689        escape_backticks(&normalize_uri(
1690            &relative_path(p, root).display().to_string(),
1691        ))
1692    };
1693
1694    out.push('\n');
1695    let _ = writeln!(
1696        out,
1697        "### File Health Scores ({} files)\n",
1698        report.file_scores.len(),
1699    );
1700    out.push_str("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density | Risk |\n");
1701    out.push_str("|:-----|:---------------|:-------|:--------|:----------|:--------|:-----|\n");
1702
1703    for score in &report.file_scores {
1704        let file_str = rel(&score.path);
1705        let _ = writeln!(
1706            out,
1707            "| `{file_str}` | {mi:.1} | {fi} | {fan_out} | {dead:.0}% | {density:.2} | {crap:.1} |",
1708            mi = score.maintainability_index,
1709            fi = score.fan_in,
1710            fan_out = score.fan_out,
1711            dead = score.dead_code_ratio * 100.0,
1712            density = score.complexity_density,
1713            crap = score.crap_max,
1714        );
1715    }
1716
1717    if let Some(avg) = report.summary.average_maintainability {
1718        let _ = write!(out, "\n**Average maintainability index:** {avg:.1}/100\n");
1719    }
1720}
1721
1722fn write_coverage_gaps_section(
1723    out: &mut String,
1724    report: &crate::health_types::HealthReport,
1725    root: &Path,
1726) {
1727    let Some(ref gaps) = report.coverage_gaps else {
1728        return;
1729    };
1730
1731    out.push('\n');
1732    let _ = writeln!(out, "### Coverage Gaps\n");
1733    let _ = writeln!(
1734        out,
1735        "*{} untested files · {} untested exports · {:.1}% file coverage*\n",
1736        gaps.summary.untested_files, gaps.summary.untested_exports, gaps.summary.file_coverage_pct,
1737    );
1738
1739    if gaps.files.is_empty() && gaps.exports.is_empty() {
1740        out.push_str("_No coverage gaps found in scope._\n");
1741        return;
1742    }
1743
1744    if !gaps.files.is_empty() {
1745        out.push_str("#### Files\n");
1746        for item in &gaps.files {
1747            let file_str = escape_backticks(&normalize_uri(
1748                &relative_path(&item.file.path, root).display().to_string(),
1749            ));
1750            let _ = writeln!(
1751                out,
1752                "- `{file_str}` ({count} value export{})",
1753                if item.file.value_export_count == 1 {
1754                    ""
1755                } else {
1756                    "s"
1757                },
1758                count = item.file.value_export_count,
1759            );
1760        }
1761        out.push('\n');
1762    }
1763
1764    if !gaps.exports.is_empty() {
1765        out.push_str("#### Exports\n");
1766        for item in &gaps.exports {
1767            let file_str = escape_backticks(&normalize_uri(
1768                &relative_path(&item.export.path, root).display().to_string(),
1769            ));
1770            let _ = writeln!(
1771                out,
1772                "- `{file_str}`:{} `{}`",
1773                item.export.line, item.export.export_name
1774            );
1775        }
1776    }
1777}
1778
1779/// Write the hotspots table to the output.
1780/// Render the four ownership table cells (bus, top contributor, declared
1781/// owner, notes) for the markdown hotspots table. Cells fall back to an
1782/// en-dash (U+2013) when ownership data is missing for an entry.
1783fn ownership_md_cells(
1784    ownership: Option<&crate::health_types::OwnershipMetrics>,
1785) -> (String, String, String, String) {
1786    let Some(o) = ownership else {
1787        let dash = "\u{2013}".to_string();
1788        return (dash.clone(), dash.clone(), dash.clone(), dash);
1789    };
1790    let bus = o.bus_factor.to_string();
1791    let top = format!(
1792        "`{}` ({:.0}%)",
1793        o.top_contributor.identifier,
1794        o.top_contributor.share * 100.0,
1795    );
1796    let owner = o
1797        .declared_owner
1798        .as_deref()
1799        .map_or_else(|| "\u{2013}".to_string(), str::to_string);
1800    let mut notes: Vec<&str> = Vec::new();
1801    if o.unowned == Some(true) {
1802        notes.push("**unowned**");
1803    }
1804    if o.ownership_state == crate::health_types::OwnershipState::DeclaredInactive {
1805        notes.push("declared owner inactive");
1806    }
1807    if o.drift {
1808        notes.push("drift");
1809    }
1810    let notes_str = if notes.is_empty() {
1811        "\u{2013}".to_string()
1812    } else {
1813        notes.join(", ")
1814    };
1815    (bus, top, owner, notes_str)
1816}
1817
1818fn write_hotspots_section(
1819    out: &mut String,
1820    report: &crate::health_types::HealthReport,
1821    root: &Path,
1822) {
1823    if report.hotspots.is_empty() {
1824        return;
1825    }
1826
1827    let rel = |p: &Path| {
1828        escape_backticks(&normalize_uri(
1829            &relative_path(p, root).display().to_string(),
1830        ))
1831    };
1832
1833    out.push('\n');
1834    let header = report.hotspot_summary.as_ref().map_or_else(
1835        || format!("### Hotspots ({} files)\n", report.hotspots.len()),
1836        |summary| {
1837            format!(
1838                "### Hotspots ({} files, since {})\n",
1839                report.hotspots.len(),
1840                summary.since,
1841            )
1842        },
1843    );
1844    let _ = writeln!(out, "{header}");
1845    let any_ownership = report.hotspots.iter().any(|e| e.ownership.is_some());
1846    if any_ownership {
1847        out.push_str(
1848            "| File | Score | Commits | Churn | Density | Fan-in | Trend | Bus | Top | Owner | Notes |\n"
1849        );
1850        out.push_str(
1851            "|:-----|:------|:--------|:------|:--------|:-------|:------|:----|:----|:------|:------|\n"
1852        );
1853    } else {
1854        out.push_str("| File | Score | Commits | Churn | Density | Fan-in | Trend |\n");
1855        out.push_str("|:-----|:------|:--------|:------|:--------|:-------|:------|\n");
1856    }
1857
1858    for entry in &report.hotspots {
1859        let file_str = rel(&entry.path);
1860        if any_ownership {
1861            let (bus, top, owner, notes) = ownership_md_cells(entry.ownership.as_ref());
1862            let _ = writeln!(
1863                out,
1864                "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} | {bus} | {top} | {owner} | {notes} |",
1865                score = entry.score,
1866                commits = entry.commits,
1867                churn = entry.lines_added + entry.lines_deleted,
1868                density = entry.complexity_density,
1869                fi = entry.fan_in,
1870                trend = entry.trend,
1871            );
1872        } else {
1873            let _ = writeln!(
1874                out,
1875                "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} |",
1876                score = entry.score,
1877                commits = entry.commits,
1878                churn = entry.lines_added + entry.lines_deleted,
1879                density = entry.complexity_density,
1880                fi = entry.fan_in,
1881                trend = entry.trend,
1882            );
1883        }
1884    }
1885
1886    if let Some(ref summary) = report.hotspot_summary
1887        && summary.files_excluded > 0
1888    {
1889        let _ = write!(
1890            out,
1891            "\n*{} file{} excluded (< {} commits)*\n",
1892            summary.files_excluded,
1893            plural(summary.files_excluded),
1894            summary.min_commits,
1895        );
1896    }
1897}
1898
1899/// Write the refactoring targets table to the output.
1900fn write_targets_section(
1901    out: &mut String,
1902    report: &crate::health_types::HealthReport,
1903    root: &Path,
1904) {
1905    if report.targets.is_empty() {
1906        return;
1907    }
1908    let _ = write!(
1909        out,
1910        "\n### Refactoring Targets ({})\n\n",
1911        report.targets.len()
1912    );
1913    out.push_str("| Efficiency | Category | Effort / Confidence | File | Recommendation |\n");
1914    out.push_str("|:-----------|:---------|:--------------------|:-----|:---------------|\n");
1915    for target in &report.targets {
1916        let file_str = normalize_uri(&relative_path(&target.path, root).display().to_string());
1917        let category = target.category.label();
1918        let effort = target.effort.label();
1919        let confidence = target.confidence.label();
1920        let _ = writeln!(
1921            out,
1922            "| {:.1} | {category} | {effort} / {confidence} | `{file_str}` | {} |",
1923            target.efficiency, target.recommendation,
1924        );
1925    }
1926}
1927
1928/// Write the metric legend collapsible section to the output.
1929fn write_metric_legend(out: &mut String, report: &crate::health_types::HealthReport) {
1930    let has_scores = !report.file_scores.is_empty();
1931    let has_coverage = report.coverage_gaps.is_some();
1932    let has_hotspots = !report.hotspots.is_empty();
1933    let has_targets = !report.targets.is_empty();
1934    if !has_scores && !has_coverage && !has_hotspots && !has_targets {
1935        return;
1936    }
1937    out.push_str("\n---\n\n<details><summary>Metric definitions</summary>\n\n");
1938    if has_scores {
1939        out.push_str("- **MI**: Maintainability Index (0\u{2013}100, higher is better)\n");
1940        out.push_str("- **Order**: risk-aware triage order using the larger of low-MI concern and CRAP risk\n");
1941        out.push_str("- **Fan-in**: files that import this file (blast radius)\n");
1942        out.push_str("- **Fan-out**: files this file imports (coupling)\n");
1943        out.push_str("- **Dead Code**: % of value exports with zero references\n");
1944        out.push_str("- **Density**: cyclomatic complexity / lines of code\n");
1945        out.push_str(
1946            "- **Risk**: max CRAP score for the file; low <15, moderate 15-30, high >=30\n",
1947        );
1948    }
1949    if has_coverage {
1950        out.push_str(
1951            "- **File coverage**: runtime files also reachable from a discovered test root\n",
1952        );
1953        out.push_str("- **Untested export**: export with no reference chain from any test-reachable module\n");
1954    }
1955    if has_hotspots {
1956        out.push_str("- **Score**: churn \u{00d7} complexity (0\u{2013}100, higher = riskier)\n");
1957        out.push_str("- **Commits**: commits in the analysis window\n");
1958        out.push_str("- **Churn**: total lines added + deleted\n");
1959        out.push_str("- **Trend**: accelerating / stable / cooling\n");
1960    }
1961    if has_targets {
1962        out.push_str(
1963            "- **Efficiency**: priority / effort (higher = better quick-win value, default sort)\n",
1964        );
1965        out.push_str("- **Category**: recommendation type (churn+complexity, high impact, dead code, complexity, coupling, circular dep)\n");
1966        out.push_str("- **Effort**: estimated effort (low / medium / high) based on file size, function count, and fan-in\n");
1967        out.push_str("- **Confidence**: recommendation reliability (high = deterministic analysis, medium = heuristic, low = git-dependent)\n");
1968    }
1969    out.push_str(
1970        "\n[Full metric reference](https://docs.fallow.tools/explanations/metrics)\n\n</details>\n",
1971    );
1972}
1973
1974#[cfg(test)]
1975mod tests {
1976    use super::*;
1977    use crate::report::test_helpers::sample_results;
1978    use fallow_core::duplicates::{
1979        CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
1980        RefactoringKind, RefactoringSuggestion,
1981    };
1982    use fallow_core::results::*;
1983    use std::path::PathBuf;
1984
1985    #[test]
1986    fn markdown_empty_results_no_issues() {
1987        let root = PathBuf::from("/project");
1988        let results = AnalysisResults::default();
1989        let md = build_markdown(&results, &root);
1990        assert_eq!(md, "## Fallow: no issues found\n");
1991    }
1992
1993    #[test]
1994    fn markdown_contains_header_with_count() {
1995        let root = PathBuf::from("/project");
1996        let results = sample_results(&root);
1997        let md = build_markdown(&results, &root);
1998        assert!(md.starts_with(&format!(
1999            "## Fallow: {} issues found\n",
2000            results.total_issues()
2001        )));
2002    }
2003
2004    #[test]
2005    fn markdown_contains_all_sections() {
2006        let root = PathBuf::from("/project");
2007        let results = sample_results(&root);
2008        let md = build_markdown(&results, &root);
2009
2010        assert!(md.contains("### Unused files (1)"));
2011        assert!(md.contains("### Unused exports (1)"));
2012        assert!(md.contains("### Unused type exports (1)"));
2013        assert!(md.contains("### Unused dependencies (1)"));
2014        assert!(md.contains("### Unused devDependencies (1)"));
2015        assert!(md.contains("### Unused enum members (1)"));
2016        assert!(md.contains("### Unused class members (1)"));
2017        assert!(md.contains("### Unresolved imports (1)"));
2018        assert!(md.contains("### Unlisted dependencies (1)"));
2019        assert!(md.contains("### Duplicate exports (1)"));
2020        assert!(md.contains("### Type-only dependencies"));
2021        assert!(md.contains("### Test-only production dependencies"));
2022        assert!(md.contains("### Circular dependencies (1)"));
2023    }
2024
2025    #[test]
2026    fn markdown_unused_file_format() {
2027        let root = PathBuf::from("/project");
2028        let mut results = AnalysisResults::default();
2029        results
2030            .unused_files
2031            .push(UnusedFileFinding::with_actions(UnusedFile {
2032                path: root.join("src/dead.ts"),
2033            }));
2034        let md = build_markdown(&results, &root);
2035        assert!(md.contains("- `src/dead.ts`"));
2036    }
2037
2038    #[test]
2039    fn markdown_unused_export_grouped_by_file() {
2040        let root = PathBuf::from("/project");
2041        let mut results = AnalysisResults::default();
2042        results
2043            .unused_exports
2044            .push(UnusedExportFinding::with_actions(UnusedExport {
2045                path: root.join("src/utils.ts"),
2046                export_name: "helperFn".to_string(),
2047                is_type_only: false,
2048                line: 10,
2049                col: 4,
2050                span_start: 120,
2051                is_re_export: false,
2052            }));
2053        let md = build_markdown(&results, &root);
2054        assert!(md.contains("- `src/utils.ts`"));
2055        assert!(md.contains(":10 `helperFn`"));
2056    }
2057
2058    #[test]
2059    fn markdown_re_export_tagged() {
2060        let root = PathBuf::from("/project");
2061        let mut results = AnalysisResults::default();
2062        results
2063            .unused_exports
2064            .push(UnusedExportFinding::with_actions(UnusedExport {
2065                path: root.join("src/index.ts"),
2066                export_name: "reExported".to_string(),
2067                is_type_only: false,
2068                line: 1,
2069                col: 0,
2070                span_start: 0,
2071                is_re_export: true,
2072            }));
2073        let md = build_markdown(&results, &root);
2074        assert!(md.contains("(re-export)"));
2075    }
2076
2077    #[test]
2078    fn markdown_unused_dep_format() {
2079        let root = PathBuf::from("/project");
2080        let mut results = AnalysisResults::default();
2081        results
2082            .unused_dependencies
2083            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2084                package_name: "lodash".to_string(),
2085                location: DependencyLocation::Dependencies,
2086                path: root.join("package.json"),
2087                line: 5,
2088                used_in_workspaces: Vec::new(),
2089            }));
2090        let md = build_markdown(&results, &root);
2091        assert!(md.contains("- `lodash`"));
2092    }
2093
2094    #[test]
2095    fn markdown_circular_dep_format() {
2096        let root = PathBuf::from("/project");
2097        let mut results = AnalysisResults::default();
2098        results
2099            .circular_dependencies
2100            .push(CircularDependencyFinding::with_actions(
2101                CircularDependency {
2102                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2103                    length: 2,
2104                    line: 3,
2105                    col: 0,
2106                    edges: Vec::new(),
2107                    is_cross_package: false,
2108                },
2109            ));
2110        let md = build_markdown(&results, &root);
2111        assert!(md.contains("`src/a.ts`"));
2112        assert!(md.contains("`src/b.ts`"));
2113        assert!(md.contains("\u{2192}"));
2114    }
2115
2116    #[test]
2117    fn markdown_strips_root_prefix() {
2118        let root = PathBuf::from("/project");
2119        let mut results = AnalysisResults::default();
2120        results
2121            .unused_files
2122            .push(UnusedFileFinding::with_actions(UnusedFile {
2123                path: PathBuf::from("/project/src/deep/nested/file.ts"),
2124            }));
2125        let md = build_markdown(&results, &root);
2126        assert!(md.contains("`src/deep/nested/file.ts`"));
2127        assert!(!md.contains("/project/"));
2128    }
2129
2130    #[test]
2131    fn markdown_single_issue_no_plural() {
2132        let root = PathBuf::from("/project");
2133        let mut results = AnalysisResults::default();
2134        results
2135            .unused_files
2136            .push(UnusedFileFinding::with_actions(UnusedFile {
2137                path: root.join("src/dead.ts"),
2138            }));
2139        let md = build_markdown(&results, &root);
2140        assert!(md.starts_with("## Fallow: 1 issue found\n"));
2141    }
2142
2143    #[test]
2144    fn markdown_type_only_dep_format() {
2145        let root = PathBuf::from("/project");
2146        let mut results = AnalysisResults::default();
2147        results
2148            .type_only_dependencies
2149            .push(TypeOnlyDependencyFinding::with_actions(
2150                TypeOnlyDependency {
2151                    package_name: "zod".to_string(),
2152                    path: root.join("package.json"),
2153                    line: 8,
2154                },
2155            ));
2156        let md = build_markdown(&results, &root);
2157        assert!(md.contains("### Type-only dependencies"));
2158        assert!(md.contains("- `zod`"));
2159    }
2160
2161    #[test]
2162    fn markdown_escapes_backticks_in_export_names() {
2163        let root = PathBuf::from("/project");
2164        let mut results = AnalysisResults::default();
2165        results
2166            .unused_exports
2167            .push(UnusedExportFinding::with_actions(UnusedExport {
2168                path: root.join("src/utils.ts"),
2169                export_name: "foo`bar".to_string(),
2170                is_type_only: false,
2171                line: 1,
2172                col: 0,
2173                span_start: 0,
2174                is_re_export: false,
2175            }));
2176        let md = build_markdown(&results, &root);
2177        assert!(md.contains("foo\\`bar"));
2178        assert!(!md.contains("foo`bar`"));
2179    }
2180
2181    #[test]
2182    fn markdown_escapes_backticks_in_package_names() {
2183        let root = PathBuf::from("/project");
2184        let mut results = AnalysisResults::default();
2185        results
2186            .unused_dependencies
2187            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2188                package_name: "pkg`name".to_string(),
2189                location: DependencyLocation::Dependencies,
2190                path: root.join("package.json"),
2191                line: 5,
2192                used_in_workspaces: Vec::new(),
2193            }));
2194        let md = build_markdown(&results, &root);
2195        assert!(md.contains("pkg\\`name"));
2196    }
2197
2198    #[test]
2199    fn duplication_markdown_empty() {
2200        let report = DuplicationReport::default();
2201        let root = PathBuf::from("/project");
2202        let md = build_duplication_markdown(&report, &root);
2203        assert_eq!(md, "## Fallow: no code duplication found\n");
2204    }
2205
2206    #[test]
2207    fn duplication_markdown_contains_groups() {
2208        let root = PathBuf::from("/project");
2209        let report = DuplicationReport {
2210            clone_groups: vec![CloneGroup {
2211                instances: vec![
2212                    CloneInstance {
2213                        file: root.join("src/a.ts"),
2214                        start_line: 1,
2215                        end_line: 10,
2216                        start_col: 0,
2217                        end_col: 0,
2218                        fragment: String::new(),
2219                    },
2220                    CloneInstance {
2221                        file: root.join("src/b.ts"),
2222                        start_line: 5,
2223                        end_line: 14,
2224                        start_col: 0,
2225                        end_col: 0,
2226                        fragment: String::new(),
2227                    },
2228                ],
2229                token_count: 50,
2230                line_count: 10,
2231            }],
2232            clone_families: vec![],
2233            mirrored_directories: vec![],
2234            stats: DuplicationStats {
2235                total_files: 10,
2236                files_with_clones: 2,
2237                total_lines: 500,
2238                duplicated_lines: 20,
2239                total_tokens: 2500,
2240                duplicated_tokens: 100,
2241                clone_groups: 1,
2242                clone_instances: 2,
2243                duplication_percentage: 4.0,
2244                clone_groups_below_min_occurrences: 0,
2245            },
2246        };
2247        let md = build_duplication_markdown(&report, &root);
2248        assert!(md.contains("**Clone group 1**"));
2249        assert!(md.contains("`src/a.ts:1-10`"));
2250        assert!(md.contains("`src/b.ts:5-14`"));
2251        assert!(md.contains("4.0% duplication"));
2252    }
2253
2254    #[test]
2255    fn duplication_markdown_contains_families() {
2256        let root = PathBuf::from("/project");
2257        let report = DuplicationReport {
2258            clone_groups: vec![CloneGroup {
2259                instances: vec![CloneInstance {
2260                    file: root.join("src/a.ts"),
2261                    start_line: 1,
2262                    end_line: 5,
2263                    start_col: 0,
2264                    end_col: 0,
2265                    fragment: String::new(),
2266                }],
2267                token_count: 30,
2268                line_count: 5,
2269            }],
2270            clone_families: vec![CloneFamily {
2271                files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2272                groups: vec![],
2273                total_duplicated_lines: 20,
2274                total_duplicated_tokens: 100,
2275                suggestions: vec![RefactoringSuggestion {
2276                    kind: RefactoringKind::ExtractFunction,
2277                    description: "Extract shared utility function".to_string(),
2278                    estimated_savings: 15,
2279                }],
2280            }],
2281            mirrored_directories: vec![],
2282            stats: DuplicationStats {
2283                clone_groups: 1,
2284                clone_instances: 1,
2285                duplication_percentage: 2.0,
2286                ..Default::default()
2287            },
2288        };
2289        let md = build_duplication_markdown(&report, &root);
2290        assert!(md.contains("### Clone Families"));
2291        assert!(md.contains("**Family 1**"));
2292        assert!(md.contains("Extract shared utility function"));
2293        assert!(md.contains("~15 lines saved"));
2294    }
2295
2296    #[test]
2297    fn health_markdown_empty_no_findings() {
2298        let root = PathBuf::from("/project");
2299        let report = crate::health_types::HealthReport {
2300            summary: crate::health_types::HealthSummary {
2301                files_analyzed: 10,
2302                functions_analyzed: 50,
2303                ..Default::default()
2304            },
2305            ..Default::default()
2306        };
2307        let md = build_health_markdown(&report, &root);
2308        assert!(md.contains("no functions exceed complexity thresholds"));
2309        assert!(md.contains("**50** functions analyzed"));
2310    }
2311
2312    #[test]
2313    fn health_markdown_table_format() {
2314        let root = PathBuf::from("/project");
2315        let report = crate::health_types::HealthReport {
2316            findings: vec![
2317                crate::health_types::ComplexityViolation {
2318                    path: root.join("src/utils.ts"),
2319                    name: "parseExpression".to_string(),
2320                    line: 42,
2321                    col: 0,
2322                    cyclomatic: 25,
2323                    cognitive: 30,
2324                    line_count: 80,
2325                    param_count: 0,
2326                    react_hook_count: 0,
2327                    react_jsx_max_depth: 0,
2328                    react_prop_count: 0,
2329                    react_hook_profile: None,
2330                    exceeded: crate::health_types::ExceededThreshold::Both,
2331                    severity: crate::health_types::FindingSeverity::High,
2332                    crap: None,
2333                    coverage_pct: None,
2334                    coverage_tier: None,
2335                    coverage_source: None,
2336                    inherited_from: None,
2337                    component_rollup: None,
2338                    contributions: Vec::new(),
2339                    effective_thresholds: None,
2340                    threshold_source: None,
2341                }
2342                .into(),
2343            ],
2344            summary: crate::health_types::HealthSummary {
2345                files_analyzed: 10,
2346                functions_analyzed: 50,
2347                functions_above_threshold: 1,
2348                ..Default::default()
2349            },
2350            ..Default::default()
2351        };
2352        let md = build_health_markdown(&report, &root);
2353        assert!(md.contains("## Fallow: 1 high complexity function\n"));
2354        assert!(md.contains("| File | Function |"));
2355        assert!(md.contains("`src/utils.ts:42`"));
2356        assert!(md.contains("`parseExpression`"));
2357        assert!(md.contains("25 **!**"));
2358        assert!(md.contains("30 **!**"));
2359        assert!(md.contains("| 80 |"));
2360        assert!(md.contains("| - |"));
2361    }
2362
2363    #[test]
2364    fn health_markdown_labels_template_complexity_entries() {
2365        let root = PathBuf::from("/project");
2366        let report = crate::health_types::HealthReport {
2367            findings: vec![
2368                crate::health_types::ComplexityViolation {
2369                    path: root.join("src/Card.vue"),
2370                    name: "<template>".to_string(),
2371                    line: 1,
2372                    col: 0,
2373                    cyclomatic: 8,
2374                    cognitive: 12,
2375                    line_count: 40,
2376                    param_count: 0,
2377                    react_hook_count: 0,
2378                    react_jsx_max_depth: 0,
2379                    react_prop_count: 0,
2380                    react_hook_profile: None,
2381                    exceeded: crate::health_types::ExceededThreshold::Cognitive,
2382                    severity: crate::health_types::FindingSeverity::Moderate,
2383                    crap: None,
2384                    coverage_pct: None,
2385                    coverage_tier: None,
2386                    coverage_source: None,
2387                    inherited_from: None,
2388                    component_rollup: None,
2389                    contributions: Vec::new(),
2390                    effective_thresholds: None,
2391                    threshold_source: None,
2392                }
2393                .into(),
2394            ],
2395            summary: crate::health_types::HealthSummary {
2396                files_analyzed: 1,
2397                functions_analyzed: 1,
2398                functions_above_threshold: 1,
2399                ..Default::default()
2400            },
2401            ..Default::default()
2402        };
2403        let md = build_health_markdown(&report, &root);
2404        assert!(md.contains("## Fallow: 1 high complexity finding\n"));
2405        assert!(md.contains("| File | Entry |"));
2406        assert!(md.contains("`<template> (template complexity)`"));
2407    }
2408
2409    #[test]
2410    fn health_markdown_includes_coverage_intelligence_and_ambiguity_summary() {
2411        use crate::health_types::{
2412            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2413            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2414            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2415            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2416            CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2417            HealthReport, HealthSummary,
2418        };
2419
2420        let root = PathBuf::from("/project");
2421        let mut report = HealthReport {
2422            summary: HealthSummary {
2423                files_analyzed: 10,
2424                functions_analyzed: 50,
2425                ..Default::default()
2426            },
2427            coverage_intelligence: Some(CoverageIntelligenceReport {
2428                schema_version: CoverageIntelligenceSchemaVersion::V1,
2429                verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2430                summary: CoverageIntelligenceSummary {
2431                    findings: 1,
2432                    high_confidence_deletes: 1,
2433                    ..Default::default()
2434                },
2435                findings: vec![CoverageIntelligenceFinding {
2436                    id: "fallow:coverage-intel:abc123".to_owned(),
2437                    path: root.join("src/dead.ts"),
2438                    identity: Some("deadPath".to_owned()),
2439                    line: 9,
2440                    verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2441                    signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2442                    recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2443                    confidence: CoverageIntelligenceConfidence::High,
2444                    related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2445                    evidence: CoverageIntelligenceEvidence {
2446                        match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2447                        ..Default::default()
2448                    },
2449                    actions: vec![CoverageIntelligenceAction {
2450                        kind: "delete-after-confirming-owner".to_owned(),
2451                        description: "Confirm ownership".to_owned(),
2452                        auto_fixable: false,
2453                    }],
2454                }],
2455            }),
2456            ..Default::default()
2457        };
2458
2459        let md = build_health_markdown(&report, &root);
2460        assert!(md.contains("## Coverage Intelligence"));
2461        assert!(md.contains("fallow:coverage-intel:abc123"));
2462        assert!(md.contains("delete-after-confirming-owner"));
2463        assert!(md.contains("runtime_cold"));
2464
2465        report.coverage_intelligence = Some(CoverageIntelligenceReport {
2466            schema_version: CoverageIntelligenceSchemaVersion::V1,
2467            verdict: CoverageIntelligenceVerdict::Clean,
2468            summary: CoverageIntelligenceSummary {
2469                skipped_ambiguous_matches: 2,
2470                ..Default::default()
2471            },
2472            findings: vec![],
2473        });
2474        let md = build_health_markdown(&report, &root);
2475        assert!(md.contains("2 ambiguous evidence matches were skipped"));
2476        assert!(!md.contains("| ID | Path |"));
2477    }
2478
2479    #[test]
2480    fn health_markdown_crap_column_shows_score_and_marker() {
2481        let root = PathBuf::from("/project");
2482        let report = crate::health_types::HealthReport {
2483            findings: vec![
2484                crate::health_types::ComplexityViolation {
2485                    path: root.join("src/risky.ts"),
2486                    name: "branchy".to_string(),
2487                    line: 1,
2488                    col: 0,
2489                    cyclomatic: 67,
2490                    cognitive: 10,
2491                    line_count: 80,
2492                    param_count: 1,
2493                    react_hook_count: 0,
2494                    react_jsx_max_depth: 0,
2495                    react_prop_count: 0,
2496                    react_hook_profile: None,
2497                    exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
2498                    severity: crate::health_types::FindingSeverity::Critical,
2499                    crap: Some(182.0),
2500                    coverage_pct: None,
2501                    coverage_tier: None,
2502                    coverage_source: None,
2503                    inherited_from: None,
2504                    component_rollup: None,
2505                    contributions: Vec::new(),
2506                    effective_thresholds: None,
2507                    threshold_source: None,
2508                }
2509                .into(),
2510            ],
2511            summary: crate::health_types::HealthSummary {
2512                files_analyzed: 1,
2513                functions_analyzed: 1,
2514                functions_above_threshold: 1,
2515                ..Default::default()
2516            },
2517            ..Default::default()
2518        };
2519        let md = build_health_markdown(&report, &root);
2520        assert!(
2521            md.contains("| CRAP |"),
2522            "markdown table should have CRAP column header: {md}"
2523        );
2524        assert!(
2525            md.contains("182.0 **!**"),
2526            "CRAP value should be rendered with a threshold marker: {md}"
2527        );
2528        assert!(
2529            md.contains("CRAP >="),
2530            "trailing summary line should reference the CRAP threshold: {md}"
2531        );
2532    }
2533
2534    #[test]
2535    fn health_markdown_no_marker_when_below_threshold() {
2536        let root = PathBuf::from("/project");
2537        let report = crate::health_types::HealthReport {
2538            findings: vec![
2539                crate::health_types::ComplexityViolation {
2540                    path: root.join("src/utils.ts"),
2541                    name: "helper".to_string(),
2542                    line: 10,
2543                    col: 0,
2544                    cyclomatic: 15,
2545                    cognitive: 20,
2546                    line_count: 30,
2547                    param_count: 0,
2548                    react_hook_count: 0,
2549                    react_jsx_max_depth: 0,
2550                    react_prop_count: 0,
2551                    react_hook_profile: None,
2552                    exceeded: crate::health_types::ExceededThreshold::Cognitive,
2553                    severity: crate::health_types::FindingSeverity::High,
2554                    crap: None,
2555                    coverage_pct: None,
2556                    coverage_tier: None,
2557                    coverage_source: None,
2558                    inherited_from: None,
2559                    component_rollup: None,
2560                    contributions: Vec::new(),
2561                    effective_thresholds: None,
2562                    threshold_source: None,
2563                }
2564                .into(),
2565            ],
2566            summary: crate::health_types::HealthSummary {
2567                files_analyzed: 5,
2568                functions_analyzed: 20,
2569                functions_above_threshold: 1,
2570                ..Default::default()
2571            },
2572            ..Default::default()
2573        };
2574        let md = build_health_markdown(&report, &root);
2575        assert!(md.contains("| 15 |"));
2576        assert!(md.contains("20 **!**"));
2577    }
2578
2579    #[test]
2580    fn health_markdown_with_targets() {
2581        use crate::health_types::*;
2582
2583        let root = PathBuf::from("/project");
2584        let report = HealthReport {
2585            summary: HealthSummary {
2586                files_analyzed: 10,
2587                functions_analyzed: 50,
2588                ..Default::default()
2589            },
2590            targets: vec![
2591                RefactoringTarget {
2592                    path: PathBuf::from("/project/src/complex.ts"),
2593                    priority: 82.5,
2594                    efficiency: 27.5,
2595                    recommendation: "Split high-impact file".into(),
2596                    category: RecommendationCategory::SplitHighImpact,
2597                    effort: crate::health_types::EffortEstimate::High,
2598                    confidence: crate::health_types::Confidence::Medium,
2599                    factors: vec![ContributingFactor {
2600                        metric: "fan_in",
2601                        value: 25.0,
2602                        threshold: 10.0,
2603                        detail: "25 files depend on this".into(),
2604                    }],
2605                    evidence: None,
2606                }
2607                .into(),
2608                RefactoringTarget {
2609                    path: PathBuf::from("/project/src/legacy.ts"),
2610                    priority: 45.0,
2611                    efficiency: 45.0,
2612                    recommendation: "Remove 5 unused exports".into(),
2613                    category: RecommendationCategory::RemoveDeadCode,
2614                    effort: crate::health_types::EffortEstimate::Low,
2615                    confidence: crate::health_types::Confidence::High,
2616                    factors: vec![],
2617                    evidence: None,
2618                }
2619                .into(),
2620            ],
2621            ..Default::default()
2622        };
2623        let md = build_health_markdown(&report, &root);
2624
2625        assert!(
2626            md.contains("Refactoring Targets"),
2627            "should contain targets heading"
2628        );
2629        assert!(
2630            md.contains("src/complex.ts"),
2631            "should contain target file path"
2632        );
2633        assert!(md.contains("27.5"), "should contain efficiency score");
2634        assert!(
2635            md.contains("Split high-impact file"),
2636            "should contain recommendation"
2637        );
2638        assert!(md.contains("src/legacy.ts"), "should contain second target");
2639    }
2640
2641    #[test]
2642    fn health_markdown_with_coverage_gaps() {
2643        use crate::health_types::*;
2644
2645        let root = PathBuf::from("/project");
2646        let report = HealthReport {
2647            summary: HealthSummary {
2648                files_analyzed: 10,
2649                functions_analyzed: 50,
2650                ..Default::default()
2651            },
2652            coverage_gaps: Some(CoverageGaps {
2653                summary: CoverageGapSummary {
2654                    runtime_files: 2,
2655                    covered_files: 0,
2656                    file_coverage_pct: 0.0,
2657                    untested_files: 1,
2658                    untested_exports: 1,
2659                },
2660                files: vec![UntestedFileFinding::with_actions(
2661                    UntestedFile {
2662                        path: root.join("src/app.ts"),
2663                        value_export_count: 2,
2664                    },
2665                    &root,
2666                )],
2667                exports: vec![UntestedExportFinding::with_actions(
2668                    UntestedExport {
2669                        path: root.join("src/app.ts"),
2670                        export_name: "loader".into(),
2671                        line: 12,
2672                        col: 4,
2673                    },
2674                    &root,
2675                )],
2676            }),
2677            ..Default::default()
2678        };
2679
2680        let md = build_health_markdown(&report, &root);
2681        assert!(md.contains("### Coverage Gaps"));
2682        assert!(md.contains("*1 untested files"));
2683        assert!(md.contains("`src/app.ts` (2 value exports)"));
2684        assert!(md.contains("`src/app.ts`:12 `loader`"));
2685    }
2686
2687    #[test]
2688    fn markdown_dep_in_workspace_shows_package_label() {
2689        let root = PathBuf::from("/project");
2690        let mut results = AnalysisResults::default();
2691        results
2692            .unused_dependencies
2693            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2694                package_name: "lodash".to_string(),
2695                location: DependencyLocation::Dependencies,
2696                path: root.join("packages/core/package.json"),
2697                line: 5,
2698                used_in_workspaces: Vec::new(),
2699            }));
2700        let md = build_markdown(&results, &root);
2701        assert!(md.contains("(packages/core/package.json)"));
2702    }
2703
2704    #[test]
2705    fn markdown_dep_at_root_no_extra_label() {
2706        let root = PathBuf::from("/project");
2707        let mut results = AnalysisResults::default();
2708        results
2709            .unused_dependencies
2710            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2711                package_name: "lodash".to_string(),
2712                location: DependencyLocation::Dependencies,
2713                path: root.join("package.json"),
2714                line: 5,
2715                used_in_workspaces: Vec::new(),
2716            }));
2717        let md = build_markdown(&results, &root);
2718        assert!(md.contains("- `lodash`"));
2719        assert!(!md.contains("(package.json)"));
2720    }
2721
2722    #[test]
2723    fn markdown_root_dep_with_cross_workspace_context_uses_context_label() {
2724        let root = PathBuf::from("/project");
2725        let mut results = AnalysisResults::default();
2726        results
2727            .unused_dependencies
2728            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2729                package_name: "lodash-es".to_string(),
2730                location: DependencyLocation::Dependencies,
2731                path: root.join("package.json"),
2732                line: 5,
2733                used_in_workspaces: vec![root.join("packages/consumer")],
2734            }));
2735        let md = build_markdown(&results, &root);
2736        assert!(md.contains("- `lodash-es` (imported in packages/consumer)"));
2737        assert!(!md.contains("(package.json; imported in packages/consumer)"));
2738    }
2739
2740    #[test]
2741    fn markdown_exports_grouped_by_file() {
2742        let root = PathBuf::from("/project");
2743        let mut results = AnalysisResults::default();
2744        results
2745            .unused_exports
2746            .push(UnusedExportFinding::with_actions(UnusedExport {
2747                path: root.join("src/utils.ts"),
2748                export_name: "alpha".to_string(),
2749                is_type_only: false,
2750                line: 5,
2751                col: 0,
2752                span_start: 0,
2753                is_re_export: false,
2754            }));
2755        results
2756            .unused_exports
2757            .push(UnusedExportFinding::with_actions(UnusedExport {
2758                path: root.join("src/utils.ts"),
2759                export_name: "beta".to_string(),
2760                is_type_only: false,
2761                line: 10,
2762                col: 0,
2763                span_start: 0,
2764                is_re_export: false,
2765            }));
2766        results
2767            .unused_exports
2768            .push(UnusedExportFinding::with_actions(UnusedExport {
2769                path: root.join("src/other.ts"),
2770                export_name: "gamma".to_string(),
2771                is_type_only: false,
2772                line: 1,
2773                col: 0,
2774                span_start: 0,
2775                is_re_export: false,
2776            }));
2777        let md = build_markdown(&results, &root);
2778        let utils_count = md.matches("- `src/utils.ts`").count();
2779        assert_eq!(utils_count, 1, "file header should appear once per file");
2780        assert!(md.contains(":5 `alpha`"));
2781        assert!(md.contains(":10 `beta`"));
2782    }
2783
2784    #[test]
2785    fn markdown_multiple_issues_plural() {
2786        let root = PathBuf::from("/project");
2787        let mut results = AnalysisResults::default();
2788        results
2789            .unused_files
2790            .push(UnusedFileFinding::with_actions(UnusedFile {
2791                path: root.join("src/a.ts"),
2792            }));
2793        results
2794            .unused_files
2795            .push(UnusedFileFinding::with_actions(UnusedFile {
2796                path: root.join("src/b.ts"),
2797            }));
2798        let md = build_markdown(&results, &root);
2799        assert!(md.starts_with("## Fallow: 2 issues found\n"));
2800    }
2801
2802    #[test]
2803    fn duplication_markdown_zero_savings_no_suffix() {
2804        let root = PathBuf::from("/project");
2805        let report = DuplicationReport {
2806            clone_groups: vec![CloneGroup {
2807                instances: vec![CloneInstance {
2808                    file: root.join("src/a.ts"),
2809                    start_line: 1,
2810                    end_line: 5,
2811                    start_col: 0,
2812                    end_col: 0,
2813                    fragment: String::new(),
2814                }],
2815                token_count: 30,
2816                line_count: 5,
2817            }],
2818            clone_families: vec![CloneFamily {
2819                files: vec![root.join("src/a.ts")],
2820                groups: vec![],
2821                total_duplicated_lines: 5,
2822                total_duplicated_tokens: 30,
2823                suggestions: vec![RefactoringSuggestion {
2824                    kind: RefactoringKind::ExtractFunction,
2825                    description: "Extract function".to_string(),
2826                    estimated_savings: 0,
2827                }],
2828            }],
2829            mirrored_directories: vec![],
2830            stats: DuplicationStats {
2831                clone_groups: 1,
2832                clone_instances: 1,
2833                duplication_percentage: 1.0,
2834                ..Default::default()
2835            },
2836        };
2837        let md = build_duplication_markdown(&report, &root);
2838        assert!(md.contains("Extract function"));
2839        assert!(!md.contains("lines saved"));
2840    }
2841
2842    #[test]
2843    fn health_markdown_vital_signs_table() {
2844        let root = PathBuf::from("/project");
2845        let report = crate::health_types::HealthReport {
2846            summary: crate::health_types::HealthSummary {
2847                files_analyzed: 10,
2848                functions_analyzed: 50,
2849                ..Default::default()
2850            },
2851            vital_signs: Some(crate::health_types::VitalSigns {
2852                avg_cyclomatic: 3.5,
2853                p90_cyclomatic: 12,
2854                dead_file_pct: Some(5.0),
2855                dead_export_pct: Some(10.2),
2856                duplication_pct: None,
2857                maintainability_avg: Some(72.3),
2858                hotspot_count: Some(3),
2859                circular_dep_count: Some(1),
2860                unused_dep_count: Some(2),
2861                counts: None,
2862                unit_size_profile: None,
2863                unit_interfacing_profile: None,
2864                p95_fan_in: None,
2865                coupling_high_pct: None,
2866                total_loc: 15_200,
2867                ..Default::default()
2868            }),
2869            hotspot_summary: Some(crate::health_types::HotspotSummary {
2870                since: "6 months".to_string(),
2871                min_commits: 3,
2872                files_analyzed: 50,
2873                files_excluded: 0,
2874                shallow_clone: false,
2875            }),
2876            ..Default::default()
2877        };
2878        let md = build_health_markdown(&report, &root);
2879        assert!(md.contains("## Vital Signs"));
2880        assert!(md.contains("| Metric | Value |"));
2881        assert!(md.contains("| Total LOC | 15200 |"));
2882        assert!(md.contains("| Avg Cyclomatic | 3.5 |"));
2883        assert!(md.contains("| P90 Cyclomatic | 12 |"));
2884        assert!(md.contains("| Dead Files | 5.0% |"));
2885        assert!(md.contains("| Dead Exports | 10.2% |"));
2886        assert!(md.contains("| Maintainability (avg) | 72.3 |"));
2887        assert!(md.contains("| Hotspots (since 6 months) | 3 |"));
2888        assert!(md.contains("| Circular Deps | 1 |"));
2889        assert!(md.contains("| Unused Deps | 2 |"));
2890    }
2891
2892    #[test]
2893    fn health_markdown_hotspots_without_summary_omits_window() {
2894        let root = PathBuf::from("/project");
2895        let report = crate::health_types::HealthReport {
2896            vital_signs: Some(crate::health_types::VitalSigns {
2897                avg_cyclomatic: 2.0,
2898                p90_cyclomatic: 5,
2899                hotspot_count: Some(0),
2900                total_loc: 1_000,
2901                ..Default::default()
2902            }),
2903            hotspot_summary: None,
2904            ..Default::default()
2905        };
2906        let md = build_health_markdown(&report, &root);
2907        assert!(md.contains("| Hotspots | 0 |"));
2908        assert!(!md.contains("Hotspots (since"));
2909    }
2910
2911    #[test]
2912    fn health_markdown_file_scores_table() {
2913        let root = PathBuf::from("/project");
2914        let report = crate::health_types::HealthReport {
2915            findings: vec![
2916                crate::health_types::ComplexityViolation {
2917                    path: root.join("src/dummy.ts"),
2918                    name: "fn".to_string(),
2919                    line: 1,
2920                    col: 0,
2921                    cyclomatic: 25,
2922                    cognitive: 20,
2923                    line_count: 50,
2924                    param_count: 0,
2925                    react_hook_count: 0,
2926                    react_jsx_max_depth: 0,
2927                    react_prop_count: 0,
2928                    react_hook_profile: None,
2929                    exceeded: crate::health_types::ExceededThreshold::Both,
2930                    severity: crate::health_types::FindingSeverity::High,
2931                    crap: None,
2932                    coverage_pct: None,
2933                    coverage_tier: None,
2934                    coverage_source: None,
2935                    inherited_from: None,
2936                    component_rollup: None,
2937                    contributions: Vec::new(),
2938                    effective_thresholds: None,
2939                    threshold_source: None,
2940                }
2941                .into(),
2942            ],
2943            summary: crate::health_types::HealthSummary {
2944                files_analyzed: 5,
2945                functions_analyzed: 10,
2946                functions_above_threshold: 1,
2947                files_scored: Some(1),
2948                average_maintainability: Some(65.0),
2949                ..Default::default()
2950            },
2951            file_scores: vec![crate::health_types::FileHealthScore {
2952                path: root.join("src/utils.ts"),
2953                fan_in: 5,
2954                fan_out: 3,
2955                dead_code_ratio: 0.25,
2956                complexity_density: 0.8,
2957                maintainability_index: 72.5,
2958                total_cyclomatic: 40,
2959                total_cognitive: 30,
2960                function_count: 10,
2961                lines: 200,
2962                crap_max: 0.0,
2963                crap_above_threshold: 0,
2964            }],
2965            ..Default::default()
2966        };
2967        let md = build_health_markdown(&report, &root);
2968        assert!(md.contains("### File Health Scores (1 files)"));
2969        assert!(md.contains("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density |"));
2970        assert!(md.contains("| `src/utils.ts` | 72.5 | 5 | 3 | 25% | 0.80 |"));
2971        assert!(md.contains("**Average maintainability index:** 65.0/100"));
2972    }
2973
2974    #[test]
2975    fn health_markdown_hotspots_table() {
2976        let root = PathBuf::from("/project");
2977        let report = crate::health_types::HealthReport {
2978            findings: vec![
2979                crate::health_types::ComplexityViolation {
2980                    path: root.join("src/dummy.ts"),
2981                    name: "fn".to_string(),
2982                    line: 1,
2983                    col: 0,
2984                    cyclomatic: 25,
2985                    cognitive: 20,
2986                    line_count: 50,
2987                    param_count: 0,
2988                    react_hook_count: 0,
2989                    react_jsx_max_depth: 0,
2990                    react_prop_count: 0,
2991                    react_hook_profile: None,
2992                    exceeded: crate::health_types::ExceededThreshold::Both,
2993                    severity: crate::health_types::FindingSeverity::High,
2994                    crap: None,
2995                    coverage_pct: None,
2996                    coverage_tier: None,
2997                    coverage_source: None,
2998                    inherited_from: None,
2999                    component_rollup: None,
3000                    contributions: Vec::new(),
3001                    effective_thresholds: None,
3002                    threshold_source: None,
3003                }
3004                .into(),
3005            ],
3006            summary: crate::health_types::HealthSummary {
3007                files_analyzed: 5,
3008                functions_analyzed: 10,
3009                functions_above_threshold: 1,
3010                ..Default::default()
3011            },
3012            hotspots: vec![
3013                crate::health_types::HotspotEntry {
3014                    path: root.join("src/hot.ts"),
3015                    score: 85.0,
3016                    commits: 42,
3017                    weighted_commits: 35.0,
3018                    lines_added: 500,
3019                    lines_deleted: 200,
3020                    complexity_density: 1.2,
3021                    fan_in: 10,
3022                    trend: fallow_core::churn::ChurnTrend::Accelerating,
3023                    ownership: None,
3024                    is_test_path: false,
3025                }
3026                .into(),
3027            ],
3028            hotspot_summary: Some(crate::health_types::HotspotSummary {
3029                since: "6 months".to_string(),
3030                min_commits: 3,
3031                files_analyzed: 50,
3032                files_excluded: 5,
3033                shallow_clone: false,
3034            }),
3035            ..Default::default()
3036        };
3037        let md = build_health_markdown(&report, &root);
3038        assert!(md.contains("### Hotspots (1 files, since 6 months)"));
3039        assert!(md.contains("| `src/hot.ts` | 85.0 | 42 | 700 | 1.20 | 10 | accelerating |"));
3040        assert!(md.contains("*5 files excluded (< 3 commits)*"));
3041    }
3042
3043    #[test]
3044    fn health_markdown_metric_legend_with_scores() {
3045        let root = PathBuf::from("/project");
3046        let report = crate::health_types::HealthReport {
3047            findings: vec![
3048                crate::health_types::ComplexityViolation {
3049                    path: root.join("src/x.ts"),
3050                    name: "f".to_string(),
3051                    line: 1,
3052                    col: 0,
3053                    cyclomatic: 25,
3054                    cognitive: 20,
3055                    line_count: 10,
3056                    param_count: 0,
3057                    react_hook_count: 0,
3058                    react_jsx_max_depth: 0,
3059                    react_prop_count: 0,
3060                    react_hook_profile: None,
3061                    exceeded: crate::health_types::ExceededThreshold::Both,
3062                    severity: crate::health_types::FindingSeverity::High,
3063                    crap: None,
3064                    coverage_pct: None,
3065                    coverage_tier: None,
3066                    coverage_source: None,
3067                    inherited_from: None,
3068                    component_rollup: None,
3069                    contributions: Vec::new(),
3070                    effective_thresholds: None,
3071                    threshold_source: None,
3072                }
3073                .into(),
3074            ],
3075            summary: crate::health_types::HealthSummary {
3076                files_analyzed: 1,
3077                functions_analyzed: 1,
3078                functions_above_threshold: 1,
3079                files_scored: Some(1),
3080                average_maintainability: Some(70.0),
3081                ..Default::default()
3082            },
3083            file_scores: vec![crate::health_types::FileHealthScore {
3084                path: root.join("src/x.ts"),
3085                fan_in: 1,
3086                fan_out: 1,
3087                dead_code_ratio: 0.0,
3088                complexity_density: 0.5,
3089                maintainability_index: 80.0,
3090                total_cyclomatic: 10,
3091                total_cognitive: 8,
3092                function_count: 2,
3093                lines: 50,
3094                crap_max: 0.0,
3095                crap_above_threshold: 0,
3096            }],
3097            ..Default::default()
3098        };
3099        let md = build_health_markdown(&report, &root);
3100        assert!(md.contains("<details><summary>Metric definitions</summary>"));
3101        assert!(md.contains("**MI**: Maintainability Index"));
3102        assert!(md.contains("**Fan-in**"));
3103        assert!(md.contains("Full metric reference"));
3104    }
3105
3106    #[test]
3107    fn health_markdown_truncated_findings_shown_count() {
3108        let root = PathBuf::from("/project");
3109        let report = crate::health_types::HealthReport {
3110            findings: vec![
3111                crate::health_types::ComplexityViolation {
3112                    path: root.join("src/x.ts"),
3113                    name: "f".to_string(),
3114                    line: 1,
3115                    col: 0,
3116                    cyclomatic: 25,
3117                    cognitive: 20,
3118                    line_count: 10,
3119                    param_count: 0,
3120                    react_hook_count: 0,
3121                    react_jsx_max_depth: 0,
3122                    react_prop_count: 0,
3123                    react_hook_profile: None,
3124                    exceeded: crate::health_types::ExceededThreshold::Both,
3125                    severity: crate::health_types::FindingSeverity::High,
3126                    crap: None,
3127                    coverage_pct: None,
3128                    coverage_tier: None,
3129                    coverage_source: None,
3130                    inherited_from: None,
3131                    component_rollup: None,
3132                    contributions: Vec::new(),
3133                    effective_thresholds: None,
3134                    threshold_source: None,
3135                }
3136                .into(),
3137            ],
3138            summary: crate::health_types::HealthSummary {
3139                files_analyzed: 10,
3140                functions_analyzed: 50,
3141                functions_above_threshold: 5, // 5 total but only 1 shown
3142                ..Default::default()
3143            },
3144            ..Default::default()
3145        };
3146        let md = build_health_markdown(&report, &root);
3147        assert!(md.contains("5 high complexity functions (1 shown)"));
3148    }
3149
3150    #[test]
3151    fn escape_backticks_handles_multiple() {
3152        assert_eq!(escape_backticks("a`b`c"), "a\\`b\\`c");
3153    }
3154
3155    #[test]
3156    fn escape_backticks_no_backticks_unchanged() {
3157        assert_eq!(escape_backticks("hello"), "hello");
3158    }
3159
3160    #[test]
3161    fn markdown_unresolved_import_grouped_by_file() {
3162        let root = PathBuf::from("/project");
3163        let mut results = AnalysisResults::default();
3164        results
3165            .unresolved_imports
3166            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
3167                path: root.join("src/app.ts"),
3168                specifier: "./missing".to_string(),
3169                line: 3,
3170                col: 0,
3171                specifier_col: 0,
3172            }));
3173        let md = build_markdown(&results, &root);
3174        assert!(md.contains("### Unresolved imports (1)"));
3175        assert!(md.contains("- `src/app.ts`"));
3176        assert!(md.contains(":3 `./missing`"));
3177    }
3178
3179    #[test]
3180    fn markdown_unused_optional_dep() {
3181        let root = PathBuf::from("/project");
3182        let mut results = AnalysisResults::default();
3183        results
3184            .unused_optional_dependencies
3185            .push(UnusedOptionalDependencyFinding::with_actions(
3186                UnusedDependency {
3187                    package_name: "fsevents".to_string(),
3188                    location: DependencyLocation::OptionalDependencies,
3189                    path: root.join("package.json"),
3190                    line: 12,
3191                    used_in_workspaces: Vec::new(),
3192                },
3193            ));
3194        let md = build_markdown(&results, &root);
3195        assert!(md.contains("### Unused optionalDependencies (1)"));
3196        assert!(md.contains("- `fsevents`"));
3197    }
3198
3199    #[test]
3200    fn health_markdown_hotspots_no_excluded_message() {
3201        let root = PathBuf::from("/project");
3202        let report = crate::health_types::HealthReport {
3203            findings: vec![
3204                crate::health_types::ComplexityViolation {
3205                    path: root.join("src/x.ts"),
3206                    name: "f".to_string(),
3207                    line: 1,
3208                    col: 0,
3209                    cyclomatic: 25,
3210                    cognitive: 20,
3211                    line_count: 10,
3212                    param_count: 0,
3213                    react_hook_count: 0,
3214                    react_jsx_max_depth: 0,
3215                    react_prop_count: 0,
3216                    react_hook_profile: None,
3217                    exceeded: crate::health_types::ExceededThreshold::Both,
3218                    severity: crate::health_types::FindingSeverity::High,
3219                    crap: None,
3220                    coverage_pct: None,
3221                    coverage_tier: None,
3222                    coverage_source: None,
3223                    inherited_from: None,
3224                    component_rollup: None,
3225                    contributions: Vec::new(),
3226                    effective_thresholds: None,
3227                    threshold_source: None,
3228                }
3229                .into(),
3230            ],
3231            summary: crate::health_types::HealthSummary {
3232                files_analyzed: 5,
3233                functions_analyzed: 10,
3234                functions_above_threshold: 1,
3235                ..Default::default()
3236            },
3237            hotspots: vec![
3238                crate::health_types::HotspotEntry {
3239                    path: root.join("src/hot.ts"),
3240                    score: 50.0,
3241                    commits: 10,
3242                    weighted_commits: 8.0,
3243                    lines_added: 100,
3244                    lines_deleted: 50,
3245                    complexity_density: 0.5,
3246                    fan_in: 3,
3247                    trend: fallow_core::churn::ChurnTrend::Stable,
3248                    ownership: None,
3249                    is_test_path: false,
3250                }
3251                .into(),
3252            ],
3253            hotspot_summary: Some(crate::health_types::HotspotSummary {
3254                since: "6 months".to_string(),
3255                min_commits: 3,
3256                files_analyzed: 50,
3257                files_excluded: 0,
3258                shallow_clone: false,
3259            }),
3260            ..Default::default()
3261        };
3262        let md = build_health_markdown(&report, &root);
3263        assert!(!md.contains("files excluded"));
3264    }
3265
3266    #[test]
3267    fn duplication_markdown_single_group_no_plural() {
3268        let root = PathBuf::from("/project");
3269        let report = DuplicationReport {
3270            clone_groups: vec![CloneGroup {
3271                instances: vec![CloneInstance {
3272                    file: root.join("src/a.ts"),
3273                    start_line: 1,
3274                    end_line: 5,
3275                    start_col: 0,
3276                    end_col: 0,
3277                    fragment: String::new(),
3278                }],
3279                token_count: 30,
3280                line_count: 5,
3281            }],
3282            clone_families: vec![],
3283            mirrored_directories: vec![],
3284            stats: DuplicationStats {
3285                clone_groups: 1,
3286                clone_instances: 1,
3287                duplication_percentage: 2.0,
3288                ..Default::default()
3289            },
3290        };
3291        let md = build_duplication_markdown(&report, &root);
3292        assert!(md.contains("1 clone group found"));
3293        assert!(!md.contains("1 clone groups found"));
3294    }
3295}