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