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