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