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