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