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