Skip to main content

fallow_api/
markdown_output.rs

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