Skip to main content

fallow_api/
markdown_output.rs

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