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