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