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