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    // Lit: `component_name` is the registered TAG, so render it as a custom
560    // element `<x-foo>` (mirrors the human formatter's `framework == "lit"`
561    // branch so the two human-facing surfaces stay consistent).
562    if c.component.framework == "lit" {
563        return vec![format!(
564            "- `{}`:{} `<{}>` is a registered custom element but rendered in no template (render it or remove it)",
565            rel(&c.component.path),
566            c.component.line,
567            escape_backticks(&c.component.component_name),
568        )];
569    }
570    vec![format!(
571        "- `{}`:{} `{}` is reachable but rendered nowhere in this project (render it somewhere or remove it)",
572        rel(&c.component.path),
573        c.component.line,
574        escape_backticks(&c.component.component_name),
575    )]
576}
577
578fn format_markdown_unused_component_prop(
579    p: &fallow_core::results::UnusedComponentPropFinding,
580    rel: &dyn Fn(&Path) -> String,
581) -> Vec<String> {
582    vec![format!(
583        "- `{}`:{} `{}` is declared but referenced nowhere in this component (remove it or use it)",
584        rel(&p.prop.path),
585        p.prop.line,
586        escape_backticks(&p.prop.prop_name),
587    )]
588}
589
590fn format_markdown_unused_component_emit(
591    e: &fallow_core::results::UnusedComponentEmitFinding,
592    rel: &dyn Fn(&Path) -> String,
593) -> Vec<String> {
594    vec![format!(
595        "- `{}`:{} `{}` is declared but emitted nowhere in this component (remove it or emit it)",
596        rel(&e.emit.path),
597        e.emit.line,
598        escape_backticks(&e.emit.emit_name),
599    )]
600}
601
602fn format_markdown_unused_svelte_event(
603    e: &fallow_core::results::UnusedSvelteEventFinding,
604    rel: &dyn Fn(&Path) -> String,
605) -> Vec<String> {
606    vec![format!(
607        "- `{}`:{} `{}` is dispatched but listened to nowhere in the project (remove it or listen for it)",
608        rel(&e.event.path),
609        e.event.line,
610        escape_backticks(&e.event.event_name),
611    )]
612}
613
614fn format_markdown_unused_component_input(
615    i: &fallow_core::results::UnusedComponentInputFinding,
616    rel: &dyn Fn(&Path) -> String,
617) -> Vec<String> {
618    vec![format!(
619        "- `{}`:{} `{}` is declared but referenced nowhere in this component (remove it or use it)",
620        rel(&i.input.path),
621        i.input.line,
622        escape_backticks(&i.input.input_name),
623    )]
624}
625
626fn format_markdown_unused_component_output(
627    o: &fallow_core::results::UnusedComponentOutputFinding,
628    rel: &dyn Fn(&Path) -> String,
629) -> Vec<String> {
630    vec![format!(
631        "- `{}`:{} `{}` is declared but emitted nowhere in this component (remove it or emit it)",
632        rel(&o.output.path),
633        o.output.line,
634        escape_backticks(&o.output.output_name),
635    )]
636}
637
638fn format_markdown_unused_server_action(
639    a: &fallow_core::results::UnusedServerActionFinding,
640    rel: &dyn Fn(&Path) -> String,
641) -> Vec<String> {
642    vec![format!(
643        "- `{}`:{} `{}` is exported from a \"use server\" file but no code in this project references it",
644        rel(&a.action.path),
645        a.action.line,
646        escape_backticks(&a.action.action_name),
647    )]
648}
649
650fn format_markdown_unused_load_data_key(
651    k: &fallow_core::results::UnusedLoadDataKeyFinding,
652    rel: &dyn Fn(&Path) -> String,
653) -> Vec<String> {
654    vec![format!(
655        "- `{}`:{} `{}` is returned from load() but no consumer reads it",
656        rel(&k.key.path),
657        k.key.line,
658        escape_backticks(&k.key.key_name),
659    )]
660}
661
662fn format_markdown_route_collision(
663    c: &fallow_core::results::RouteCollisionFinding,
664    rel: &dyn Fn(&Path) -> String,
665) -> Vec<String> {
666    vec![format!(
667        "- `{}` resolves to `{}` (shared with {} other route file(s))",
668        rel(&c.collision.path),
669        c.collision.url,
670        c.collision.conflicting_paths.len(),
671    )]
672}
673
674fn format_markdown_dynamic_segment_name_conflict(
675    c: &fallow_core::results::DynamicSegmentNameConflictFinding,
676    rel: &dyn Fn(&Path) -> String,
677) -> Vec<String> {
678    vec![format!(
679        "- `{}` crashes at runtime: different slug names ({}) at the same dynamic path `{}`; \
680         `next build` passes but the route fails on its first request (rename to one consistent slug)",
681        rel(&c.conflict.path),
682        c.conflict.conflicting_segments.join(" vs "),
683        c.conflict.position,
684    )]
685}
686
687fn push_markdown_catalog_sections(
688    out: &mut String,
689    results: &AnalysisResults,
690    rel: &dyn Fn(&Path) -> String,
691) {
692    markdown_section(
693        out,
694        &results.unused_catalog_entries,
695        "Unused catalog entries",
696        |entry| format_unused_catalog_entry(entry, rel),
697    );
698    markdown_section(
699        out,
700        &results.empty_catalog_groups,
701        "Empty catalog groups",
702        |group| {
703            vec![format!(
704                "- `{}` `{}`:{}",
705                escape_backticks(&group.group.catalog_name),
706                rel(&group.group.path),
707                group.group.line,
708            )]
709        },
710    );
711    markdown_section(
712        out,
713        &results.unresolved_catalog_references,
714        "Unresolved catalog references",
715        |finding| format_unresolved_catalog_reference(finding, rel),
716    );
717    markdown_section(
718        out,
719        &results.unused_dependency_overrides,
720        "Unused dependency overrides",
721        |finding| format_unused_dependency_override(finding, rel),
722    );
723    markdown_section(
724        out,
725        &results.misconfigured_dependency_overrides,
726        "Misconfigured dependency overrides",
727        |finding| {
728            vec![format!(
729                "- `{}` -> `{}` (`{}`) `{}`:{} ({})",
730                escape_backticks(&finding.entry.raw_key),
731                escape_backticks(&finding.entry.raw_value),
732                finding.entry.source.as_label(),
733                rel(&finding.entry.path),
734                finding.entry.line,
735                finding.entry.reason.describe(),
736            )]
737        },
738    );
739}
740
741fn format_unused_catalog_entry(
742    entry: &UnusedCatalogEntryFinding,
743    rel: &dyn Fn(&Path) -> String,
744) -> Vec<String> {
745    let mut row = format!(
746        "- `{}` (`{}`) `{}`:{}",
747        escape_backticks(&entry.entry.entry_name),
748        escape_backticks(&entry.entry.catalog_name),
749        rel(&entry.entry.path),
750        entry.entry.line,
751    );
752    if !entry.entry.hardcoded_consumers.is_empty() {
753        let consumers = entry
754            .entry
755            .hardcoded_consumers
756            .iter()
757            .map(|p| format!("`{}`", rel(p)))
758            .collect::<Vec<_>>()
759            .join(", ");
760        let _ = write!(row, " (hardcoded in {consumers})");
761    }
762    vec![row]
763}
764
765fn format_unresolved_catalog_reference(
766    finding: &UnresolvedCatalogReferenceFinding,
767    rel: &dyn Fn(&Path) -> String,
768) -> Vec<String> {
769    let mut row = format!(
770        "- `{}` (`{}`) `{}`:{}",
771        escape_backticks(&finding.reference.entry_name),
772        escape_backticks(&finding.reference.catalog_name),
773        rel(&finding.reference.path),
774        finding.reference.line,
775    );
776    if !finding.reference.available_in_catalogs.is_empty() {
777        let alts = finding
778            .reference
779            .available_in_catalogs
780            .iter()
781            .map(|c| format!("`{}`", escape_backticks(c)))
782            .collect::<Vec<_>>()
783            .join(", ");
784        let _ = write!(row, " (available in: {alts})");
785    }
786    vec![row]
787}
788
789fn format_unused_dependency_override(
790    finding: &UnusedDependencyOverrideFinding,
791    rel: &dyn Fn(&Path) -> String,
792) -> Vec<String> {
793    let mut row = format!(
794        "- `{}` -> `{}` (`{}`) `{}`:{}",
795        escape_backticks(&finding.entry.raw_key),
796        escape_backticks(&finding.entry.version_range),
797        finding.entry.source.as_label(),
798        rel(&finding.entry.path),
799        finding.entry.line,
800    );
801    if let Some(hint) = &finding.entry.hint {
802        let _ = write!(row, " (hint: {})", escape_backticks(hint));
803    }
804    vec![row]
805}
806
807/// Print grouped markdown output: each group gets an `## owner (N issues)` heading.
808pub(super) fn print_grouped_markdown(groups: &[ResultGroup], root: &Path) {
809    let total: usize = groups.iter().map(|g| g.results.total_issues()).sum();
810
811    if total == 0 {
812        outln!("## Fallow: no issues found");
813        return;
814    }
815
816    outln!(
817        "## Fallow: {total} issue{} found (grouped)\n",
818        plural(total)
819    );
820
821    for group in groups {
822        let count = group.results.total_issues();
823        if count == 0 {
824            continue;
825        }
826        outln!(
827            "## {} ({count} issue{})\n",
828            escape_backticks(&group.key),
829            plural(count)
830        );
831        if let Some(ref owners) = group.owners
832            && !owners.is_empty()
833        {
834            let joined = owners
835                .iter()
836                .map(|o| escape_backticks(o))
837                .collect::<Vec<_>>()
838                .join(" ");
839            outln!("Owners: {joined}\n");
840        }
841        let body = build_markdown(&group.results, root);
842        let sections = body
843            .strip_prefix("## Fallow: no issues found\n")
844            .or_else(|| body.find("\n\n").map(|pos| &body[pos + 2..]))
845            .unwrap_or(&body);
846        out!("{sections}");
847    }
848}
849
850fn format_export(e: &UnusedExport) -> String {
851    let re = if e.is_re_export { " (re-export)" } else { "" };
852    format!(":{} `{}`{re}", e.line, escape_backticks(&e.export_name))
853}
854
855fn format_private_type_leak(
856    entry: &fallow_types::output_dead_code::PrivateTypeLeakFinding,
857) -> String {
858    let e = &entry.leak;
859    format!(
860        ":{} `{}` references private type `{}`",
861        e.line,
862        escape_backticks(&e.export_name),
863        escape_backticks(&e.type_name)
864    )
865}
866
867fn format_member(m: &UnusedMember) -> String {
868    format!(
869        ":{} `{}.{}`",
870        m.line,
871        escape_backticks(&m.parent_name),
872        escape_backticks(&m.member_name)
873    )
874}
875
876fn format_dependency(
877    dep_name: &str,
878    pkg_path: &Path,
879    used_in_workspaces: &[std::path::PathBuf],
880    root: &Path,
881) -> Vec<String> {
882    let name = escape_backticks(dep_name);
883    let pkg_label = relative_path(pkg_path, root).display().to_string();
884    let workspace_context = if used_in_workspaces.is_empty() {
885        String::new()
886    } else {
887        let workspaces = used_in_workspaces
888            .iter()
889            .map(|path| escape_backticks(&relative_path(path, root).display().to_string()))
890            .collect::<Vec<_>>()
891            .join(", ");
892        format!("; imported in {workspaces}")
893    };
894    if pkg_label == "package.json" && workspace_context.is_empty() {
895        vec![format!("- `{name}`")]
896    } else {
897        let label = if pkg_label == "package.json" {
898            workspace_context.trim_start_matches("; ").to_string()
899        } else {
900            format!("{}{workspace_context}", escape_backticks(&pkg_label))
901        };
902        vec![format!("- `{name}` ({label})")]
903    }
904}
905
906/// Emit a markdown section with a header and per-item lines. Skipped if empty.
907fn markdown_section<T>(
908    out: &mut String,
909    items: &[T],
910    title: &str,
911    format_lines: impl Fn(&T) -> Vec<String>,
912) {
913    if items.is_empty() {
914        return;
915    }
916    let _ = write!(out, "### {title} ({})\n\n", items.len());
917    for item in items {
918        for line in format_lines(item) {
919            out.push_str(&line);
920            out.push('\n');
921        }
922    }
923    out.push('\n');
924}
925
926/// Emit a markdown section whose items are grouped by file path.
927fn markdown_grouped_section<'a, T>(
928    out: &mut String,
929    items: &'a [T],
930    title: &str,
931    root: &Path,
932    get_path: impl Fn(&'a T) -> &'a Path,
933    format_detail: impl Fn(&T) -> String,
934) {
935    if items.is_empty() {
936        return;
937    }
938    let _ = write!(out, "### {title} ({})\n\n", items.len());
939
940    let mut indices: Vec<usize> = (0..items.len()).collect();
941    indices.sort_by(|&a, &b| get_path(&items[a]).cmp(get_path(&items[b])));
942
943    let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
944    let mut last_file = String::new();
945    for &i in &indices {
946        let item = &items[i];
947        let file_str = rel(get_path(item));
948        if file_str != last_file {
949            let _ = writeln!(out, "- `{file_str}`");
950            last_file = file_str;
951        }
952        let _ = writeln!(out, "  - {}", format_detail(item));
953    }
954    out.push('\n');
955}
956
957pub(super) fn print_duplication_markdown(report: &DuplicationReport, root: &Path) {
958    outln!("{}", build_duplication_markdown(report, root));
959}
960
961/// Build markdown output for duplication results.
962#[must_use]
963pub fn build_duplication_markdown(report: &DuplicationReport, root: &Path) -> String {
964    let mut out = String::new();
965
966    if report.clone_groups.is_empty() {
967        out.push_str("## Fallow: no code duplication found\n");
968        return out;
969    }
970
971    let stats = &report.stats;
972    let _ = write!(
973        out,
974        "## Fallow: {} clone group{} found ({:.1}% duplication)\n\n",
975        stats.clone_groups,
976        plural(stats.clone_groups),
977        stats.duplication_percentage,
978    );
979
980    write_duplication_groups(&mut out, report, root);
981    write_duplication_families(&mut out, report, root);
982
983    let _ = writeln!(
984        out,
985        "**Summary:** {} duplicated lines ({:.1}%) across {} file{}",
986        stats.duplicated_lines,
987        stats.duplication_percentage,
988        stats.files_with_clones,
989        plural(stats.files_with_clones),
990    );
991
992    out
993}
994
995/// Write the clone-groups subsection of the duplication markdown.
996fn write_duplication_groups(out: &mut String, report: &DuplicationReport, root: &Path) {
997    let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
998    out.push_str("### Duplicates\n\n");
999    for (i, group) in report.clone_groups.iter().enumerate() {
1000        let instance_count = group.instances.len();
1001        let _ = write!(
1002            out,
1003            "**Clone group {}** ({} lines, {instance_count} instance{})\n\n",
1004            i + 1,
1005            group.line_count,
1006            plural(instance_count)
1007        );
1008        for instance in &group.instances {
1009            let relative = rel(&instance.file);
1010            let _ = writeln!(
1011                out,
1012                "- `{relative}:{}-{}`",
1013                instance.start_line, instance.end_line
1014            );
1015        }
1016        out.push('\n');
1017    }
1018}
1019
1020/// Write the clone-families subsection of the duplication markdown.
1021fn write_duplication_families(out: &mut String, report: &DuplicationReport, root: &Path) {
1022    if report.clone_families.is_empty() {
1023        return;
1024    }
1025    let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
1026    out.push_str("### Clone Families\n\n");
1027    for (i, family) in report.clone_families.iter().enumerate() {
1028        let file_names: Vec<_> = family.files.iter().map(|f| rel(f)).collect();
1029        let _ = write!(
1030            out,
1031            "**Family {}** ({} group{}, {} lines across {})\n\n",
1032            i + 1,
1033            family.groups.len(),
1034            plural(family.groups.len()),
1035            family.total_duplicated_lines,
1036            file_names
1037                .iter()
1038                .map(|s| format!("`{s}`"))
1039                .collect::<Vec<_>>()
1040                .join(", "),
1041        );
1042        for suggestion in &family.suggestions {
1043            let savings = if suggestion.estimated_savings > 0 {
1044                format!(" (~{} lines saved)", suggestion.estimated_savings)
1045            } else {
1046                String::new()
1047            };
1048            let _ = writeln!(out, "- {}{savings}", suggestion.description);
1049        }
1050        out.push('\n');
1051    }
1052}
1053
1054pub(super) fn print_health_markdown(report: &crate::health_types::HealthReport, root: &Path) {
1055    outln!("{}", build_health_markdown(report, root));
1056}
1057
1058/// Build markdown output for health (complexity) results.
1059#[must_use]
1060pub fn build_health_markdown(report: &crate::health_types::HealthReport, root: &Path) -> String {
1061    let mut out = String::new();
1062
1063    if let Some(ref hs) = report.health_score {
1064        let _ = writeln!(out, "## Health Score: {:.0} ({})\n", hs.score, hs.grade);
1065    }
1066
1067    write_trend_section(&mut out, report);
1068    write_vital_signs_section(&mut out, report);
1069
1070    if report.findings.is_empty()
1071        && report.file_scores.is_empty()
1072        && report.coverage_gaps.is_none()
1073        && report.hotspots.is_empty()
1074        && report.targets.is_empty()
1075        && report.runtime_coverage.is_none()
1076        && report.coverage_intelligence.is_none()
1077        && report.threshold_overrides.is_empty()
1078        && report.css_analytics.is_none()
1079    {
1080        if report.vital_signs.is_none() {
1081            let _ = write!(
1082                out,
1083                "## Fallow: no functions exceed complexity thresholds\n\n\
1084                 **{}** functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {:.1})\n",
1085                report.summary.functions_analyzed,
1086                report.summary.max_cyclomatic_threshold,
1087                report.summary.max_cognitive_threshold,
1088                report.summary.max_crap_threshold,
1089            );
1090        }
1091        return out;
1092    }
1093
1094    write_findings_section(&mut out, report, root);
1095    write_threshold_overrides_section(&mut out, report, root);
1096    write_runtime_coverage_section(&mut out, report, root);
1097    write_coverage_intelligence_section(&mut out, report, root);
1098    write_coverage_gaps_section(&mut out, report, root);
1099    write_file_scores_section(&mut out, report, root);
1100    write_hotspots_section(&mut out, report, root);
1101    write_targets_section(&mut out, report, root);
1102    write_css_analytics_section(&mut out, report);
1103    write_metric_legend(&mut out, report);
1104
1105    out
1106}
1107
1108/// Render the opt-in `## CSS Health` markdown section (present only with
1109/// `--css`): a summary of structural metrics, value sprawl, and candidate counts
1110/// plus a bounded list of the most actionable located candidates.
1111fn write_css_analytics_section(out: &mut String, report: &crate::health_types::HealthReport) {
1112    let Some(ref css) = report.css_analytics else {
1113        return;
1114    };
1115    let s = &css.summary;
1116    if !out.is_empty() && !out.ends_with("\n\n") {
1117        out.push('\n');
1118    }
1119    out.push_str("## CSS Health\n\n");
1120    let important_pct = if s.total_declarations > 0 {
1121        f64::from(s.important_declarations) / f64::from(s.total_declarations) * 100.0
1122    } else {
1123        0.0
1124    };
1125    let _ = writeln!(
1126        out,
1127        "- Stylesheets: {} | Rules: {} | !important: {important_pct:.1}% | Empty rules: {} | Max nesting: {}",
1128        s.files_analyzed, s.total_rules, s.empty_rules, s.max_nesting_depth,
1129    );
1130    let _ = writeln!(
1131        out,
1132        "- Value sprawl: {} colors | {} font sizes | {} z-index | {} shadows | {} radii | {} line-heights",
1133        s.unique_colors,
1134        s.unique_font_sizes,
1135        s.unique_z_indexes,
1136        s.unique_box_shadows,
1137        s.unique_border_radii,
1138        s.unique_line_heights,
1139    );
1140    let _ = writeln!(
1141        out,
1142        "- 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",
1143        s.keyframes_unreferenced,
1144        s.keyframes_undefined,
1145        s.duplicate_declaration_blocks,
1146        s.scoped_unused_classes,
1147        s.tailwind_arbitrary_values,
1148        s.unused_property_registrations,
1149        s.unused_layers,
1150        s.unresolved_class_references,
1151        s.unreferenced_css_classes,
1152        s.unused_font_faces,
1153        s.unused_theme_tokens,
1154    );
1155    write_css_candidate_details(out, css);
1156    out.push('\n');
1157}
1158
1159fn write_css_candidate_details(out: &mut String, css: &crate::health_types::CssAnalyticsReport) {
1160    write_css_keyframe_details(out, css);
1161    write_css_tailwind_details(out, css);
1162    write_css_class_candidate_details(out, css);
1163    write_css_font_candidate_details(out, css);
1164    write_css_font_size_mix_details(out, css);
1165}
1166
1167fn write_css_keyframe_details(out: &mut String, css: &crate::health_types::CssAnalyticsReport) {
1168    if !css.undefined_keyframes.is_empty() {
1169        let named: Vec<String> = css
1170            .undefined_keyframes
1171            .iter()
1172            .take(5)
1173            .map(|kf| format!("`{}` ({})", kf.name, kf.path))
1174            .collect();
1175        let _ = writeln!(
1176            out,
1177            "- Undefined @keyframes (candidates; likely typo or CSS-in-JS): {}",
1178            named.join(", "),
1179        );
1180    }
1181}
1182
1183fn write_css_tailwind_details(out: &mut String, css: &crate::health_types::CssAnalyticsReport) {
1184    if !css.tailwind_arbitrary_values.is_empty() {
1185        let named: Vec<String> = css
1186            .tailwind_arbitrary_values
1187            .iter()
1188            .take(5)
1189            .map(|a| format!("`{}` ({}x)", a.value, a.count))
1190            .collect();
1191        let _ = writeln!(out, "- Top Tailwind arbitrary values: {}", named.join(", "));
1192    }
1193}
1194
1195fn write_css_class_candidate_details(
1196    out: &mut String,
1197    css: &crate::health_types::CssAnalyticsReport,
1198) {
1199    if !css.unresolved_class_references.is_empty() {
1200        let named: Vec<String> = css
1201            .unresolved_class_references
1202            .iter()
1203            .take(5)
1204            .map(|u| {
1205                format!(
1206                    "`{}` -> `{}` ({}:{})",
1207                    u.class, u.suggestion, u.path, u.line
1208                )
1209            })
1210            .collect();
1211        let _ = writeln!(
1212            out,
1213            "- Likely class typos (candidates; verify, may be CSS-in-JS or external): {}",
1214            named.join(", "),
1215        );
1216    }
1217    if !css.unreferenced_css_classes.is_empty() {
1218        let named: Vec<String> = css
1219            .unreferenced_css_classes
1220            .iter()
1221            .take(5)
1222            .map(|u| format!("`.{}` ({}:{})", u.class, u.path, u.line))
1223            .collect();
1224        let _ = writeln!(
1225            out,
1226            "- Unreferenced global classes (candidates; verify no email / server / CMS / Markdown applies them): {}",
1227            named.join(", "),
1228        );
1229    }
1230}
1231
1232fn write_css_font_candidate_details(
1233    out: &mut String,
1234    css: &crate::health_types::CssAnalyticsReport,
1235) {
1236    if !css.unused_font_faces.is_empty() {
1237        let named: Vec<String> = css
1238            .unused_font_faces
1239            .iter()
1240            .take(5)
1241            .map(|u| format!("`{}` ({})", u.family, u.path))
1242            .collect();
1243        let _ = writeln!(
1244            out,
1245            "- Unused @font-face (dead web-font; candidates, may be set from JS/inline): {}",
1246            named.join(", "),
1247        );
1248    }
1249    if !css.unused_theme_tokens.is_empty() {
1250        let named: Vec<String> = css
1251            .unused_theme_tokens
1252            .iter()
1253            .take(5)
1254            .map(|u| format!("`{}` ({}:{})", u.token, u.path, u.line))
1255            .collect();
1256        let _ = writeln!(
1257            out,
1258            "- Unused @theme tokens (dead Tailwind v4 design tokens; candidates, may be consumed by a plugin or downstream repo): {}",
1259            named.join(", "),
1260        );
1261    }
1262}
1263
1264fn write_css_font_size_mix_details(
1265    out: &mut String,
1266    css: &crate::health_types::CssAnalyticsReport,
1267) {
1268    if let Some(mix) = &css.font_size_unit_mix {
1269        let breakdown: Vec<String> = mix
1270            .notations
1271            .iter()
1272            .map(|n| format!("{} {}", n.count, n.notation))
1273            .collect();
1274        let _ = writeln!(
1275            out,
1276            "- Font sizes mix {} units (candidate, standardize unless intentional): {}",
1277            mix.notations.len(),
1278            breakdown.join(", "),
1279        );
1280    }
1281}
1282
1283fn write_coverage_intelligence_section(
1284    out: &mut String,
1285    report: &crate::health_types::HealthReport,
1286    root: &Path,
1287) {
1288    let Some(ref intelligence) = report.coverage_intelligence else {
1289        return;
1290    };
1291    if !out.is_empty() && !out.ends_with("\n\n") {
1292        out.push('\n');
1293    }
1294    let _ = writeln!(
1295        out,
1296        "## Coverage Intelligence\n\n- Verdict: {}\n- Findings: {}\n- Ambiguous matches skipped: {}\n",
1297        intelligence.verdict,
1298        intelligence.summary.findings,
1299        intelligence.summary.skipped_ambiguous_matches,
1300    );
1301    if intelligence.findings.is_empty() {
1302        if intelligence.summary.skipped_ambiguous_matches > 0 {
1303            let match_phrase = if intelligence.summary.skipped_ambiguous_matches == 1 {
1304                "evidence match was"
1305            } else {
1306                "evidence matches were"
1307            };
1308            let _ = writeln!(
1309                out,
1310                "No actionable findings were emitted because {} ambiguous {match_phrase} skipped.\n",
1311                intelligence.summary.skipped_ambiguous_matches,
1312            );
1313        }
1314        return;
1315    }
1316    out.push_str("| ID | Path | Identity | Verdict | Recommendation | Confidence | Signals |\n");
1317    out.push_str("|:---|:-----|:---------|:--------|:---------------|:-----------|:--------|\n");
1318    for finding in &intelligence.findings {
1319        write_coverage_intelligence_row(out, finding, root);
1320    }
1321    out.push('\n');
1322}
1323
1324/// Write one coverage-intelligence finding row.
1325fn write_coverage_intelligence_row(
1326    out: &mut String,
1327    finding: &crate::health_types::CoverageIntelligenceFinding,
1328    root: &Path,
1329) {
1330    let path = escape_backticks(&normalize_uri(
1331        &relative_path(&finding.path, root).display().to_string(),
1332    ));
1333    let identity = finding
1334        .identity
1335        .as_deref()
1336        .map_or_else(|| "-".to_owned(), escape_backticks);
1337    let signals = finding
1338        .signals
1339        .iter()
1340        .map(ToString::to_string)
1341        .collect::<Vec<_>>()
1342        .join(", ");
1343    let _ = writeln!(
1344        out,
1345        "| `{}` | `{}`:{} | `{}` | {} | {} | {} | {} |",
1346        escape_backticks(&finding.id),
1347        path,
1348        finding.line,
1349        identity,
1350        finding.verdict,
1351        finding.recommendation,
1352        finding.confidence,
1353        signals,
1354    );
1355}
1356
1357fn write_runtime_coverage_section(
1358    out: &mut String,
1359    report: &crate::health_types::HealthReport,
1360    root: &Path,
1361) {
1362    let Some(ref production) = report.runtime_coverage else {
1363        return;
1364    };
1365    if !out.is_empty() && !out.ends_with("\n\n") {
1366        out.push('\n');
1367    }
1368    write_runtime_coverage_summary(out, production);
1369    write_runtime_coverage_findings(out, production, root);
1370    write_runtime_coverage_hot_paths(out, production, root);
1371}
1372
1373/// Write the runtime-coverage summary header and capture-quality lines.
1374fn write_runtime_coverage_summary(
1375    out: &mut String,
1376    production: &crate::health_types::RuntimeCoverageReport,
1377) {
1378    let _ = writeln!(
1379        out,
1380        "## 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",
1381        production.verdict,
1382        production.summary.functions_tracked,
1383        production.summary.functions_hit,
1384        production.summary.functions_unhit,
1385        production.summary.functions_untracked,
1386        production.summary.coverage_percent,
1387        production.summary.trace_count,
1388        production.summary.period_days,
1389        production.summary.deployments_seen,
1390    );
1391    if let Some(watermark) = production.watermark {
1392        let _ = writeln!(out, "- Watermark: {watermark}\n");
1393    }
1394    if let Some(ref quality) = production.summary.capture_quality
1395        && quality.lazy_parse_warning
1396    {
1397        let window = super::human::health::format_window(quality.window_seconds);
1398        let _ = writeln!(
1399            out,
1400            "- Capture quality: short window ({} from {} instance(s), {:.1}% of functions untracked); lazy-parsed scripts may not appear.\n",
1401            window, quality.instances_observed, quality.untracked_ratio_percent,
1402        );
1403    }
1404}
1405
1406/// Write the runtime-coverage per-finding table.
1407fn write_runtime_coverage_findings(
1408    out: &mut String,
1409    production: &crate::health_types::RuntimeCoverageReport,
1410    root: &Path,
1411) {
1412    if production.findings.is_empty() {
1413        return;
1414    }
1415    out.push_str("| ID | Path | Function | Verdict | Invocations | Confidence |\n");
1416    out.push_str("|:---|:-----|:---------|:--------|------------:|:-----------|\n");
1417    for finding in &production.findings {
1418        let invocations = finding
1419            .invocations
1420            .map_or_else(|| "-".to_owned(), |hits| hits.to_string());
1421        let _ = writeln!(
1422            out,
1423            "| `{}` | `{}`:{} | `{}` | {} | {} | {} |",
1424            escape_backticks(&finding.id),
1425            escape_backticks(&normalize_uri(
1426                &relative_path(&finding.path, root).display().to_string(),
1427            )),
1428            finding.line,
1429            escape_backticks(&finding.function),
1430            finding.verdict,
1431            invocations,
1432            finding.confidence,
1433        );
1434    }
1435    out.push('\n');
1436}
1437
1438/// Write the runtime-coverage hot-paths table.
1439fn write_runtime_coverage_hot_paths(
1440    out: &mut String,
1441    production: &crate::health_types::RuntimeCoverageReport,
1442    root: &Path,
1443) {
1444    if production.hot_paths.is_empty() {
1445        return;
1446    }
1447    out.push_str("| ID | Hot path | Function | Invocations | Percentile |\n");
1448    out.push_str("|:---|:---------|:---------|------------:|-----------:|\n");
1449    for entry in &production.hot_paths {
1450        let _ = writeln!(
1451            out,
1452            "| `{}` | `{}`:{} | `{}` | {} | {} |",
1453            escape_backticks(&entry.id),
1454            escape_backticks(&normalize_uri(
1455                &relative_path(&entry.path, root).display().to_string(),
1456            )),
1457            entry.line,
1458            escape_backticks(&entry.function),
1459            entry.invocations,
1460            entry.percentile,
1461        );
1462    }
1463    out.push('\n');
1464}
1465
1466/// Write the trend comparison table to the output.
1467fn write_trend_section(out: &mut String, report: &crate::health_types::HealthReport) {
1468    let Some(ref trend) = report.health_trend else {
1469        return;
1470    };
1471    let sha_str = trend
1472        .compared_to
1473        .git_sha
1474        .as_deref()
1475        .map_or(String::new(), |sha| format!(" ({sha})"));
1476    let _ = writeln!(
1477        out,
1478        "## Trend (vs {}{})\n",
1479        trend
1480            .compared_to
1481            .timestamp
1482            .get(..10)
1483            .unwrap_or(&trend.compared_to.timestamp),
1484        sha_str,
1485    );
1486    out.push_str("| Metric | Previous | Current | Delta | Direction |\n");
1487    out.push_str("|:-------|:---------|:--------|:------|:----------|\n");
1488    for m in &trend.metrics {
1489        write_trend_metric_row(out, m);
1490    }
1491    let md_sha = trend
1492        .compared_to
1493        .git_sha
1494        .as_deref()
1495        .map_or(String::new(), |sha| format!(" ({sha})"));
1496    let _ = writeln!(
1497        out,
1498        "\n*vs {}{} · {} {} available*\n",
1499        trend
1500            .compared_to
1501            .timestamp
1502            .get(..10)
1503            .unwrap_or(&trend.compared_to.timestamp),
1504        md_sha,
1505        trend.snapshots_loaded,
1506        if trend.snapshots_loaded == 1 {
1507            "snapshot"
1508        } else {
1509            "snapshots"
1510        },
1511    );
1512}
1513
1514/// Write one trend metric row with unit-aware value and delta formatting.
1515fn write_trend_metric_row(out: &mut String, m: &crate::health_types::TrendMetric) {
1516    let fmt_val = |v: f64| -> String {
1517        if m.unit == "%" {
1518            format!("{v:.1}%")
1519        } else if (v - v.round()).abs() < 0.05 {
1520            format!("{v:.0}")
1521        } else {
1522            format!("{v:.1}")
1523        }
1524    };
1525    let prev = fmt_val(m.previous);
1526    let cur = fmt_val(m.current);
1527    let delta = if m.unit == "%" {
1528        format!("{:+.1}%", m.delta)
1529    } else if (m.delta - m.delta.round()).abs() < 0.05 {
1530        format!("{:+.0}", m.delta)
1531    } else {
1532        format!("{:+.1}", m.delta)
1533    };
1534    let _ = writeln!(
1535        out,
1536        "| {} | {} | {} | {} | {} {} |",
1537        m.label,
1538        prev,
1539        cur,
1540        delta,
1541        m.direction.arrow(),
1542        m.direction.label(),
1543    );
1544}
1545
1546/// Write the vital signs summary table to the output.
1547fn write_vital_signs_section(out: &mut String, report: &crate::health_types::HealthReport) {
1548    let Some(ref vs) = report.vital_signs else {
1549        return;
1550    };
1551    out.push_str("## Vital Signs\n\n");
1552    out.push_str("| Metric | Value |\n");
1553    out.push_str("|:-------|------:|\n");
1554    if vs.total_loc > 0 {
1555        let _ = writeln!(out, "| Total LOC | {} |", vs.total_loc);
1556    }
1557    let _ = writeln!(out, "| Avg Cyclomatic | {:.1} |", vs.avg_cyclomatic);
1558    let _ = writeln!(out, "| P90 Cyclomatic | {} |", vs.p90_cyclomatic);
1559    if let Some(v) = vs.dead_file_pct {
1560        let _ = writeln!(out, "| Dead Files | {v:.1}% |");
1561    }
1562    if let Some(v) = vs.dead_export_pct {
1563        let _ = writeln!(out, "| Dead Exports | {v:.1}% |");
1564    }
1565    if let Some(v) = vs.maintainability_avg {
1566        let _ = writeln!(out, "| Maintainability (avg) | {v:.1} |");
1567    }
1568    if let Some(v) = vs.hotspot_count {
1569        let label = report.hotspot_summary.as_ref().map_or_else(
1570            || "Hotspots".to_string(),
1571            |summary| format!("Hotspots (since {})", summary.since),
1572        );
1573        let _ = writeln!(out, "| {label} | {v} |");
1574    }
1575    if let Some(v) = vs.circular_dep_count {
1576        let _ = writeln!(out, "| Circular Deps | {v} |");
1577    }
1578    if let Some(v) = vs.unused_dep_count {
1579        let _ = writeln!(out, "| Unused Deps | {v} |");
1580    }
1581    out.push('\n');
1582}
1583
1584/// Write the complexity findings table to the output.
1585fn write_findings_section(
1586    out: &mut String,
1587    report: &crate::health_types::HealthReport,
1588    root: &Path,
1589) {
1590    if report.findings.is_empty() {
1591        return;
1592    }
1593
1594    let has_synthetic = report
1595        .findings
1596        .iter()
1597        .any(|finding| matches!(finding.name.as_str(), "<template>" | "<component>"));
1598    write_findings_heading(out, report, has_synthetic);
1599    write_findings_table_header(out, has_synthetic);
1600
1601    for finding in &report.findings {
1602        write_findings_row(out, finding, report, root);
1603    }
1604
1605    let s = &report.summary;
1606    let _ = write!(
1607        out,
1608        "\n**{files}** files, **{funcs}** functions analyzed \
1609         (thresholds: cyclomatic > {cyc}, cognitive > {cog}, CRAP >= {crap:.1})\n",
1610        files = s.files_analyzed,
1611        funcs = s.functions_analyzed,
1612        cyc = s.max_cyclomatic_threshold,
1613        cog = s.max_cognitive_threshold,
1614        crap = s.max_crap_threshold,
1615    );
1616}
1617
1618/// Write the heading line for the complexity findings section.
1619fn write_findings_heading(
1620    out: &mut String,
1621    report: &crate::health_types::HealthReport,
1622    has_synthetic: bool,
1623) {
1624    let count = report.summary.functions_above_threshold;
1625    let shown = report.findings.len();
1626    let subject = if has_synthetic {
1627        "high complexity finding"
1628    } else {
1629        "high complexity function"
1630    };
1631    if shown < count {
1632        let _ = write!(
1633            out,
1634            "## Fallow: {count} {subject}{} ({shown} shown)\n\n",
1635            plural(count),
1636        );
1637    } else {
1638        let _ = write!(out, "## Fallow: {count} {subject}{}\n\n", plural(count));
1639    }
1640}
1641
1642/// Write the table header row for the complexity findings section.
1643fn write_findings_table_header(out: &mut String, has_synthetic: bool) {
1644    let name_header = if has_synthetic { "Entry" } else { "Function" };
1645    let _ = writeln!(
1646        out,
1647        "| File | {name_header} | Severity | Cyclomatic | Cognitive | CRAP | Lines |"
1648    );
1649    out.push_str("|:-----|:---------|:---------|:-----------|:----------|:-----|:------|\n");
1650}
1651
1652/// Write one complexity finding row, including threshold-breach markers.
1653fn write_findings_row(
1654    out: &mut String,
1655    finding: &crate::health_types::HealthFinding,
1656    report: &crate::health_types::HealthReport,
1657    root: &Path,
1658) {
1659    let file_str = escape_backticks(&normalize_uri(
1660        &relative_path(&finding.path, root).display().to_string(),
1661    ));
1662    let thresholds =
1663        finding
1664            .effective_thresholds
1665            .unwrap_or(crate::health_types::HealthEffectiveThresholds {
1666                max_cyclomatic: report.summary.max_cyclomatic_threshold,
1667                max_cognitive: report.summary.max_cognitive_threshold,
1668                max_crap: report.summary.max_crap_threshold,
1669            });
1670    let cyc_marker = if finding.cyclomatic > thresholds.max_cyclomatic {
1671        " **!**"
1672    } else {
1673        ""
1674    };
1675    let cog_marker = if finding.cognitive > thresholds.max_cognitive {
1676        " **!**"
1677    } else {
1678        ""
1679    };
1680    let severity_label = match finding.severity {
1681        crate::health_types::FindingSeverity::Critical => "critical",
1682        crate::health_types::FindingSeverity::High => "high",
1683        crate::health_types::FindingSeverity::Moderate => "moderate",
1684    };
1685    let crap_cell = match finding.crap {
1686        Some(crap) => {
1687            let marker = if crap >= thresholds.max_crap {
1688                " **!**"
1689            } else {
1690                ""
1691            };
1692            format!("{crap:.1}{marker}")
1693        }
1694        None => "-".to_string(),
1695    };
1696    let _ = writeln!(
1697        out,
1698        "| `{file_str}:{line}` | `{name}` | {severity_label} | {cyc}{cyc_marker} | {cog}{cog_marker} | {crap_cell} | {lines} |",
1699        line = finding.line,
1700        name = escape_backticks(display_complexity_entry_name(&finding.name).as_ref()),
1701        cyc = finding.cyclomatic,
1702        cog = finding.cognitive,
1703        lines = finding.line_count,
1704    );
1705}
1706
1707fn write_threshold_overrides_section(
1708    out: &mut String,
1709    report: &crate::health_types::HealthReport,
1710    root: &Path,
1711) {
1712    if report.threshold_overrides.is_empty() {
1713        return;
1714    }
1715    if !out.is_empty() && !out.ends_with("\n\n") {
1716        out.push('\n');
1717    }
1718    out.push_str("## Health Threshold Overrides\n\n");
1719    out.push_str("| Override | Status | Target | Metrics |\n");
1720    out.push_str("|---------:|:-------|:-------|:--------|\n");
1721    for entry in &report.threshold_overrides {
1722        let status = match entry.status {
1723            crate::health_types::ThresholdOverrideStatus::Active => "active",
1724            crate::health_types::ThresholdOverrideStatus::Stale => "stale",
1725            crate::health_types::ThresholdOverrideStatus::NoMatch => "no_match",
1726        };
1727        let target = entry.path.as_ref().map_or_else(
1728            || "<no matching file or function>".to_string(),
1729            |path| {
1730                let display = escape_backticks(&normalize_uri(
1731                    &relative_path(path, root).display().to_string(),
1732                ));
1733                entry.function.as_ref().map_or_else(
1734                    || display.clone(),
1735                    |name| format!("{display}:{}", escape_backticks(name)),
1736                )
1737            },
1738        );
1739        let metrics = entry.metrics.map_or_else(
1740            || "-".to_string(),
1741            |metrics| {
1742                let crap = metrics
1743                    .crap
1744                    .map_or(String::new(), |value| format!(", CRAP {value:.1}"));
1745                format!(
1746                    "cyclomatic {}, cognitive {}{}",
1747                    metrics.cyclomatic, metrics.cognitive, crap
1748                )
1749            },
1750        );
1751        let _ = writeln!(
1752            out,
1753            "| {} | {} | `{}` | {} |",
1754            entry.override_index, status, target, metrics
1755        );
1756    }
1757    out.push('\n');
1758}
1759
1760/// Write the file health scores table to the output.
1761fn write_file_scores_section(
1762    out: &mut String,
1763    report: &crate::health_types::HealthReport,
1764    root: &Path,
1765) {
1766    if report.file_scores.is_empty() {
1767        return;
1768    }
1769
1770    let rel = |p: &Path| {
1771        escape_backticks(&normalize_uri(
1772            &relative_path(p, root).display().to_string(),
1773        ))
1774    };
1775
1776    out.push('\n');
1777    let _ = writeln!(
1778        out,
1779        "### File Health Scores ({} files)\n",
1780        report.file_scores.len(),
1781    );
1782    out.push_str("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density | Risk |\n");
1783    out.push_str("|:-----|:---------------|:-------|:--------|:----------|:--------|:-----|\n");
1784
1785    for score in &report.file_scores {
1786        let file_str = rel(&score.path);
1787        let _ = writeln!(
1788            out,
1789            "| `{file_str}` | {mi:.1} | {fi} | {fan_out} | {dead:.0}% | {density:.2} | {crap:.1} |",
1790            mi = score.maintainability_index,
1791            fi = score.fan_in,
1792            fan_out = score.fan_out,
1793            dead = score.dead_code_ratio * 100.0,
1794            density = score.complexity_density,
1795            crap = score.crap_max,
1796        );
1797    }
1798
1799    if let Some(avg) = report.summary.average_maintainability {
1800        let _ = write!(out, "\n**Average maintainability index:** {avg:.1}/100\n");
1801    }
1802}
1803
1804fn write_coverage_gaps_section(
1805    out: &mut String,
1806    report: &crate::health_types::HealthReport,
1807    root: &Path,
1808) {
1809    let Some(ref gaps) = report.coverage_gaps else {
1810        return;
1811    };
1812
1813    out.push('\n');
1814    let _ = writeln!(out, "### Coverage Gaps\n");
1815    let _ = writeln!(
1816        out,
1817        "*{} untested files · {} untested exports · {:.1}% file coverage*\n",
1818        gaps.summary.untested_files, gaps.summary.untested_exports, gaps.summary.file_coverage_pct,
1819    );
1820
1821    if gaps.files.is_empty() && gaps.exports.is_empty() {
1822        out.push_str("_No coverage gaps found in scope._\n");
1823        return;
1824    }
1825
1826    if !gaps.files.is_empty() {
1827        out.push_str("#### Files\n");
1828        for item in &gaps.files {
1829            let file_str = escape_backticks(&normalize_uri(
1830                &relative_path(&item.file.path, root).display().to_string(),
1831            ));
1832            let _ = writeln!(
1833                out,
1834                "- `{file_str}` ({count} value export{})",
1835                if item.file.value_export_count == 1 {
1836                    ""
1837                } else {
1838                    "s"
1839                },
1840                count = item.file.value_export_count,
1841            );
1842        }
1843        out.push('\n');
1844    }
1845
1846    if !gaps.exports.is_empty() {
1847        out.push_str("#### Exports\n");
1848        for item in &gaps.exports {
1849            let file_str = escape_backticks(&normalize_uri(
1850                &relative_path(&item.export.path, root).display().to_string(),
1851            ));
1852            let _ = writeln!(
1853                out,
1854                "- `{file_str}`:{} `{}`",
1855                item.export.line, item.export.export_name
1856            );
1857        }
1858    }
1859}
1860
1861/// Write the hotspots table to the output.
1862/// Render the four ownership table cells (bus, top contributor, declared
1863/// owner, notes) for the markdown hotspots table. Cells fall back to an
1864/// en-dash (U+2013) when ownership data is missing for an entry.
1865fn ownership_md_cells(
1866    ownership: Option<&crate::health_types::OwnershipMetrics>,
1867) -> (String, String, String, String) {
1868    let Some(o) = ownership else {
1869        let dash = "\u{2013}".to_string();
1870        return (dash.clone(), dash.clone(), dash.clone(), dash);
1871    };
1872    let bus = o.bus_factor.to_string();
1873    let top = format!(
1874        "`{}` ({:.0}%)",
1875        o.top_contributor.identifier,
1876        o.top_contributor.share * 100.0,
1877    );
1878    let owner = o
1879        .declared_owner
1880        .as_deref()
1881        .map_or_else(|| "\u{2013}".to_string(), str::to_string);
1882    let mut notes: Vec<&str> = Vec::new();
1883    if o.unowned == Some(true) {
1884        notes.push("**unowned**");
1885    }
1886    if o.ownership_state == crate::health_types::OwnershipState::DeclaredInactive {
1887        notes.push("declared owner inactive");
1888    }
1889    if o.drift {
1890        notes.push("drift");
1891    }
1892    let notes_str = if notes.is_empty() {
1893        "\u{2013}".to_string()
1894    } else {
1895        notes.join(", ")
1896    };
1897    (bus, top, owner, notes_str)
1898}
1899
1900fn write_hotspots_section(
1901    out: &mut String,
1902    report: &crate::health_types::HealthReport,
1903    root: &Path,
1904) {
1905    if report.hotspots.is_empty() {
1906        return;
1907    }
1908
1909    out.push('\n');
1910    let header = report.hotspot_summary.as_ref().map_or_else(
1911        || format!("### Hotspots ({} files)\n", report.hotspots.len()),
1912        |summary| {
1913            format!(
1914                "### Hotspots ({} files, since {})\n",
1915                report.hotspots.len(),
1916                summary.since,
1917            )
1918        },
1919    );
1920    let _ = writeln!(out, "{header}");
1921    let any_ownership = report.hotspots.iter().any(|e| e.ownership.is_some());
1922    write_hotspots_table_header(out, any_ownership);
1923
1924    for entry in &report.hotspots {
1925        write_hotspots_row(out, entry, any_ownership, root);
1926    }
1927
1928    if let Some(ref summary) = report.hotspot_summary
1929        && summary.files_excluded > 0
1930    {
1931        let _ = write!(
1932            out,
1933            "\n*{} file{} excluded (< {} commits)*\n",
1934            summary.files_excluded,
1935            plural(summary.files_excluded),
1936            summary.min_commits,
1937        );
1938    }
1939}
1940
1941/// Write the hotspots table header, widening with ownership columns when present.
1942fn write_hotspots_table_header(out: &mut String, any_ownership: bool) {
1943    if any_ownership {
1944        out.push_str(
1945            "| File | Score | Commits | Churn | Density | Fan-in | Trend | Bus | Top | Owner | Notes |\n"
1946        );
1947        out.push_str(
1948            "|:-----|:------|:--------|:------|:--------|:-------|:------|:----|:----|:------|:------|\n"
1949        );
1950    } else {
1951        out.push_str("| File | Score | Commits | Churn | Density | Fan-in | Trend |\n");
1952        out.push_str("|:-----|:------|:--------|:------|:--------|:-------|:------|\n");
1953    }
1954}
1955
1956/// Write one hotspot row, including ownership cells when the table is widened.
1957fn write_hotspots_row(
1958    out: &mut String,
1959    entry: &crate::health_types::HotspotFinding,
1960    any_ownership: bool,
1961    root: &Path,
1962) {
1963    let file_str = escape_backticks(&normalize_uri(
1964        &relative_path(&entry.path, root).display().to_string(),
1965    ));
1966    if any_ownership {
1967        let (bus, top, owner, notes) = ownership_md_cells(entry.ownership.as_ref());
1968        let _ = writeln!(
1969            out,
1970            "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} | {bus} | {top} | {owner} | {notes} |",
1971            score = entry.score,
1972            commits = entry.commits,
1973            churn = entry.lines_added + entry.lines_deleted,
1974            density = entry.complexity_density,
1975            fi = entry.fan_in,
1976            trend = entry.trend,
1977        );
1978    } else {
1979        let _ = writeln!(
1980            out,
1981            "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} |",
1982            score = entry.score,
1983            commits = entry.commits,
1984            churn = entry.lines_added + entry.lines_deleted,
1985            density = entry.complexity_density,
1986            fi = entry.fan_in,
1987            trend = entry.trend,
1988        );
1989    }
1990}
1991
1992/// Write the refactoring targets table to the output.
1993fn write_targets_section(
1994    out: &mut String,
1995    report: &crate::health_types::HealthReport,
1996    root: &Path,
1997) {
1998    if report.targets.is_empty() {
1999        return;
2000    }
2001    let _ = write!(
2002        out,
2003        "\n### Refactoring Targets ({})\n\n",
2004        report.targets.len()
2005    );
2006    out.push_str("| Efficiency | Category | Effort / Confidence | File | Recommendation |\n");
2007    out.push_str("|:-----------|:---------|:--------------------|:-----|:---------------|\n");
2008    for target in &report.targets {
2009        let file_str = normalize_uri(&relative_path(&target.path, root).display().to_string());
2010        let category = target.category.label();
2011        let effort = target.effort.label();
2012        let confidence = target.confidence.label();
2013        let _ = writeln!(
2014            out,
2015            "| {:.1} | {category} | {effort} / {confidence} | `{file_str}` | {} |",
2016            target.efficiency, target.recommendation,
2017        );
2018    }
2019}
2020
2021/// Write the metric legend collapsible section to the output.
2022fn write_metric_legend(out: &mut String, report: &crate::health_types::HealthReport) {
2023    let has_scores = !report.file_scores.is_empty();
2024    let has_coverage = report.coverage_gaps.is_some();
2025    let has_hotspots = !report.hotspots.is_empty();
2026    let has_targets = !report.targets.is_empty();
2027    if !has_scores && !has_coverage && !has_hotspots && !has_targets {
2028        return;
2029    }
2030    out.push_str("\n---\n\n<details><summary>Metric definitions</summary>\n\n");
2031    if has_scores {
2032        out.push_str("- **MI**: Maintainability Index (0\u{2013}100, higher is better)\n");
2033        out.push_str("- **Order**: risk-aware triage order using the larger of low-MI concern and CRAP risk\n");
2034        out.push_str("- **Fan-in**: files that import this file (blast radius)\n");
2035        out.push_str("- **Fan-out**: files this file imports (coupling)\n");
2036        out.push_str("- **Dead Code**: % of value exports with zero references\n");
2037        out.push_str("- **Density**: cyclomatic complexity / lines of code\n");
2038        out.push_str(
2039            "- **Risk**: max CRAP score for the file; low <15, moderate 15-30, high >=30\n",
2040        );
2041    }
2042    if has_coverage {
2043        out.push_str(
2044            "- **File coverage**: runtime files also reachable from a discovered test root\n",
2045        );
2046        out.push_str("- **Untested export**: export with no reference chain from any test-reachable module\n");
2047    }
2048    if has_hotspots {
2049        out.push_str("- **Score**: churn \u{00d7} complexity (0\u{2013}100, higher = riskier)\n");
2050        out.push_str("- **Commits**: commits in the analysis window\n");
2051        out.push_str("- **Churn**: total lines added + deleted\n");
2052        out.push_str("- **Trend**: accelerating / stable / cooling\n");
2053    }
2054    if has_targets {
2055        out.push_str(
2056            "- **Efficiency**: priority / effort (higher = better quick-win value, default sort)\n",
2057        );
2058        out.push_str("- **Category**: recommendation type (churn+complexity, high impact, dead code, complexity, coupling, circular dep)\n");
2059        out.push_str("- **Effort**: estimated effort (low / medium / high) based on file size, function count, and fan-in\n");
2060        out.push_str("- **Confidence**: recommendation reliability (high = deterministic analysis, medium = heuristic, low = git-dependent)\n");
2061    }
2062    out.push_str(
2063        "\n[Full metric reference](https://docs.fallow.tools/explanations/metrics)\n\n</details>\n",
2064    );
2065}
2066
2067#[cfg(test)]
2068mod tests {
2069    use super::*;
2070    use crate::report::test_helpers::sample_results;
2071    use fallow_core::duplicates::{
2072        CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
2073        RefactoringKind, RefactoringSuggestion,
2074    };
2075    use fallow_core::results::*;
2076    use std::path::PathBuf;
2077
2078    #[test]
2079    fn markdown_empty_results_no_issues() {
2080        let root = PathBuf::from("/project");
2081        let results = AnalysisResults::default();
2082        let md = build_markdown(&results, &root);
2083        assert_eq!(md, "## Fallow: no issues found\n");
2084    }
2085
2086    #[test]
2087    fn markdown_contains_header_with_count() {
2088        let root = PathBuf::from("/project");
2089        let results = sample_results(&root);
2090        let md = build_markdown(&results, &root);
2091        assert!(md.starts_with(&format!(
2092            "## Fallow: {} issues found\n",
2093            results.total_issues()
2094        )));
2095    }
2096
2097    #[test]
2098    fn markdown_contains_all_sections() {
2099        let root = PathBuf::from("/project");
2100        let results = sample_results(&root);
2101        let md = build_markdown(&results, &root);
2102
2103        assert!(md.contains("### Unused files (1)"));
2104        assert!(md.contains("### Unused exports (1)"));
2105        assert!(md.contains("### Unused type exports (1)"));
2106        assert!(md.contains("### Unused dependencies (1)"));
2107        assert!(md.contains("### Unused devDependencies (1)"));
2108        assert!(md.contains("### Unused enum members (1)"));
2109        assert!(md.contains("### Unused class members (1)"));
2110        assert!(md.contains("### Unresolved imports (1)"));
2111        assert!(md.contains("### Unlisted dependencies (1)"));
2112        assert!(md.contains("### Duplicate exports (1)"));
2113        assert!(md.contains("### Type-only dependencies"));
2114        assert!(md.contains("### Test-only production dependencies"));
2115        assert!(md.contains("### Circular dependencies (1)"));
2116    }
2117
2118    #[test]
2119    fn markdown_unused_file_format() {
2120        let root = PathBuf::from("/project");
2121        let mut results = AnalysisResults::default();
2122        results
2123            .unused_files
2124            .push(UnusedFileFinding::with_actions(UnusedFile {
2125                path: root.join("src/dead.ts"),
2126            }));
2127        let md = build_markdown(&results, &root);
2128        assert!(md.contains("- `src/dead.ts`"));
2129    }
2130
2131    #[test]
2132    fn markdown_unused_export_grouped_by_file() {
2133        let root = PathBuf::from("/project");
2134        let mut results = AnalysisResults::default();
2135        results
2136            .unused_exports
2137            .push(UnusedExportFinding::with_actions(UnusedExport {
2138                path: root.join("src/utils.ts"),
2139                export_name: "helperFn".to_string(),
2140                is_type_only: false,
2141                line: 10,
2142                col: 4,
2143                span_start: 120,
2144                is_re_export: false,
2145            }));
2146        let md = build_markdown(&results, &root);
2147        assert!(md.contains("- `src/utils.ts`"));
2148        assert!(md.contains(":10 `helperFn`"));
2149    }
2150
2151    #[test]
2152    fn markdown_re_export_tagged() {
2153        let root = PathBuf::from("/project");
2154        let mut results = AnalysisResults::default();
2155        results
2156            .unused_exports
2157            .push(UnusedExportFinding::with_actions(UnusedExport {
2158                path: root.join("src/index.ts"),
2159                export_name: "reExported".to_string(),
2160                is_type_only: false,
2161                line: 1,
2162                col: 0,
2163                span_start: 0,
2164                is_re_export: true,
2165            }));
2166        let md = build_markdown(&results, &root);
2167        assert!(md.contains("(re-export)"));
2168    }
2169
2170    #[test]
2171    fn markdown_unused_dep_format() {
2172        let root = PathBuf::from("/project");
2173        let mut results = AnalysisResults::default();
2174        results
2175            .unused_dependencies
2176            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2177                package_name: "lodash".to_string(),
2178                location: DependencyLocation::Dependencies,
2179                path: root.join("package.json"),
2180                line: 5,
2181                used_in_workspaces: Vec::new(),
2182            }));
2183        let md = build_markdown(&results, &root);
2184        assert!(md.contains("- `lodash`"));
2185    }
2186
2187    #[test]
2188    fn markdown_circular_dep_format() {
2189        let root = PathBuf::from("/project");
2190        let mut results = AnalysisResults::default();
2191        results
2192            .circular_dependencies
2193            .push(CircularDependencyFinding::with_actions(
2194                CircularDependency {
2195                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2196                    length: 2,
2197                    line: 3,
2198                    col: 0,
2199                    edges: Vec::new(),
2200                    is_cross_package: false,
2201                },
2202            ));
2203        let md = build_markdown(&results, &root);
2204        assert!(md.contains("`src/a.ts`"));
2205        assert!(md.contains("`src/b.ts`"));
2206        assert!(md.contains("\u{2192}"));
2207    }
2208
2209    #[test]
2210    fn markdown_strips_root_prefix() {
2211        let root = PathBuf::from("/project");
2212        let mut results = AnalysisResults::default();
2213        results
2214            .unused_files
2215            .push(UnusedFileFinding::with_actions(UnusedFile {
2216                path: PathBuf::from("/project/src/deep/nested/file.ts"),
2217            }));
2218        let md = build_markdown(&results, &root);
2219        assert!(md.contains("`src/deep/nested/file.ts`"));
2220        assert!(!md.contains("/project/"));
2221    }
2222
2223    #[test]
2224    fn markdown_single_issue_no_plural() {
2225        let root = PathBuf::from("/project");
2226        let mut results = AnalysisResults::default();
2227        results
2228            .unused_files
2229            .push(UnusedFileFinding::with_actions(UnusedFile {
2230                path: root.join("src/dead.ts"),
2231            }));
2232        let md = build_markdown(&results, &root);
2233        assert!(md.starts_with("## Fallow: 1 issue found\n"));
2234    }
2235
2236    #[test]
2237    fn markdown_type_only_dep_format() {
2238        let root = PathBuf::from("/project");
2239        let mut results = AnalysisResults::default();
2240        results
2241            .type_only_dependencies
2242            .push(TypeOnlyDependencyFinding::with_actions(
2243                TypeOnlyDependency {
2244                    package_name: "zod".to_string(),
2245                    path: root.join("package.json"),
2246                    line: 8,
2247                },
2248            ));
2249        let md = build_markdown(&results, &root);
2250        assert!(md.contains("### Type-only dependencies"));
2251        assert!(md.contains("- `zod`"));
2252    }
2253
2254    #[test]
2255    fn markdown_escapes_backticks_in_export_names() {
2256        let root = PathBuf::from("/project");
2257        let mut results = AnalysisResults::default();
2258        results
2259            .unused_exports
2260            .push(UnusedExportFinding::with_actions(UnusedExport {
2261                path: root.join("src/utils.ts"),
2262                export_name: "foo`bar".to_string(),
2263                is_type_only: false,
2264                line: 1,
2265                col: 0,
2266                span_start: 0,
2267                is_re_export: false,
2268            }));
2269        let md = build_markdown(&results, &root);
2270        assert!(md.contains("foo\\`bar"));
2271        assert!(!md.contains("foo`bar`"));
2272    }
2273
2274    #[test]
2275    fn markdown_escapes_backticks_in_package_names() {
2276        let root = PathBuf::from("/project");
2277        let mut results = AnalysisResults::default();
2278        results
2279            .unused_dependencies
2280            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2281                package_name: "pkg`name".to_string(),
2282                location: DependencyLocation::Dependencies,
2283                path: root.join("package.json"),
2284                line: 5,
2285                used_in_workspaces: Vec::new(),
2286            }));
2287        let md = build_markdown(&results, &root);
2288        assert!(md.contains("pkg\\`name"));
2289    }
2290
2291    #[test]
2292    fn duplication_markdown_empty() {
2293        let report = DuplicationReport::default();
2294        let root = PathBuf::from("/project");
2295        let md = build_duplication_markdown(&report, &root);
2296        assert_eq!(md, "## Fallow: no code duplication found\n");
2297    }
2298
2299    #[test]
2300    fn duplication_markdown_contains_groups() {
2301        let root = PathBuf::from("/project");
2302        let report = DuplicationReport {
2303            clone_groups: vec![CloneGroup {
2304                instances: vec![
2305                    CloneInstance {
2306                        file: root.join("src/a.ts"),
2307                        start_line: 1,
2308                        end_line: 10,
2309                        start_col: 0,
2310                        end_col: 0,
2311                        fragment: String::new(),
2312                    },
2313                    CloneInstance {
2314                        file: root.join("src/b.ts"),
2315                        start_line: 5,
2316                        end_line: 14,
2317                        start_col: 0,
2318                        end_col: 0,
2319                        fragment: String::new(),
2320                    },
2321                ],
2322                token_count: 50,
2323                line_count: 10,
2324            }],
2325            clone_families: vec![],
2326            mirrored_directories: vec![],
2327            stats: DuplicationStats {
2328                total_files: 10,
2329                files_with_clones: 2,
2330                total_lines: 500,
2331                duplicated_lines: 20,
2332                total_tokens: 2500,
2333                duplicated_tokens: 100,
2334                clone_groups: 1,
2335                clone_instances: 2,
2336                duplication_percentage: 4.0,
2337                clone_groups_below_min_occurrences: 0,
2338            },
2339        };
2340        let md = build_duplication_markdown(&report, &root);
2341        assert!(md.contains("**Clone group 1**"));
2342        assert!(md.contains("`src/a.ts:1-10`"));
2343        assert!(md.contains("`src/b.ts:5-14`"));
2344        assert!(md.contains("4.0% duplication"));
2345    }
2346
2347    #[test]
2348    fn duplication_markdown_contains_families() {
2349        let root = PathBuf::from("/project");
2350        let report = DuplicationReport {
2351            clone_groups: vec![CloneGroup {
2352                instances: vec![CloneInstance {
2353                    file: root.join("src/a.ts"),
2354                    start_line: 1,
2355                    end_line: 5,
2356                    start_col: 0,
2357                    end_col: 0,
2358                    fragment: String::new(),
2359                }],
2360                token_count: 30,
2361                line_count: 5,
2362            }],
2363            clone_families: vec![CloneFamily {
2364                files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2365                groups: vec![],
2366                total_duplicated_lines: 20,
2367                total_duplicated_tokens: 100,
2368                suggestions: vec![RefactoringSuggestion {
2369                    kind: RefactoringKind::ExtractFunction,
2370                    description: "Extract shared utility function".to_string(),
2371                    estimated_savings: 15,
2372                }],
2373            }],
2374            mirrored_directories: vec![],
2375            stats: DuplicationStats {
2376                clone_groups: 1,
2377                clone_instances: 1,
2378                duplication_percentage: 2.0,
2379                ..Default::default()
2380            },
2381        };
2382        let md = build_duplication_markdown(&report, &root);
2383        assert!(md.contains("### Clone Families"));
2384        assert!(md.contains("**Family 1**"));
2385        assert!(md.contains("Extract shared utility function"));
2386        assert!(md.contains("~15 lines saved"));
2387    }
2388
2389    #[test]
2390    fn health_markdown_empty_no_findings() {
2391        let root = PathBuf::from("/project");
2392        let report = crate::health_types::HealthReport {
2393            summary: crate::health_types::HealthSummary {
2394                files_analyzed: 10,
2395                functions_analyzed: 50,
2396                ..Default::default()
2397            },
2398            ..Default::default()
2399        };
2400        let md = build_health_markdown(&report, &root);
2401        assert!(md.contains("no functions exceed complexity thresholds"));
2402        assert!(md.contains("**50** functions analyzed"));
2403    }
2404
2405    #[test]
2406    fn health_markdown_table_format() {
2407        let root = PathBuf::from("/project");
2408        let report = crate::health_types::HealthReport {
2409            findings: vec![
2410                crate::health_types::ComplexityViolation {
2411                    path: root.join("src/utils.ts"),
2412                    name: "parseExpression".to_string(),
2413                    line: 42,
2414                    col: 0,
2415                    cyclomatic: 25,
2416                    cognitive: 30,
2417                    line_count: 80,
2418                    param_count: 0,
2419                    react_hook_count: 0,
2420                    react_jsx_max_depth: 0,
2421                    react_prop_count: 0,
2422                    react_hook_profile: None,
2423                    exceeded: crate::health_types::ExceededThreshold::Both,
2424                    severity: crate::health_types::FindingSeverity::High,
2425                    crap: None,
2426                    coverage_pct: None,
2427                    coverage_tier: None,
2428                    coverage_source: None,
2429                    inherited_from: None,
2430                    component_rollup: None,
2431                    contributions: Vec::new(),
2432                    effective_thresholds: None,
2433                    threshold_source: None,
2434                }
2435                .into(),
2436            ],
2437            summary: crate::health_types::HealthSummary {
2438                files_analyzed: 10,
2439                functions_analyzed: 50,
2440                functions_above_threshold: 1,
2441                ..Default::default()
2442            },
2443            ..Default::default()
2444        };
2445        let md = build_health_markdown(&report, &root);
2446        assert!(md.contains("## Fallow: 1 high complexity function\n"));
2447        assert!(md.contains("| File | Function |"));
2448        assert!(md.contains("`src/utils.ts:42`"));
2449        assert!(md.contains("`parseExpression`"));
2450        assert!(md.contains("25 **!**"));
2451        assert!(md.contains("30 **!**"));
2452        assert!(md.contains("| 80 |"));
2453        assert!(md.contains("| - |"));
2454    }
2455
2456    #[test]
2457    fn health_markdown_labels_template_complexity_entries() {
2458        let root = PathBuf::from("/project");
2459        let report = crate::health_types::HealthReport {
2460            findings: vec![
2461                crate::health_types::ComplexityViolation {
2462                    path: root.join("src/Card.vue"),
2463                    name: "<template>".to_string(),
2464                    line: 1,
2465                    col: 0,
2466                    cyclomatic: 8,
2467                    cognitive: 12,
2468                    line_count: 40,
2469                    param_count: 0,
2470                    react_hook_count: 0,
2471                    react_jsx_max_depth: 0,
2472                    react_prop_count: 0,
2473                    react_hook_profile: None,
2474                    exceeded: crate::health_types::ExceededThreshold::Cognitive,
2475                    severity: crate::health_types::FindingSeverity::Moderate,
2476                    crap: None,
2477                    coverage_pct: None,
2478                    coverage_tier: None,
2479                    coverage_source: None,
2480                    inherited_from: None,
2481                    component_rollup: None,
2482                    contributions: Vec::new(),
2483                    effective_thresholds: None,
2484                    threshold_source: None,
2485                }
2486                .into(),
2487            ],
2488            summary: crate::health_types::HealthSummary {
2489                files_analyzed: 1,
2490                functions_analyzed: 1,
2491                functions_above_threshold: 1,
2492                ..Default::default()
2493            },
2494            ..Default::default()
2495        };
2496        let md = build_health_markdown(&report, &root);
2497        assert!(md.contains("## Fallow: 1 high complexity finding\n"));
2498        assert!(md.contains("| File | Entry |"));
2499        assert!(md.contains("`<template> (template complexity)`"));
2500    }
2501
2502    #[test]
2503    fn health_markdown_includes_coverage_intelligence_and_ambiguity_summary() {
2504        use crate::health_types::{
2505            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2506            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2507            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2508            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2509            CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2510            HealthReport, HealthSummary,
2511        };
2512
2513        let root = PathBuf::from("/project");
2514        let mut report = HealthReport {
2515            summary: HealthSummary {
2516                files_analyzed: 10,
2517                functions_analyzed: 50,
2518                ..Default::default()
2519            },
2520            coverage_intelligence: Some(CoverageIntelligenceReport {
2521                schema_version: CoverageIntelligenceSchemaVersion::V1,
2522                verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2523                summary: CoverageIntelligenceSummary {
2524                    findings: 1,
2525                    high_confidence_deletes: 1,
2526                    ..Default::default()
2527                },
2528                findings: vec![CoverageIntelligenceFinding {
2529                    id: "fallow:coverage-intel:abc123".to_owned(),
2530                    path: root.join("src/dead.ts"),
2531                    identity: Some("deadPath".to_owned()),
2532                    line: 9,
2533                    verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2534                    signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2535                    recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2536                    confidence: CoverageIntelligenceConfidence::High,
2537                    related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2538                    evidence: CoverageIntelligenceEvidence {
2539                        match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2540                        ..Default::default()
2541                    },
2542                    actions: vec![CoverageIntelligenceAction {
2543                        kind: "delete-after-confirming-owner".to_owned(),
2544                        description: "Confirm ownership".to_owned(),
2545                        auto_fixable: false,
2546                    }],
2547                }],
2548            }),
2549            ..Default::default()
2550        };
2551
2552        let md = build_health_markdown(&report, &root);
2553        assert!(md.contains("## Coverage Intelligence"));
2554        assert!(md.contains("fallow:coverage-intel:abc123"));
2555        assert!(md.contains("delete-after-confirming-owner"));
2556        assert!(md.contains("runtime_cold"));
2557
2558        report.coverage_intelligence = Some(CoverageIntelligenceReport {
2559            schema_version: CoverageIntelligenceSchemaVersion::V1,
2560            verdict: CoverageIntelligenceVerdict::Clean,
2561            summary: CoverageIntelligenceSummary {
2562                skipped_ambiguous_matches: 2,
2563                ..Default::default()
2564            },
2565            findings: vec![],
2566        });
2567        let md = build_health_markdown(&report, &root);
2568        assert!(md.contains("2 ambiguous evidence matches were skipped"));
2569        assert!(!md.contains("| ID | Path |"));
2570    }
2571
2572    #[test]
2573    fn health_markdown_crap_column_shows_score_and_marker() {
2574        let root = PathBuf::from("/project");
2575        let report = crate::health_types::HealthReport {
2576            findings: vec![
2577                crate::health_types::ComplexityViolation {
2578                    path: root.join("src/risky.ts"),
2579                    name: "branchy".to_string(),
2580                    line: 1,
2581                    col: 0,
2582                    cyclomatic: 67,
2583                    cognitive: 10,
2584                    line_count: 80,
2585                    param_count: 1,
2586                    react_hook_count: 0,
2587                    react_jsx_max_depth: 0,
2588                    react_prop_count: 0,
2589                    react_hook_profile: None,
2590                    exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
2591                    severity: crate::health_types::FindingSeverity::Critical,
2592                    crap: Some(182.0),
2593                    coverage_pct: None,
2594                    coverage_tier: None,
2595                    coverage_source: None,
2596                    inherited_from: None,
2597                    component_rollup: None,
2598                    contributions: Vec::new(),
2599                    effective_thresholds: None,
2600                    threshold_source: None,
2601                }
2602                .into(),
2603            ],
2604            summary: crate::health_types::HealthSummary {
2605                files_analyzed: 1,
2606                functions_analyzed: 1,
2607                functions_above_threshold: 1,
2608                ..Default::default()
2609            },
2610            ..Default::default()
2611        };
2612        let md = build_health_markdown(&report, &root);
2613        assert!(
2614            md.contains("| CRAP |"),
2615            "markdown table should have CRAP column header: {md}"
2616        );
2617        assert!(
2618            md.contains("182.0 **!**"),
2619            "CRAP value should be rendered with a threshold marker: {md}"
2620        );
2621        assert!(
2622            md.contains("CRAP >="),
2623            "trailing summary line should reference the CRAP threshold: {md}"
2624        );
2625    }
2626
2627    #[test]
2628    fn health_markdown_no_marker_when_below_threshold() {
2629        let root = PathBuf::from("/project");
2630        let report = crate::health_types::HealthReport {
2631            findings: vec![
2632                crate::health_types::ComplexityViolation {
2633                    path: root.join("src/utils.ts"),
2634                    name: "helper".to_string(),
2635                    line: 10,
2636                    col: 0,
2637                    cyclomatic: 15,
2638                    cognitive: 20,
2639                    line_count: 30,
2640                    param_count: 0,
2641                    react_hook_count: 0,
2642                    react_jsx_max_depth: 0,
2643                    react_prop_count: 0,
2644                    react_hook_profile: None,
2645                    exceeded: crate::health_types::ExceededThreshold::Cognitive,
2646                    severity: crate::health_types::FindingSeverity::High,
2647                    crap: None,
2648                    coverage_pct: None,
2649                    coverage_tier: None,
2650                    coverage_source: None,
2651                    inherited_from: None,
2652                    component_rollup: None,
2653                    contributions: Vec::new(),
2654                    effective_thresholds: None,
2655                    threshold_source: None,
2656                }
2657                .into(),
2658            ],
2659            summary: crate::health_types::HealthSummary {
2660                files_analyzed: 5,
2661                functions_analyzed: 20,
2662                functions_above_threshold: 1,
2663                ..Default::default()
2664            },
2665            ..Default::default()
2666        };
2667        let md = build_health_markdown(&report, &root);
2668        assert!(md.contains("| 15 |"));
2669        assert!(md.contains("20 **!**"));
2670    }
2671
2672    #[test]
2673    fn health_markdown_with_targets() {
2674        use crate::health_types::*;
2675
2676        let root = PathBuf::from("/project");
2677        let report = HealthReport {
2678            summary: HealthSummary {
2679                files_analyzed: 10,
2680                functions_analyzed: 50,
2681                ..Default::default()
2682            },
2683            targets: vec![
2684                RefactoringTarget {
2685                    path: PathBuf::from("/project/src/complex.ts"),
2686                    priority: 82.5,
2687                    efficiency: 27.5,
2688                    recommendation: "Split high-impact file".into(),
2689                    category: RecommendationCategory::SplitHighImpact,
2690                    effort: crate::health_types::EffortEstimate::High,
2691                    confidence: crate::health_types::Confidence::Medium,
2692                    factors: vec![ContributingFactor {
2693                        metric: "fan_in",
2694                        value: 25.0,
2695                        threshold: 10.0,
2696                        detail: "25 files depend on this".into(),
2697                    }],
2698                    evidence: None,
2699                }
2700                .into(),
2701                RefactoringTarget {
2702                    path: PathBuf::from("/project/src/legacy.ts"),
2703                    priority: 45.0,
2704                    efficiency: 45.0,
2705                    recommendation: "Remove 5 unused exports".into(),
2706                    category: RecommendationCategory::RemoveDeadCode,
2707                    effort: crate::health_types::EffortEstimate::Low,
2708                    confidence: crate::health_types::Confidence::High,
2709                    factors: vec![],
2710                    evidence: None,
2711                }
2712                .into(),
2713            ],
2714            ..Default::default()
2715        };
2716        let md = build_health_markdown(&report, &root);
2717
2718        assert!(
2719            md.contains("Refactoring Targets"),
2720            "should contain targets heading"
2721        );
2722        assert!(
2723            md.contains("src/complex.ts"),
2724            "should contain target file path"
2725        );
2726        assert!(md.contains("27.5"), "should contain efficiency score");
2727        assert!(
2728            md.contains("Split high-impact file"),
2729            "should contain recommendation"
2730        );
2731        assert!(md.contains("src/legacy.ts"), "should contain second target");
2732    }
2733
2734    #[test]
2735    fn health_markdown_with_coverage_gaps() {
2736        use crate::health_types::*;
2737
2738        let root = PathBuf::from("/project");
2739        let report = HealthReport {
2740            summary: HealthSummary {
2741                files_analyzed: 10,
2742                functions_analyzed: 50,
2743                ..Default::default()
2744            },
2745            coverage_gaps: Some(CoverageGaps {
2746                summary: CoverageGapSummary {
2747                    runtime_files: 2,
2748                    covered_files: 0,
2749                    file_coverage_pct: 0.0,
2750                    untested_files: 1,
2751                    untested_exports: 1,
2752                },
2753                files: vec![UntestedFileFinding::with_actions(
2754                    UntestedFile {
2755                        path: root.join("src/app.ts"),
2756                        value_export_count: 2,
2757                    },
2758                    &root,
2759                )],
2760                exports: vec![UntestedExportFinding::with_actions(
2761                    UntestedExport {
2762                        path: root.join("src/app.ts"),
2763                        export_name: "loader".into(),
2764                        line: 12,
2765                        col: 4,
2766                    },
2767                    &root,
2768                )],
2769            }),
2770            ..Default::default()
2771        };
2772
2773        let md = build_health_markdown(&report, &root);
2774        assert!(md.contains("### Coverage Gaps"));
2775        assert!(md.contains("*1 untested files"));
2776        assert!(md.contains("`src/app.ts` (2 value exports)"));
2777        assert!(md.contains("`src/app.ts`:12 `loader`"));
2778    }
2779
2780    #[test]
2781    fn markdown_dep_in_workspace_shows_package_label() {
2782        let root = PathBuf::from("/project");
2783        let mut results = AnalysisResults::default();
2784        results
2785            .unused_dependencies
2786            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2787                package_name: "lodash".to_string(),
2788                location: DependencyLocation::Dependencies,
2789                path: root.join("packages/core/package.json"),
2790                line: 5,
2791                used_in_workspaces: Vec::new(),
2792            }));
2793        let md = build_markdown(&results, &root);
2794        assert!(md.contains("(packages/core/package.json)"));
2795    }
2796
2797    #[test]
2798    fn markdown_dep_at_root_no_extra_label() {
2799        let root = PathBuf::from("/project");
2800        let mut results = AnalysisResults::default();
2801        results
2802            .unused_dependencies
2803            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2804                package_name: "lodash".to_string(),
2805                location: DependencyLocation::Dependencies,
2806                path: root.join("package.json"),
2807                line: 5,
2808                used_in_workspaces: Vec::new(),
2809            }));
2810        let md = build_markdown(&results, &root);
2811        assert!(md.contains("- `lodash`"));
2812        assert!(!md.contains("(package.json)"));
2813    }
2814
2815    #[test]
2816    fn markdown_root_dep_with_cross_workspace_context_uses_context_label() {
2817        let root = PathBuf::from("/project");
2818        let mut results = AnalysisResults::default();
2819        results
2820            .unused_dependencies
2821            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2822                package_name: "lodash-es".to_string(),
2823                location: DependencyLocation::Dependencies,
2824                path: root.join("package.json"),
2825                line: 5,
2826                used_in_workspaces: vec![root.join("packages/consumer")],
2827            }));
2828        let md = build_markdown(&results, &root);
2829        assert!(md.contains("- `lodash-es` (imported in packages/consumer)"));
2830        assert!(!md.contains("(package.json; imported in packages/consumer)"));
2831    }
2832
2833    #[test]
2834    fn markdown_exports_grouped_by_file() {
2835        let root = PathBuf::from("/project");
2836        let mut results = AnalysisResults::default();
2837        results
2838            .unused_exports
2839            .push(UnusedExportFinding::with_actions(UnusedExport {
2840                path: root.join("src/utils.ts"),
2841                export_name: "alpha".to_string(),
2842                is_type_only: false,
2843                line: 5,
2844                col: 0,
2845                span_start: 0,
2846                is_re_export: false,
2847            }));
2848        results
2849            .unused_exports
2850            .push(UnusedExportFinding::with_actions(UnusedExport {
2851                path: root.join("src/utils.ts"),
2852                export_name: "beta".to_string(),
2853                is_type_only: false,
2854                line: 10,
2855                col: 0,
2856                span_start: 0,
2857                is_re_export: false,
2858            }));
2859        results
2860            .unused_exports
2861            .push(UnusedExportFinding::with_actions(UnusedExport {
2862                path: root.join("src/other.ts"),
2863                export_name: "gamma".to_string(),
2864                is_type_only: false,
2865                line: 1,
2866                col: 0,
2867                span_start: 0,
2868                is_re_export: false,
2869            }));
2870        let md = build_markdown(&results, &root);
2871        let utils_count = md.matches("- `src/utils.ts`").count();
2872        assert_eq!(utils_count, 1, "file header should appear once per file");
2873        assert!(md.contains(":5 `alpha`"));
2874        assert!(md.contains(":10 `beta`"));
2875    }
2876
2877    #[test]
2878    fn markdown_multiple_issues_plural() {
2879        let root = PathBuf::from("/project");
2880        let mut results = AnalysisResults::default();
2881        results
2882            .unused_files
2883            .push(UnusedFileFinding::with_actions(UnusedFile {
2884                path: root.join("src/a.ts"),
2885            }));
2886        results
2887            .unused_files
2888            .push(UnusedFileFinding::with_actions(UnusedFile {
2889                path: root.join("src/b.ts"),
2890            }));
2891        let md = build_markdown(&results, &root);
2892        assert!(md.starts_with("## Fallow: 2 issues found\n"));
2893    }
2894
2895    #[test]
2896    fn duplication_markdown_zero_savings_no_suffix() {
2897        let root = PathBuf::from("/project");
2898        let report = DuplicationReport {
2899            clone_groups: vec![CloneGroup {
2900                instances: vec![CloneInstance {
2901                    file: root.join("src/a.ts"),
2902                    start_line: 1,
2903                    end_line: 5,
2904                    start_col: 0,
2905                    end_col: 0,
2906                    fragment: String::new(),
2907                }],
2908                token_count: 30,
2909                line_count: 5,
2910            }],
2911            clone_families: vec![CloneFamily {
2912                files: vec![root.join("src/a.ts")],
2913                groups: vec![],
2914                total_duplicated_lines: 5,
2915                total_duplicated_tokens: 30,
2916                suggestions: vec![RefactoringSuggestion {
2917                    kind: RefactoringKind::ExtractFunction,
2918                    description: "Extract function".to_string(),
2919                    estimated_savings: 0,
2920                }],
2921            }],
2922            mirrored_directories: vec![],
2923            stats: DuplicationStats {
2924                clone_groups: 1,
2925                clone_instances: 1,
2926                duplication_percentage: 1.0,
2927                ..Default::default()
2928            },
2929        };
2930        let md = build_duplication_markdown(&report, &root);
2931        assert!(md.contains("Extract function"));
2932        assert!(!md.contains("lines saved"));
2933    }
2934
2935    #[test]
2936    fn health_markdown_vital_signs_table() {
2937        let root = PathBuf::from("/project");
2938        let report = crate::health_types::HealthReport {
2939            summary: crate::health_types::HealthSummary {
2940                files_analyzed: 10,
2941                functions_analyzed: 50,
2942                ..Default::default()
2943            },
2944            vital_signs: Some(crate::health_types::VitalSigns {
2945                avg_cyclomatic: 3.5,
2946                p90_cyclomatic: 12,
2947                dead_file_pct: Some(5.0),
2948                dead_export_pct: Some(10.2),
2949                duplication_pct: None,
2950                maintainability_avg: Some(72.3),
2951                hotspot_count: Some(3),
2952                circular_dep_count: Some(1),
2953                unused_dep_count: Some(2),
2954                counts: None,
2955                unit_size_profile: None,
2956                unit_interfacing_profile: None,
2957                p95_fan_in: None,
2958                coupling_high_pct: None,
2959                total_loc: 15_200,
2960                ..Default::default()
2961            }),
2962            hotspot_summary: Some(crate::health_types::HotspotSummary {
2963                since: "6 months".to_string(),
2964                min_commits: 3,
2965                files_analyzed: 50,
2966                files_excluded: 0,
2967                shallow_clone: false,
2968            }),
2969            ..Default::default()
2970        };
2971        let md = build_health_markdown(&report, &root);
2972        assert!(md.contains("## Vital Signs"));
2973        assert!(md.contains("| Metric | Value |"));
2974        assert!(md.contains("| Total LOC | 15200 |"));
2975        assert!(md.contains("| Avg Cyclomatic | 3.5 |"));
2976        assert!(md.contains("| P90 Cyclomatic | 12 |"));
2977        assert!(md.contains("| Dead Files | 5.0% |"));
2978        assert!(md.contains("| Dead Exports | 10.2% |"));
2979        assert!(md.contains("| Maintainability (avg) | 72.3 |"));
2980        assert!(md.contains("| Hotspots (since 6 months) | 3 |"));
2981        assert!(md.contains("| Circular Deps | 1 |"));
2982        assert!(md.contains("| Unused Deps | 2 |"));
2983    }
2984
2985    #[test]
2986    fn health_markdown_hotspots_without_summary_omits_window() {
2987        let root = PathBuf::from("/project");
2988        let report = crate::health_types::HealthReport {
2989            vital_signs: Some(crate::health_types::VitalSigns {
2990                avg_cyclomatic: 2.0,
2991                p90_cyclomatic: 5,
2992                hotspot_count: Some(0),
2993                total_loc: 1_000,
2994                ..Default::default()
2995            }),
2996            hotspot_summary: None,
2997            ..Default::default()
2998        };
2999        let md = build_health_markdown(&report, &root);
3000        assert!(md.contains("| Hotspots | 0 |"));
3001        assert!(!md.contains("Hotspots (since"));
3002    }
3003
3004    #[test]
3005    fn health_markdown_file_scores_table() {
3006        let root = PathBuf::from("/project");
3007        let report = crate::health_types::HealthReport {
3008            findings: vec![
3009                crate::health_types::ComplexityViolation {
3010                    path: root.join("src/dummy.ts"),
3011                    name: "fn".to_string(),
3012                    line: 1,
3013                    col: 0,
3014                    cyclomatic: 25,
3015                    cognitive: 20,
3016                    line_count: 50,
3017                    param_count: 0,
3018                    react_hook_count: 0,
3019                    react_jsx_max_depth: 0,
3020                    react_prop_count: 0,
3021                    react_hook_profile: None,
3022                    exceeded: crate::health_types::ExceededThreshold::Both,
3023                    severity: crate::health_types::FindingSeverity::High,
3024                    crap: None,
3025                    coverage_pct: None,
3026                    coverage_tier: None,
3027                    coverage_source: None,
3028                    inherited_from: None,
3029                    component_rollup: None,
3030                    contributions: Vec::new(),
3031                    effective_thresholds: None,
3032                    threshold_source: None,
3033                }
3034                .into(),
3035            ],
3036            summary: crate::health_types::HealthSummary {
3037                files_analyzed: 5,
3038                functions_analyzed: 10,
3039                functions_above_threshold: 1,
3040                files_scored: Some(1),
3041                average_maintainability: Some(65.0),
3042                ..Default::default()
3043            },
3044            file_scores: vec![crate::health_types::FileHealthScore {
3045                path: root.join("src/utils.ts"),
3046                fan_in: 5,
3047                fan_out: 3,
3048                dead_code_ratio: 0.25,
3049                complexity_density: 0.8,
3050                maintainability_index: 72.5,
3051                total_cyclomatic: 40,
3052                total_cognitive: 30,
3053                function_count: 10,
3054                lines: 200,
3055                crap_max: 0.0,
3056                crap_above_threshold: 0,
3057            }],
3058            ..Default::default()
3059        };
3060        let md = build_health_markdown(&report, &root);
3061        assert!(md.contains("### File Health Scores (1 files)"));
3062        assert!(md.contains("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density |"));
3063        assert!(md.contains("| `src/utils.ts` | 72.5 | 5 | 3 | 25% | 0.80 |"));
3064        assert!(md.contains("**Average maintainability index:** 65.0/100"));
3065    }
3066
3067    #[test]
3068    fn health_markdown_hotspots_table() {
3069        let root = PathBuf::from("/project");
3070        let report = crate::health_types::HealthReport {
3071            findings: vec![
3072                crate::health_types::ComplexityViolation {
3073                    path: root.join("src/dummy.ts"),
3074                    name: "fn".to_string(),
3075                    line: 1,
3076                    col: 0,
3077                    cyclomatic: 25,
3078                    cognitive: 20,
3079                    line_count: 50,
3080                    param_count: 0,
3081                    react_hook_count: 0,
3082                    react_jsx_max_depth: 0,
3083                    react_prop_count: 0,
3084                    react_hook_profile: None,
3085                    exceeded: crate::health_types::ExceededThreshold::Both,
3086                    severity: crate::health_types::FindingSeverity::High,
3087                    crap: None,
3088                    coverage_pct: None,
3089                    coverage_tier: None,
3090                    coverage_source: None,
3091                    inherited_from: None,
3092                    component_rollup: None,
3093                    contributions: Vec::new(),
3094                    effective_thresholds: None,
3095                    threshold_source: None,
3096                }
3097                .into(),
3098            ],
3099            summary: crate::health_types::HealthSummary {
3100                files_analyzed: 5,
3101                functions_analyzed: 10,
3102                functions_above_threshold: 1,
3103                ..Default::default()
3104            },
3105            hotspots: vec![
3106                crate::health_types::HotspotEntry {
3107                    path: root.join("src/hot.ts"),
3108                    score: 85.0,
3109                    commits: 42,
3110                    weighted_commits: 35.0,
3111                    lines_added: 500,
3112                    lines_deleted: 200,
3113                    complexity_density: 1.2,
3114                    fan_in: 10,
3115                    trend: fallow_core::churn::ChurnTrend::Accelerating,
3116                    ownership: None,
3117                    is_test_path: false,
3118                }
3119                .into(),
3120            ],
3121            hotspot_summary: Some(crate::health_types::HotspotSummary {
3122                since: "6 months".to_string(),
3123                min_commits: 3,
3124                files_analyzed: 50,
3125                files_excluded: 5,
3126                shallow_clone: false,
3127            }),
3128            ..Default::default()
3129        };
3130        let md = build_health_markdown(&report, &root);
3131        assert!(md.contains("### Hotspots (1 files, since 6 months)"));
3132        assert!(md.contains("| `src/hot.ts` | 85.0 | 42 | 700 | 1.20 | 10 | accelerating |"));
3133        assert!(md.contains("*5 files excluded (< 3 commits)*"));
3134    }
3135
3136    #[test]
3137    fn health_markdown_metric_legend_with_scores() {
3138        let root = PathBuf::from("/project");
3139        let report = crate::health_types::HealthReport {
3140            findings: vec![
3141                crate::health_types::ComplexityViolation {
3142                    path: root.join("src/x.ts"),
3143                    name: "f".to_string(),
3144                    line: 1,
3145                    col: 0,
3146                    cyclomatic: 25,
3147                    cognitive: 20,
3148                    line_count: 10,
3149                    param_count: 0,
3150                    react_hook_count: 0,
3151                    react_jsx_max_depth: 0,
3152                    react_prop_count: 0,
3153                    react_hook_profile: None,
3154                    exceeded: crate::health_types::ExceededThreshold::Both,
3155                    severity: crate::health_types::FindingSeverity::High,
3156                    crap: None,
3157                    coverage_pct: None,
3158                    coverage_tier: None,
3159                    coverage_source: None,
3160                    inherited_from: None,
3161                    component_rollup: None,
3162                    contributions: Vec::new(),
3163                    effective_thresholds: None,
3164                    threshold_source: None,
3165                }
3166                .into(),
3167            ],
3168            summary: crate::health_types::HealthSummary {
3169                files_analyzed: 1,
3170                functions_analyzed: 1,
3171                functions_above_threshold: 1,
3172                files_scored: Some(1),
3173                average_maintainability: Some(70.0),
3174                ..Default::default()
3175            },
3176            file_scores: vec![crate::health_types::FileHealthScore {
3177                path: root.join("src/x.ts"),
3178                fan_in: 1,
3179                fan_out: 1,
3180                dead_code_ratio: 0.0,
3181                complexity_density: 0.5,
3182                maintainability_index: 80.0,
3183                total_cyclomatic: 10,
3184                total_cognitive: 8,
3185                function_count: 2,
3186                lines: 50,
3187                crap_max: 0.0,
3188                crap_above_threshold: 0,
3189            }],
3190            ..Default::default()
3191        };
3192        let md = build_health_markdown(&report, &root);
3193        assert!(md.contains("<details><summary>Metric definitions</summary>"));
3194        assert!(md.contains("**MI**: Maintainability Index"));
3195        assert!(md.contains("**Fan-in**"));
3196        assert!(md.contains("Full metric reference"));
3197    }
3198
3199    #[test]
3200    fn health_markdown_truncated_findings_shown_count() {
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: 10,
3233                functions_analyzed: 50,
3234                functions_above_threshold: 5, // 5 total but only 1 shown
3235                ..Default::default()
3236            },
3237            ..Default::default()
3238        };
3239        let md = build_health_markdown(&report, &root);
3240        assert!(md.contains("5 high complexity functions (1 shown)"));
3241    }
3242
3243    #[test]
3244    fn escape_backticks_handles_multiple() {
3245        assert_eq!(escape_backticks("a`b`c"), "a\\`b\\`c");
3246    }
3247
3248    #[test]
3249    fn escape_backticks_no_backticks_unchanged() {
3250        assert_eq!(escape_backticks("hello"), "hello");
3251    }
3252
3253    #[test]
3254    fn markdown_unresolved_import_grouped_by_file() {
3255        let root = PathBuf::from("/project");
3256        let mut results = AnalysisResults::default();
3257        results
3258            .unresolved_imports
3259            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
3260                path: root.join("src/app.ts"),
3261                specifier: "./missing".to_string(),
3262                line: 3,
3263                col: 0,
3264                specifier_col: 0,
3265            }));
3266        let md = build_markdown(&results, &root);
3267        assert!(md.contains("### Unresolved imports (1)"));
3268        assert!(md.contains("- `src/app.ts`"));
3269        assert!(md.contains(":3 `./missing`"));
3270    }
3271
3272    #[test]
3273    fn markdown_unused_optional_dep() {
3274        let root = PathBuf::from("/project");
3275        let mut results = AnalysisResults::default();
3276        results
3277            .unused_optional_dependencies
3278            .push(UnusedOptionalDependencyFinding::with_actions(
3279                UnusedDependency {
3280                    package_name: "fsevents".to_string(),
3281                    location: DependencyLocation::OptionalDependencies,
3282                    path: root.join("package.json"),
3283                    line: 12,
3284                    used_in_workspaces: Vec::new(),
3285                },
3286            ));
3287        let md = build_markdown(&results, &root);
3288        assert!(md.contains("### Unused optionalDependencies (1)"));
3289        assert!(md.contains("- `fsevents`"));
3290    }
3291
3292    #[test]
3293    fn health_markdown_hotspots_no_excluded_message() {
3294        let root = PathBuf::from("/project");
3295        let report = crate::health_types::HealthReport {
3296            findings: vec![
3297                crate::health_types::ComplexityViolation {
3298                    path: root.join("src/x.ts"),
3299                    name: "f".to_string(),
3300                    line: 1,
3301                    col: 0,
3302                    cyclomatic: 25,
3303                    cognitive: 20,
3304                    line_count: 10,
3305                    param_count: 0,
3306                    react_hook_count: 0,
3307                    react_jsx_max_depth: 0,
3308                    react_prop_count: 0,
3309                    react_hook_profile: None,
3310                    exceeded: crate::health_types::ExceededThreshold::Both,
3311                    severity: crate::health_types::FindingSeverity::High,
3312                    crap: None,
3313                    coverage_pct: None,
3314                    coverage_tier: None,
3315                    coverage_source: None,
3316                    inherited_from: None,
3317                    component_rollup: None,
3318                    contributions: Vec::new(),
3319                    effective_thresholds: None,
3320                    threshold_source: None,
3321                }
3322                .into(),
3323            ],
3324            summary: crate::health_types::HealthSummary {
3325                files_analyzed: 5,
3326                functions_analyzed: 10,
3327                functions_above_threshold: 1,
3328                ..Default::default()
3329            },
3330            hotspots: vec![
3331                crate::health_types::HotspotEntry {
3332                    path: root.join("src/hot.ts"),
3333                    score: 50.0,
3334                    commits: 10,
3335                    weighted_commits: 8.0,
3336                    lines_added: 100,
3337                    lines_deleted: 50,
3338                    complexity_density: 0.5,
3339                    fan_in: 3,
3340                    trend: fallow_core::churn::ChurnTrend::Stable,
3341                    ownership: None,
3342                    is_test_path: false,
3343                }
3344                .into(),
3345            ],
3346            hotspot_summary: Some(crate::health_types::HotspotSummary {
3347                since: "6 months".to_string(),
3348                min_commits: 3,
3349                files_analyzed: 50,
3350                files_excluded: 0,
3351                shallow_clone: false,
3352            }),
3353            ..Default::default()
3354        };
3355        let md = build_health_markdown(&report, &root);
3356        assert!(!md.contains("files excluded"));
3357    }
3358
3359    #[test]
3360    fn duplication_markdown_single_group_no_plural() {
3361        let root = PathBuf::from("/project");
3362        let report = DuplicationReport {
3363            clone_groups: vec![CloneGroup {
3364                instances: vec![CloneInstance {
3365                    file: root.join("src/a.ts"),
3366                    start_line: 1,
3367                    end_line: 5,
3368                    start_col: 0,
3369                    end_col: 0,
3370                    fragment: String::new(),
3371                }],
3372                token_count: 30,
3373                line_count: 5,
3374            }],
3375            clone_families: vec![],
3376            mirrored_directories: vec![],
3377            stats: DuplicationStats {
3378                clone_groups: 1,
3379                clone_instances: 1,
3380                duplication_percentage: 2.0,
3381                ..Default::default()
3382            },
3383        };
3384        let md = build_duplication_markdown(&report, &root);
3385        assert!(md.contains("1 clone group found"));
3386        assert!(!md.contains("1 clone groups found"));
3387    }
3388
3389    // -------------------------------------------------------------------------
3390    // display_complexity_entry_name: <component> case (lines 24-25)
3391    // -------------------------------------------------------------------------
3392
3393    #[test]
3394    fn display_complexity_entry_name_component_variant() {
3395        assert_eq!(
3396            display_complexity_entry_name("<component>"),
3397            "<component> (component rollup)"
3398        );
3399    }
3400
3401    // -------------------------------------------------------------------------
3402    // private_type_leaks section (lines 95-99)
3403    // -------------------------------------------------------------------------
3404
3405    #[test]
3406    fn markdown_private_type_leak_section() {
3407        use fallow_types::output_dead_code::PrivateTypeLeakFinding;
3408        use fallow_types::results::PrivateTypeLeak;
3409        let root = PathBuf::from("/project");
3410        let mut results = AnalysisResults::default();
3411        results
3412            .private_type_leaks
3413            .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
3414                path: root.join("src/api.ts"),
3415                export_name: "publicFn".to_string(),
3416                type_name: "InternalType".to_string(),
3417                line: 7,
3418                col: 0,
3419                span_start: 0,
3420            }));
3421        let md = build_markdown(&results, &root);
3422        assert!(md.contains("### Private type leaks (1)"));
3423        let normalized = md.replace('\\', "/");
3424        assert!(normalized.contains("`src/api.ts`"));
3425        assert!(normalized.contains("`publicFn` references private type `InternalType`"));
3426    }
3427
3428    // -------------------------------------------------------------------------
3429    // circular dependency with is_cross_package: true (lines 409-413)
3430    // -------------------------------------------------------------------------
3431
3432    #[test]
3433    fn markdown_circular_dep_cross_package_tag() {
3434        let root = PathBuf::from("/project");
3435        let mut results = AnalysisResults::default();
3436        results
3437            .circular_dependencies
3438            .push(CircularDependencyFinding::with_actions(
3439                CircularDependency {
3440                    files: vec![root.join("pkg-a/src/a.ts"), root.join("pkg-b/src/b.ts")],
3441                    length: 2,
3442                    line: 1,
3443                    col: 0,
3444                    edges: Vec::new(),
3445                    is_cross_package: true,
3446                },
3447            ));
3448        let md = build_markdown(&results, &root);
3449        assert!(md.contains("*(cross-package)*"));
3450    }
3451
3452    // -------------------------------------------------------------------------
3453    // boundary coverage violation (lines 460-468)
3454    // -------------------------------------------------------------------------
3455
3456    #[test]
3457    fn markdown_boundary_coverage_violation_format() {
3458        use fallow_types::output_dead_code::BoundaryCoverageViolationFinding;
3459        use fallow_types::results::BoundaryCoverageViolation;
3460        let root = PathBuf::from("/project");
3461        let mut results = AnalysisResults::default();
3462        results
3463            .boundary_coverage_violations
3464            .push(BoundaryCoverageViolationFinding::with_actions(
3465                BoundaryCoverageViolation {
3466                    path: root.join("src/orphan.ts"),
3467                    line: 1,
3468                    col: 0,
3469                },
3470            ));
3471        let md = build_markdown(&results, &root);
3472        assert!(md.contains("### Boundary coverage (1)"));
3473        let normalized = md.replace('\\', "/");
3474        assert!(normalized.contains("src/orphan.ts"));
3475        assert!(normalized.contains("no matching boundary zone"));
3476    }
3477
3478    // -------------------------------------------------------------------------
3479    // boundary call violation (lines 471-483)
3480    // -------------------------------------------------------------------------
3481
3482    #[test]
3483    fn markdown_boundary_call_violation_format() {
3484        use fallow_types::output_dead_code::BoundaryCallViolationFinding;
3485        use fallow_types::results::BoundaryCallViolation;
3486        let root = PathBuf::from("/project");
3487        let mut results = AnalysisResults::default();
3488        results
3489            .boundary_call_violations
3490            .push(BoundaryCallViolationFinding::with_actions(
3491                BoundaryCallViolation {
3492                    path: root.join("src/caller.ts"),
3493                    line: 42,
3494                    col: 0,
3495                    callee: "dangerousCall".to_string(),
3496                    zone: "public".to_string(),
3497                    pattern: "dangerous*".to_string(),
3498                },
3499            ));
3500        let md = build_markdown(&results, &root);
3501        assert!(md.contains("### Boundary calls (1)"));
3502        let normalized = md.replace('\\', "/");
3503        assert!(normalized.contains("`dangerousCall` forbidden in zone `public`"));
3504        assert!(normalized.contains("pattern `dangerous*`"));
3505    }
3506
3507    // -------------------------------------------------------------------------
3508    // policy violation (lines 485-502)
3509    // -------------------------------------------------------------------------
3510
3511    #[test]
3512    fn markdown_policy_violation_without_message() {
3513        use fallow_types::output_dead_code::PolicyViolationFinding;
3514        use fallow_types::results::{PolicyRuleKind, PolicyViolation, PolicyViolationSeverity};
3515        let root = PathBuf::from("/project");
3516        let mut results = AnalysisResults::default();
3517        results
3518            .policy_violations
3519            .push(PolicyViolationFinding::with_actions(PolicyViolation {
3520                path: root.join("src/banned.ts"),
3521                line: 3,
3522                col: 0,
3523                matched: "eval".to_string(),
3524                pack: "security".to_string(),
3525                rule_id: "no-eval".to_string(),
3526                kind: PolicyRuleKind::BannedCall,
3527                severity: PolicyViolationSeverity::Error,
3528                message: None,
3529            }));
3530        let md = build_markdown(&results, &root);
3531        assert!(md.contains("### Policy violations (1)"));
3532        let normalized = md.replace('\\', "/");
3533        assert!(normalized.contains("`eval` banned by `security/no-eval`"));
3534    }
3535
3536    #[test]
3537    fn markdown_policy_violation_with_message() {
3538        use fallow_types::output_dead_code::PolicyViolationFinding;
3539        use fallow_types::results::{PolicyRuleKind, PolicyViolation, PolicyViolationSeverity};
3540        let root = PathBuf::from("/project");
3541        let mut results = AnalysisResults::default();
3542        results
3543            .policy_violations
3544            .push(PolicyViolationFinding::with_actions(PolicyViolation {
3545                path: root.join("src/banned.ts"),
3546                line: 8,
3547                col: 0,
3548                matched: "console.log".to_string(),
3549                pack: "style".to_string(),
3550                rule_id: "no-console".to_string(),
3551                kind: PolicyRuleKind::BannedCall,
3552                severity: PolicyViolationSeverity::Warn,
3553                message: Some("Use a logger instead".to_string()),
3554            }));
3555        let md = build_markdown(&results, &root);
3556        let normalized = md.replace('\\', "/");
3557        assert!(normalized.contains("(Use a logger instead)"));
3558    }
3559
3560    // -------------------------------------------------------------------------
3561    // misplaced directive (lines 530-540)
3562    // -------------------------------------------------------------------------
3563
3564    #[test]
3565    fn markdown_misplaced_directive_format() {
3566        use fallow_types::output_dead_code::MisplacedDirectiveFinding;
3567        use fallow_types::results::MisplacedDirective;
3568        let root = PathBuf::from("/project");
3569        let mut results = AnalysisResults::default();
3570        results
3571            .misplaced_directives
3572            .push(MisplacedDirectiveFinding::with_actions(
3573                MisplacedDirective {
3574                    path: root.join("src/app.ts"),
3575                    line: 10,
3576                    col: 0,
3577                    directive: "use client".to_string(),
3578                },
3579            ));
3580        let md = build_markdown(&results, &root);
3581        assert!(md.contains("### Misplaced directives (1)"));
3582        let normalized = md.replace('\\', "/");
3583        assert!(normalized.contains("src/app.ts"));
3584        assert!(normalized.contains("not in the leading position"));
3585    }
3586
3587    // -------------------------------------------------------------------------
3588    // route collision (lines 651-661)
3589    // -------------------------------------------------------------------------
3590
3591    #[test]
3592    fn markdown_route_collision_format() {
3593        use fallow_types::output_dead_code::RouteCollisionFinding;
3594        use fallow_types::results::RouteCollision;
3595        let root = PathBuf::from("/project");
3596        let mut results = AnalysisResults::default();
3597        results
3598            .route_collisions
3599            .push(RouteCollisionFinding::with_actions(RouteCollision {
3600                path: root.join("app/dashboard/page.tsx"),
3601                url: "/dashboard".to_string(),
3602                conflicting_paths: vec![root.join("pages/dashboard.tsx")],
3603                line: 1,
3604                col: 0,
3605            }));
3606        let md = build_markdown(&results, &root);
3607        assert!(md.contains("### Route collisions (1)"));
3608        let normalized = md.replace('\\', "/");
3609        assert!(normalized.contains("/dashboard"));
3610        assert!(normalized.contains("dashboard"));
3611    }
3612
3613    // -------------------------------------------------------------------------
3614    // dynamic segment name conflict (lines 663-674)
3615    // -------------------------------------------------------------------------
3616
3617    #[test]
3618    fn markdown_dynamic_segment_name_conflict_format() {
3619        use fallow_types::output_dead_code::DynamicSegmentNameConflictFinding;
3620        use fallow_types::results::DynamicSegmentNameConflict;
3621        let root = PathBuf::from("/project");
3622        let mut results = AnalysisResults::default();
3623        results.dynamic_segment_name_conflicts.push(
3624            DynamicSegmentNameConflictFinding::with_actions(DynamicSegmentNameConflict {
3625                path: root.join("app/[slug]/page.tsx"),
3626                conflicting_segments: vec!["[slug]".to_string(), "[id]".to_string()],
3627                conflicting_paths: vec![root.join("app/[id]/page.tsx")],
3628                position: "/product".to_string(),
3629                line: 1,
3630                col: 0,
3631            }),
3632        );
3633        let md = build_markdown(&results, &root);
3634        assert!(md.contains("### Dynamic segment conflicts (1)"));
3635        let normalized = md.replace('\\', "/");
3636        assert!(normalized.contains("slug"));
3637        assert!(normalized.contains("id"));
3638    }
3639
3640    // -------------------------------------------------------------------------
3641    // catalog entry with hardcoded consumers (lines 741-751)
3642    // -------------------------------------------------------------------------
3643
3644    #[test]
3645    fn markdown_catalog_entry_with_hardcoded_consumers() {
3646        use fallow_types::output_dead_code::UnusedCatalogEntryFinding;
3647        use fallow_types::results::UnusedCatalogEntry;
3648        let root = PathBuf::from("/project");
3649        let mut results = AnalysisResults::default();
3650        results
3651            .unused_catalog_entries
3652            .push(UnusedCatalogEntryFinding::with_actions(
3653                UnusedCatalogEntry {
3654                    entry_name: "lodash".to_string(),
3655                    catalog_name: "default".to_string(),
3656                    path: root.join("pnpm-workspace.yaml"),
3657                    line: 4,
3658                    hardcoded_consumers: vec![root.join("packages/legacy")],
3659                },
3660            ));
3661        let md = build_markdown(&results, &root);
3662        assert!(md.contains("### Unused catalog entries (1)"));
3663        let normalized = md.replace('\\', "/");
3664        assert!(normalized.contains("hardcoded in"));
3665        assert!(normalized.contains("packages/legacy"));
3666    }
3667
3668    // -------------------------------------------------------------------------
3669    // unresolved catalog reference with available_in_catalogs (lines 765-774)
3670    // -------------------------------------------------------------------------
3671
3672    #[test]
3673    fn markdown_unresolved_catalog_reference_with_alts() {
3674        use fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding;
3675        use fallow_types::results::UnresolvedCatalogReference;
3676        let root = PathBuf::from("/project");
3677        let mut results = AnalysisResults::default();
3678        results.unresolved_catalog_references.push(
3679            UnresolvedCatalogReferenceFinding::with_actions(UnresolvedCatalogReference {
3680                entry_name: "react".to_string(),
3681                catalog_name: "default".to_string(),
3682                path: root.join("packages/app/package.json"),
3683                line: 12,
3684                available_in_catalogs: vec!["shared".to_string()],
3685            }),
3686        );
3687        let md = build_markdown(&results, &root);
3688        assert!(md.contains("### Unresolved catalog references (1)"));
3689        let normalized = md.replace('\\', "/");
3690        assert!(normalized.contains("available in: `shared`"));
3691    }
3692
3693    // -------------------------------------------------------------------------
3694    // vital signs section (lines 1536-1571)
3695    // -------------------------------------------------------------------------
3696
3697    #[test]
3698    fn health_markdown_vital_signs_all_optional_fields() {
3699        use crate::health_types::{HotspotSummary, VitalSigns};
3700        let root = PathBuf::from("/project");
3701        let report = crate::health_types::HealthReport {
3702            vital_signs: Some(VitalSigns {
3703                dead_file_pct: Some(2.5),
3704                dead_export_pct: Some(10.0),
3705                avg_cyclomatic: 3.2,
3706                critical_complexity_pct: None,
3707                p90_cyclomatic: 8,
3708                duplication_pct: None,
3709                hotspot_count: Some(4),
3710                hotspot_top_pct_count: None,
3711                maintainability_avg: Some(75.0),
3712                maintainability_low_pct: None,
3713                unused_dep_count: Some(3),
3714                unused_deps_per_k_files: None,
3715                circular_dep_count: Some(2),
3716                circular_deps_per_k_files: None,
3717                counts: None,
3718                unit_size_profile: None,
3719                functions_over_60_loc_per_k: None,
3720                unit_interfacing_profile: None,
3721                p95_fan_in: None,
3722                coupling_high_pct: None,
3723                prop_drilling_chain_count: None,
3724                prop_drilling_max_depth: None,
3725                p95_render_fan_in: None,
3726                render_fan_in_high_pct: None,
3727                max_render_fan_in: None,
3728                top_render_fan_in: Vec::new(),
3729                total_loc: 5000,
3730            }),
3731            hotspot_summary: Some(HotspotSummary {
3732                since: "3 months".to_string(),
3733                min_commits: 2,
3734                files_analyzed: 20,
3735                files_excluded: 0,
3736                shallow_clone: false,
3737            }),
3738            summary: crate::health_types::HealthSummary {
3739                files_analyzed: 10,
3740                functions_analyzed: 80,
3741                ..Default::default()
3742            },
3743            ..Default::default()
3744        };
3745        let md = build_health_markdown(&report, &root);
3746        assert!(md.contains("## Vital Signs"));
3747        assert!(md.contains("| Total LOC | 5000 |"));
3748        assert!(md.contains("| Avg Cyclomatic | 3.2 |"));
3749        assert!(md.contains("| Dead Files | 2.5% |"));
3750        assert!(md.contains("| Dead Exports | 10.0% |"));
3751        assert!(md.contains("| Maintainability (avg) | 75.0 |"));
3752        assert!(md.contains("| Hotspots (since 3 months) | 4 |"));
3753        assert!(md.contains("| Circular Deps | 2 |"));
3754        assert!(md.contains("| Unused Deps | 3 |"));
3755    }
3756
3757    #[test]
3758    fn health_markdown_vital_signs_hotspot_without_summary() {
3759        use crate::health_types::VitalSigns;
3760        let root = PathBuf::from("/project");
3761        let report = crate::health_types::HealthReport {
3762            vital_signs: Some(VitalSigns {
3763                avg_cyclomatic: 2.0,
3764                p90_cyclomatic: 5,
3765                total_loc: 0,
3766                dead_file_pct: None,
3767                dead_export_pct: None,
3768                critical_complexity_pct: None,
3769                duplication_pct: None,
3770                hotspot_count: Some(7),
3771                hotspot_top_pct_count: None,
3772                maintainability_avg: None,
3773                maintainability_low_pct: None,
3774                unused_dep_count: None,
3775                unused_deps_per_k_files: None,
3776                circular_dep_count: None,
3777                circular_deps_per_k_files: None,
3778                counts: None,
3779                unit_size_profile: None,
3780                functions_over_60_loc_per_k: None,
3781                unit_interfacing_profile: None,
3782                p95_fan_in: None,
3783                coupling_high_pct: None,
3784                prop_drilling_chain_count: None,
3785                prop_drilling_max_depth: None,
3786                p95_render_fan_in: None,
3787                render_fan_in_high_pct: None,
3788                max_render_fan_in: None,
3789                top_render_fan_in: Vec::new(),
3790            }),
3791            hotspot_summary: None,
3792            summary: crate::health_types::HealthSummary {
3793                files_analyzed: 5,
3794                functions_analyzed: 20,
3795                ..Default::default()
3796            },
3797            ..Default::default()
3798        };
3799        let md = build_health_markdown(&report, &root);
3800        // When hotspot_summary is None, the label should just say "Hotspots"
3801        assert!(md.contains("| Hotspots | 7 |"));
3802        assert!(!md.contains("since"));
3803    }
3804
3805    // -------------------------------------------------------------------------
3806    // health score section (lines 1052-1054 / 1679-1681)
3807    // -------------------------------------------------------------------------
3808
3809    #[test]
3810    fn health_markdown_health_score_header() {
3811        use crate::health_types::{HealthScore, HealthScorePenalties};
3812        let root = PathBuf::from("/project");
3813        let report = crate::health_types::HealthReport {
3814            health_score: Some(HealthScore {
3815                formula_version: 2,
3816                score: 82.0,
3817                grade: "B",
3818                penalties: HealthScorePenalties {
3819                    dead_files: None,
3820                    dead_exports: None,
3821                    complexity: 5.0,
3822                    p90_complexity: 3.0,
3823                    maintainability: None,
3824                    hotspots: None,
3825                    unused_deps: None,
3826                    circular_deps: None,
3827                    unit_size: None,
3828                    coupling: None,
3829                    duplication: None,
3830                    prop_drilling: None,
3831                },
3832            }),
3833            summary: crate::health_types::HealthSummary {
3834                files_analyzed: 5,
3835                functions_analyzed: 20,
3836                ..Default::default()
3837            },
3838            ..Default::default()
3839        };
3840        let md = build_health_markdown(&report, &root);
3841        assert!(md.contains("## Health Score: 82 (B)"));
3842    }
3843
3844    // -------------------------------------------------------------------------
3845    // threshold overrides section (lines 1696-1747)
3846    // -------------------------------------------------------------------------
3847
3848    #[test]
3849    fn health_markdown_threshold_overrides_active() {
3850        use crate::health_types::{
3851            HealthConfiguredThresholds, HealthEffectiveThresholds, ThresholdOverrideMetrics,
3852            ThresholdOverrideState, ThresholdOverrideStatus,
3853        };
3854        let root = PathBuf::from("/project");
3855        let report = crate::health_types::HealthReport {
3856            findings: vec![
3857                crate::health_types::ComplexityViolation {
3858                    path: root.join("src/complex.ts"),
3859                    name: "bigFn".to_string(),
3860                    line: 10,
3861                    col: 0,
3862                    cyclomatic: 25,
3863                    cognitive: 20,
3864                    line_count: 50,
3865                    param_count: 0,
3866                    react_hook_count: 0,
3867                    react_jsx_max_depth: 0,
3868                    react_prop_count: 0,
3869                    react_hook_profile: None,
3870                    exceeded: crate::health_types::ExceededThreshold::Both,
3871                    severity: crate::health_types::FindingSeverity::High,
3872                    crap: None,
3873                    coverage_pct: None,
3874                    coverage_tier: None,
3875                    coverage_source: None,
3876                    inherited_from: None,
3877                    component_rollup: None,
3878                    contributions: Vec::new(),
3879                    effective_thresholds: None,
3880                    threshold_source: None,
3881                }
3882                .into(),
3883            ],
3884            summary: crate::health_types::HealthSummary {
3885                files_analyzed: 2,
3886                functions_analyzed: 5,
3887                functions_above_threshold: 1,
3888                ..Default::default()
3889            },
3890            threshold_overrides: vec![ThresholdOverrideState {
3891                status: ThresholdOverrideStatus::Active,
3892                override_index: 0,
3893                path: Some(root.join("src/complex.ts")),
3894                function: Some("bigFn".to_string()),
3895                configured_thresholds: HealthConfiguredThresholds {
3896                    max_cyclomatic: Some(30),
3897                    max_cognitive: Some(25),
3898                    max_crap: None,
3899                },
3900                effective_thresholds: HealthEffectiveThresholds {
3901                    max_cyclomatic: 30,
3902                    max_cognitive: 25,
3903                    max_crap: 30.0,
3904                },
3905                metrics: Some(ThresholdOverrideMetrics {
3906                    cyclomatic: 25,
3907                    cognitive: 20,
3908                    crap: None,
3909                }),
3910                reason: None,
3911            }],
3912            ..Default::default()
3913        };
3914        let md = build_health_markdown(&report, &root);
3915        assert!(md.contains("## Health Threshold Overrides"));
3916        assert!(md.contains("| Override | Status |"));
3917        let normalized = md.replace('\\', "/");
3918        assert!(normalized.contains("active"));
3919        assert!(normalized.contains("src/complex.ts:bigFn"));
3920        assert!(normalized.contains("cyclomatic 25, cognitive 20"));
3921    }
3922
3923    #[test]
3924    fn health_markdown_threshold_overrides_stale_no_match() {
3925        use crate::health_types::{
3926            HealthConfiguredThresholds, HealthEffectiveThresholds, ThresholdOverrideState,
3927            ThresholdOverrideStatus,
3928        };
3929        let root = PathBuf::from("/project");
3930        let report = crate::health_types::HealthReport {
3931            findings: vec![
3932                crate::health_types::ComplexityViolation {
3933                    path: root.join("src/x.ts"),
3934                    name: "fn1".to_string(),
3935                    line: 1,
3936                    col: 0,
3937                    cyclomatic: 22,
3938                    cognitive: 18,
3939                    line_count: 40,
3940                    param_count: 0,
3941                    react_hook_count: 0,
3942                    react_jsx_max_depth: 0,
3943                    react_prop_count: 0,
3944                    react_hook_profile: None,
3945                    exceeded: crate::health_types::ExceededThreshold::Both,
3946                    severity: crate::health_types::FindingSeverity::High,
3947                    crap: None,
3948                    coverage_pct: None,
3949                    coverage_tier: None,
3950                    coverage_source: None,
3951                    inherited_from: None,
3952                    component_rollup: None,
3953                    contributions: Vec::new(),
3954                    effective_thresholds: None,
3955                    threshold_source: None,
3956                }
3957                .into(),
3958            ],
3959            summary: crate::health_types::HealthSummary {
3960                files_analyzed: 1,
3961                functions_analyzed: 3,
3962                functions_above_threshold: 1,
3963                ..Default::default()
3964            },
3965            threshold_overrides: vec![
3966                ThresholdOverrideState {
3967                    status: ThresholdOverrideStatus::Stale,
3968                    override_index: 1,
3969                    path: None,
3970                    function: None,
3971                    configured_thresholds: HealthConfiguredThresholds {
3972                        max_cyclomatic: Some(40),
3973                        max_cognitive: None,
3974                        max_crap: None,
3975                    },
3976                    effective_thresholds: HealthEffectiveThresholds {
3977                        max_cyclomatic: 40,
3978                        max_cognitive: 15,
3979                        max_crap: 30.0,
3980                    },
3981                    metrics: None,
3982                    reason: None,
3983                },
3984                ThresholdOverrideState {
3985                    status: ThresholdOverrideStatus::NoMatch,
3986                    override_index: 2,
3987                    path: None,
3988                    function: None,
3989                    configured_thresholds: HealthConfiguredThresholds {
3990                        max_cyclomatic: None,
3991                        max_cognitive: None,
3992                        max_crap: Some(50.0),
3993                    },
3994                    effective_thresholds: HealthEffectiveThresholds {
3995                        max_cyclomatic: 20,
3996                        max_cognitive: 15,
3997                        max_crap: 50.0,
3998                    },
3999                    metrics: None,
4000                    reason: None,
4001                },
4002            ],
4003            ..Default::default()
4004        };
4005        let md = build_health_markdown(&report, &root);
4006        assert!(md.contains("stale"));
4007        assert!(md.contains("no_match"));
4008        assert!(md.contains("<no matching file or function>"));
4009        // No metrics so the column is a dash
4010        assert!(md.contains("| - |"));
4011    }
4012
4013    // -------------------------------------------------------------------------
4014    // file scores section (lines 1749-1791)
4015    // -------------------------------------------------------------------------
4016
4017    #[test]
4018    fn health_markdown_file_scores_section() {
4019        use crate::health_types::FileHealthScore;
4020        let root = PathBuf::from("/project");
4021        let report = crate::health_types::HealthReport {
4022            file_scores: vec![FileHealthScore {
4023                path: root.join("src/util.ts"),
4024                fan_in: 5,
4025                fan_out: 3,
4026                dead_code_ratio: 0.1,
4027                complexity_density: 0.25,
4028                maintainability_index: 68.0,
4029                total_cyclomatic: 40,
4030                total_cognitive: 30,
4031                function_count: 8,
4032                lines: 200,
4033                crap_max: 22.5,
4034                crap_above_threshold: 1,
4035            }],
4036            summary: crate::health_types::HealthSummary {
4037                files_analyzed: 5,
4038                functions_analyzed: 20,
4039                average_maintainability: Some(71.0),
4040                ..Default::default()
4041            },
4042            ..Default::default()
4043        };
4044        let md = build_health_markdown(&report, &root);
4045        assert!(md.contains("### File Health Scores (1 files)"));
4046        assert!(md.contains("| File | Maintainability |"));
4047        let normalized = md.replace('\\', "/");
4048        assert!(normalized.contains("`src/util.ts`"));
4049        assert!(normalized.contains("68.0"));
4050        // avg maintainability is shown when set
4051        assert!(md.contains("**Average maintainability index:** 71.0/100"));
4052    }
4053
4054    // -------------------------------------------------------------------------
4055    // coverage gaps: empty files and exports (lines 1810-1813)
4056    // -------------------------------------------------------------------------
4057
4058    #[test]
4059    fn health_markdown_coverage_gaps_empty_files_and_exports() {
4060        use crate::health_types::{CoverageGapSummary, CoverageGaps};
4061        let root = PathBuf::from("/project");
4062        let report = crate::health_types::HealthReport {
4063            coverage_gaps: Some(CoverageGaps {
4064                summary: CoverageGapSummary {
4065                    runtime_files: 5,
4066                    covered_files: 5,
4067                    file_coverage_pct: 100.0,
4068                    untested_files: 0,
4069                    untested_exports: 0,
4070                },
4071                files: vec![],
4072                exports: vec![],
4073            }),
4074            summary: crate::health_types::HealthSummary {
4075                files_analyzed: 5,
4076                functions_analyzed: 20,
4077                ..Default::default()
4078            },
4079            ..Default::default()
4080        };
4081        let md = build_health_markdown(&report, &root);
4082        assert!(md.contains("### Coverage Gaps"));
4083        assert!(md.contains("_No coverage gaps found in scope._"));
4084    }
4085
4086    // -------------------------------------------------------------------------
4087    // hotspots section without ownership (lines 1889-1928)
4088    // -------------------------------------------------------------------------
4089
4090    #[test]
4091    fn health_markdown_hotspots_without_ownership() {
4092        let root = PathBuf::from("/project");
4093        let report = crate::health_types::HealthReport {
4094            findings: vec![
4095                crate::health_types::ComplexityViolation {
4096                    path: root.join("src/x.ts"),
4097                    name: "hotFn".to_string(),
4098                    line: 5,
4099                    col: 0,
4100                    cyclomatic: 22,
4101                    cognitive: 18,
4102                    line_count: 60,
4103                    param_count: 0,
4104                    react_hook_count: 0,
4105                    react_jsx_max_depth: 0,
4106                    react_prop_count: 0,
4107                    react_hook_profile: None,
4108                    exceeded: crate::health_types::ExceededThreshold::Both,
4109                    severity: crate::health_types::FindingSeverity::High,
4110                    crap: None,
4111                    coverage_pct: None,
4112                    coverage_tier: None,
4113                    coverage_source: None,
4114                    inherited_from: None,
4115                    component_rollup: None,
4116                    contributions: Vec::new(),
4117                    effective_thresholds: None,
4118                    threshold_source: None,
4119                }
4120                .into(),
4121            ],
4122            summary: crate::health_types::HealthSummary {
4123                files_analyzed: 5,
4124                functions_analyzed: 10,
4125                functions_above_threshold: 1,
4126                ..Default::default()
4127            },
4128            hotspots: vec![
4129                crate::health_types::HotspotEntry {
4130                    path: root.join("src/hot.ts"),
4131                    score: 75.0,
4132                    commits: 20,
4133                    weighted_commits: 18.0,
4134                    lines_added: 200,
4135                    lines_deleted: 80,
4136                    complexity_density: 0.7,
4137                    fan_in: 8,
4138                    trend: fallow_core::churn::ChurnTrend::Accelerating,
4139                    ownership: None,
4140                    is_test_path: false,
4141                }
4142                .into(),
4143            ],
4144            hotspot_summary: None,
4145            ..Default::default()
4146        };
4147        let md = build_health_markdown(&report, &root);
4148        assert!(md.contains("### Hotspots (1 files)"));
4149        // No "since" when hotspot_summary is None
4150        assert!(!md.contains("since"));
4151        // Without ownership the narrow table is used
4152        assert!(md.contains("| File | Score | Commits | Churn | Density | Fan-in | Trend |"));
4153        assert!(!md.contains("Bus"));
4154        let normalized = md.replace('\\', "/");
4155        assert!(normalized.contains("`src/hot.ts`"));
4156        assert!(md.contains("75.0"));
4157        assert!(md.contains("accelerating"));
4158    }
4159
4160    #[test]
4161    fn health_markdown_hotspots_with_summary_since_and_excluded() {
4162        let root = PathBuf::from("/project");
4163        let report = crate::health_types::HealthReport {
4164            findings: vec![
4165                crate::health_types::ComplexityViolation {
4166                    path: root.join("src/z.ts"),
4167                    name: "g".to_string(),
4168                    line: 1,
4169                    col: 0,
4170                    cyclomatic: 22,
4171                    cognitive: 18,
4172                    line_count: 30,
4173                    param_count: 0,
4174                    react_hook_count: 0,
4175                    react_jsx_max_depth: 0,
4176                    react_prop_count: 0,
4177                    react_hook_profile: None,
4178                    exceeded: crate::health_types::ExceededThreshold::Both,
4179                    severity: crate::health_types::FindingSeverity::High,
4180                    crap: None,
4181                    coverage_pct: None,
4182                    coverage_tier: None,
4183                    coverage_source: None,
4184                    inherited_from: None,
4185                    component_rollup: None,
4186                    contributions: Vec::new(),
4187                    effective_thresholds: None,
4188                    threshold_source: None,
4189                }
4190                .into(),
4191            ],
4192            summary: crate::health_types::HealthSummary {
4193                files_analyzed: 5,
4194                functions_analyzed: 10,
4195                functions_above_threshold: 1,
4196                ..Default::default()
4197            },
4198            hotspots: vec![
4199                crate::health_types::HotspotEntry {
4200                    path: root.join("src/churn.ts"),
4201                    score: 60.0,
4202                    commits: 15,
4203                    weighted_commits: 12.0,
4204                    lines_added: 150,
4205                    lines_deleted: 40,
4206                    complexity_density: 0.4,
4207                    fan_in: 2,
4208                    trend: fallow_core::churn::ChurnTrend::Cooling,
4209                    ownership: None,
4210                    is_test_path: false,
4211                }
4212                .into(),
4213            ],
4214            hotspot_summary: Some(crate::health_types::HotspotSummary {
4215                since: "12 months".to_string(),
4216                min_commits: 5,
4217                files_analyzed: 100,
4218                files_excluded: 3,
4219                shallow_clone: false,
4220            }),
4221            ..Default::default()
4222        };
4223        let md = build_health_markdown(&report, &root);
4224        assert!(md.contains("### Hotspots (1 files, since 12 months)"));
4225        // files_excluded > 0 triggers the excluded message
4226        assert!(md.contains("3 file"));
4227        assert!(md.contains("excluded"));
4228        assert!(md.contains("< 5 commits"));
4229    }
4230
4231    // -------------------------------------------------------------------------
4232    // hotspots with ownership (lines 1854-1887)
4233    // -------------------------------------------------------------------------
4234
4235    #[test]
4236    fn health_markdown_hotspots_with_ownership() {
4237        use crate::health_types::{
4238            ContributorEntry, ContributorIdentifierFormat, OwnershipMetrics, OwnershipState,
4239        };
4240        let root = PathBuf::from("/project");
4241        let report = crate::health_types::HealthReport {
4242            findings: vec![
4243                crate::health_types::ComplexityViolation {
4244                    path: root.join("src/owned.ts"),
4245                    name: "fn2".to_string(),
4246                    line: 1,
4247                    col: 0,
4248                    cyclomatic: 22,
4249                    cognitive: 18,
4250                    line_count: 50,
4251                    param_count: 0,
4252                    react_hook_count: 0,
4253                    react_jsx_max_depth: 0,
4254                    react_prop_count: 0,
4255                    react_hook_profile: None,
4256                    exceeded: crate::health_types::ExceededThreshold::Both,
4257                    severity: crate::health_types::FindingSeverity::High,
4258                    crap: None,
4259                    coverage_pct: None,
4260                    coverage_tier: None,
4261                    coverage_source: None,
4262                    inherited_from: None,
4263                    component_rollup: None,
4264                    contributions: Vec::new(),
4265                    effective_thresholds: None,
4266                    threshold_source: None,
4267                }
4268                .into(),
4269            ],
4270            summary: crate::health_types::HealthSummary {
4271                files_analyzed: 2,
4272                functions_analyzed: 4,
4273                functions_above_threshold: 1,
4274                ..Default::default()
4275            },
4276            hotspots: vec![
4277                crate::health_types::HotspotEntry {
4278                    path: root.join("src/owned.ts"),
4279                    score: 80.0,
4280                    commits: 25,
4281                    weighted_commits: 22.0,
4282                    lines_added: 300,
4283                    lines_deleted: 100,
4284                    complexity_density: 0.6,
4285                    fan_in: 5,
4286                    trend: fallow_core::churn::ChurnTrend::Stable,
4287                    ownership: Some(OwnershipMetrics {
4288                        bus_factor: 1,
4289                        contributor_count: 2,
4290                        top_contributor: ContributorEntry {
4291                            identifier: "alice".to_string(),
4292                            format: ContributorIdentifierFormat::Raw,
4293                            share: 0.8,
4294                            stale_days: 5,
4295                            commits: 20,
4296                        },
4297                        recent_contributors: vec![],
4298                        suggested_reviewers: vec![],
4299                        declared_owner: Some("@team/core".to_string()),
4300                        unowned: Some(false),
4301                        ownership_state: OwnershipState::Active,
4302                        drift: false,
4303                        drift_reason: None,
4304                    }),
4305                    is_test_path: false,
4306                }
4307                .into(),
4308            ],
4309            hotspot_summary: None,
4310            ..Default::default()
4311        };
4312        let md = build_health_markdown(&report, &root);
4313        // Ownership widens the table with Bus/Top/Owner/Notes columns
4314        assert!(md.contains("| Bus | Top | Owner | Notes |"));
4315        assert!(md.contains("`alice` (80%)"));
4316        assert!(md.contains("@team/core"));
4317    }
4318
4319    // -------------------------------------------------------------------------
4320    // health trend section (lines 1456-1501)
4321    // -------------------------------------------------------------------------
4322
4323    #[test]
4324    fn health_markdown_trend_section() {
4325        use crate::health_types::{HealthTrend, TrendDirection, TrendMetric, TrendPoint};
4326        let root = PathBuf::from("/project");
4327        let report = crate::health_types::HealthReport {
4328            health_trend: Some(HealthTrend {
4329                compared_to: TrendPoint {
4330                    timestamp: "2026-05-01T00:00:00Z".to_string(),
4331                    git_sha: Some("abc1234".to_string()),
4332                    score: Some(70.0),
4333                    grade: Some("B".to_string()),
4334                    coverage_model: None,
4335                    snapshot_schema_version: None,
4336                },
4337                metrics: vec![TrendMetric {
4338                    name: "score",
4339                    label: "Health Score",
4340                    previous: 70.0,
4341                    current: 82.0,
4342                    delta: 12.0,
4343                    direction: TrendDirection::Improving,
4344                    unit: "pts",
4345                    previous_count: None,
4346                    current_count: None,
4347                }],
4348                snapshots_loaded: 3,
4349                overall_direction: TrendDirection::Improving,
4350            }),
4351            summary: crate::health_types::HealthSummary {
4352                files_analyzed: 5,
4353                functions_analyzed: 20,
4354                ..Default::default()
4355            },
4356            ..Default::default()
4357        };
4358        let md = build_health_markdown(&report, &root);
4359        assert!(md.contains("## Trend (vs 2026-05-01 (abc1234))"));
4360        assert!(md.contains("| Metric | Previous | Current | Delta | Direction |"));
4361        assert!(md.contains("Health Score"));
4362        assert!(md.contains("+12"));
4363        assert!(md.contains("improving"));
4364        assert!(md.contains("3 snapshots available"));
4365    }
4366
4367    #[test]
4368    fn health_markdown_trend_single_snapshot_singular() {
4369        use crate::health_types::{HealthTrend, TrendDirection, TrendPoint};
4370        let root = PathBuf::from("/project");
4371        let report = crate::health_types::HealthReport {
4372            health_trend: Some(HealthTrend {
4373                compared_to: TrendPoint {
4374                    timestamp: "2026-06-01T12:00:00Z".to_string(),
4375                    git_sha: None,
4376                    score: None,
4377                    grade: None,
4378                    coverage_model: None,
4379                    snapshot_schema_version: None,
4380                },
4381                metrics: vec![],
4382                snapshots_loaded: 1,
4383                overall_direction: TrendDirection::Stable,
4384            }),
4385            summary: crate::health_types::HealthSummary {
4386                files_analyzed: 2,
4387                functions_analyzed: 5,
4388                ..Default::default()
4389            },
4390            ..Default::default()
4391        };
4392        let md = build_health_markdown(&report, &root);
4393        assert!(md.contains("1 snapshot available"));
4394        assert!(!md.contains("1 snapshots available"));
4395    }
4396
4397    // -------------------------------------------------------------------------
4398    // trend metric with % unit (lines 1506-1507, 1516-1517)
4399    // -------------------------------------------------------------------------
4400
4401    #[test]
4402    fn health_markdown_trend_metric_percent_unit() {
4403        use crate::health_types::{HealthTrend, TrendDirection, TrendMetric, TrendPoint};
4404        let root = PathBuf::from("/project");
4405        let report = crate::health_types::HealthReport {
4406            health_trend: Some(HealthTrend {
4407                compared_to: TrendPoint {
4408                    timestamp: "2026-04-01T00:00:00Z".to_string(),
4409                    git_sha: None,
4410                    score: None,
4411                    grade: None,
4412                    coverage_model: None,
4413                    snapshot_schema_version: None,
4414                },
4415                metrics: vec![TrendMetric {
4416                    name: "dead_file_pct",
4417                    label: "Dead Files",
4418                    previous: 5.0,
4419                    current: 3.0,
4420                    delta: -2.0,
4421                    direction: TrendDirection::Improving,
4422                    unit: "%",
4423                    previous_count: None,
4424                    current_count: None,
4425                }],
4426                snapshots_loaded: 2,
4427                overall_direction: TrendDirection::Improving,
4428            }),
4429            summary: crate::health_types::HealthSummary {
4430                files_analyzed: 5,
4431                functions_analyzed: 20,
4432                ..Default::default()
4433            },
4434            ..Default::default()
4435        };
4436        let md = build_health_markdown(&report, &root);
4437        // Percent values include the % sign
4438        assert!(md.contains("5.0%"));
4439        assert!(md.contains("3.0%"));
4440        assert!(md.contains("-2.0%"));
4441    }
4442
4443    // -------------------------------------------------------------------------
4444    // runtime coverage with watermark (lines 1380-1382)
4445    // -------------------------------------------------------------------------
4446
4447    #[test]
4448    fn health_markdown_runtime_coverage_with_watermark() {
4449        use crate::health_types::{
4450            RuntimeCoverageReport, RuntimeCoverageReportVerdict, RuntimeCoverageSchemaVersion,
4451            RuntimeCoverageSummary, RuntimeCoverageWatermark,
4452        };
4453        let root = PathBuf::from("/project");
4454        let report = crate::health_types::HealthReport {
4455            runtime_coverage: Some(RuntimeCoverageReport {
4456                schema_version: RuntimeCoverageSchemaVersion::V1,
4457                verdict: RuntimeCoverageReportVerdict::Clean,
4458                signals: vec![],
4459                summary: RuntimeCoverageSummary {
4460                    functions_tracked: 100,
4461                    functions_hit: 90,
4462                    functions_unhit: 10,
4463                    functions_untracked: 5,
4464                    coverage_percent: 90.0,
4465                    trace_count: 5000,
4466                    period_days: 7,
4467                    deployments_seen: 2,
4468                    ..Default::default()
4469                },
4470                findings: vec![],
4471                hot_paths: vec![],
4472                blast_radius: vec![],
4473                importance: vec![],
4474                watermark: Some(RuntimeCoverageWatermark::TrialExpired),
4475                warnings: vec![],
4476            }),
4477            summary: crate::health_types::HealthSummary {
4478                files_analyzed: 5,
4479                functions_analyzed: 30,
4480                ..Default::default()
4481            },
4482            ..Default::default()
4483        };
4484        let md = build_health_markdown(&report, &root);
4485        assert!(md.contains("## Runtime Coverage"));
4486        assert!(md.contains("- Watermark: trial-expired"));
4487    }
4488
4489    // -------------------------------------------------------------------------
4490    // runtime coverage hot paths (lines 1428-1453)
4491    // -------------------------------------------------------------------------
4492
4493    #[test]
4494    fn health_markdown_runtime_coverage_hot_paths() {
4495        use crate::health_types::{
4496            RuntimeCoverageHotPath, RuntimeCoverageReport, RuntimeCoverageReportVerdict,
4497            RuntimeCoverageSchemaVersion, RuntimeCoverageSummary,
4498        };
4499        let root = PathBuf::from("/project");
4500        let report = crate::health_types::HealthReport {
4501            runtime_coverage: Some(RuntimeCoverageReport {
4502                schema_version: RuntimeCoverageSchemaVersion::V1,
4503                verdict: RuntimeCoverageReportVerdict::HotPathTouched,
4504                signals: vec![],
4505                summary: RuntimeCoverageSummary {
4506                    functions_tracked: 50,
4507                    functions_hit: 40,
4508                    functions_unhit: 10,
4509                    functions_untracked: 0,
4510                    coverage_percent: 80.0,
4511                    trace_count: 2000,
4512                    period_days: 3,
4513                    deployments_seen: 1,
4514                    ..Default::default()
4515                },
4516                findings: vec![],
4517                hot_paths: vec![RuntimeCoverageHotPath {
4518                    id: "fallow:hot:deadbeef".to_string(),
4519                    stable_id: None,
4520                    path: root.join("src/service.ts"),
4521                    function: "handleRequest".to_string(),
4522                    line: 42,
4523                    end_line: 80,
4524                    invocations: 12_345,
4525                    percentile: 99,
4526                    actions: vec![],
4527                }],
4528                blast_radius: vec![],
4529                importance: vec![],
4530                watermark: None,
4531                warnings: vec![],
4532            }),
4533            summary: crate::health_types::HealthSummary {
4534                files_analyzed: 5,
4535                functions_analyzed: 20,
4536                ..Default::default()
4537            },
4538            ..Default::default()
4539        };
4540        let md = build_health_markdown(&report, &root);
4541        assert!(md.contains("| ID | Hot path | Function |"));
4542        let normalized = md.replace('\\', "/");
4543        assert!(normalized.contains("fallow:hot:deadbeef"));
4544        assert!(normalized.contains("handleRequest"));
4545        assert!(normalized.contains("12345"));
4546    }
4547
4548    // -------------------------------------------------------------------------
4549    // CSS analytics section (lines 1100-1270)
4550    // -------------------------------------------------------------------------
4551
4552    #[test]
4553    fn health_markdown_css_analytics_basic() {
4554        use crate::health_types::{CssAnalyticsReport, CssAnalyticsSummary};
4555        let root = PathBuf::from("/project");
4556        let report = crate::health_types::HealthReport {
4557            css_analytics: Some(CssAnalyticsReport {
4558                files: vec![],
4559                summary: CssAnalyticsSummary {
4560                    files_analyzed: 3,
4561                    total_rules: 120,
4562                    total_declarations: 600,
4563                    important_declarations: 10,
4564                    empty_rules: 2,
4565                    max_nesting_depth: 4,
4566                    unique_colors: 15,
4567                    unique_font_sizes: 8,
4568                    unique_z_indexes: 3,
4569                    unique_box_shadows: 2,
4570                    unique_border_radii: 5,
4571                    unique_line_heights: 4,
4572                    custom_properties_defined: 20,
4573                    custom_properties_unreferenced: 3,
4574                    custom_properties_undefined: 1,
4575                    keyframes_defined: 5,
4576                    keyframes_unreferenced: 2,
4577                    keyframes_undefined: 1,
4578                    scoped_unused_classes: 4,
4579                    duplicate_declaration_blocks: 1,
4580                    duplicate_declarations_total: 8,
4581                    tailwind_arbitrary_values: 3,
4582                    tailwind_arbitrary_value_uses: 7,
4583                    unused_property_registrations: 0,
4584                    unused_layers: 1,
4585                    unresolved_class_references: 2,
4586                    unreferenced_css_classes: 0,
4587                    unused_font_faces: 1,
4588                    unused_theme_tokens: 0,
4589                    font_size_units_used: 2,
4590                    notable_truncated_files: 0,
4591                },
4592                scoped_unused: vec![],
4593                unreferenced_keyframes: vec![],
4594                undefined_keyframes: vec![],
4595                duplicate_declaration_blocks: vec![],
4596                tailwind_arbitrary_values: vec![],
4597                unused_at_rules: vec![],
4598                unresolved_class_references: vec![],
4599                unreferenced_css_classes: vec![],
4600                unused_font_faces: vec![],
4601                unused_theme_tokens: vec![],
4602                font_size_unit_mix: None,
4603            }),
4604            summary: crate::health_types::HealthSummary {
4605                files_analyzed: 5,
4606                functions_analyzed: 20,
4607                ..Default::default()
4608            },
4609            ..Default::default()
4610        };
4611        let md = build_health_markdown(&report, &root);
4612        assert!(md.contains("## CSS Health"));
4613        assert!(md.contains("Stylesheets: 3 | Rules: 120"));
4614        assert!(md.contains("Value sprawl:"));
4615        assert!(md.contains("Candidates:"));
4616    }
4617
4618    #[test]
4619    fn health_markdown_css_analytics_undefined_keyframes() {
4620        use crate::health_types::{
4621            CssAnalyticsReport, CssAnalyticsSummary, CssCandidateAction, CssCandidateActionType,
4622            UndefinedKeyframes,
4623        };
4624        let root = PathBuf::from("/project");
4625        let report = crate::health_types::HealthReport {
4626            css_analytics: Some(CssAnalyticsReport {
4627                files: vec![],
4628                summary: CssAnalyticsSummary {
4629                    files_analyzed: 1,
4630                    total_rules: 10,
4631                    total_declarations: 50,
4632                    important_declarations: 0,
4633                    empty_rules: 0,
4634                    max_nesting_depth: 2,
4635                    unique_colors: 5,
4636                    unique_font_sizes: 2,
4637                    unique_z_indexes: 1,
4638                    unique_box_shadows: 0,
4639                    unique_border_radii: 2,
4640                    unique_line_heights: 2,
4641                    custom_properties_defined: 5,
4642                    custom_properties_unreferenced: 0,
4643                    custom_properties_undefined: 0,
4644                    keyframes_defined: 2,
4645                    keyframes_unreferenced: 0,
4646                    keyframes_undefined: 1,
4647                    scoped_unused_classes: 0,
4648                    duplicate_declaration_blocks: 0,
4649                    duplicate_declarations_total: 0,
4650                    tailwind_arbitrary_values: 0,
4651                    tailwind_arbitrary_value_uses: 0,
4652                    unused_property_registrations: 0,
4653                    unused_layers: 0,
4654                    unresolved_class_references: 0,
4655                    unreferenced_css_classes: 0,
4656                    unused_font_faces: 0,
4657                    unused_theme_tokens: 0,
4658                    font_size_units_used: 1,
4659                    notable_truncated_files: 0,
4660                },
4661                scoped_unused: vec![],
4662                unreferenced_keyframes: vec![],
4663                undefined_keyframes: vec![UndefinedKeyframes {
4664                    name: "slide-in".to_string(),
4665                    path: "src/styles.css".to_string(),
4666                    actions: vec![CssCandidateAction {
4667                        kind: CssCandidateActionType::VerifyUnused,
4668                        auto_fixable: false,
4669                        description: "Verify unused keyframe".to_string(),
4670                        command: None,
4671                    }],
4672                }],
4673                duplicate_declaration_blocks: vec![],
4674                tailwind_arbitrary_values: vec![],
4675                unused_at_rules: vec![],
4676                unresolved_class_references: vec![],
4677                unreferenced_css_classes: vec![],
4678                unused_font_faces: vec![],
4679                unused_theme_tokens: vec![],
4680                font_size_unit_mix: None,
4681            }),
4682            summary: crate::health_types::HealthSummary {
4683                files_analyzed: 2,
4684                functions_analyzed: 5,
4685                ..Default::default()
4686            },
4687            ..Default::default()
4688        };
4689        let md = build_health_markdown(&report, &root);
4690        assert!(md.contains("Undefined @keyframes"));
4691        assert!(md.contains("`slide-in`"));
4692    }
4693
4694    #[test]
4695    fn health_markdown_css_analytics_tailwind_and_class_candidates() {
4696        use crate::health_types::{
4697            CssAnalyticsReport, CssAnalyticsSummary, CssCandidateAction, CssCandidateActionType,
4698            TailwindArbitraryValue, UnreferencedCssClass, UnresolvedClassReference,
4699        };
4700        let root = PathBuf::from("/project");
4701        let report = crate::health_types::HealthReport {
4702            css_analytics: Some(CssAnalyticsReport {
4703                files: vec![],
4704                summary: CssAnalyticsSummary {
4705                    files_analyzed: 1,
4706                    total_rules: 5,
4707                    total_declarations: 20,
4708                    important_declarations: 0,
4709                    empty_rules: 0,
4710                    max_nesting_depth: 1,
4711                    unique_colors: 2,
4712                    unique_font_sizes: 1,
4713                    unique_z_indexes: 0,
4714                    unique_box_shadows: 0,
4715                    unique_border_radii: 1,
4716                    unique_line_heights: 1,
4717                    custom_properties_defined: 0,
4718                    custom_properties_unreferenced: 0,
4719                    custom_properties_undefined: 0,
4720                    keyframes_defined: 0,
4721                    keyframes_unreferenced: 0,
4722                    keyframes_undefined: 0,
4723                    scoped_unused_classes: 0,
4724                    duplicate_declaration_blocks: 0,
4725                    duplicate_declarations_total: 0,
4726                    tailwind_arbitrary_values: 2,
4727                    tailwind_arbitrary_value_uses: 4,
4728                    unused_property_registrations: 0,
4729                    unused_layers: 0,
4730                    unresolved_class_references: 1,
4731                    unreferenced_css_classes: 1,
4732                    unused_font_faces: 0,
4733                    unused_theme_tokens: 0,
4734                    font_size_units_used: 1,
4735                    notable_truncated_files: 0,
4736                },
4737                scoped_unused: vec![],
4738                unreferenced_keyframes: vec![],
4739                undefined_keyframes: vec![],
4740                duplicate_declaration_blocks: vec![],
4741                tailwind_arbitrary_values: vec![TailwindArbitraryValue {
4742                    value: "w-[42px]".to_string(),
4743                    count: 3,
4744                    path: "src/App.tsx".to_string(),
4745                    line: 7,
4746                    actions: vec![CssCandidateAction {
4747                        kind: CssCandidateActionType::VerifyUnused,
4748                        auto_fixable: false,
4749                        description: "Replace with a scale token".to_string(),
4750                        command: None,
4751                    }],
4752                }],
4753                unused_at_rules: vec![],
4754                unresolved_class_references: vec![UnresolvedClassReference {
4755                    class: "btn-primry".to_string(),
4756                    suggestion: "btn-primary".to_string(),
4757                    path: "src/index.html".to_string(),
4758                    line: 15,
4759                    actions: vec![],
4760                }],
4761                unreferenced_css_classes: vec![UnreferencedCssClass {
4762                    class: "old-header".to_string(),
4763                    path: "src/styles.css".to_string(),
4764                    line: 22,
4765                    actions: vec![],
4766                }],
4767                unused_font_faces: vec![],
4768                unused_theme_tokens: vec![],
4769                font_size_unit_mix: None,
4770            }),
4771            summary: crate::health_types::HealthSummary {
4772                files_analyzed: 2,
4773                functions_analyzed: 5,
4774                ..Default::default()
4775            },
4776            ..Default::default()
4777        };
4778        let md = build_health_markdown(&report, &root);
4779        assert!(md.contains("Top Tailwind arbitrary values"));
4780        assert!(md.contains("`w-[42px]` (3x)"));
4781        assert!(md.contains("Likely class typos"));
4782        assert!(md.contains("`btn-primry` -> `btn-primary`"));
4783        assert!(md.contains("Unreferenced global classes"));
4784        assert!(md.contains("`.old-header`"));
4785    }
4786
4787    #[test]
4788    fn health_markdown_css_analytics_font_candidates() {
4789        use crate::health_types::{
4790            CssAnalyticsReport, CssAnalyticsSummary, CssCandidateAction, CssCandidateActionType,
4791            CssNotationConsistency, CssNotationCount, UnusedFontFace, UnusedThemeToken,
4792        };
4793        let root = PathBuf::from("/project");
4794        let report = crate::health_types::HealthReport {
4795            css_analytics: Some(CssAnalyticsReport {
4796                files: vec![],
4797                summary: CssAnalyticsSummary {
4798                    files_analyzed: 1,
4799                    total_rules: 5,
4800                    total_declarations: 20,
4801                    important_declarations: 0,
4802                    empty_rules: 0,
4803                    max_nesting_depth: 1,
4804                    unique_colors: 2,
4805                    unique_font_sizes: 3,
4806                    unique_z_indexes: 0,
4807                    unique_box_shadows: 0,
4808                    unique_border_radii: 1,
4809                    unique_line_heights: 1,
4810                    custom_properties_defined: 0,
4811                    custom_properties_unreferenced: 0,
4812                    custom_properties_undefined: 0,
4813                    keyframes_defined: 0,
4814                    keyframes_unreferenced: 0,
4815                    keyframes_undefined: 0,
4816                    scoped_unused_classes: 0,
4817                    duplicate_declaration_blocks: 0,
4818                    duplicate_declarations_total: 0,
4819                    tailwind_arbitrary_values: 0,
4820                    tailwind_arbitrary_value_uses: 0,
4821                    unused_property_registrations: 0,
4822                    unused_layers: 0,
4823                    unresolved_class_references: 0,
4824                    unreferenced_css_classes: 0,
4825                    unused_font_faces: 1,
4826                    unused_theme_tokens: 1,
4827                    font_size_units_used: 2,
4828                    notable_truncated_files: 0,
4829                },
4830                scoped_unused: vec![],
4831                unreferenced_keyframes: vec![],
4832                undefined_keyframes: vec![],
4833                duplicate_declaration_blocks: vec![],
4834                tailwind_arbitrary_values: vec![],
4835                unused_at_rules: vec![],
4836                unresolved_class_references: vec![],
4837                unreferenced_css_classes: vec![],
4838                unused_font_faces: vec![UnusedFontFace {
4839                    family: "OldFont".to_string(),
4840                    path: "src/fonts.css".to_string(),
4841                    actions: vec![CssCandidateAction {
4842                        kind: CssCandidateActionType::VerifyUnused,
4843                        auto_fixable: false,
4844                        description: "Check if used from JS".to_string(),
4845                        command: None,
4846                    }],
4847                }],
4848                unused_theme_tokens: vec![UnusedThemeToken {
4849                    token: "--color-stale".to_string(),
4850                    namespace: "color".to_string(),
4851                    path: "src/theme.css".to_string(),
4852                    line: 10,
4853                    actions: vec![],
4854                }],
4855                font_size_unit_mix: Some(CssNotationConsistency {
4856                    axis: "Font sizes".to_string(),
4857                    notations: vec![
4858                        CssNotationCount {
4859                            notation: "rem".to_string(),
4860                            count: 8,
4861                        },
4862                        CssNotationCount {
4863                            notation: "px".to_string(),
4864                            count: 3,
4865                        },
4866                    ],
4867                    actions: vec![],
4868                }),
4869            }),
4870            summary: crate::health_types::HealthSummary {
4871                files_analyzed: 2,
4872                functions_analyzed: 5,
4873                ..Default::default()
4874            },
4875            ..Default::default()
4876        };
4877        let md = build_health_markdown(&report, &root);
4878        assert!(md.contains("Unused @font-face"));
4879        assert!(md.contains("`OldFont` (src/fonts.css)"));
4880        assert!(md.contains("Unused @theme tokens"));
4881        assert!(md.contains("`--color-stale` (src/theme.css:10)"));
4882        assert!(md.contains("Font sizes mix 2 units"));
4883        assert!(md.contains("8 rem"));
4884        assert!(md.contains("3 px"));
4885    }
4886
4887    // -------------------------------------------------------------------------
4888    // metric legend (lines 2010-2054)
4889    // -------------------------------------------------------------------------
4890
4891    #[test]
4892    fn health_markdown_metric_legend_shown_when_relevant() {
4893        use crate::health_types::FileHealthScore;
4894        let root = PathBuf::from("/project");
4895        let report = crate::health_types::HealthReport {
4896            file_scores: vec![FileHealthScore {
4897                path: root.join("src/x.ts"),
4898                fan_in: 1,
4899                fan_out: 1,
4900                dead_code_ratio: 0.0,
4901                complexity_density: 0.1,
4902                maintainability_index: 80.0,
4903                total_cyclomatic: 5,
4904                total_cognitive: 4,
4905                function_count: 2,
4906                lines: 50,
4907                crap_max: 5.0,
4908                crap_above_threshold: 0,
4909            }],
4910            summary: crate::health_types::HealthSummary {
4911                files_analyzed: 1,
4912                functions_analyzed: 2,
4913                ..Default::default()
4914            },
4915            ..Default::default()
4916        };
4917        let md = build_health_markdown(&report, &root);
4918        assert!(md.contains("<details><summary>Metric definitions</summary>"));
4919        assert!(md.contains("**MI**"));
4920        assert!(md.contains("[Full metric reference]"));
4921    }
4922
4923    #[test]
4924    fn health_markdown_metric_legend_not_shown_without_sections() {
4925        let root = PathBuf::from("/project");
4926        let report = crate::health_types::HealthReport {
4927            findings: vec![
4928                crate::health_types::ComplexityViolation {
4929                    path: root.join("src/y.ts"),
4930                    name: "noLegend".to_string(),
4931                    line: 1,
4932                    col: 0,
4933                    cyclomatic: 22,
4934                    cognitive: 18,
4935                    line_count: 30,
4936                    param_count: 0,
4937                    react_hook_count: 0,
4938                    react_jsx_max_depth: 0,
4939                    react_prop_count: 0,
4940                    react_hook_profile: None,
4941                    exceeded: crate::health_types::ExceededThreshold::Both,
4942                    severity: crate::health_types::FindingSeverity::High,
4943                    crap: None,
4944                    coverage_pct: None,
4945                    coverage_tier: None,
4946                    coverage_source: None,
4947                    inherited_from: None,
4948                    component_rollup: None,
4949                    contributions: Vec::new(),
4950                    effective_thresholds: None,
4951                    threshold_source: None,
4952                }
4953                .into(),
4954            ],
4955            summary: crate::health_types::HealthSummary {
4956                files_analyzed: 1,
4957                functions_analyzed: 1,
4958                functions_above_threshold: 1,
4959                ..Default::default()
4960            },
4961            ..Default::default()
4962        };
4963        let md = build_health_markdown(&report, &root);
4964        // Findings only, no file_scores / hotspots / targets / coverage_gaps
4965        // so the legend is suppressed
4966        assert!(!md.contains("<details>"));
4967    }
4968
4969    // -------------------------------------------------------------------------
4970    // coverage_gaps section with single file (plural/singular wording)
4971    // -------------------------------------------------------------------------
4972
4973    #[test]
4974    fn health_markdown_coverage_gaps_files_section_singular() {
4975        use crate::health_types::{
4976            CoverageGapSummary, CoverageGaps, UntestedFile, UntestedFileFinding,
4977        };
4978        let root = PathBuf::from("/project");
4979        let report = crate::health_types::HealthReport {
4980            coverage_gaps: Some(CoverageGaps {
4981                summary: CoverageGapSummary {
4982                    runtime_files: 1,
4983                    covered_files: 0,
4984                    file_coverage_pct: 0.0,
4985                    untested_files: 1,
4986                    untested_exports: 0,
4987                },
4988                files: vec![UntestedFileFinding::with_actions(
4989                    UntestedFile {
4990                        path: root.join("src/single.ts"),
4991                        value_export_count: 1,
4992                    },
4993                    &root,
4994                )],
4995                exports: vec![],
4996            }),
4997            summary: crate::health_types::HealthSummary {
4998                files_analyzed: 2,
4999                functions_analyzed: 5,
5000                ..Default::default()
5001            },
5002            ..Default::default()
5003        };
5004        let md = build_health_markdown(&report, &root);
5005        assert!(md.contains("#### Files"));
5006        let normalized = md.replace('\\', "/");
5007        // single export uses no plural "s"
5008        assert!(normalized.contains("`src/single.ts` (1 value export)"));
5009    }
5010
5011    // -------------------------------------------------------------------------
5012    // health markdown with "shown" subset vs total (lines 1620-1626)
5013    // -------------------------------------------------------------------------
5014
5015    #[test]
5016    fn health_markdown_findings_subset_shown() {
5017        let root = PathBuf::from("/project");
5018        let report = crate::health_types::HealthReport {
5019            findings: vec![
5020                crate::health_types::ComplexityViolation {
5021                    path: root.join("src/big.ts"),
5022                    name: "bigFn".to_string(),
5023                    line: 5,
5024                    col: 0,
5025                    cyclomatic: 30,
5026                    cognitive: 25,
5027                    line_count: 100,
5028                    param_count: 0,
5029                    react_hook_count: 0,
5030                    react_jsx_max_depth: 0,
5031                    react_prop_count: 0,
5032                    react_hook_profile: None,
5033                    exceeded: crate::health_types::ExceededThreshold::Both,
5034                    severity: crate::health_types::FindingSeverity::High,
5035                    crap: None,
5036                    coverage_pct: None,
5037                    coverage_tier: None,
5038                    coverage_source: None,
5039                    inherited_from: None,
5040                    component_rollup: None,
5041                    contributions: Vec::new(),
5042                    effective_thresholds: None,
5043                    threshold_source: None,
5044                }
5045                .into(),
5046            ],
5047            summary: crate::health_types::HealthSummary {
5048                files_analyzed: 2,
5049                functions_analyzed: 10,
5050                // total > shown triggers the "(N shown)" suffix
5051                functions_above_threshold: 5,
5052                ..Default::default()
5053            },
5054            ..Default::default()
5055        };
5056        let md = build_health_markdown(&report, &root);
5057        // 5 total but only 1 shown
5058        assert!(md.contains("## Fallow: 5 high complexity functions (1 shown)"));
5059    }
5060
5061    // -------------------------------------------------------------------------
5062    // unused type export with is_type_only: true
5063    // -------------------------------------------------------------------------
5064
5065    #[test]
5066    fn markdown_unused_type_export_format() {
5067        let root = PathBuf::from("/project");
5068        let mut results = AnalysisResults::default();
5069        results
5070            .unused_types
5071            .push(UnusedTypeFinding::with_actions(UnusedExport {
5072                path: root.join("src/types.ts"),
5073                export_name: "MyInterface".to_string(),
5074                is_type_only: true,
5075                line: 3,
5076                col: 0,
5077                span_start: 0,
5078                is_re_export: false,
5079            }));
5080        let md = build_markdown(&results, &root);
5081        assert!(md.contains("### Unused type exports (1)"));
5082        let normalized = md.replace('\\', "/");
5083        assert!(normalized.contains("- `src/types.ts`"));
5084        assert!(normalized.contains(":3 `MyInterface`"));
5085    }
5086
5087    // -------------------------------------------------------------------------
5088    // dependency with used_in_workspaces (lines 875-882)
5089    // -------------------------------------------------------------------------
5090
5091    #[test]
5092    fn markdown_dep_with_multiple_workspace_consumers() {
5093        let root = PathBuf::from("/project");
5094        let mut results = AnalysisResults::default();
5095        results
5096            .unused_dependencies
5097            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
5098                package_name: "shared-utils".to_string(),
5099                location: DependencyLocation::Dependencies,
5100                path: root.join("packages/core/package.json"),
5101                line: 3,
5102                used_in_workspaces: vec![root.join("packages/app"), root.join("packages/admin")],
5103            }));
5104        let md = build_markdown(&results, &root);
5105        let normalized = md.replace('\\', "/");
5106        assert!(normalized.contains("imported in packages/app, packages/admin"));
5107    }
5108
5109    // -------------------------------------------------------------------------
5110    // <component> name in findings (lines 1586-1588 / <component> branch)
5111    // -------------------------------------------------------------------------
5112
5113    #[test]
5114    fn health_markdown_component_rollup_entry_label() {
5115        let root = PathBuf::from("/project");
5116        let report = crate::health_types::HealthReport {
5117            findings: vec![
5118                crate::health_types::ComplexityViolation {
5119                    path: root.join("src/Card.vue"),
5120                    name: "<component>".to_string(),
5121                    line: 1,
5122                    col: 0,
5123                    cyclomatic: 10,
5124                    cognitive: 14,
5125                    line_count: 60,
5126                    param_count: 0,
5127                    react_hook_count: 0,
5128                    react_jsx_max_depth: 0,
5129                    react_prop_count: 0,
5130                    react_hook_profile: None,
5131                    exceeded: crate::health_types::ExceededThreshold::Cognitive,
5132                    severity: crate::health_types::FindingSeverity::Moderate,
5133                    crap: None,
5134                    coverage_pct: None,
5135                    coverage_tier: None,
5136                    coverage_source: None,
5137                    inherited_from: None,
5138                    component_rollup: None,
5139                    contributions: Vec::new(),
5140                    effective_thresholds: None,
5141                    threshold_source: None,
5142                }
5143                .into(),
5144            ],
5145            summary: crate::health_types::HealthSummary {
5146                files_analyzed: 1,
5147                functions_analyzed: 1,
5148                functions_above_threshold: 1,
5149                ..Default::default()
5150            },
5151            ..Default::default()
5152        };
5153        let md = build_health_markdown(&report, &root);
5154        // The table header says "Entry" for synthetic names
5155        assert!(md.contains("| File | Entry |"));
5156        assert!(md.contains("`<component> (component rollup)`"));
5157    }
5158}