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