Skip to main content

fallow_api/
markdown_output.rs

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