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