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