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