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