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 escape_markdown_inline(s: &str) -> String {
46 let mut out = String::with_capacity(s.len());
47 for c in s.chars() {
48 if matches!(
49 c,
50 '\\' | '`' | '*' | '_' | '[' | ']' | '<' | '>' | '|' | '#'
51 ) {
52 out.push('\\');
53 }
54 out.push(c);
55 }
56 out
57}
58
59fn display_complexity_entry_name(name: &str) -> Cow<'_, str> {
60 match name {
61 "<template>" => Cow::Borrowed("<template> (template complexity)"),
62 "<component>" => Cow::Borrowed("<component> (component rollup)"),
63 name if fallow_types::extract::is_synthetic_template_unit(name) => {
64 Cow::Owned(format!("{name} (snippet complexity)"))
65 }
66 _ => Cow::Borrowed(name),
67 }
68}
69
70pub fn build_markdown(results: &AnalysisResults, root: &Path) -> String {
72 let total = results.total_issues();
73 let mut out = String::new();
74
75 if total == 0 {
76 out.push_str("## Fallow: no issues found\n");
77 if health_signal_count(results) == 0 {
78 return out;
79 }
80 out.push('\n');
81 } else {
82 let _ = write!(out, "## Fallow: {total} issue{} found\n\n", plural(total));
83 }
84
85 push_markdown_sections(&mut out, results, root);
86 out
87}
88
89fn health_signal_count(results: &AnalysisResults) -> usize {
92 results.prop_drilling_chains.len()
93 + results.thin_wrappers.len()
94 + results.duplicate_prop_shapes.len()
95}
96
97fn push_markdown_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
98 push_markdown_primary_sections(out, results, root);
99 push_markdown_import_sections(out, results, root);
100 push_markdown_dependency_detail_sections(out, results, root);
101 push_markdown_graph_sections(out, results, &|path| markdown_relative_path(path, root));
102 push_markdown_catalog_sections(out, results, &|path| markdown_relative_path(path, root));
103}
104
105fn markdown_relative_path(path: &Path, root: &Path) -> String {
106 normalize_uri(&relative_path(path, root).display().to_string())
107}
108
109fn push_markdown_primary_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
110 markdown_section(out, &results.unused_files, "Unused files", |file| {
111 vec![format!(
112 "- {}{}",
113 markdown_code_span(&markdown_relative_path(&file.file.path, root)),
114 markdown_caveat_suffix(&file.reachability_caveats)
115 )]
116 });
117
118 markdown_grouped_section(
119 out,
120 &results.unused_exports,
121 "Unused exports",
122 root,
123 |e| e.export.path.as_path(),
124 |e: &UnusedExportFinding| format_export(&e.export, &e.reachability_caveats),
125 );
126
127 markdown_grouped_section(
128 out,
129 &results.unused_types,
130 "Unused type exports",
131 root,
132 |e| e.export.path.as_path(),
133 |e: &UnusedTypeFinding| format_export(&e.export, &e.reachability_caveats),
134 );
135
136 markdown_grouped_section(
137 out,
138 &results.private_type_leaks,
139 "Private type leaks",
140 root,
141 |e| e.leak.path.as_path(),
142 format_private_type_leak,
143 );
144
145 markdown_grouped_section(
146 out,
147 &results.deprecated_exports_in_use,
148 "Deprecated exports in use",
149 root,
150 |e| e.export.path.as_path(),
151 format_deprecated_export_in_use,
152 );
153
154 push_markdown_dependency_sections(out, results, root);
155 push_markdown_member_sections(out, results, root);
156}
157
158fn push_markdown_import_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
159 markdown_grouped_section(
160 out,
161 &results.unresolved_imports,
162 "Unresolved imports",
163 root,
164 |i| i.import.path.as_path(),
165 |i| {
166 format!(
167 ":{} {}",
168 i.import.line,
169 markdown_code_span(&i.import.specifier)
170 )
171 },
172 );
173
174 markdown_section(
175 out,
176 &results.unlisted_dependencies,
177 "Unlisted dependencies",
178 |dep| vec![format!("- {}", markdown_code_span(&dep.dep.package_name))],
179 );
180
181 markdown_section(
182 out,
183 &results.duplicate_exports,
184 "Duplicate exports",
185 |dup| {
186 let locations: Vec<String> = dup
187 .export
188 .locations
189 .iter()
190 .map(|loc| markdown_code_span(&markdown_relative_path(&loc.path, root)))
191 .collect();
192 vec![format!(
193 "- {} in {}",
194 markdown_code_span(&dup.export.export_name),
195 locations.join(", ")
196 )]
197 },
198 );
199}
200
201fn push_markdown_dependency_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
202 markdown_section(
203 out,
204 &results.unused_dependencies,
205 "Unused dependencies",
206 |dep| {
207 format_dependency(
208 &dep.dep.package_name,
209 &dep.dep.path,
210 &dep.dep.used_in_workspaces,
211 root,
212 &dep.reachability_caveats,
213 )
214 },
215 );
216 markdown_section(
217 out,
218 &results.unused_dev_dependencies,
219 "Unused devDependencies",
220 |dep| {
221 format_dependency(
222 &dep.dep.package_name,
223 &dep.dep.path,
224 &dep.dep.used_in_workspaces,
225 root,
226 &dep.reachability_caveats,
227 )
228 },
229 );
230 markdown_section(
231 out,
232 &results.unused_optional_dependencies,
233 "Unused optionalDependencies",
234 |dep| {
235 format_dependency(
236 &dep.dep.package_name,
237 &dep.dep.path,
238 &dep.dep.used_in_workspaces,
239 root,
240 &dep.reachability_caveats,
241 )
242 },
243 );
244}
245
246fn push_markdown_member_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
247 markdown_grouped_section(
248 out,
249 &results.unused_enum_members,
250 "Unused enum members",
251 root,
252 |m| m.member.path.as_path(),
253 |m: &UnusedEnumMemberFinding| format_member(&m.member, &m.reachability_caveats),
254 );
255 markdown_grouped_section(
256 out,
257 &results.unused_class_members,
258 "Unused class members",
259 root,
260 |m| m.member.path.as_path(),
261 |m: &UnusedClassMemberFinding| format_member(&m.member, &m.reachability_caveats),
262 );
263 markdown_grouped_section(
264 out,
265 &results.unused_store_members,
266 "Unused store members",
267 root,
268 |m| m.member.path.as_path(),
269 |m: &UnusedStoreMemberFinding| format_member(&m.member, &m.reachability_caveats),
270 );
271}
272
273fn push_markdown_dependency_detail_sections(
274 out: &mut String,
275 results: &AnalysisResults,
276 root: &Path,
277) {
278 markdown_section(
279 out,
280 &results.type_only_dependencies,
281 "Type-only dependencies (consider moving to devDependencies)",
282 |dep| format_dependency(&dep.dep.package_name, &dep.dep.path, &[], root, &[]),
283 );
284 markdown_section(
285 out,
286 &results.test_only_dependencies,
287 "Test-only production dependencies (consider moving to devDependencies)",
288 |dep| format_dependency(&dep.dep.package_name, &dep.dep.path, &[], root, &[]),
289 );
290 markdown_section(
291 out,
292 &results.dev_dependencies_in_production,
293 "Dev dependencies used in production (consider moving to dependencies)",
294 |dep| format_dependency(&dep.dep.package_name, &dep.dep.path, &[], root, &[]),
295 );
296}
297
298fn push_markdown_graph_sections(
299 out: &mut String,
300 results: &AnalysisResults,
301 rel: &dyn Fn(&Path) -> String,
302) {
303 push_markdown_structure_sections(out, results, rel);
304 push_markdown_framework_sections(out, results, rel);
305 push_markdown_component_sections(out, results, rel);
306 push_markdown_component_health_sections(out, results, rel);
307 push_markdown_suppression_sections(out, results, rel);
308}
309
310fn push_markdown_component_health_sections(
313 out: &mut String,
314 results: &AnalysisResults,
315 rel: &dyn Fn(&Path) -> String,
316) {
317 markdown_section(out, &results.prop_drilling_chains, "Prop drilling", |c| {
318 format_markdown_prop_drilling_chain(c, rel)
319 });
320 markdown_section(out, &results.thin_wrappers, "Thin wrappers", |w| {
321 let w = &w.wrapper;
322 vec![format!(
323 "- {}:{} {} is a thin wrapper around {} (candidate for inlining at call sites or deleting)",
324 markdown_code_span(&rel(&w.file)),
325 w.line,
326 markdown_code_span(&w.component),
327 markdown_code_span(&w.child_component),
328 )]
329 });
330 markdown_section(
331 out,
332 &results.duplicate_prop_shapes,
333 "Duplicate prop shapes",
334 |d| {
335 let d = &d.shape;
336 vec![format!(
337 "- {}:{} {} shares an identical prop shape {} with {} other component(s) (extract a shared Props type)",
338 markdown_code_span(&rel(&d.file)),
339 d.line,
340 markdown_code_span(&d.component),
341 markdown_code_span(&format!("{{{}}}", d.shape.join(", "))),
342 d.group_size.saturating_sub(1),
343 )]
344 },
345 );
346}
347
348fn format_markdown_prop_drilling_chain(
349 entry: &PropDrillingChainFinding,
350 rel: &dyn Fn(&Path) -> String,
351) -> Vec<String> {
352 let c = &entry.chain;
353 let (path, line) = c
354 .hops
355 .first()
356 .map_or((String::new(), 0), |hop| (rel(&hop.file), hop.line));
357 let trail = c
358 .hops
359 .iter()
360 .map(|hop| escape_markdown_prose(&hop.component))
361 .collect::<Vec<_>>()
362 .join(" \u{2192} ");
363 vec![format!(
364 "- {}:{line} {} is forwarded unused through {trail} (depth {}); colocate the consumer or lift it to a context at a mid-chain hop",
365 markdown_code_span(&path),
366 markdown_code_span(&c.prop),
367 c.depth,
368 )]
369}
370
371fn push_markdown_structure_sections(
372 out: &mut String,
373 results: &AnalysisResults,
374 rel: &dyn Fn(&Path) -> String,
375) {
376 markdown_section(
377 out,
378 &results.circular_dependencies,
379 "Circular dependencies",
380 |cycle| format_markdown_circular_dependency(cycle, rel),
381 );
382 markdown_section(
383 out,
384 &results.re_export_cycles,
385 "Re-export cycles",
386 |cycle| format_markdown_re_export_cycle(cycle, rel),
387 );
388 markdown_section(out, &results.package_cycles, "Package cycles", |cycle| {
389 format_markdown_package_cycle(cycle, rel)
390 });
391 markdown_section(
392 out,
393 &results.boundary_violations,
394 "Boundary violations",
395 |v| format_markdown_boundary_violation(v, rel),
396 );
397 markdown_section(
398 out,
399 &results.boundary_coverage_violations,
400 "Boundary coverage",
401 |v| format_markdown_boundary_coverage(v, rel),
402 );
403 markdown_section(
404 out,
405 &results.boundary_call_violations,
406 "Boundary calls",
407 |v| format_markdown_boundary_call(v, rel),
408 );
409 markdown_section(out, &results.policy_violations, "Policy violations", |v| {
410 format_markdown_policy_violation(v, rel)
411 });
412}
413
414fn push_markdown_framework_sections(
415 out: &mut String,
416 results: &AnalysisResults,
417 rel: &dyn Fn(&Path) -> String,
418) {
419 markdown_section(
420 out,
421 &results.invalid_client_exports,
422 "Invalid client exports",
423 |e| format_markdown_invalid_client_export(e, rel),
424 );
425 markdown_section(
426 out,
427 &results.mixed_client_server_barrels,
428 "Mixed client/server barrels",
429 |b| format_markdown_mixed_client_server_barrel(b, rel),
430 );
431 markdown_section(
432 out,
433 &results.misplaced_directives,
434 "Misplaced directives",
435 |d| format_markdown_misplaced_directive(d, rel),
436 );
437 markdown_section(out, &results.route_collisions, "Route collisions", |c| {
438 format_markdown_route_collision(c, rel)
439 });
440 markdown_section(
441 out,
442 &results.dynamic_segment_name_conflicts,
443 "Dynamic segment conflicts",
444 |c| format_markdown_dynamic_segment_name_conflict(c, rel),
445 );
446 markdown_section(
447 out,
448 &results.unprovided_injects,
449 "Unprovided injects",
450 |i| format_markdown_unprovided_inject(i, rel),
451 );
452}
453
454fn push_markdown_component_sections(
455 out: &mut String,
456 results: &AnalysisResults,
457 rel: &dyn Fn(&Path) -> String,
458) {
459 markdown_section(
460 out,
461 &results.unrendered_components,
462 "Unrendered components",
463 |c| format_markdown_unrendered_component(c, rel),
464 );
465 markdown_section(
466 out,
467 &results.unused_component_props,
468 "Unused component props",
469 |p| format_markdown_unused_component_prop(p, rel),
470 );
471 markdown_section(
472 out,
473 &results.unused_component_emits,
474 "Unused component emits",
475 |e| format_markdown_unused_component_emit(e, rel),
476 );
477 markdown_section(
478 out,
479 &results.unused_component_inputs,
480 "Unused component inputs",
481 |i| format_markdown_unused_component_input(i, rel),
482 );
483 markdown_section(
484 out,
485 &results.unused_component_outputs,
486 "Unused component outputs",
487 |o| format_markdown_unused_component_output(o, rel),
488 );
489 markdown_section(
490 out,
491 &results.unused_svelte_events,
492 "Unused Svelte events",
493 |e| format_markdown_unused_svelte_event(e, rel),
494 );
495 markdown_section(
496 out,
497 &results.unused_server_actions,
498 "Unused server actions",
499 |a| format_markdown_unused_server_action(a, rel),
500 );
501 markdown_section(
502 out,
503 &results.unused_load_data_keys,
504 "Unused load data keys",
505 |k| format_markdown_unused_load_data_key(k, rel),
506 );
507}
508
509fn push_markdown_suppression_sections(
510 out: &mut String,
511 results: &AnalysisResults,
512 rel: &dyn Fn(&Path) -> String,
513) {
514 markdown_section(
515 out,
516 &results.stale_suppressions,
517 "Stale suppressions",
518 |s| {
519 vec![format!(
520 "- {}:{} {} ({})",
521 markdown_code_span(&rel(&s.path)),
522 s.line,
523 markdown_code_span(&s.description()),
524 escape_markdown_prose(&s.explanation()),
525 )]
526 },
527 );
528}
529
530fn format_markdown_circular_dependency(
531 cycle: &fallow_types::output_dead_code::CircularDependencyFinding,
532 rel: &dyn Fn(&Path) -> String,
533) -> Vec<String> {
534 let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
535 let mut display_chain = chain.clone();
536 if let Some(first) = chain.first() {
537 display_chain.push(first.clone());
538 }
539 let cross_pkg_tag = if cycle.cycle.is_cross_package {
540 " *(cross-package)*"
541 } else {
542 ""
543 };
544 vec![format!(
545 "- {}{}",
546 display_chain
547 .iter()
548 .map(|s| markdown_code_span(s))
549 .collect::<Vec<_>>()
550 .join(" \u{2192} "),
551 cross_pkg_tag
552 )]
553}
554
555fn format_markdown_re_export_cycle(
556 cycle: &fallow_types::output_dead_code::ReExportCycleFinding,
557 rel: &dyn Fn(&Path) -> String,
558) -> Vec<String> {
559 let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
560 let kind_tag = match cycle.cycle.kind {
561 fallow_types::results::ReExportCycleKind::SelfLoop => " *(self-loop)*",
562 fallow_types::results::ReExportCycleKind::MultiNode => "",
563 };
564 vec![format!(
565 "- {}{}",
566 chain
567 .iter()
568 .map(|s| markdown_code_span(s))
569 .collect::<Vec<_>>()
570 .join(" <-> "),
571 kind_tag
572 )]
573}
574
575fn format_markdown_package_cycle(
576 cycle: &fallow_types::output_dead_code::PackageCycleFinding,
577 rel: &dyn Fn(&Path) -> String,
578) -> Vec<String> {
579 let mut chain: Vec<String> = cycle
580 .cycle
581 .packages
582 .iter()
583 .map(|name| markdown_code_span(name))
584 .collect();
585 if let Some(first) = chain.first().cloned() {
586 chain.push(first);
587 }
588 let note = if cycle.cycle.group_truncated {
589 format!(
590 " *({})*",
591 fallow_types::results::PackageCycle::GROUP_TRUNCATED_NOTE
592 )
593 } else {
594 String::new()
595 };
596 let mut lines = vec![format!("- {}{note}", chain.join(" \u{2192} "))];
597 for edge in &cycle.cycle.edges {
598 let type_tag = if edge.type_only { " *(type-only)*" } else { "" };
599 lines.push(format!(
600 " - {} imports {}{}",
601 markdown_code_span(&format!("{}:{}", rel(&edge.path), edge.line)),
602 markdown_code_span(&rel(&edge.target_path)),
603 type_tag
604 ));
605 }
606 lines
607}
608
609fn format_markdown_boundary_violation(
610 v: &fallow_types::output_dead_code::BoundaryViolationFinding,
611 rel: &dyn Fn(&Path) -> String,
612) -> Vec<String> {
613 let via = v
614 .violation
615 .via_path
616 .as_ref()
617 .map_or_else(String::new, |via| {
618 format!(" via {}", markdown_code_span(&rel(via)))
619 });
620 vec![format!(
621 "- {}:{} \u{2192} {} ({} \u{2192} {}){}",
622 markdown_code_span(&rel(&v.violation.from_path)),
623 v.violation.line,
624 markdown_code_span(&rel(&v.violation.to_path)),
625 v.violation.from_zone,
626 v.violation.to_zone,
627 via,
628 )]
629}
630
631fn format_markdown_boundary_coverage(
632 v: &fallow_types::output_dead_code::BoundaryCoverageViolationFinding,
633 rel: &dyn Fn(&Path) -> String,
634) -> Vec<String> {
635 vec![format!(
636 "- {}:{} no matching boundary zone",
637 markdown_code_span(&rel(&v.violation.path)),
638 v.violation.line,
639 )]
640}
641
642fn format_markdown_boundary_call(
643 v: &fallow_types::output_dead_code::BoundaryCallViolationFinding,
644 rel: &dyn Fn(&Path) -> String,
645) -> Vec<String> {
646 vec![format!(
647 "- {}:{} {} forbidden in zone {} (pattern {})",
648 markdown_code_span(&rel(&v.violation.path)),
649 v.violation.line,
650 markdown_code_span(&v.violation.callee),
651 markdown_code_span(&v.violation.zone),
652 markdown_code_span(&v.violation.pattern),
653 )]
654}
655
656fn format_markdown_policy_violation(
657 v: &fallow_types::output_dead_code::PolicyViolationFinding,
658 rel: &dyn Fn(&Path) -> String,
659) -> Vec<String> {
660 let policy = format!("{}/{}", v.violation.pack, v.violation.rule_id);
661 vec![format!(
662 "- {}:{} {} banned by {}{}",
663 markdown_code_span(&rel(&v.violation.path)),
664 v.violation.line,
665 markdown_code_span(&v.violation.matched),
666 markdown_code_span(&policy),
667 v.violation
668 .message
669 .as_deref()
670 .map(|m| format!(" ({m})"))
671 .unwrap_or_default(),
672 )]
673}
674
675fn format_markdown_invalid_client_export(
676 e: &fallow_types::output_dead_code::InvalidClientExportFinding,
677 rel: &dyn Fn(&Path) -> String,
678) -> Vec<String> {
679 let directive = format!("\"{}\"", e.export.directive);
680 vec![format!(
681 "- {}:{} {} (from {})",
682 markdown_code_span(&rel(&e.export.path)),
683 e.export.line,
684 markdown_code_span(&e.export.export_name),
685 markdown_code_span(&directive),
686 )]
687}
688
689fn format_markdown_mixed_client_server_barrel(
690 b: &fallow_types::output_dead_code::MixedClientServerBarrelFinding,
691 rel: &dyn Fn(&Path) -> String,
692) -> Vec<String> {
693 vec![format!(
694 "- {}:{} re-exports client {} and server-only {}",
695 markdown_code_span(&rel(&b.barrel.path)),
696 b.barrel.line,
697 markdown_code_span(&b.barrel.client_origin),
698 markdown_code_span(&b.barrel.server_origin),
699 )]
700}
701
702fn format_markdown_misplaced_directive(
703 d: &fallow_types::output_dead_code::MisplacedDirectiveFinding,
704 rel: &dyn Fn(&Path) -> String,
705) -> Vec<String> {
706 let directive = format!("\"{}\"", d.directive_site.directive);
707 vec![format!(
708 "- {}:{} {} is not in the leading position and is ignored",
709 markdown_code_span(&rel(&d.directive_site.path)),
710 d.directive_site.line,
711 markdown_code_span(&directive),
712 )]
713}
714
715fn format_markdown_unprovided_inject(
716 i: &fallow_types::output_dead_code::UnprovidedInjectFinding,
717 rel: &dyn Fn(&Path) -> String,
718) -> Vec<String> {
719 vec![format!(
720 "- {}:{} {} has no matching provide({}) in this project; at runtime it returns undefined",
721 markdown_code_span(&rel(&i.inject.path)),
722 i.inject.line,
723 markdown_code_span(&i.inject.key_name),
724 markdown_code_span(&i.inject.key_name),
725 )]
726}
727
728fn format_markdown_unrendered_component(
729 c: &fallow_types::output_dead_code::UnrenderedComponentFinding,
730 rel: &dyn Fn(&Path) -> String,
731) -> Vec<String> {
732 if c.component.framework == "lit" {
736 let component = format!("<{}>", c.component.component_name);
737 return vec![format!(
738 "- {}:{} {} is a registered custom element but rendered in no template (render it or remove it)",
739 markdown_code_span(&rel(&c.component.path)),
740 c.component.line,
741 markdown_code_span(&component),
742 )];
743 }
744 vec![format!(
745 "- {}:{} {} is reachable but rendered nowhere in this project (render it somewhere or remove it)",
746 markdown_code_span(&rel(&c.component.path)),
747 c.component.line,
748 markdown_code_span(&c.component.component_name),
749 )]
750}
751
752fn format_markdown_unused_component_prop(
753 p: &fallow_types::output_dead_code::UnusedComponentPropFinding,
754 rel: &dyn Fn(&Path) -> String,
755) -> Vec<String> {
756 vec![format!(
757 "- {}:{} {} is declared but referenced nowhere in this component (remove it or use it)",
758 markdown_code_span(&rel(&p.prop.path)),
759 p.prop.line,
760 markdown_code_span(&p.prop.prop_name),
761 )]
762}
763
764fn format_markdown_unused_component_emit(
765 e: &fallow_types::output_dead_code::UnusedComponentEmitFinding,
766 rel: &dyn Fn(&Path) -> String,
767) -> Vec<String> {
768 vec![format!(
769 "- {}:{} {} is declared but emitted nowhere in this component (remove it or emit it)",
770 markdown_code_span(&rel(&e.emit.path)),
771 e.emit.line,
772 markdown_code_span(&e.emit.emit_name),
773 )]
774}
775
776fn format_markdown_unused_svelte_event(
777 e: &fallow_types::output_dead_code::UnusedSvelteEventFinding,
778 rel: &dyn Fn(&Path) -> String,
779) -> Vec<String> {
780 vec![format!(
781 "- {}:{} {} is dispatched but listened to nowhere in the project (remove it or listen for it)",
782 markdown_code_span(&rel(&e.event.path)),
783 e.event.line,
784 markdown_code_span(&e.event.event_name),
785 )]
786}
787
788fn format_markdown_unused_component_input(
789 i: &fallow_types::output_dead_code::UnusedComponentInputFinding,
790 rel: &dyn Fn(&Path) -> String,
791) -> Vec<String> {
792 vec![format!(
793 "- {}:{} {} is declared but referenced nowhere in this component (remove it or use it)",
794 markdown_code_span(&rel(&i.input.path)),
795 i.input.line,
796 markdown_code_span(&i.input.input_name),
797 )]
798}
799
800fn format_markdown_unused_component_output(
801 o: &fallow_types::output_dead_code::UnusedComponentOutputFinding,
802 rel: &dyn Fn(&Path) -> String,
803) -> Vec<String> {
804 vec![format!(
805 "- {}:{} {} is declared but emitted nowhere in this component (remove it or emit it)",
806 markdown_code_span(&rel(&o.output.path)),
807 o.output.line,
808 markdown_code_span(&o.output.output_name),
809 )]
810}
811
812fn format_markdown_unused_server_action(
813 a: &fallow_types::output_dead_code::UnusedServerActionFinding,
814 rel: &dyn Fn(&Path) -> String,
815) -> Vec<String> {
816 vec![format!(
817 "- {}:{} {} is exported from a \"use server\" file but no code in this project references it",
818 markdown_code_span(&rel(&a.action.path)),
819 a.action.line,
820 markdown_code_span(&a.action.action_name),
821 )]
822}
823
824fn format_markdown_unused_load_data_key(
825 k: &fallow_types::output_dead_code::UnusedLoadDataKeyFinding,
826 rel: &dyn Fn(&Path) -> String,
827) -> Vec<String> {
828 vec![format!(
829 "- {}:{} {} is returned from load() but no consumer reads it",
830 markdown_code_span(&rel(&k.key.path)),
831 k.key.line,
832 markdown_code_span(&k.key.key_name),
833 )]
834}
835
836fn format_markdown_route_collision(
837 c: &fallow_types::output_dead_code::RouteCollisionFinding,
838 rel: &dyn Fn(&Path) -> String,
839) -> Vec<String> {
840 vec![format!(
841 "- {} resolves to {} (shared with {} other route file(s))",
842 markdown_code_span(&rel(&c.collision.path)),
843 markdown_code_span(&c.collision.url),
844 c.collision.conflicting_paths.len(),
845 )]
846}
847
848fn format_markdown_dynamic_segment_name_conflict(
849 c: &fallow_types::output_dead_code::DynamicSegmentNameConflictFinding,
850 rel: &dyn Fn(&Path) -> String,
851) -> Vec<String> {
852 vec![format!(
853 "- {} crashes at runtime: different slug names ({}) at the same dynamic path {}; \
854 `next build` passes but the route fails on its first request (rename to one consistent slug)",
855 markdown_code_span(&rel(&c.conflict.path)),
856 c.conflict.conflicting_segments.join(" vs "),
857 markdown_code_span(&c.conflict.position),
858 )]
859}
860
861fn push_markdown_catalog_sections(
862 out: &mut String,
863 results: &AnalysisResults,
864 rel: &dyn Fn(&Path) -> String,
865) {
866 markdown_section(
867 out,
868 &results.unused_catalog_entries,
869 "Unused catalog entries",
870 |entry| format_unused_catalog_entry(entry, rel),
871 );
872 markdown_section(
873 out,
874 &results.empty_catalog_groups,
875 "Empty catalog groups",
876 |group| {
877 vec![format!(
878 "- {} {}:{}",
879 markdown_code_span(&group.group.catalog_name),
880 markdown_code_span(&rel(&group.group.path)),
881 group.group.line,
882 )]
883 },
884 );
885 markdown_section(
886 out,
887 &results.unresolved_catalog_references,
888 "Unresolved catalog references",
889 |finding| format_unresolved_catalog_reference(finding, rel),
890 );
891 markdown_section(
892 out,
893 &results.unused_dependency_overrides,
894 "Unused dependency overrides",
895 |finding| format_unused_dependency_override(finding, rel),
896 );
897 markdown_section(
898 out,
899 &results.misconfigured_dependency_overrides,
900 "Misconfigured dependency overrides",
901 |finding| {
902 vec![format!(
903 "- {} -> {} ({}) {}:{} ({})",
904 markdown_code_span(&finding.entry.raw_key),
905 markdown_code_span(&finding.entry.raw_value),
906 markdown_code_span(finding.entry.source.as_label()),
907 markdown_code_span(&rel(&finding.entry.path)),
908 finding.entry.line,
909 finding.entry.reason.describe(),
910 )]
911 },
912 );
913}
914
915fn format_unused_catalog_entry(
916 entry: &UnusedCatalogEntryFinding,
917 rel: &dyn Fn(&Path) -> String,
918) -> Vec<String> {
919 let mut row = format!(
920 "- {} ({}) {}:{}",
921 markdown_code_span(&entry.entry.entry_name),
922 markdown_code_span(&entry.entry.catalog_name),
923 markdown_code_span(&rel(&entry.entry.path)),
924 entry.entry.line,
925 );
926 if !entry.entry.hardcoded_consumers.is_empty() {
927 let consumers = entry
928 .entry
929 .hardcoded_consumers
930 .iter()
931 .map(|p| markdown_code_span(&rel(p)))
932 .collect::<Vec<_>>()
933 .join(", ");
934 let _ = write!(row, " (hardcoded in {consumers})");
935 }
936 vec![row]
937}
938
939fn format_unresolved_catalog_reference(
940 finding: &UnresolvedCatalogReferenceFinding,
941 rel: &dyn Fn(&Path) -> String,
942) -> Vec<String> {
943 let mut row = format!(
944 "- {} ({}) {}:{}",
945 markdown_code_span(&finding.reference.entry_name),
946 markdown_code_span(&finding.reference.catalog_name),
947 markdown_code_span(&rel(&finding.reference.path)),
948 finding.reference.line,
949 );
950 if !finding.reference.available_in_catalogs.is_empty() {
951 let alts = finding
952 .reference
953 .available_in_catalogs
954 .iter()
955 .map(|c| markdown_code_span(c))
956 .collect::<Vec<_>>()
957 .join(", ");
958 let _ = write!(row, " (available in: {alts})");
959 }
960 vec![row]
961}
962
963fn format_unused_dependency_override(
964 finding: &UnusedDependencyOverrideFinding,
965 rel: &dyn Fn(&Path) -> String,
966) -> Vec<String> {
967 let mut row = format!(
968 "- {} -> {} ({}) {}:{}",
969 markdown_code_span(&finding.entry.raw_key),
970 markdown_code_span(&finding.entry.version_range),
971 markdown_code_span(finding.entry.source.as_label()),
972 markdown_code_span(&rel(&finding.entry.path)),
973 finding.entry.line,
974 );
975 if let Some(hint) = &finding.entry.hint {
976 let _ = write!(row, " (hint: {})", escape_markdown_prose(hint));
977 }
978 vec![row]
979}
980
981#[must_use]
983pub fn build_grouped_markdown(groups: &[ResultGroup], root: &Path) -> String {
984 let total: usize = groups.iter().map(|g| g.results.total_issues()).sum();
985 let signals: usize = groups.iter().map(|g| health_signal_count(&g.results)).sum();
986 let mut out = String::new();
987
988 if total == 0 && signals == 0 {
989 out.push_str("## Fallow: no issues found\n");
990 return out;
991 }
992
993 if total == 0 {
994 out.push_str("## Fallow: no issues found (grouped)\n\n");
995 } else {
996 let _ = writeln!(
997 out,
998 "## Fallow: {total} issue{} found (grouped)\n",
999 plural(total)
1000 );
1001 }
1002
1003 for group in groups {
1004 let count = group.results.total_issues();
1005 let group_signals = health_signal_count(&group.results);
1006 if count == 0 && group_signals == 0 {
1007 continue;
1008 }
1009 let signal_part = crate::grouped_output::health_signal_header_part(&group.results);
1010 let _ = writeln!(
1011 out,
1012 "## {} ({count} issue{}{signal_part})\n",
1013 escape_markdown_prose(&group.key),
1014 plural(count)
1015 );
1016 if let Some(ref owners) = group.owners
1017 && !owners.is_empty()
1018 {
1019 let joined = owners
1020 .iter()
1021 .map(|owner| escape_markdown_prose(owner))
1022 .collect::<Vec<_>>()
1023 .join(" ");
1024 let _ = writeln!(out, "Owners: {joined}\n");
1025 }
1026 push_markdown_sections(&mut out, &group.results, root);
1027 }
1028
1029 out
1030}
1031
1032fn markdown_caveat_suffix(caveats: &[ReachabilityCaveat]) -> String {
1039 caveat_labels(caveats).map_or_else(String::new, |labels| format!(" *(caveat: {labels})*"))
1040}
1041
1042fn format_export(e: &UnusedExport, caveats: &[ReachabilityCaveat]) -> String {
1043 let re = if e.is_re_export { " (re-export)" } else { "" };
1044 let deprecated = if e.deprecated {
1045 " (marked @deprecated)"
1046 } else {
1047 ""
1048 };
1049 format!(
1050 ":{} {}{re}{deprecated}{}",
1051 e.line,
1052 markdown_code_span(&e.export_name),
1053 markdown_caveat_suffix(caveats)
1054 )
1055}
1056
1057fn format_deprecated_export_in_use(
1058 entry: &fallow_types::output_dead_code::DeprecatedExportInUseFinding,
1059) -> String {
1060 let e = &entry.export;
1061 let consumers = format!("{} consumer{}", e.consumer_count, plural(e.consumer_count));
1062 let scope = if e.public_api { " (public API)" } else { "" };
1063 let reason = e
1064 .deprecated_reason
1065 .as_deref()
1066 .map_or_else(String::new, |reason| {
1067 format!(": {}", escape_markdown_inline(reason))
1068 });
1069 format!(
1070 ":{} {} still used by {consumers}{scope}{reason}",
1071 e.line,
1072 markdown_code_span(&e.export_name),
1073 )
1074}
1075
1076fn format_private_type_leak(
1077 entry: &fallow_types::output_dead_code::PrivateTypeLeakFinding,
1078) -> String {
1079 let e = &entry.leak;
1080 format!(
1081 ":{} {} references private type {}",
1082 e.line,
1083 markdown_code_span(&e.export_name),
1084 markdown_code_span(&e.type_name)
1085 )
1086}
1087
1088fn format_member(m: &UnusedMember, caveats: &[ReachabilityCaveat]) -> String {
1089 let member = format!("{}.{}", m.parent_name, m.member_name);
1090 format!(
1091 ":{} {}{}",
1092 m.line,
1093 markdown_code_span(&member),
1094 markdown_caveat_suffix(caveats)
1095 )
1096}
1097
1098fn format_dependency(
1099 dep_name: &str,
1100 pkg_path: &Path,
1101 used_in_workspaces: &[std::path::PathBuf],
1102 root: &Path,
1103 caveats: &[ReachabilityCaveat],
1104) -> Vec<String> {
1105 let caveat = markdown_caveat_suffix(caveats);
1106 let name = markdown_code_span(dep_name);
1107 let pkg_label = relative_path(pkg_path, root).display().to_string();
1108 let workspace_context = if used_in_workspaces.is_empty() {
1109 String::new()
1110 } else {
1111 let workspaces = used_in_workspaces
1112 .iter()
1113 .map(|path| markdown_code_span(&relative_path(path, root).display().to_string()))
1114 .collect::<Vec<_>>()
1115 .join(", ");
1116 format!("; imported in {workspaces}")
1117 };
1118 if pkg_label == "package.json" && workspace_context.is_empty() {
1119 vec![format!("- {name}{caveat}")]
1120 } else {
1121 let label = if pkg_label == "package.json" {
1122 workspace_context.trim_start_matches("; ").to_string()
1123 } else {
1124 format!("{}{workspace_context}", markdown_code_span(&pkg_label))
1125 };
1126 vec![format!("- {name} ({label}){caveat}")]
1127 }
1128}
1129
1130fn markdown_section<T>(
1132 out: &mut String,
1133 items: &[T],
1134 title: &str,
1135 format_lines: impl Fn(&T) -> Vec<String>,
1136) {
1137 if items.is_empty() {
1138 return;
1139 }
1140 let _ = write!(out, "### {title} ({})\n\n", items.len());
1141 for item in items {
1142 for line in format_lines(item) {
1143 out.push_str(&line);
1144 out.push('\n');
1145 }
1146 }
1147 out.push('\n');
1148}
1149
1150fn markdown_grouped_section<'a, T>(
1151 out: &mut String,
1152 items: &'a [T],
1153 title: &str,
1154 root: &Path,
1155 get_path: impl Fn(&'a T) -> &'a Path,
1156 format_detail: impl Fn(&T) -> String,
1157) {
1158 if items.is_empty() {
1159 return;
1160 }
1161 let _ = write!(out, "### {title} ({})\n\n", items.len());
1162
1163 let mut indices: Vec<usize> = (0..items.len()).collect();
1164 indices.sort_by(|&a, &b| get_path(&items[a]).cmp(get_path(&items[b])));
1165
1166 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
1167 let mut last_file = String::new();
1168 for &i in &indices {
1169 let item = &items[i];
1170 let file_str = rel(get_path(item));
1171 if file_str != last_file {
1172 let _ = writeln!(out, "- {}", markdown_code_span(&file_str));
1173 last_file = file_str;
1174 }
1175 let _ = writeln!(out, " - {}", format_detail(item));
1176 }
1177 out.push('\n');
1178}
1179
1180fn write_duplication_omission_note(out: &mut String, report: &DuplicationReport) {
1186 let groups_omitted = report.clone_groups_omitted();
1187 let families_omitted = report.clone_families_omitted();
1188 if groups_omitted == 0 && families_omitted == 0 {
1189 return;
1190 }
1191
1192 let mut withheld: Vec<String> = Vec::with_capacity(2);
1193 if groups_omitted > 0 {
1194 withheld.push(format!(
1195 "{} more clone group{}",
1196 groups_omitted,
1197 plural(groups_omitted)
1198 ));
1199 }
1200 if families_omitted > 0 {
1201 withheld.push(format!(
1202 "{} more clone famil{}",
1203 families_omitted,
1204 if families_omitted == 1 { "y" } else { "ies" }
1205 ));
1206 }
1207
1208 let _ = write!(
1209 out,
1210 "_Listing {} of them; {} withheld by a display limit._\n\n",
1211 report.clone_groups_shown(),
1212 withheld.join(" and "),
1213 );
1214}
1215
1216#[must_use]
1218pub fn build_duplication_markdown(report: &DuplicationReport, root: &Path) -> String {
1219 let mut out = String::new();
1220
1221 if report.clone_groups.is_empty() {
1222 out.push_str("## Fallow: no code duplication found\n");
1223 return out;
1224 }
1225
1226 let stats = &report.stats;
1227 let corpus_groups = report.clone_groups_total();
1232 let _ = write!(
1233 out,
1234 "## Fallow: {} clone group{} found ({:.1}% duplication)\n\n",
1235 corpus_groups,
1236 plural(corpus_groups),
1237 stats.duplication_percentage,
1238 );
1239 write_duplication_omission_note(&mut out, report);
1240
1241 write_duplication_groups(&mut out, report, root);
1242 write_duplication_families(&mut out, report, root);
1243
1244 let _ = writeln!(
1245 out,
1246 "**Summary:** {} duplicated lines ({:.1}%) across {} file{}",
1247 stats.duplicated_lines,
1248 stats.duplication_percentage,
1249 stats.files_with_clones,
1250 plural(stats.files_with_clones),
1251 );
1252
1253 out
1254}
1255
1256fn write_duplication_groups(out: &mut String, report: &DuplicationReport, root: &Path) {
1258 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
1259 out.push_str("### Duplicates\n\n");
1260 for (i, group) in report.clone_groups.iter().enumerate() {
1261 let instance_count = group.instances.len();
1262 let _ = write!(
1263 out,
1264 "**Clone group {}** ({} lines, {instance_count} instance{})\n\n",
1265 i + 1,
1266 group.line_count,
1267 plural(instance_count)
1268 );
1269 for instance in &group.instances {
1270 let relative = rel(&instance.file);
1271 let location = format!("{relative}:{}-{}", instance.start_line, instance.end_line);
1272 let _ = writeln!(out, "- {}", markdown_code_span(&location));
1273 }
1274 out.push('\n');
1275 }
1276}
1277
1278fn write_duplication_families(out: &mut String, report: &DuplicationReport, root: &Path) {
1280 if report.clone_families.is_empty() {
1281 return;
1282 }
1283 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
1284 out.push_str("### Clone Families\n\n");
1285 for (i, family) in report.clone_families.iter().enumerate() {
1286 let file_names: Vec<_> = family.files.iter().map(|f| rel(f)).collect();
1287 let _ = write!(
1288 out,
1289 "**Family {}** ({} group{}, {} lines across {})\n\n",
1290 i + 1,
1291 family.groups.len(),
1292 plural(family.groups.len()),
1293 family.total_duplicated_lines,
1294 file_names
1295 .iter()
1296 .map(|s| markdown_code_span(s))
1297 .collect::<Vec<_>>()
1298 .join(", "),
1299 );
1300 for suggestion in &family.suggestions {
1301 let savings = if suggestion.estimated_savings > 0 {
1302 format!(" (~{} lines saved)", suggestion.estimated_savings)
1303 } else {
1304 String::new()
1305 };
1306 let _ = writeln!(out, "- {}{savings}", suggestion.description);
1307 }
1308 out.push('\n');
1309 }
1310}
1311
1312#[must_use]
1314pub fn build_health_markdown(report: &fallow_output::HealthReport, root: &Path) -> String {
1315 let mut out = String::new();
1316
1317 if let Some(ref hs) = report.health_score {
1318 let _ = writeln!(out, "## Health Score: {:.0} ({})\n", hs.score, hs.grade);
1319 }
1320
1321 write_trend_section(&mut out, report);
1322 write_vital_signs_section(&mut out, report);
1323
1324 if report.findings.is_empty()
1325 && report.file_scores.is_empty()
1326 && report.coverage_gaps.is_none()
1327 && report.hotspots.is_empty()
1328 && report.targets.is_empty()
1329 && report.runtime_coverage.is_none()
1330 && report.coverage_intelligence.is_none()
1331 && report.threshold_overrides.is_empty()
1332 && report.css_analytics.is_none()
1333 && report.styling_findings.is_empty()
1334 {
1335 if report.vital_signs.is_none() {
1336 let _ = write!(
1337 out,
1338 "## Fallow: no functions exceed complexity thresholds\n\n\
1339 **{}** functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {:.1})\n",
1340 report.summary.functions_analyzed,
1341 report.summary.max_cyclomatic_threshold,
1342 report.summary.max_cognitive_threshold,
1343 report.summary.max_crap_threshold,
1344 );
1345 }
1346 return out;
1347 }
1348
1349 write_findings_section(&mut out, report, root);
1350 write_styling_findings_section(&mut out, report, root);
1351 write_threshold_overrides_section(&mut out, report, root);
1352 write_runtime_coverage_section(&mut out, report, root);
1353 write_coverage_intelligence_section(&mut out, report, root);
1354 write_coverage_gaps_section(&mut out, report, root);
1355 write_file_scores_section(&mut out, report, root);
1356 write_hotspots_section(&mut out, report, root);
1357 write_targets_section(&mut out, report, root);
1358 write_css_analytics_section(&mut out, report);
1359 write_metric_legend(&mut out, report);
1360
1361 out
1362}
1363
1364fn write_styling_findings_section(
1365 out: &mut String,
1366 report: &fallow_output::HealthReport,
1367 root: &Path,
1368) {
1369 if report.styling_findings.is_empty() {
1370 return;
1371 }
1372 if !out.is_empty() && !out.ends_with("\n\n") {
1373 out.push('\n');
1374 }
1375 out.push_str("## Styling Findings\n\n");
1376 out.push_str("| File | Rule | Severity | Value |\n");
1377 out.push_str("|:-----|:-----|:---------|:------|\n");
1378 for finding in report.styling_findings.iter().take(20) {
1379 let path = markdown_relative_path(Path::new(&finding.path), root);
1380 let location = format!("{path}:{}", finding.line);
1381 let severity = match finding.effective_severity {
1382 fallow_output::StylingFindingSeverity::Error => "error",
1383 fallow_output::StylingFindingSeverity::Warn => "warn",
1384 };
1385 let _ = writeln!(
1386 out,
1387 "| {} | {} / {} | {severity} | {} |",
1388 markdown_table_code_span(&location),
1389 markdown_table_code_span(&finding.code),
1390 markdown_table_code_span(&finding.sub_kind),
1391 markdown_table_code_span(&finding.value),
1392 );
1393 }
1394 if report.styling_findings.len() > 20 {
1395 let more = report.styling_findings.len() - 20;
1396 let _ = writeln!(out, "\n... and {more} more styling findings.");
1397 }
1398 out.push('\n');
1399}
1400
1401fn write_css_analytics_section(out: &mut String, report: &fallow_output::HealthReport) {
1405 let Some(ref css) = report.css_analytics else {
1406 return;
1407 };
1408 let s = &css.summary;
1409 if !out.is_empty() && !out.ends_with("\n\n") {
1410 out.push('\n');
1411 }
1412 out.push_str("## CSS Health\n\n");
1413 let important_pct = if s.total_declarations > 0 {
1414 f64::from(s.important_declarations) / f64::from(s.total_declarations) * 100.0
1415 } else {
1416 0.0
1417 };
1418 let _ = writeln!(
1419 out,
1420 "- Stylesheets: {} | Rules: {} | !important: {important_pct:.1}% | Empty rules: {} | Max nesting: {}",
1421 s.files_analyzed, s.total_rules, s.empty_rules, s.max_nesting_depth,
1422 );
1423 let _ = writeln!(
1424 out,
1425 "- Value sprawl: {} colors | {} font sizes | {} z-index | {} shadows | {} radii | {} line-heights",
1426 s.unique_colors,
1427 s.unique_font_sizes,
1428 s.unique_z_indexes,
1429 s.unique_box_shadows,
1430 s.unique_border_radii,
1431 s.unique_line_heights,
1432 );
1433 let _ = writeln!(
1434 out,
1435 "- 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",
1436 s.keyframes_unreferenced,
1437 s.keyframes_undefined,
1438 s.duplicate_declaration_blocks,
1439 s.scoped_unused_classes,
1440 s.tailwind_arbitrary_values,
1441 s.unused_property_registrations,
1442 s.unused_layers,
1443 s.unresolved_class_references,
1444 s.unreferenced_css_classes,
1445 s.unused_font_faces,
1446 s.unused_theme_tokens,
1447 );
1448 write_css_candidate_details(out, css);
1449 out.push('\n');
1450}
1451
1452fn write_css_candidate_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
1453 write_css_keyframe_details(out, css);
1454 write_css_tailwind_details(out, css);
1455 write_css_class_candidate_details(out, css);
1456 write_css_font_candidate_details(out, css);
1457 write_css_font_size_mix_details(out, css);
1458}
1459
1460fn write_css_keyframe_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
1461 if !css.undefined_keyframes.is_empty() {
1462 let named: Vec<String> = css
1463 .undefined_keyframes
1464 .iter()
1465 .take(5)
1466 .map(|kf| format!("{} ({})", markdown_code_span(&kf.name), kf.path))
1467 .collect();
1468 let _ = writeln!(
1469 out,
1470 "- Undefined @keyframes (candidates; likely typo or CSS-in-JS): {}",
1471 named.join(", "),
1472 );
1473 }
1474}
1475
1476fn write_css_tailwind_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
1477 if !css.tailwind_arbitrary_values.is_empty() {
1478 let named: Vec<String> = css
1479 .tailwind_arbitrary_values
1480 .iter()
1481 .take(5)
1482 .map(|a| format!("{} ({}x)", markdown_code_span(&a.value), a.count))
1483 .collect();
1484 let _ = writeln!(out, "- Top Tailwind arbitrary values: {}", named.join(", "));
1485 }
1486}
1487
1488fn write_css_class_candidate_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
1489 if !css.unresolved_class_references.is_empty() {
1490 let named: Vec<String> = css
1491 .unresolved_class_references
1492 .iter()
1493 .take(5)
1494 .map(|u| {
1495 format!(
1496 "{} -> {} ({}:{})",
1497 markdown_code_span(&u.class),
1498 markdown_code_span(&u.suggestion),
1499 u.path,
1500 u.line
1501 )
1502 })
1503 .collect();
1504 let _ = writeln!(
1505 out,
1506 "- Likely class typos (candidates; verify, may be CSS-in-JS or external): {}",
1507 named.join(", "),
1508 );
1509 }
1510 if !css.unreferenced_css_classes.is_empty() {
1511 let named: Vec<String> = css
1512 .unreferenced_css_classes
1513 .iter()
1514 .take(5)
1515 .map(|u| {
1516 format!(
1517 "{} ({}:{})",
1518 markdown_code_span(&format!(".{}", u.class)),
1519 u.path,
1520 u.line
1521 )
1522 })
1523 .collect();
1524 let _ = writeln!(
1525 out,
1526 "- Unreferenced global classes (candidates; verify no email / server / CMS / Markdown applies them): {}",
1527 named.join(", "),
1528 );
1529 }
1530}
1531
1532fn write_css_font_candidate_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
1533 if !css.unused_font_faces.is_empty() {
1534 let named: Vec<String> = css
1535 .unused_font_faces
1536 .iter()
1537 .take(5)
1538 .map(|u| format!("{} ({})", markdown_code_span(&u.family), u.path))
1539 .collect();
1540 let _ = writeln!(
1541 out,
1542 "- Unused @font-face (dead web-font; candidates, may be set from JS/inline): {}",
1543 named.join(", "),
1544 );
1545 }
1546 if !css.unused_theme_tokens.is_empty() {
1547 let named: Vec<String> = css
1548 .unused_theme_tokens
1549 .iter()
1550 .take(5)
1551 .map(|u| format!("{} ({}:{})", markdown_code_span(&u.token), u.path, u.line))
1552 .collect();
1553 let _ = writeln!(
1554 out,
1555 "- Unused @theme tokens (dead Tailwind v4 design tokens; candidates, may be consumed by a plugin or downstream repo): {}",
1556 named.join(", "),
1557 );
1558 }
1559}
1560
1561fn write_css_font_size_mix_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
1562 if let Some(mix) = &css.font_size_unit_mix {
1563 let breakdown: Vec<String> = mix
1564 .notations
1565 .iter()
1566 .map(|n| format!("{} {}", n.count, n.notation))
1567 .collect();
1568 let _ = writeln!(
1569 out,
1570 "- Font sizes mix {} units (candidate, standardize unless intentional): {}",
1571 mix.notations.len(),
1572 breakdown.join(", "),
1573 );
1574 }
1575}
1576
1577fn write_coverage_intelligence_section(
1578 out: &mut String,
1579 report: &fallow_output::HealthReport,
1580 root: &Path,
1581) {
1582 let Some(ref intelligence) = report.coverage_intelligence else {
1583 return;
1584 };
1585 if !out.is_empty() && !out.ends_with("\n\n") {
1586 out.push('\n');
1587 }
1588 let _ = writeln!(
1589 out,
1590 "## Coverage Intelligence\n\n- Verdict: {}\n- Findings: {}\n- Ambiguous matches skipped: {}\n",
1591 intelligence.verdict,
1592 intelligence.summary.findings,
1593 intelligence.summary.skipped_ambiguous_matches,
1594 );
1595 if intelligence.findings.is_empty() {
1596 if intelligence.summary.skipped_ambiguous_matches > 0 {
1597 let match_phrase = if intelligence.summary.skipped_ambiguous_matches == 1 {
1598 "evidence match was"
1599 } else {
1600 "evidence matches were"
1601 };
1602 let _ = writeln!(
1603 out,
1604 "No actionable findings were emitted because {} ambiguous {match_phrase} skipped.\n",
1605 intelligence.summary.skipped_ambiguous_matches,
1606 );
1607 }
1608 return;
1609 }
1610 out.push_str("| ID | Path | Identity | Verdict | Recommendation | Confidence | Signals |\n");
1611 out.push_str("|:---|:-----|:---------|:--------|:---------------|:-----------|:--------|\n");
1612 for finding in &intelligence.findings {
1613 write_coverage_intelligence_row(out, finding, root);
1614 }
1615 out.push('\n');
1616}
1617
1618fn write_coverage_intelligence_row(
1620 out: &mut String,
1621 finding: &fallow_output::CoverageIntelligenceFinding,
1622 root: &Path,
1623) {
1624 let path = normalize_uri(&relative_path(&finding.path, root).display().to_string());
1625 let identity = finding.identity.as_deref().unwrap_or("-");
1626 let signals = finding
1627 .signals
1628 .iter()
1629 .map(ToString::to_string)
1630 .collect::<Vec<_>>()
1631 .join(", ");
1632 let _ = writeln!(
1633 out,
1634 "| {} | {}:{} | {} | {} | {} | {} | {} |",
1635 markdown_table_code_span(&finding.id),
1636 markdown_table_code_span(&path),
1637 finding.line,
1638 markdown_table_code_span(identity),
1639 finding.verdict,
1640 finding.recommendation,
1641 finding.confidence,
1642 signals,
1643 );
1644}
1645
1646fn write_runtime_coverage_section(
1647 out: &mut String,
1648 report: &fallow_output::HealthReport,
1649 root: &Path,
1650) {
1651 let Some(ref production) = report.runtime_coverage else {
1652 return;
1653 };
1654 if !out.is_empty() && !out.ends_with("\n\n") {
1655 out.push('\n');
1656 }
1657 write_runtime_coverage_summary(out, production);
1658 write_runtime_coverage_findings(out, production, root);
1659 write_runtime_coverage_hot_paths(out, production, root);
1660}
1661
1662fn write_runtime_coverage_summary(
1664 out: &mut String,
1665 production: &fallow_output::RuntimeCoverageReport,
1666) {
1667 let _ = writeln!(
1668 out,
1669 "## 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",
1670 production.verdict,
1671 production.summary.functions_tracked,
1672 production.summary.functions_hit,
1673 production.summary.functions_unhit,
1674 production.summary.functions_untracked,
1675 production.summary.coverage_percent,
1676 production.summary.trace_count,
1677 production.summary.period_days,
1678 production.summary.deployments_seen,
1679 );
1680 if let Some(watermark) = production.watermark {
1681 let _ = writeln!(out, "- Watermark: {watermark}\n");
1682 }
1683 if let Some(ref quality) = production.summary.capture_quality
1684 && quality.lazy_parse_warning
1685 {
1686 let window = format_window(quality.window_seconds);
1687 let _ = writeln!(
1688 out,
1689 "- Capture quality: short window ({} from {} instance(s), {:.1}% of functions untracked); lazy-parsed scripts may not appear.\n",
1690 window, quality.instances_observed, quality.untracked_ratio_percent,
1691 );
1692 }
1693}
1694
1695fn write_runtime_coverage_findings(
1697 out: &mut String,
1698 production: &fallow_output::RuntimeCoverageReport,
1699 root: &Path,
1700) {
1701 if production.findings.is_empty() {
1702 return;
1703 }
1704 out.push_str("| ID | Path | Function | Verdict | Invocations | Confidence |\n");
1705 out.push_str("|:---|:-----|:---------|:--------|------------:|:-----------|\n");
1706 for finding in &production.findings {
1707 let invocations = finding
1708 .invocations
1709 .map_or_else(|| "-".to_owned(), |hits| hits.to_string());
1710 let path = normalize_uri(&relative_path(&finding.path, root).display().to_string());
1711 let _ = writeln!(
1712 out,
1713 "| {} | {}:{} | {} | {} | {} | {} |",
1714 markdown_table_code_span(&finding.id),
1715 markdown_table_code_span(&path),
1716 finding.line,
1717 markdown_table_code_span(&finding.function),
1718 finding.verdict,
1719 invocations,
1720 finding.confidence,
1721 );
1722 }
1723 out.push('\n');
1724}
1725
1726fn write_runtime_coverage_hot_paths(
1728 out: &mut String,
1729 production: &fallow_output::RuntimeCoverageReport,
1730 root: &Path,
1731) {
1732 if production.hot_paths.is_empty() {
1733 return;
1734 }
1735 out.push_str("| ID | Hot path | Function | Invocations | Percentile |\n");
1736 out.push_str("|:---|:---------|:---------|------------:|-----------:|\n");
1737 for entry in &production.hot_paths {
1738 let path = normalize_uri(&relative_path(&entry.path, root).display().to_string());
1739 let _ = writeln!(
1740 out,
1741 "| {} | {}:{} | {} | {} | {} |",
1742 markdown_table_code_span(&entry.id),
1743 markdown_table_code_span(&path),
1744 entry.line,
1745 markdown_table_code_span(&entry.function),
1746 entry.invocations,
1747 entry.percentile,
1748 );
1749 }
1750 out.push('\n');
1751}
1752
1753fn write_trend_section(out: &mut String, report: &fallow_output::HealthReport) {
1755 let Some(ref trend) = report.health_trend else {
1756 return;
1757 };
1758 let sha_str = trend
1759 .compared_to
1760 .git_sha
1761 .as_deref()
1762 .map_or(String::new(), |sha| format!(" ({sha})"));
1763 let _ = writeln!(
1764 out,
1765 "## Trend (vs {}{})\n",
1766 trend
1767 .compared_to
1768 .timestamp
1769 .get(..10)
1770 .unwrap_or(&trend.compared_to.timestamp),
1771 sha_str,
1772 );
1773 out.push_str("| Metric | Previous | Current | Delta | Direction |\n");
1774 out.push_str("|:-------|:---------|:--------|:------|:----------|\n");
1775 for m in &trend.metrics {
1776 write_trend_metric_row(out, m);
1777 }
1778 let md_sha = trend
1779 .compared_to
1780 .git_sha
1781 .as_deref()
1782 .map_or(String::new(), |sha| format!(" ({sha})"));
1783 let _ = writeln!(
1784 out,
1785 "\n*vs {}{} · {} {} available*\n",
1786 trend
1787 .compared_to
1788 .timestamp
1789 .get(..10)
1790 .unwrap_or(&trend.compared_to.timestamp),
1791 md_sha,
1792 trend.snapshots_loaded,
1793 if trend.snapshots_loaded == 1 {
1794 "snapshot"
1795 } else {
1796 "snapshots"
1797 },
1798 );
1799}
1800
1801fn write_trend_metric_row(out: &mut String, m: &fallow_output::TrendMetric) {
1803 let fmt_val = |v: f64| -> String {
1804 if m.unit == "%" {
1805 format!("{v:.1}%")
1806 } else if (v - v.round()).abs() < 0.05 {
1807 format!("{v:.0}")
1808 } else {
1809 format!("{v:.1}")
1810 }
1811 };
1812 let prev = fmt_val(m.previous);
1813 let cur = fmt_val(m.current);
1814 let delta = if m.unit == "%" {
1815 format!("{:+.1}%", m.delta)
1816 } else if (m.delta - m.delta.round()).abs() < 0.05 {
1817 format!("{:+.0}", m.delta)
1818 } else {
1819 format!("{:+.1}", m.delta)
1820 };
1821 let _ = writeln!(
1822 out,
1823 "| {} | {} | {} | {} | {} {} |",
1824 m.label,
1825 prev,
1826 cur,
1827 delta,
1828 m.direction.arrow(),
1829 m.direction.label(),
1830 );
1831}
1832
1833fn write_vital_signs_section(out: &mut String, report: &fallow_output::HealthReport) {
1835 let Some(ref vs) = report.vital_signs else {
1836 return;
1837 };
1838 out.push_str("## Vital Signs\n\n");
1839 out.push_str("| Metric | Value |\n");
1840 out.push_str("|:-------|------:|\n");
1841 if vs.total_loc > 0 {
1842 let _ = writeln!(out, "| Total LOC | {} |", vs.total_loc);
1843 }
1844 let _ = writeln!(out, "| Avg Cyclomatic | {:.1} |", vs.avg_cyclomatic);
1845 let _ = writeln!(out, "| P90 Cyclomatic | {} |", vs.p90_cyclomatic);
1846 if let Some(population) = &vs.cyclomatic_population {
1847 let _ = writeln!(
1848 out,
1849 "| Cyclomatic units | Functions: {}, module scopes: {}, templates: {} |",
1850 population.functions.count, population.modules.count, population.templates.count
1851 );
1852 if let Some(max) = population.modules.max {
1853 let _ = writeln!(
1854 out,
1855 "| Module-scope max cyclomatic (aggregate only) | {max} |"
1856 );
1857 }
1858 }
1859 if let Some(v) = vs.dead_file_pct {
1860 let _ = writeln!(out, "| Dead Files | {v:.1}% |");
1861 }
1862 if let Some(v) = vs.dead_export_pct {
1863 let _ = writeln!(out, "| Dead Exports | {v:.1}% |");
1864 }
1865 if let Some(v) = vs.maintainability_avg {
1866 let _ = writeln!(out, "| Maintainability (avg) | {v:.1} |");
1867 }
1868 if let Some(v) = vs.hotspot_count {
1869 let label = report.hotspot_summary.as_ref().map_or_else(
1870 || "Hotspots".to_string(),
1871 |summary| format!("Hotspots (since {})", summary.since),
1872 );
1873 let _ = writeln!(out, "| {label} | {v} |");
1874 }
1875 if let Some(v) = vs.circular_dep_count {
1876 let _ = writeln!(out, "| Circular Deps | {v} |");
1877 }
1878 if let Some(v) = vs.unused_dep_count {
1879 let _ = writeln!(out, "| Unused Deps | {v} |");
1880 }
1881 out.push('\n');
1882}
1883
1884fn write_findings_section(out: &mut String, report: &fallow_output::HealthReport, root: &Path) {
1886 if report.findings.is_empty() {
1887 return;
1888 }
1889
1890 let has_synthetic = report.findings.iter().any(|finding| {
1891 fallow_types::extract::is_synthetic_template_unit(&finding.name)
1892 || finding.name == "<component>"
1893 });
1894 write_findings_heading(out, report, has_synthetic);
1895 write_findings_table_header(out, has_synthetic);
1896
1897 for finding in &report.findings {
1898 write_findings_row(out, finding, root);
1899 }
1900
1901 let s = &report.summary;
1902 out.push_str("\n**!** marks the dimension that breached.\n");
1903 let _ = write!(
1904 out,
1905 "\n**{files}** files, **{funcs}** functions analyzed \
1906 (thresholds: cyclomatic > {cyc}, cognitive > {cog}, CRAP >= {crap:.1})\n",
1907 files = s.files_analyzed,
1908 funcs = s.functions_analyzed,
1909 cyc = s.max_cyclomatic_threshold,
1910 cog = s.max_cognitive_threshold,
1911 crap = s.max_crap_threshold,
1912 );
1913}
1914
1915fn write_findings_heading(
1917 out: &mut String,
1918 report: &fallow_output::HealthReport,
1919 has_synthetic: bool,
1920) {
1921 let count = report.summary.functions_above_threshold;
1922 let shown = report.findings.len();
1923 let subject = if has_synthetic {
1924 "high complexity finding"
1925 } else {
1926 "high complexity function"
1927 };
1928 if shown < count {
1929 let _ = write!(
1930 out,
1931 "## Fallow: {count} {subject}{} ({shown} shown)\n\n",
1932 plural(count),
1933 );
1934 } else {
1935 let _ = write!(out, "## Fallow: {count} {subject}{}\n\n", plural(count));
1936 }
1937}
1938
1939fn write_findings_table_header(out: &mut String, has_synthetic: bool) {
1941 let name_header = if has_synthetic { "Entry" } else { "Function" };
1942 let _ = writeln!(
1943 out,
1944 "| File | {name_header} | Severity | Cyclomatic | Cognitive | CRAP | Lines |"
1945 );
1946 out.push_str("|:-----|:---------|:---------|:-----------|:----------|:-----|:------|\n");
1947}
1948
1949fn write_findings_row(out: &mut String, finding: &fallow_output::HealthFinding, root: &Path) {
1951 let file_str = normalize_uri(&relative_path(&finding.path, root).display().to_string());
1952 let location = format!("{file_str}:{}", finding.line);
1953 let cyc_marker = if finding.exceeded.includes_cyclomatic() {
1956 " **!**"
1957 } else {
1958 ""
1959 };
1960 let cog_marker = if finding.exceeded.includes_cognitive() {
1961 " **!**"
1962 } else {
1963 ""
1964 };
1965 let severity_label = match finding.severity {
1966 fallow_output::FindingSeverity::Critical => "critical",
1967 fallow_output::FindingSeverity::High => "high",
1968 fallow_output::FindingSeverity::Moderate => "moderate",
1969 };
1970 let crap_cell = match finding.crap {
1971 Some(crap) => {
1972 let marker = if finding.exceeded.includes_crap() {
1973 " **!**"
1974 } else {
1975 ""
1976 };
1977 format!("{crap:.1}{marker}")
1978 }
1979 None => "-".to_string(),
1980 };
1981 let _ = writeln!(
1982 out,
1983 "| {} | {} | {severity_label} | {cyc}{cyc_marker} | {cog}{cog_marker} | {crap_cell} | {lines} |",
1984 markdown_table_code_span(&location),
1985 markdown_table_code_span(display_complexity_entry_name(&finding.name).as_ref()),
1986 cyc = finding.cyclomatic,
1987 cog = finding.cognitive,
1988 lines = finding.line_count,
1989 );
1990}
1991
1992fn write_threshold_overrides_section(
1993 out: &mut String,
1994 report: &fallow_output::HealthReport,
1995 root: &Path,
1996) {
1997 if report.threshold_overrides.is_empty() {
1998 return;
1999 }
2000 if !out.is_empty() && !out.ends_with("\n\n") {
2001 out.push('\n');
2002 }
2003 out.push_str("## Health Threshold Overrides\n\n");
2004 out.push_str("| Override | Dimension | Status | Target | Metrics | Outstanding |\n");
2005 out.push_str("|---------:|:----------|:-------|:-------|:--------|:------------|\n");
2006 for entry in &report.threshold_overrides {
2007 let status = match entry.status {
2008 fallow_output::ThresholdOverrideStatus::Active => "active",
2009 fallow_output::ThresholdOverrideStatus::Stale => "stale",
2010 fallow_output::ThresholdOverrideStatus::Insufficient => "insufficient",
2011 fallow_output::ThresholdOverrideStatus::NoMatch => "no_match",
2012 };
2013 let dimension = threshold_override_dimension_label(entry.dimension);
2014 let outstanding = if entry.outstanding.is_empty() {
2015 "-".to_string()
2016 } else {
2017 entry
2018 .outstanding
2019 .iter()
2020 .map(|value| threshold_override_dimension_label(*value))
2021 .collect::<Vec<_>>()
2022 .join(", ")
2023 };
2024 let target = entry.path.as_ref().map_or_else(
2025 || "<no matching file or function>".to_string(),
2026 |path| {
2027 entry.target_label(&normalize_uri(
2028 &relative_path(path, root).display().to_string(),
2029 ))
2030 },
2031 );
2032 let metrics = entry.metrics.map_or_else(
2033 || "-".to_string(),
2034 |metrics| {
2035 let crap = metrics
2036 .crap
2037 .map_or(String::new(), |value| format!(", CRAP {value:.1}"));
2038 let line_count = metrics
2039 .line_count
2040 .map_or(String::new(), |value| format!(", {value} lines"));
2041 format!(
2042 "cyclomatic {}, cognitive {}{}{}",
2043 metrics.cyclomatic, metrics.cognitive, line_count, crap
2044 )
2045 },
2046 );
2047 let metrics = if threshold_override_crap_not_applicable(entry) {
2051 format!("{metrics}; not scored on CRAP (this entry can be removed)")
2052 } else {
2053 metrics
2054 };
2055 let _ = writeln!(
2056 out,
2057 "| {} | {} | {} | {} | {} | {} |",
2058 entry.override_index,
2059 dimension,
2060 status,
2061 markdown_table_code_span(&target),
2062 metrics,
2063 outstanding
2064 );
2065 }
2066 out.push('\n');
2067}
2068
2069fn threshold_override_dimension_label(
2070 dimension: fallow_output::ThresholdOverrideDimension,
2071) -> &'static str {
2072 match dimension {
2073 fallow_output::ThresholdOverrideDimension::Complexity => "complexity",
2074 fallow_output::ThresholdOverrideDimension::Crap => "crap",
2075 }
2076}
2077
2078fn threshold_override_crap_not_applicable(entry: &fallow_output::ThresholdOverrideState) -> bool {
2082 matches!(
2083 entry.dimension,
2084 fallow_output::ThresholdOverrideDimension::Crap
2085 ) && entry.metrics.is_some_and(|metrics| metrics.crap.is_none())
2086 && entry
2087 .function
2088 .as_deref()
2089 .is_some_and(fallow_types::extract::is_synthetic_template_unit)
2090}
2091
2092fn write_file_scores_section(out: &mut String, report: &fallow_output::HealthReport, root: &Path) {
2094 if report.file_scores.is_empty() {
2095 return;
2096 }
2097
2098 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
2099
2100 out.push('\n');
2101 let count = report.file_scores.len();
2102 let _ = writeln!(
2103 out,
2104 "### File Health Scores ({count} file{})\n",
2105 plural(count),
2106 );
2107 out.push_str("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density | Risk |\n");
2108 out.push_str("|:-----|:---------------|:-------|:--------|:----------|:--------|:-----|\n");
2109
2110 for score in &report.file_scores {
2111 let file_str = rel(&score.path);
2112 let _ = writeln!(
2113 out,
2114 "| {} | {mi:.1} | {fi} | {fan_out} | {dead:.0}% | {density:.2} | {crap:.1} |",
2115 markdown_table_code_span(&file_str),
2116 mi = score.maintainability_index,
2117 fi = score.fan_in,
2118 fan_out = score.fan_out,
2119 dead = score.dead_code_ratio * 100.0,
2120 density = score.complexity_density,
2121 crap = score.crap_max,
2122 );
2123 }
2124
2125 if let Some(avg) = report.summary.average_maintainability {
2126 let _ = write!(out, "\n**Average maintainability index:** {avg:.1}/100\n");
2127 }
2128}
2129
2130fn write_coverage_gaps_section(
2131 out: &mut String,
2132 report: &fallow_output::HealthReport,
2133 root: &Path,
2134) {
2135 let Some(ref gaps) = report.coverage_gaps else {
2136 return;
2137 };
2138
2139 out.push('\n');
2140 let _ = writeln!(out, "### Coverage Gaps\n");
2141 let _ = writeln!(
2142 out,
2143 "*{} untested files · {} untested exports · {:.1}% file coverage*\n",
2144 gaps.summary.untested_files, gaps.summary.untested_exports, gaps.summary.file_coverage_pct,
2145 );
2146
2147 if gaps.files.is_empty() && gaps.exports.is_empty() {
2148 out.push_str("_No coverage gaps found in scope._\n");
2149 return;
2150 }
2151
2152 if !gaps.files.is_empty() {
2153 out.push_str("#### Files\n");
2154 for item in &gaps.files {
2155 let file_str =
2156 normalize_uri(&relative_path(&item.file.path, root).display().to_string());
2157 let _ = writeln!(
2158 out,
2159 "- {} ({count} value export{})",
2160 markdown_code_span(&file_str),
2161 if item.file.value_export_count == 1 {
2162 ""
2163 } else {
2164 "s"
2165 },
2166 count = item.file.value_export_count,
2167 );
2168 }
2169 out.push('\n');
2170 }
2171
2172 if !gaps.exports.is_empty() {
2173 out.push_str("#### Exports\n");
2174 for item in &gaps.exports {
2175 let file_str =
2176 normalize_uri(&relative_path(&item.export.path, root).display().to_string());
2177 let _ = writeln!(
2178 out,
2179 "- {}:{} {}",
2180 markdown_code_span(&file_str),
2181 item.export.line,
2182 markdown_code_span(&item.export.export_name)
2183 );
2184 }
2185 }
2186}
2187
2188fn ownership_md_cells(
2193 ownership: Option<&fallow_output::OwnershipMetrics>,
2194) -> (String, String, String, String) {
2195 let Some(o) = ownership else {
2196 let dash = "\u{2013}".to_string();
2197 return (dash.clone(), dash.clone(), dash.clone(), dash);
2198 };
2199 let bus = o.bus_factor.to_string();
2200 let top = format!(
2201 "{} ({:.0}%)",
2202 markdown_table_code_span(&o.top_contributor.identifier),
2203 o.top_contributor.share * 100.0,
2204 );
2205 let owner = o
2206 .declared_owner
2207 .as_deref()
2208 .map_or_else(|| "\u{2013}".to_string(), str::to_string);
2209 let mut notes: Vec<&str> = Vec::new();
2210 if o.unowned == Some(true) {
2211 notes.push("**unowned**");
2212 }
2213 if o.ownership_state == fallow_output::OwnershipState::DeclaredInactive {
2214 notes.push("declared owner inactive");
2215 }
2216 if o.drift {
2217 notes.push("drift");
2218 }
2219 let notes_str = if notes.is_empty() {
2220 "\u{2013}".to_string()
2221 } else {
2222 notes.join(", ")
2223 };
2224 (bus, top, owner, notes_str)
2225}
2226
2227fn write_hotspots_section(out: &mut String, report: &fallow_output::HealthReport, root: &Path) {
2228 if report.hotspots.is_empty() {
2229 return;
2230 }
2231
2232 out.push('\n');
2233 let count = report.hotspots.len();
2234 let header = report.hotspot_summary.as_ref().map_or_else(
2235 || format!("### Hotspots ({count} file{})\n", plural(count)),
2236 |summary| {
2237 format!(
2238 "### Hotspots ({count} file{}, since {})\n",
2239 plural(count),
2240 summary.since,
2241 )
2242 },
2243 );
2244 let _ = writeln!(out, "{header}");
2245 let any_ownership = report.hotspots.iter().any(|e| e.ownership.is_some());
2246 write_hotspots_table_header(out, any_ownership);
2247
2248 for entry in &report.hotspots {
2249 write_hotspots_row(out, entry, any_ownership, root);
2250 }
2251
2252 if let Some(ref summary) = report.hotspot_summary
2253 && summary.files_excluded > 0
2254 {
2255 let _ = write!(
2256 out,
2257 "\n*{} file{} excluded (< {} commits)*\n",
2258 summary.files_excluded,
2259 plural(summary.files_excluded),
2260 summary.min_commits,
2261 );
2262 }
2263}
2264
2265fn write_hotspots_table_header(out: &mut String, any_ownership: bool) {
2267 if any_ownership {
2268 out.push_str(
2269 "| File | Score | Commits | Churn | Density | Fan-in | Trend | Bus | Top | Owner | Notes |\n"
2270 );
2271 out.push_str(
2272 "|:-----|:------|:--------|:------|:--------|:-------|:------|:----|:----|:------|:------|\n"
2273 );
2274 } else {
2275 out.push_str("| File | Score | Commits | Churn | Density | Fan-in | Trend |\n");
2276 out.push_str("|:-----|:------|:--------|:------|:--------|:-------|:------|\n");
2277 }
2278}
2279
2280fn write_hotspots_row(
2282 out: &mut String,
2283 entry: &fallow_output::HotspotFinding,
2284 any_ownership: bool,
2285 root: &Path,
2286) {
2287 let file_str = normalize_uri(&relative_path(&entry.path, root).display().to_string());
2288 let file_span = markdown_table_code_span(&file_str);
2289 if any_ownership {
2290 let (bus, top, owner, notes) = ownership_md_cells(entry.ownership.as_ref());
2291 let _ = writeln!(
2292 out,
2293 "| {file_span} | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} | {bus} | {top} | {owner} | {notes} |",
2294 score = entry.score,
2295 commits = entry.commits,
2296 churn = entry.lines_added + entry.lines_deleted,
2297 density = entry.complexity_density,
2298 fi = entry.fan_in,
2299 trend = entry.trend,
2300 );
2301 } else {
2302 let _ = writeln!(
2303 out,
2304 "| {file_span} | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} |",
2305 score = entry.score,
2306 commits = entry.commits,
2307 churn = entry.lines_added + entry.lines_deleted,
2308 density = entry.complexity_density,
2309 fi = entry.fan_in,
2310 trend = entry.trend,
2311 );
2312 }
2313}
2314
2315fn write_targets_section(out: &mut String, report: &fallow_output::HealthReport, root: &Path) {
2317 if report.targets.is_empty() {
2318 return;
2319 }
2320 let _ = write!(
2321 out,
2322 "\n### Refactoring Targets ({})\n\n",
2323 report.targets.len()
2324 );
2325 out.push_str("| Efficiency | Category | Effort / Confidence | File | Recommendation |\n");
2326 out.push_str("|:-----------|:---------|:--------------------|:-----|:---------------|\n");
2327 for target in &report.targets {
2328 let file_str = normalize_uri(&relative_path(&target.path, root).display().to_string());
2329 let category = target.category.label();
2330 let effort = target.effort.label();
2331 let confidence = target.confidence.label();
2332 let _ = writeln!(
2333 out,
2334 "| {:.1} | {category} | {effort} / {confidence} | {} | {} |",
2335 target.efficiency,
2336 markdown_table_code_span(&file_str),
2337 markdown_table_text(&target.recommendation),
2338 );
2339 }
2340}
2341
2342fn write_metric_legend(out: &mut String, report: &fallow_output::HealthReport) {
2344 let has_scores = !report.file_scores.is_empty();
2345 let has_coverage = report.coverage_gaps.is_some();
2346 let has_hotspots = !report.hotspots.is_empty();
2347 let has_targets = !report.targets.is_empty();
2348 if !has_scores && !has_coverage && !has_hotspots && !has_targets {
2349 return;
2350 }
2351 out.push_str("\n---\n\n<details><summary>Metric definitions</summary>\n\n");
2352 if has_scores {
2353 out.push_str("- **MI**: Maintainability Index (0\u{2013}100, higher is better)\n");
2354 out.push_str("- **Order**: risk-aware triage order using the larger of low-MI concern and CRAP risk\n");
2355 out.push_str("- **Fan-in**: files that import this file (blast radius)\n");
2356 out.push_str("- **Fan-out**: files this file imports (coupling)\n");
2357 out.push_str("- **Dead Code**: % of value exports with zero references\n");
2358 out.push_str("- **Density**: cyclomatic complexity / lines of code\n");
2359 out.push_str(
2360 "- **Risk**: max CRAP score for the file; low <15, moderate 15-30, high >=30\n",
2361 );
2362 }
2363 if has_coverage {
2364 out.push_str(
2365 "- **File coverage**: runtime files also reachable from a discovered test root\n",
2366 );
2367 out.push_str("- **Untested export**: export with no reference chain from any test-reachable module\n");
2368 }
2369 if has_hotspots {
2370 out.push_str("- **Score**: churn \u{00d7} complexity (0\u{2013}100, higher = riskier)\n");
2371 out.push_str("- **Commits**: commits in the analysis window\n");
2372 out.push_str("- **Churn**: total lines added + deleted\n");
2373 out.push_str("- **Trend**: accelerating / stable / cooling\n");
2374 }
2375 if has_targets {
2376 out.push_str(
2377 "- **Efficiency**: priority / effort (higher = better quick-win value, default sort)\n",
2378 );
2379 out.push_str("- **Category**: recommendation type (churn+complexity, high impact, dead code, complexity, coupling, circular dep)\n");
2380 out.push_str("- **Effort**: estimated effort (low / medium / high) based on file size, function count, and fan-in\n");
2381 out.push_str("- **Confidence**: recommendation reliability (high = deterministic analysis, medium = heuristic, low = git-dependent)\n");
2382 }
2383 out.push_str(
2384 "\n[Full metric reference](https://docs.fallow.tools/explanations/metrics)\n\n</details>\n",
2385 );
2386}
2387
2388#[must_use]
2401pub fn build_walkthrough_markdown(
2402 guide: &fallow_output::StandardWalkthroughGuide,
2403 root: &Path,
2404 viewed: &[String],
2405) -> String {
2406 let mut out = String::new();
2407 out.push_str("## Fallow Review: Walkthrough\n\n");
2408 push_walkthrough_focus(&mut out, guide, viewed);
2409
2410 let unstaged = fallow_output::decisions_outside_units(guide);
2411 if guide.direction.order.is_empty() && unstaged.is_empty() {
2412 out.push_str("_No reviewable units in this change (orientation only)._\n");
2413 return out;
2414 }
2415
2416 let (stage1, stage2) = partition_walkthrough_stages(guide, viewed);
2417 push_walkthrough_stage(
2418 &mut out,
2419 "Stage 1 \u{00b7} Affects code outside this PR",
2420 &stage1,
2421 guide,
2422 root,
2423 );
2424 push_walkthrough_stage(
2425 &mut out,
2426 "Stage 2 \u{00b7} Self-contained",
2427 &stage2,
2428 guide,
2429 root,
2430 );
2431 push_walkthrough_unstaged_decisions(&mut out, &unstaged, root);
2432 push_walkthrough_cleared(&mut out, guide, root, viewed);
2433 out
2434}
2435
2436fn push_walkthrough_unstaged_decisions(
2439 out: &mut String,
2440 decisions: &[&fallow_output::Decision],
2441 root: &Path,
2442) {
2443 if decisions.is_empty() {
2444 return;
2445 }
2446 let _ = writeln!(
2447 out,
2448 "### Decisions outside the staged files ({})\n",
2449 decisions.len()
2450 );
2451 for decision in decisions {
2452 let token = match decision.category {
2453 fallow_output::DecisionCategory::CouplingBoundary => "COUPLING",
2454 fallow_output::DecisionCategory::PublicApiContract => "PUBLIC-API",
2455 fallow_output::DecisionCategory::Dependency => "DEPENDENCY",
2456 };
2457 let _ = writeln!(
2458 out,
2459 "- {} `{token}` \n {}",
2460 markdown_code_span(&markdown_relative_path_str(&decision.anchor_file, root)),
2461 fallow_output::clean_decision_fact(
2462 &decision.question,
2463 &decision.anchor_file,
2464 fallow_output::MAX_CONTRACT_MEMBERS
2465 )
2466 );
2467 }
2468 out.push('\n');
2469}
2470
2471fn push_walkthrough_focus(
2475 out: &mut String,
2476 guide: &fallow_output::StandardWalkthroughGuide,
2477 viewed: &[String],
2478) {
2479 let triage = &guide.digest.triage;
2480 let acc = fallow_output::WalkthroughAccounting::compute(guide, viewed);
2481 let total = acc.header_total();
2482 let _ = write!(
2483 out,
2484 "**Focus:** {} risk \u{00b7} {} \u{00b7} {} file{}",
2485 walkthrough_risk_label(triage.risk_class),
2486 walkthrough_effort_label(triage.review_effort),
2487 total,
2488 plural(total),
2489 );
2490 let mut parts = vec![format!("{} in stages", acc.staged)];
2491 if acc.cleared > 0 {
2492 parts.push(format!("{} cleared", acc.cleared));
2493 }
2494 if acc.excluded > 0 {
2495 parts.push(format!("{} non-source not reviewed", acc.excluded));
2496 }
2497 if acc.cleared > 0 || acc.excluded > 0 {
2498 let _ = write!(out, " ({})", parts.join(" \u{00b7} "));
2499 }
2500 out.push_str("\n\n");
2501}
2502
2503fn partition_walkthrough_stages<'a>(
2507 guide: &'a fallow_output::StandardWalkthroughGuide,
2508 viewed: &[String],
2509) -> (
2510 Vec<&'a fallow_output::DirectionUnit>,
2511 Vec<&'a fallow_output::DirectionUnit>,
2512) {
2513 let mut load_bearing = Vec::new();
2514 let mut mechanical = Vec::new();
2515 for unit in fallow_output::visible_stage_units(guide, viewed) {
2516 if unit.concern_lens == "contract-break" {
2517 load_bearing.push(unit);
2518 } else {
2519 mechanical.push(unit);
2520 }
2521 }
2522 (load_bearing, mechanical)
2523}
2524
2525fn push_walkthrough_stage(
2527 out: &mut String,
2528 title: &str,
2529 units: &[&fallow_output::DirectionUnit],
2530 guide: &fallow_output::StandardWalkthroughGuide,
2531 root: &Path,
2532) {
2533 if units.is_empty() {
2534 return;
2535 }
2536 let _ = write!(out, "### {title}\n\n");
2537 for unit in units {
2538 let rel = markdown_relative_path_str(&unit.file, root);
2539 let badges = walkthrough_markdown_badges(unit, guide);
2540 let suffix = if badges.is_empty() {
2541 String::new()
2542 } else {
2543 format!(" {}", badges.join(" "))
2544 };
2545 let _ = writeln!(
2551 out,
2552 "- {}: {}{suffix}",
2553 markdown_code_span(&rel),
2554 walkthrough_fact(unit, guide)
2555 );
2556 }
2557 out.push('\n');
2558}
2559
2560fn walkthrough_markdown_badges(
2562 unit: &fallow_output::DirectionUnit,
2563 guide: &fallow_output::StandardWalkthroughGuide,
2564) -> Vec<String> {
2565 let mut badges: Vec<String> = Vec::new();
2566 for decision in &guide.digest.decisions.decisions {
2567 if decision.anchor_file != unit.file {
2568 continue;
2569 }
2570 let token = match decision.category {
2571 fallow_output::DecisionCategory::CouplingBoundary => "COUPLING",
2572 fallow_output::DecisionCategory::PublicApiContract => "PUBLIC-API",
2573 fallow_output::DecisionCategory::Dependency => "DEPENDENCY",
2574 };
2575 let chip = format!("`{token}`");
2576 if !badges.contains(&chip) {
2577 badges.push(chip);
2578 }
2579 }
2580 if walkthrough_introduced(&unit.file, guide) {
2581 badges.push("`INTRODUCED`".to_string());
2582 }
2583 if unit.concern_lens == "contract-break" {
2584 badges.push("`OUT-OF-DIFF`".to_string());
2585 }
2586 if let Some(owner) = unit.expert.first() {
2587 badges.push(markdown_code_span(&format!("OWNER:{owner}")));
2588 }
2589 if walkthrough_bus_factor(&unit.file, guide) {
2590 badges.push("`BUS-FACTOR-1`".to_string());
2591 }
2592 if walkthrough_weakened(&unit.file, guide) {
2593 badges.push("`WEAKENED`".to_string());
2594 }
2595 if unit.test_adjacency == Some(fallow_output::TestAdjacency::None) {
2596 badges.push("`NO-DIRECT-TEST`".to_string());
2597 }
2598 badges
2599}
2600
2601fn walkthrough_fact(
2606 unit: &fallow_output::DirectionUnit,
2607 guide: &fallow_output::StandardWalkthroughGuide,
2608) -> String {
2609 if let Some(decision) = guide
2610 .digest
2611 .decisions
2612 .decisions
2613 .iter()
2614 .find(|d| d.anchor_file == unit.file)
2615 {
2616 return fallow_output::clean_decision_fact(
2621 &decision.question,
2622 &unit.file,
2623 fallow_output::MAX_CONTRACT_MEMBERS,
2624 );
2625 }
2626 if !unit.out_of_diff.is_empty() {
2627 return format!(
2628 "{} out-of-diff consumer{}",
2629 unit.out_of_diff.len(),
2630 plural(unit.out_of_diff.len())
2631 );
2632 }
2633 if let Some(fu) = guide
2634 .digest
2635 .focus
2636 .review_here
2637 .iter()
2638 .chain(guide.digest.focus.deprioritized.iter())
2639 .find(|fu| fu.file == unit.file)
2640 {
2641 return escape_markdown_prose(&fu.reason);
2642 }
2643 "orientation only".to_string()
2644}
2645
2646fn walkthrough_introduced(file: &str, guide: &fallow_output::StandardWalkthroughGuide) -> bool {
2647 let deltas = &guide.digest.deltas;
2648 deltas
2649 .boundary_introduced
2650 .iter()
2651 .chain(deltas.cycle_introduced.iter())
2652 .chain(deltas.public_api_added.iter())
2653 .any(|entry| entry.contains(file))
2654}
2655
2656fn walkthrough_bus_factor(file: &str, guide: &fallow_output::StandardWalkthroughGuide) -> bool {
2657 guide
2658 .digest
2659 .routing
2660 .units
2661 .iter()
2662 .any(|u| u.file == file && u.bus_factor_one)
2663}
2664
2665fn walkthrough_weakened(file: &str, guide: &fallow_output::StandardWalkthroughGuide) -> bool {
2666 guide.digest.weakening.iter().any(|w| w.file == file)
2667}
2668
2669fn push_walkthrough_cleared(
2674 out: &mut String,
2675 guide: &fallow_output::StandardWalkthroughGuide,
2676 root: &Path,
2677 viewed: &[String],
2678) {
2679 let deprioritized = &guide.digest.focus.deprioritized;
2680 let viewed_only: Vec<&String> = viewed
2683 .iter()
2684 .filter(|file| !deprioritized.iter().any(|u| &u.file == *file))
2685 .collect();
2686 if deprioritized.is_empty() && viewed_only.is_empty() {
2687 return;
2688 }
2689 let _ = write!(
2690 out,
2691 "<details><summary>Cleared ({} de-prioritized, {} viewed)</summary>\n\n",
2692 deprioritized.len(),
2693 viewed_only.len(),
2694 );
2695 for unit in deprioritized {
2696 let _ = writeln!(
2697 out,
2698 "- {}: {}",
2699 markdown_code_span(&markdown_relative_path_str(&unit.file, root)),
2700 escape_markdown_prose(&unit.reason),
2701 );
2702 }
2703 for file in viewed_only {
2704 let _ = writeln!(
2705 out,
2706 "- {}: \u{2713} viewed",
2707 markdown_code_span(&markdown_relative_path_str(file, root)),
2708 );
2709 }
2710 out.push_str("\n</details>\n");
2711}
2712
2713fn markdown_relative_path_str(file: &str, root: &Path) -> String {
2716 let path = Path::new(file);
2717 if path.is_absolute() {
2718 return markdown_relative_path(path, root);
2719 }
2720 normalize_uri(file)
2721}
2722
2723fn walkthrough_risk_label(risk: fallow_output::RiskClass) -> &'static str {
2724 match risk {
2725 fallow_output::RiskClass::Low => "low",
2726 fallow_output::RiskClass::Medium => "medium",
2727 fallow_output::RiskClass::High => "high",
2728 }
2729}
2730
2731fn walkthrough_effort_label(effort: fallow_output::ReviewEffort) -> &'static str {
2732 match effort {
2733 fallow_output::ReviewEffort::Glance => "glance",
2734 fallow_output::ReviewEffort::Review => "review",
2735 fallow_output::ReviewEffort::DeepDive => "deep-dive",
2736 }
2737}
2738
2739#[cfg(test)]
2740mod duplication_markdown_tests {
2741 use std::path::{Path, PathBuf};
2742
2743 use fallow_types::duplicates::{
2744 CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
2745 };
2746
2747 use super::build_duplication_markdown;
2748
2749 fn capped_report(
2750 shown: usize,
2751 corpus_groups: usize,
2752 corpus_families: usize,
2753 ) -> DuplicationReport {
2754 let group = |n: usize| CloneGroup {
2755 instances: vec![CloneInstance {
2756 file: PathBuf::from(format!("/project/src/a{n}.ts")),
2757 start_line: 1,
2758 end_line: 4,
2759 start_col: 0,
2760 end_col: 10,
2761 fragment: "const a = 1;".to_string(),
2762 }],
2763 token_count: 8,
2764 line_count: 4,
2765 similarity: None,
2766 };
2767 let family = |n: usize| CloneFamily {
2768 files: vec![PathBuf::from(format!("/project/src/a{n}.ts"))],
2769 groups: vec![group(n)],
2770 total_duplicated_lines: 4,
2771 total_duplicated_tokens: 8,
2772 suggestions: Vec::new(),
2773 };
2774 DuplicationReport {
2775 clone_groups: (0..shown).map(group).collect(),
2776 clone_families: (0..shown.min(corpus_families)).map(family).collect(),
2777 mirrored_directories: Vec::new(),
2778 stats: DuplicationStats {
2779 clone_groups: corpus_groups,
2780 clone_families: corpus_families,
2781 clone_instances: corpus_groups,
2782 total_files: 40,
2783 files_with_clones: 12,
2784 total_lines: 1000,
2785 duplicated_lines: 252,
2786 total_tokens: 5000,
2787 duplicated_tokens: 1200,
2788 duplication_percentage: 25.2,
2789 ..DuplicationStats::default()
2790 },
2791 }
2792 }
2793
2794 #[test]
2797 fn a_capped_listing_still_names_the_measured_corpus() {
2798 let md = build_duplication_markdown(&capped_report(3, 251, 163), Path::new("/project"));
2799 assert!(
2800 md.starts_with("## Fallow: 251 clone groups found (25.2% duplication)"),
2801 "got: {md}"
2802 );
2803 assert!(
2804 md.contains("_Listing 3 of them; 248 more clone groups and 160 more clone families withheld by a display limit._"),
2805 "got: {md}"
2806 );
2807 }
2808
2809 #[test]
2811 fn an_untruncated_listing_carries_no_omission_note() {
2812 let md = build_duplication_markdown(&capped_report(3, 3, 0), Path::new("/project"));
2813 assert!(
2814 md.starts_with("## Fallow: 3 clone groups found (25.2% duplication)"),
2815 "got: {md}"
2816 );
2817 assert!(!md.contains("withheld by a display limit"), "got: {md}");
2818 }
2819}
2820
2821#[cfg(test)]
2822mod health_markdown_tests {
2823 use std::path::Path;
2824
2825 use fallow_output::{HealthReport, StylingFinding, StylingFindingSeverity};
2826
2827 use super::build_health_markdown;
2828
2829 fn one_file_report(root: &Path, since: Option<&str>) -> HealthReport {
2830 HealthReport {
2831 file_scores: vec![fallow_output::FileHealthScore {
2832 path: root.join("src/core.ts"),
2833 fan_in: 1,
2834 fan_out: 0,
2835 dead_code_ratio: 0.0,
2836 complexity_density: 0.5,
2837 maintainability_index: 80.0,
2838 total_cyclomatic: 4,
2839 total_cognitive: 2,
2840 function_count: 1,
2841 lines: 10,
2842 crap_max: 4.0,
2843 crap_above_threshold: 0,
2844 crap_exempted: 0,
2845 crap_effective_threshold: None,
2846 }],
2847 hotspots: vec![
2848 fallow_output::HotspotEntry {
2849 path: root.join("src/core.ts"),
2850 score: 75.0,
2851 commits: 4,
2852 weighted_commits: 3.0,
2853 lines_added: 10,
2854 lines_deleted: 2,
2855 complexity_density: 0.5,
2856 fan_in: 1,
2857 trend: fallow_types::churn::ChurnTrend::Stable,
2858 ownership: None,
2859 is_test_path: false,
2860 }
2861 .into(),
2862 ],
2863 hotspot_summary: since.map(|since| fallow_output::HotspotSummary {
2864 since: since.to_string(),
2865 min_commits: 3,
2866 files_analyzed: 1,
2867 files_excluded: 0,
2868 shallow_clone: false,
2869 clock: None,
2870 }),
2871 ..HealthReport::default()
2872 }
2873 }
2874
2875 #[test]
2876 fn health_markdown_headers_use_the_singular_for_one_file() {
2877 let root = Path::new("/project");
2878
2879 let output = build_health_markdown(&one_file_report(root, None), root);
2880 assert!(
2881 output.contains("### File Health Scores (1 file)"),
2882 "{output}"
2883 );
2884 assert!(output.contains("### Hotspots (1 file)"), "{output}");
2885
2886 let output = build_health_markdown(&one_file_report(root, Some("6 months")), root);
2887 assert!(
2888 output.contains("### Hotspots (1 file, since 6 months)"),
2889 "{output}"
2890 );
2891 }
2892
2893 #[test]
2894 fn health_markdown_includes_styling_findings() {
2895 let report = HealthReport {
2896 styling_findings: vec![StylingFinding {
2897 code: "css-broken-reference".to_string(),
2898 sub_kind: "unresolved-class-reference".to_string(),
2899 path: "src/app.css".to_string(),
2900 line: 9,
2901 value: "btn-prmary | btn-primary".to_string(),
2902 effective_severity: StylingFindingSeverity::Warn,
2903 blast_radius: None,
2904 confidence: None,
2905 agent_disposition: None,
2906 nearest_token: None,
2907 fix_hint: None,
2908 actions: Vec::new(),
2909 }],
2910 ..HealthReport::default()
2911 };
2912
2913 let output = build_health_markdown(&report, Path::new("/project"));
2914
2915 assert!(output.contains("## Styling Findings"));
2916 assert!(output.contains("css-broken-reference"));
2917 assert!(output.contains("btn-prmary \\| btn-primary"));
2918 }
2919
2920 #[test]
2921 fn health_markdown_fences_untrusted_styling_values() {
2922 let report = HealthReport {
2923 styling_findings: vec![StylingFinding {
2924 code: "css-broken-reference".to_string(),
2925 sub_kind: "unresolved-class-reference".to_string(),
2926 path: "src/app.css".to_string(),
2927 line: 9,
2928 value: "btn` **injected** | btn``primary".to_string(),
2929 effective_severity: StylingFindingSeverity::Warn,
2930 blast_radius: None,
2931 confidence: None,
2932 agent_disposition: None,
2933 nearest_token: None,
2934 fix_hint: None,
2935 actions: Vec::new(),
2936 }],
2937 ..HealthReport::default()
2938 };
2939
2940 let output = build_health_markdown(&report, Path::new("/project"));
2941
2942 assert!(output.contains("```btn` **injected** \\| btn``primary```"));
2943 }
2944
2945 #[test]
2946 fn health_markdown_escapes_pipes_in_target_recommendation_cell() {
2947 use fallow_output::{
2948 Confidence, EffortEstimate, RecommendationCategory, RefactoringTarget,
2949 RefactoringTargetFinding,
2950 };
2951
2952 let report = HealthReport {
2953 targets: vec![RefactoringTargetFinding {
2954 target: RefactoringTarget {
2955 path: "/project/src/big.ts".into(),
2956 priority: 80.0,
2957 efficiency: 4.0,
2958 recommendation: "Extract render|inject (cognitive: 30) into smaller functions"
2959 .to_string(),
2960 category: RecommendationCategory::ExtractComplexFunctions,
2961 effort: EffortEstimate::Medium,
2962 confidence: Confidence::Medium,
2963 factors: Vec::new(),
2964 evidence: None,
2965 },
2966 actions: Vec::new(),
2967 }],
2968 ..HealthReport::default()
2969 };
2970
2971 let output = build_health_markdown(&report, Path::new("/project"));
2972
2973 assert!(output.contains("Extract render\\|inject (cognitive: 30)"));
2974 assert!(!output.contains("Extract render|inject"));
2975 }
2976}
2977
2978#[cfg(test)]
2979mod caveat_markdown_tests {
2980 use std::path::{Path, PathBuf};
2981
2982 use fallow_types::extract::MemberKind;
2983 use fallow_types::output_dead_code::{
2984 ReachabilityCaveat, UnusedClassMemberFinding, UnusedDependencyFinding,
2985 UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding, UnusedStoreMemberFinding,
2986 };
2987 use fallow_types::results::{
2988 AnalysisResults, DependencyLocation, UnusedDependency, UnusedExport, UnusedFile,
2989 UnusedMember,
2990 };
2991
2992 use super::build_markdown;
2993
2994 fn caveated_results(root: &Path) -> AnalysisResults {
2995 let caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
2996 let mut results = AnalysisResults::default();
2997
2998 let mut file = UnusedFileFinding::with_actions(UnusedFile {
2999 path: root.join("src/lib.ts"),
3000 });
3001 file.reachability_caveats.clone_from(&caveats);
3002 results.unused_files.push(file);
3003
3004 let mut export = UnusedExportFinding::with_actions(UnusedExport {
3005 path: root.join("src/api.ts"),
3006 export_name: "needed".to_owned(),
3007 is_type_only: false,
3008 line: 3,
3009 col: 0,
3010 span_start: 0,
3011 is_re_export: false,
3012 deprecated: false,
3013 deprecated_reason: None,
3014 });
3015 export.reachability_caveats.clone_from(&caveats);
3016 results.unused_exports.push(export);
3017
3018 let mut dep = UnusedDependencyFinding::with_actions(UnusedDependency {
3019 package_name: "left-pad".to_owned(),
3020 location: DependencyLocation::Dependencies,
3021 path: root.join("package.json"),
3022 line: 5,
3023 used_in_workspaces: Vec::new(),
3024 });
3025 dep.reachability_caveats.clone_from(&caveats);
3026 results.unused_dependencies.push(dep);
3027
3028 let member = |parent: &str, name: &str, kind| UnusedMember {
3029 path: root.join("src/api.ts"),
3030 parent_name: parent.to_owned(),
3031 member_name: name.to_owned(),
3032 kind,
3033 line: 7,
3034 col: 2,
3035 };
3036 let mut enum_member =
3037 UnusedEnumMemberFinding::with_actions(member("Mode", "Legacy", MemberKind::EnumMember));
3038 enum_member.reachability_caveats.clone_from(&caveats);
3039 results.unused_enum_members.push(enum_member);
3040
3041 let mut class_member = UnusedClassMemberFinding::with_actions(member(
3042 "Widget",
3043 "render",
3044 MemberKind::ClassMethod,
3045 ));
3046 class_member.reachability_caveats.clone_from(&caveats);
3047 results.unused_class_members.push(class_member);
3048
3049 let mut store_member = UnusedStoreMemberFinding::with_actions(member(
3050 "useCart",
3051 "subtotal",
3052 MemberKind::StoreMember,
3053 ));
3054 store_member.reachability_caveats.clone_from(&caveats);
3055 results.unused_store_members.push(store_member);
3056
3057 results
3058 }
3059
3060 #[test]
3063 fn caveated_findings_hedge_their_markdown_lines() {
3064 let root = PathBuf::from("/project");
3065
3066 let out = build_markdown(&caveated_results(&root), &root);
3067
3068 assert!(
3069 out.contains("- `src/lib.ts` *(caveat: incomplete import graph)*"),
3070 "{out}"
3071 );
3072 assert!(
3073 out.contains("- :3 `needed` *(caveat: incomplete import graph)*"),
3074 "{out}"
3075 );
3076 assert!(
3077 out.contains("- `left-pad` *(caveat: incomplete import graph)*"),
3078 "{out}"
3079 );
3080 for expected in [
3081 "- :7 `Mode.Legacy` *(caveat: incomplete import graph)*",
3082 "- :7 `Widget.render` *(caveat: incomplete import graph)*",
3083 "- :7 `useCart.subtotal` *(caveat: incomplete import graph)*",
3084 ] {
3085 assert!(out.contains(expected), "missing {expected}: {out}");
3086 }
3087 }
3088
3089 #[test]
3091 fn a_clean_run_renders_no_caveat() {
3092 let root = PathBuf::from("/project");
3093 let mut results = caveated_results(&root);
3094 results.unused_files[0].reachability_caveats.clear();
3095 results.unused_exports[0].reachability_caveats.clear();
3096 results.unused_dependencies[0].reachability_caveats.clear();
3097 results.unused_enum_members[0].reachability_caveats.clear();
3098 results.unused_class_members[0].reachability_caveats.clear();
3099 results.unused_store_members[0].reachability_caveats.clear();
3100
3101 let out = build_markdown(&results, &root);
3102
3103 assert!(!out.contains("caveat"), "{out}");
3104 }
3105}
3106
3107#[cfg(test)]
3108mod markdown_code_span_tests {
3109 use std::path::{Path, PathBuf};
3110
3111 use super::markdown_grouped_section;
3112
3113 #[test]
3114 fn grouped_paths_use_safe_code_span_delimiters_and_padding() {
3115 let paths = vec![
3116 PathBuf::from("src/ordinary.ts"),
3117 PathBuf::from("src/one`# injected.md"),
3118 PathBuf::from("src/two``ticks.ts"),
3119 PathBuf::from(" leading and trailing "),
3120 PathBuf::from("`leading-tick.ts"),
3121 ];
3122 let mut output = String::new();
3123
3124 markdown_grouped_section(
3125 &mut output,
3126 &paths,
3127 "Paths",
3128 Path::new("/project"),
3129 PathBuf::as_path,
3130 |_| "detail".to_string(),
3131 );
3132
3133 assert!(output.contains("- `src/ordinary.ts`\n"));
3134 assert!(output.contains("- ``src/one`# injected.md``\n"));
3135 assert!(output.contains("- ```src/two``ticks.ts```\n"));
3136 assert!(output.contains("- ` leading and trailing `\n"));
3137 assert!(output.contains("- `` `leading-tick.ts ``\n"));
3138 assert!(!output.contains("\\`"));
3139 }
3140}
3141
3142#[cfg(test)]
3143mod walkthrough_markdown_tests {
3144 use super::build_walkthrough_markdown;
3145 use fallow_output::{
3146 AgentSchema, Decision, DecisionCategory, DecisionSurface, DiffTriage, DirectionUnit,
3147 FocusLabel, FocusMap, FocusScore, FocusUnit, GraphFacts, INJECTION_NOTE,
3148 ImpactClosureFacts, PartitionFacts, ReviewBriefSchemaVersion, ReviewDeltas,
3149 ReviewDirection, ReviewEffort, RiskClass, RoutingFacts, StandardReviewBriefOutput,
3150 StandardWalkthroughGuide,
3151 };
3152 use std::path::Path;
3153
3154 fn guide_with_question(file: &str, question: &str) -> StandardWalkthroughGuide {
3155 let unit = DirectionUnit {
3156 file: file.to_string(),
3157 concern_lens: "contract-break".to_string(),
3158 scoring_budget: 3,
3159 out_of_diff: vec!["src/consumer.ts".to_string()],
3160 expert: Vec::new(),
3161 test_adjacency: None,
3162 };
3163 let review_unit = FocusUnit {
3167 file: file.to_string(),
3168 score: FocusScore::default(),
3169 label: FocusLabel::ReviewHere,
3170 reason: "reason".to_string(),
3171 confidence: Vec::new(),
3172 };
3173 let decision = Decision {
3174 signal_id: "sig:1".to_string(),
3175 category: DecisionCategory::CouplingBoundary,
3176 question: question.to_string(),
3177 anchor_file: file.to_string(),
3178 anchor_line: 1,
3179 signal_key: "k".to_string(),
3180 previous_signal_id: None,
3181 blast: 1,
3182 consequence: 2,
3183 expert: Vec::new(),
3184 bus_factor_one: false,
3185 internal_consumer_count: 0,
3186 tradeoff: String::new(),
3187 };
3188 let digest = StandardReviewBriefOutput {
3189 branching: None,
3190 schema_version: ReviewBriefSchemaVersion::default(),
3191 version: "test".to_string(),
3192 command: "audit-brief".to_string(),
3193 triage: DiffTriage {
3194 files: 1,
3195 hunks: None,
3196 net_lines: None,
3197 risk_class: RiskClass::Low,
3198 review_effort: ReviewEffort::Glance,
3199 },
3200 graph_facts: GraphFacts {
3201 exports_added: 0,
3202 api_width_delta: 0,
3203 boundaries_touched: Vec::new(),
3204 },
3205 partition: PartitionFacts::default(),
3206 impact_closure: ImpactClosureFacts::default(),
3207 focus: FocusMap {
3208 review_here: vec![review_unit],
3209 deprioritized: Vec::new(),
3210 },
3211 deltas: ReviewDeltas::default(),
3212 weakening: Vec::new(),
3213 routing: RoutingFacts::default(),
3214 ownership: None,
3215 decisions: DecisionSurface {
3216 decisions: vec![decision],
3217 truncated: None,
3218 emitted_signal_ids: Vec::new(),
3219 },
3220 };
3221 StandardWalkthroughGuide {
3222 schema_version: ReviewBriefSchemaVersion::default(),
3223 version: "test".to_string(),
3224 command: "review-walkthrough-guide".to_string(),
3225 graph_snapshot_hash: "graph:abc".to_string(),
3226 digest,
3227 direction: ReviewDirection {
3228 order: vec![file.to_string()],
3229 units: vec![unit],
3230 },
3231 change_anchors: Vec::new(),
3232 agent_schema: AgentSchema {
3233 judgment_shape: "",
3234 echo_field: "graph_snapshot_hash",
3235 anchoring_rule: "",
3236 action_vocabulary: &[],
3237 concern_vocabulary: &[],
3238 },
3239 injection_note: INJECTION_NOTE,
3240 }
3241 }
3242
3243 #[test]
3244 fn renders_header_stage_and_code_span_badges() {
3245 let guide = guide_with_question("src/page.ts", "Couple ui to db?");
3246 let md = build_walkthrough_markdown(&guide, Path::new("/project"), &[]);
3247 assert!(md.starts_with("## Fallow Review"), "got: {md}");
3248 assert!(md.contains("### Stage 1"), "got: {md}");
3249 assert!(md.contains("`COUPLING`"), "badges are code spans: {md}");
3250 assert!(md.contains("`OUT-OF-DIFF`"), "got: {md}");
3251 assert!(!md.contains('\u{1b}'), "no ANSI in markdown");
3252 assert!(
3255 md.contains("- `src/page.ts`: "),
3256 "list items use a colon separator: {md}"
3257 );
3258 assert!(
3259 !md.contains("- `src/page.ts` \u{2014} "),
3260 "no em-dash file separator: {md}"
3261 );
3262 }
3263
3264 #[test]
3265 fn ungrouped_walkthrough_paths_use_safe_code_spans() {
3266 let guide = guide_with_question("src/one`# injected.md", "Review this path?");
3267
3268 let md = build_walkthrough_markdown(&guide, Path::new("/project"), &[]);
3269
3270 assert!(
3271 md.contains("- ``src/one`# injected.md``: "),
3272 "path remains inside one code span: {md}"
3273 );
3274 assert!(!md.contains("\\`"));
3275 }
3276
3277 #[test]
3281 fn viewed_file_collapses_into_cleared_in_markdown() {
3282 let guide = guide_with_question("src/page.ts", "Couple ui to db?");
3283 let viewed = vec!["src/page.ts".to_string()];
3284 let md = build_walkthrough_markdown(&guide, Path::new("/project"), &viewed);
3285 assert!(
3287 !md.contains("### Stage 1"),
3288 "viewed file left its stage: {md}"
3289 );
3290 assert!(
3292 md.contains("Cleared (0 de-prioritized, 1 viewed)"),
3293 "cleared reports viewed count: {md}"
3294 );
3295 assert!(
3296 md.contains("- `src/page.ts`: \u{2713} viewed"),
3297 "viewed file listed under cleared: {md}"
3298 );
3299 }
3300
3301 #[test]
3305 fn fact_does_not_reprint_path_or_emit_escaped_backticks() {
3306 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?";
3307 let guide = guide_with_question("src/page.ts", q);
3308 let md = build_walkthrough_markdown(&guide, Path::new("/project"), &[]);
3309 assert!(
3311 !md.contains("\\`"),
3312 "fact must never emit a backslash-backtick sequence: {md}"
3313 );
3314 assert!(
3316 !md.contains("`src/page.ts` changes exports"),
3317 "fact must not re-print the path: {md}"
3318 );
3319 assert!(md.contains("+3 more"), "member list capped: {md}");
3321 assert!(
3323 !md.contains("break or alter"),
3324 "the per-file question must be dropped in the tour: {md}"
3325 );
3326 assert!(!md.contains("(score "), "raw score removed: {md}");
3328 }
3329
3330 #[test]
3331 fn empty_order_renders_orientation_only_note() {
3332 let mut guide = guide_with_question("src/page.ts", "q");
3333 guide.direction.order.clear();
3334 guide.direction.units.clear();
3335 guide.digest.decisions.decisions.clear();
3336 let md = build_walkthrough_markdown(&guide, Path::new("/project"), &[]);
3337 assert!(md.contains("orientation only"), "got: {md}");
3338 }
3339
3340 #[test]
3343 fn decision_outside_staged_units_renders_its_own_section() {
3344 let mut guide = guide_with_question(
3345 "package.json",
3346 "`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?",
3347 );
3348 guide.direction.order.clear();
3349 guide.direction.units.clear();
3350 let md = build_walkthrough_markdown(&guide, Path::new("/project"), &[]);
3351 assert!(
3352 md.contains("### Decisions outside the staged files (1)"),
3353 "got: {md}"
3354 );
3355 assert!(md.contains("`package.json`"), "got: {md}");
3356 assert!(!md.contains("orientation only"), "got: {md}");
3357 }
3358}