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