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