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