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