1use crate::report::sink::{out, outln};
2use std::borrow::Cow;
3use std::fmt::Write;
4use std::path::Path;
5
6use fallow_core::duplicates::DuplicationReport;
7use fallow_core::results::{
8 AnalysisResults, UnresolvedCatalogReferenceFinding, UnusedCatalogEntryFinding,
9 UnusedClassMemberFinding, UnusedDependencyOverrideFinding, UnusedEnumMemberFinding,
10 UnusedExport, UnusedExportFinding, UnusedMember, UnusedStoreMemberFinding, UnusedTypeFinding,
11};
12
13use super::grouping::ResultGroup;
14use super::{normalize_uri, plural, relative_path};
15
16fn escape_backticks(s: &str) -> String {
18 s.replace('`', "\\`")
19}
20
21fn display_complexity_entry_name(name: &str) -> Cow<'_, str> {
22 match name {
23 "<template>" => Cow::Borrowed("<template> (template complexity)"),
24 "<component>" => Cow::Borrowed("<component> (component rollup)"),
25 _ => Cow::Borrowed(name),
26 }
27}
28
29pub(super) fn print_markdown(results: &AnalysisResults, root: &Path) {
30 outln!("{}", build_markdown(results, root));
31}
32
33pub fn build_markdown(results: &AnalysisResults, root: &Path) -> String {
35 let total = results.total_issues();
36 let mut out = String::new();
37
38 if total == 0 {
39 out.push_str("## Fallow: no issues found\n");
40 return out;
41 }
42
43 let _ = write!(out, "## Fallow: {total} issue{} found\n\n", plural(total));
44
45 push_markdown_primary_sections(&mut out, results, root);
46 push_markdown_import_sections(&mut out, results, root);
47 push_markdown_dependency_detail_sections(&mut out, results, root);
48 push_markdown_graph_sections(&mut out, results, &|path| {
49 markdown_relative_path(path, root)
50 });
51 push_markdown_catalog_sections(&mut out, results, &|path| {
52 markdown_relative_path(path, root)
53 });
54
55 out
56}
57
58fn markdown_relative_path(path: &Path, root: &Path) -> String {
59 escape_backticks(&normalize_uri(
60 &relative_path(path, root).display().to_string(),
61 ))
62}
63
64fn push_markdown_primary_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
65 markdown_section(out, &results.unused_files, "Unused files", |file| {
66 vec![format!(
67 "- `{}`",
68 markdown_relative_path(&file.file.path, root)
69 )]
70 });
71
72 markdown_grouped_section(
73 out,
74 &results.unused_exports,
75 "Unused exports",
76 root,
77 |e| e.export.path.as_path(),
78 |e: &UnusedExportFinding| format_export(&e.export),
79 );
80
81 markdown_grouped_section(
82 out,
83 &results.unused_types,
84 "Unused type exports",
85 root,
86 |e| e.export.path.as_path(),
87 |e: &UnusedTypeFinding| format_export(&e.export),
88 );
89
90 markdown_grouped_section(
91 out,
92 &results.private_type_leaks,
93 "Private type leaks",
94 root,
95 |e| e.leak.path.as_path(),
96 format_private_type_leak,
97 );
98
99 push_markdown_dependency_sections(out, results, root);
100 push_markdown_member_sections(out, results, root);
101}
102
103fn push_markdown_import_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
104 markdown_grouped_section(
105 out,
106 &results.unresolved_imports,
107 "Unresolved imports",
108 root,
109 |i| i.import.path.as_path(),
110 |i| {
111 format!(
112 ":{} `{}`",
113 i.import.line,
114 escape_backticks(&i.import.specifier)
115 )
116 },
117 );
118
119 markdown_section(
120 out,
121 &results.unlisted_dependencies,
122 "Unlisted dependencies",
123 |dep| vec![format!("- `{}`", escape_backticks(&dep.dep.package_name))],
124 );
125
126 markdown_section(
127 out,
128 &results.duplicate_exports,
129 "Duplicate exports",
130 |dup| {
131 let locations: Vec<String> = dup
132 .export
133 .locations
134 .iter()
135 .map(|loc| format!("`{}`", markdown_relative_path(&loc.path, root)))
136 .collect();
137 vec![format!(
138 "- `{}` in {}",
139 escape_backticks(&dup.export.export_name),
140 locations.join(", ")
141 )]
142 },
143 );
144}
145
146fn push_markdown_dependency_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
147 markdown_section(
148 out,
149 &results.unused_dependencies,
150 "Unused dependencies",
151 |dep| {
152 format_dependency(
153 &dep.dep.package_name,
154 &dep.dep.path,
155 &dep.dep.used_in_workspaces,
156 root,
157 )
158 },
159 );
160 markdown_section(
161 out,
162 &results.unused_dev_dependencies,
163 "Unused devDependencies",
164 |dep| {
165 format_dependency(
166 &dep.dep.package_name,
167 &dep.dep.path,
168 &dep.dep.used_in_workspaces,
169 root,
170 )
171 },
172 );
173 markdown_section(
174 out,
175 &results.unused_optional_dependencies,
176 "Unused optionalDependencies",
177 |dep| {
178 format_dependency(
179 &dep.dep.package_name,
180 &dep.dep.path,
181 &dep.dep.used_in_workspaces,
182 root,
183 )
184 },
185 );
186}
187
188fn push_markdown_member_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
189 markdown_grouped_section(
190 out,
191 &results.unused_enum_members,
192 "Unused enum members",
193 root,
194 |m| m.member.path.as_path(),
195 |m: &UnusedEnumMemberFinding| format_member(&m.member),
196 );
197 markdown_grouped_section(
198 out,
199 &results.unused_class_members,
200 "Unused class members",
201 root,
202 |m| m.member.path.as_path(),
203 |m: &UnusedClassMemberFinding| format_member(&m.member),
204 );
205 markdown_grouped_section(
206 out,
207 &results.unused_store_members,
208 "Unused store members",
209 root,
210 |m| m.member.path.as_path(),
211 |m: &UnusedStoreMemberFinding| format_member(&m.member),
212 );
213}
214
215fn push_markdown_dependency_detail_sections(
216 out: &mut String,
217 results: &AnalysisResults,
218 root: &Path,
219) {
220 markdown_section(
221 out,
222 &results.type_only_dependencies,
223 "Type-only dependencies (consider moving to devDependencies)",
224 |dep| format_dependency(&dep.dep.package_name, &dep.dep.path, &[], root),
225 );
226 markdown_section(
227 out,
228 &results.test_only_dependencies,
229 "Test-only production dependencies (consider moving to devDependencies)",
230 |dep| format_dependency(&dep.dep.package_name, &dep.dep.path, &[], root),
231 );
232}
233
234fn push_markdown_graph_sections(
235 out: &mut String,
236 results: &AnalysisResults,
237 rel: &dyn Fn(&Path) -> String,
238) {
239 push_markdown_structure_sections(out, results, rel);
240 push_markdown_framework_sections(out, results, rel);
241 push_markdown_component_sections(out, results, rel);
242 push_markdown_suppression_sections(out, results, rel);
243}
244
245fn push_markdown_structure_sections(
246 out: &mut String,
247 results: &AnalysisResults,
248 rel: &dyn Fn(&Path) -> String,
249) {
250 markdown_section(
251 out,
252 &results.circular_dependencies,
253 "Circular dependencies",
254 |cycle| format_markdown_circular_dependency(cycle, rel),
255 );
256 markdown_section(
257 out,
258 &results.re_export_cycles,
259 "Re-export cycles",
260 |cycle| format_markdown_re_export_cycle(cycle, rel),
261 );
262 markdown_section(
263 out,
264 &results.boundary_violations,
265 "Boundary violations",
266 |v| format_markdown_boundary_violation(v, rel),
267 );
268 markdown_section(
269 out,
270 &results.boundary_coverage_violations,
271 "Boundary coverage",
272 |v| format_markdown_boundary_coverage(v, rel),
273 );
274 markdown_section(
275 out,
276 &results.boundary_call_violations,
277 "Boundary calls",
278 |v| format_markdown_boundary_call(v, rel),
279 );
280 markdown_section(out, &results.policy_violations, "Policy violations", |v| {
281 format_markdown_policy_violation(v, rel)
282 });
283}
284
285fn push_markdown_framework_sections(
286 out: &mut String,
287 results: &AnalysisResults,
288 rel: &dyn Fn(&Path) -> String,
289) {
290 markdown_section(
291 out,
292 &results.invalid_client_exports,
293 "Invalid client exports",
294 |e| format_markdown_invalid_client_export(e, rel),
295 );
296 markdown_section(
297 out,
298 &results.mixed_client_server_barrels,
299 "Mixed client/server barrels",
300 |b| format_markdown_mixed_client_server_barrel(b, rel),
301 );
302 markdown_section(
303 out,
304 &results.misplaced_directives,
305 "Misplaced directives",
306 |d| format_markdown_misplaced_directive(d, rel),
307 );
308 markdown_section(out, &results.route_collisions, "Route collisions", |c| {
309 format_markdown_route_collision(c, rel)
310 });
311 markdown_section(
312 out,
313 &results.dynamic_segment_name_conflicts,
314 "Dynamic segment conflicts",
315 |c| format_markdown_dynamic_segment_name_conflict(c, rel),
316 );
317 markdown_section(
318 out,
319 &results.unprovided_injects,
320 "Unprovided injects",
321 |i| format_markdown_unprovided_inject(i, rel),
322 );
323}
324
325fn push_markdown_component_sections(
326 out: &mut String,
327 results: &AnalysisResults,
328 rel: &dyn Fn(&Path) -> String,
329) {
330 markdown_section(
331 out,
332 &results.unrendered_components,
333 "Unrendered components",
334 |c| format_markdown_unrendered_component(c, rel),
335 );
336 markdown_section(
337 out,
338 &results.unused_component_props,
339 "Unused component props",
340 |p| format_markdown_unused_component_prop(p, rel),
341 );
342 markdown_section(
343 out,
344 &results.unused_component_emits,
345 "Unused component emits",
346 |e| format_markdown_unused_component_emit(e, rel),
347 );
348 markdown_section(
349 out,
350 &results.unused_component_inputs,
351 "Unused component inputs",
352 |i| format_markdown_unused_component_input(i, rel),
353 );
354 markdown_section(
355 out,
356 &results.unused_component_outputs,
357 "Unused component outputs",
358 |o| format_markdown_unused_component_output(o, rel),
359 );
360 markdown_section(
361 out,
362 &results.unused_svelte_events,
363 "Unused Svelte events",
364 |e| format_markdown_unused_svelte_event(e, rel),
365 );
366 markdown_section(
367 out,
368 &results.unused_server_actions,
369 "Unused server actions",
370 |a| format_markdown_unused_server_action(a, rel),
371 );
372 markdown_section(
373 out,
374 &results.unused_load_data_keys,
375 "Unused load data keys",
376 |k| format_markdown_unused_load_data_key(k, rel),
377 );
378}
379
380fn push_markdown_suppression_sections(
381 out: &mut String,
382 results: &AnalysisResults,
383 rel: &dyn Fn(&Path) -> String,
384) {
385 markdown_section(
386 out,
387 &results.stale_suppressions,
388 "Stale suppressions",
389 |s| {
390 vec![format!(
391 "- `{}`:{} `{}` ({})",
392 rel(&s.path),
393 s.line,
394 escape_backticks(&s.description()),
395 escape_backticks(&s.explanation()),
396 )]
397 },
398 );
399}
400
401fn format_markdown_circular_dependency(
402 cycle: &fallow_core::results::CircularDependencyFinding,
403 rel: &dyn Fn(&Path) -> String,
404) -> Vec<String> {
405 let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
406 let mut display_chain = chain.clone();
407 if let Some(first) = chain.first() {
408 display_chain.push(first.clone());
409 }
410 let cross_pkg_tag = if cycle.cycle.is_cross_package {
411 " *(cross-package)*"
412 } else {
413 ""
414 };
415 vec![format!(
416 "- {}{}",
417 display_chain
418 .iter()
419 .map(|s| format!("`{s}`"))
420 .collect::<Vec<_>>()
421 .join(" \u{2192} "),
422 cross_pkg_tag
423 )]
424}
425
426fn format_markdown_re_export_cycle(
427 cycle: &fallow_core::results::ReExportCycleFinding,
428 rel: &dyn Fn(&Path) -> String,
429) -> Vec<String> {
430 let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
431 let kind_tag = match cycle.cycle.kind {
432 fallow_core::results::ReExportCycleKind::SelfLoop => " *(self-loop)*",
433 fallow_core::results::ReExportCycleKind::MultiNode => "",
434 };
435 vec![format!(
436 "- {}{}",
437 chain
438 .iter()
439 .map(|s| format!("`{s}`"))
440 .collect::<Vec<_>>()
441 .join(" <-> "),
442 kind_tag
443 )]
444}
445
446fn format_markdown_boundary_violation(
447 v: &fallow_core::results::BoundaryViolationFinding,
448 rel: &dyn Fn(&Path) -> String,
449) -> Vec<String> {
450 vec![format!(
451 "- `{}`:{} \u{2192} `{}` ({} \u{2192} {})",
452 rel(&v.violation.from_path),
453 v.violation.line,
454 rel(&v.violation.to_path),
455 v.violation.from_zone,
456 v.violation.to_zone,
457 )]
458}
459
460fn format_markdown_boundary_coverage(
461 v: &fallow_core::results::BoundaryCoverageViolationFinding,
462 rel: &dyn Fn(&Path) -> String,
463) -> Vec<String> {
464 vec![format!(
465 "- `{}`:{} no matching boundary zone",
466 rel(&v.violation.path),
467 v.violation.line,
468 )]
469}
470
471fn format_markdown_boundary_call(
472 v: &fallow_core::results::BoundaryCallViolationFinding,
473 rel: &dyn Fn(&Path) -> String,
474) -> Vec<String> {
475 vec![format!(
476 "- `{}`:{} `{}` forbidden in zone `{}` (pattern `{}`)",
477 rel(&v.violation.path),
478 v.violation.line,
479 v.violation.callee,
480 v.violation.zone,
481 v.violation.pattern,
482 )]
483}
484
485fn format_markdown_policy_violation(
486 v: &fallow_core::results::PolicyViolationFinding,
487 rel: &dyn Fn(&Path) -> String,
488) -> Vec<String> {
489 vec![format!(
490 "- `{}`:{} `{}` banned by `{}/{}`{}",
491 rel(&v.violation.path),
492 v.violation.line,
493 v.violation.matched,
494 v.violation.pack,
495 v.violation.rule_id,
496 v.violation
497 .message
498 .as_deref()
499 .map(|m| format!(" ({m})"))
500 .unwrap_or_default(),
501 )]
502}
503
504fn format_markdown_invalid_client_export(
505 e: &fallow_core::results::InvalidClientExportFinding,
506 rel: &dyn Fn(&Path) -> String,
507) -> Vec<String> {
508 vec![format!(
509 "- `{}`:{} `{}` (from `\"{}\"`)",
510 rel(&e.export.path),
511 e.export.line,
512 e.export.export_name,
513 e.export.directive,
514 )]
515}
516
517fn format_markdown_mixed_client_server_barrel(
518 b: &fallow_core::results::MixedClientServerBarrelFinding,
519 rel: &dyn Fn(&Path) -> String,
520) -> Vec<String> {
521 vec![format!(
522 "- `{}`:{} re-exports client `{}` and server-only `{}`",
523 rel(&b.barrel.path),
524 b.barrel.line,
525 b.barrel.client_origin,
526 b.barrel.server_origin,
527 )]
528}
529
530fn format_markdown_misplaced_directive(
531 d: &fallow_core::results::MisplacedDirectiveFinding,
532 rel: &dyn Fn(&Path) -> String,
533) -> Vec<String> {
534 vec![format!(
535 "- `{}`:{} `\"{}\"` is not in the leading position and is ignored",
536 rel(&d.directive_site.path),
537 d.directive_site.line,
538 d.directive_site.directive,
539 )]
540}
541
542fn format_markdown_unprovided_inject(
543 i: &fallow_core::results::UnprovidedInjectFinding,
544 rel: &dyn Fn(&Path) -> String,
545) -> Vec<String> {
546 vec![format!(
547 "- `{}`:{} `{}` has no matching provide(`{}`) in this project; at runtime it returns undefined",
548 rel(&i.inject.path),
549 i.inject.line,
550 escape_backticks(&i.inject.key_name),
551 escape_backticks(&i.inject.key_name),
552 )]
553}
554
555fn format_markdown_unrendered_component(
556 c: &fallow_core::results::UnrenderedComponentFinding,
557 rel: &dyn Fn(&Path) -> String,
558) -> Vec<String> {
559 if c.component.framework == "lit" {
563 return vec![format!(
564 "- `{}`:{} `<{}>` is a registered custom element but rendered in no template (render it or remove it)",
565 rel(&c.component.path),
566 c.component.line,
567 escape_backticks(&c.component.component_name),
568 )];
569 }
570 vec![format!(
571 "- `{}`:{} `{}` is reachable but rendered nowhere in this project (render it somewhere or remove it)",
572 rel(&c.component.path),
573 c.component.line,
574 escape_backticks(&c.component.component_name),
575 )]
576}
577
578fn format_markdown_unused_component_prop(
579 p: &fallow_core::results::UnusedComponentPropFinding,
580 rel: &dyn Fn(&Path) -> String,
581) -> Vec<String> {
582 vec![format!(
583 "- `{}`:{} `{}` is declared but referenced nowhere in this component (remove it or use it)",
584 rel(&p.prop.path),
585 p.prop.line,
586 escape_backticks(&p.prop.prop_name),
587 )]
588}
589
590fn format_markdown_unused_component_emit(
591 e: &fallow_core::results::UnusedComponentEmitFinding,
592 rel: &dyn Fn(&Path) -> String,
593) -> Vec<String> {
594 vec![format!(
595 "- `{}`:{} `{}` is declared but emitted nowhere in this component (remove it or emit it)",
596 rel(&e.emit.path),
597 e.emit.line,
598 escape_backticks(&e.emit.emit_name),
599 )]
600}
601
602fn format_markdown_unused_svelte_event(
603 e: &fallow_core::results::UnusedSvelteEventFinding,
604 rel: &dyn Fn(&Path) -> String,
605) -> Vec<String> {
606 vec![format!(
607 "- `{}`:{} `{}` is dispatched but listened to nowhere in the project (remove it or listen for it)",
608 rel(&e.event.path),
609 e.event.line,
610 escape_backticks(&e.event.event_name),
611 )]
612}
613
614fn format_markdown_unused_component_input(
615 i: &fallow_core::results::UnusedComponentInputFinding,
616 rel: &dyn Fn(&Path) -> String,
617) -> Vec<String> {
618 vec![format!(
619 "- `{}`:{} `{}` is declared but referenced nowhere in this component (remove it or use it)",
620 rel(&i.input.path),
621 i.input.line,
622 escape_backticks(&i.input.input_name),
623 )]
624}
625
626fn format_markdown_unused_component_output(
627 o: &fallow_core::results::UnusedComponentOutputFinding,
628 rel: &dyn Fn(&Path) -> String,
629) -> Vec<String> {
630 vec![format!(
631 "- `{}`:{} `{}` is declared but emitted nowhere in this component (remove it or emit it)",
632 rel(&o.output.path),
633 o.output.line,
634 escape_backticks(&o.output.output_name),
635 )]
636}
637
638fn format_markdown_unused_server_action(
639 a: &fallow_core::results::UnusedServerActionFinding,
640 rel: &dyn Fn(&Path) -> String,
641) -> Vec<String> {
642 vec![format!(
643 "- `{}`:{} `{}` is exported from a \"use server\" file but no code in this project references it",
644 rel(&a.action.path),
645 a.action.line,
646 escape_backticks(&a.action.action_name),
647 )]
648}
649
650fn format_markdown_unused_load_data_key(
651 k: &fallow_core::results::UnusedLoadDataKeyFinding,
652 rel: &dyn Fn(&Path) -> String,
653) -> Vec<String> {
654 vec![format!(
655 "- `{}`:{} `{}` is returned from load() but no consumer reads it",
656 rel(&k.key.path),
657 k.key.line,
658 escape_backticks(&k.key.key_name),
659 )]
660}
661
662fn format_markdown_route_collision(
663 c: &fallow_core::results::RouteCollisionFinding,
664 rel: &dyn Fn(&Path) -> String,
665) -> Vec<String> {
666 vec![format!(
667 "- `{}` resolves to `{}` (shared with {} other route file(s))",
668 rel(&c.collision.path),
669 c.collision.url,
670 c.collision.conflicting_paths.len(),
671 )]
672}
673
674fn format_markdown_dynamic_segment_name_conflict(
675 c: &fallow_core::results::DynamicSegmentNameConflictFinding,
676 rel: &dyn Fn(&Path) -> String,
677) -> Vec<String> {
678 vec![format!(
679 "- `{}` crashes at runtime: different slug names ({}) at the same dynamic path `{}`; \
680 `next build` passes but the route fails on its first request (rename to one consistent slug)",
681 rel(&c.conflict.path),
682 c.conflict.conflicting_segments.join(" vs "),
683 c.conflict.position,
684 )]
685}
686
687fn push_markdown_catalog_sections(
688 out: &mut String,
689 results: &AnalysisResults,
690 rel: &dyn Fn(&Path) -> String,
691) {
692 markdown_section(
693 out,
694 &results.unused_catalog_entries,
695 "Unused catalog entries",
696 |entry| format_unused_catalog_entry(entry, rel),
697 );
698 markdown_section(
699 out,
700 &results.empty_catalog_groups,
701 "Empty catalog groups",
702 |group| {
703 vec![format!(
704 "- `{}` `{}`:{}",
705 escape_backticks(&group.group.catalog_name),
706 rel(&group.group.path),
707 group.group.line,
708 )]
709 },
710 );
711 markdown_section(
712 out,
713 &results.unresolved_catalog_references,
714 "Unresolved catalog references",
715 |finding| format_unresolved_catalog_reference(finding, rel),
716 );
717 markdown_section(
718 out,
719 &results.unused_dependency_overrides,
720 "Unused dependency overrides",
721 |finding| format_unused_dependency_override(finding, rel),
722 );
723 markdown_section(
724 out,
725 &results.misconfigured_dependency_overrides,
726 "Misconfigured dependency overrides",
727 |finding| {
728 vec![format!(
729 "- `{}` -> `{}` (`{}`) `{}`:{} ({})",
730 escape_backticks(&finding.entry.raw_key),
731 escape_backticks(&finding.entry.raw_value),
732 finding.entry.source.as_label(),
733 rel(&finding.entry.path),
734 finding.entry.line,
735 finding.entry.reason.describe(),
736 )]
737 },
738 );
739}
740
741fn format_unused_catalog_entry(
742 entry: &UnusedCatalogEntryFinding,
743 rel: &dyn Fn(&Path) -> String,
744) -> Vec<String> {
745 let mut row = format!(
746 "- `{}` (`{}`) `{}`:{}",
747 escape_backticks(&entry.entry.entry_name),
748 escape_backticks(&entry.entry.catalog_name),
749 rel(&entry.entry.path),
750 entry.entry.line,
751 );
752 if !entry.entry.hardcoded_consumers.is_empty() {
753 let consumers = entry
754 .entry
755 .hardcoded_consumers
756 .iter()
757 .map(|p| format!("`{}`", rel(p)))
758 .collect::<Vec<_>>()
759 .join(", ");
760 let _ = write!(row, " (hardcoded in {consumers})");
761 }
762 vec![row]
763}
764
765fn format_unresolved_catalog_reference(
766 finding: &UnresolvedCatalogReferenceFinding,
767 rel: &dyn Fn(&Path) -> String,
768) -> Vec<String> {
769 let mut row = format!(
770 "- `{}` (`{}`) `{}`:{}",
771 escape_backticks(&finding.reference.entry_name),
772 escape_backticks(&finding.reference.catalog_name),
773 rel(&finding.reference.path),
774 finding.reference.line,
775 );
776 if !finding.reference.available_in_catalogs.is_empty() {
777 let alts = finding
778 .reference
779 .available_in_catalogs
780 .iter()
781 .map(|c| format!("`{}`", escape_backticks(c)))
782 .collect::<Vec<_>>()
783 .join(", ");
784 let _ = write!(row, " (available in: {alts})");
785 }
786 vec![row]
787}
788
789fn format_unused_dependency_override(
790 finding: &UnusedDependencyOverrideFinding,
791 rel: &dyn Fn(&Path) -> String,
792) -> Vec<String> {
793 let mut row = format!(
794 "- `{}` -> `{}` (`{}`) `{}`:{}",
795 escape_backticks(&finding.entry.raw_key),
796 escape_backticks(&finding.entry.version_range),
797 finding.entry.source.as_label(),
798 rel(&finding.entry.path),
799 finding.entry.line,
800 );
801 if let Some(hint) = &finding.entry.hint {
802 let _ = write!(row, " (hint: {})", escape_backticks(hint));
803 }
804 vec![row]
805}
806
807pub(super) fn print_grouped_markdown(groups: &[ResultGroup], root: &Path) {
809 let total: usize = groups.iter().map(|g| g.results.total_issues()).sum();
810
811 if total == 0 {
812 outln!("## Fallow: no issues found");
813 return;
814 }
815
816 outln!(
817 "## Fallow: {total} issue{} found (grouped)\n",
818 plural(total)
819 );
820
821 for group in groups {
822 let count = group.results.total_issues();
823 if count == 0 {
824 continue;
825 }
826 outln!(
827 "## {} ({count} issue{})\n",
828 escape_backticks(&group.key),
829 plural(count)
830 );
831 if let Some(ref owners) = group.owners
832 && !owners.is_empty()
833 {
834 let joined = owners
835 .iter()
836 .map(|o| escape_backticks(o))
837 .collect::<Vec<_>>()
838 .join(" ");
839 outln!("Owners: {joined}\n");
840 }
841 let body = build_markdown(&group.results, root);
842 let sections = body
843 .strip_prefix("## Fallow: no issues found\n")
844 .or_else(|| body.find("\n\n").map(|pos| &body[pos + 2..]))
845 .unwrap_or(&body);
846 out!("{sections}");
847 }
848}
849
850fn format_export(e: &UnusedExport) -> String {
851 let re = if e.is_re_export { " (re-export)" } else { "" };
852 format!(":{} `{}`{re}", e.line, escape_backticks(&e.export_name))
853}
854
855fn format_private_type_leak(
856 entry: &fallow_types::output_dead_code::PrivateTypeLeakFinding,
857) -> String {
858 let e = &entry.leak;
859 format!(
860 ":{} `{}` references private type `{}`",
861 e.line,
862 escape_backticks(&e.export_name),
863 escape_backticks(&e.type_name)
864 )
865}
866
867fn format_member(m: &UnusedMember) -> String {
868 format!(
869 ":{} `{}.{}`",
870 m.line,
871 escape_backticks(&m.parent_name),
872 escape_backticks(&m.member_name)
873 )
874}
875
876fn format_dependency(
877 dep_name: &str,
878 pkg_path: &Path,
879 used_in_workspaces: &[std::path::PathBuf],
880 root: &Path,
881) -> Vec<String> {
882 let name = escape_backticks(dep_name);
883 let pkg_label = relative_path(pkg_path, root).display().to_string();
884 let workspace_context = if used_in_workspaces.is_empty() {
885 String::new()
886 } else {
887 let workspaces = used_in_workspaces
888 .iter()
889 .map(|path| escape_backticks(&relative_path(path, root).display().to_string()))
890 .collect::<Vec<_>>()
891 .join(", ");
892 format!("; imported in {workspaces}")
893 };
894 if pkg_label == "package.json" && workspace_context.is_empty() {
895 vec![format!("- `{name}`")]
896 } else {
897 let label = if pkg_label == "package.json" {
898 workspace_context.trim_start_matches("; ").to_string()
899 } else {
900 format!("{}{workspace_context}", escape_backticks(&pkg_label))
901 };
902 vec![format!("- `{name}` ({label})")]
903 }
904}
905
906fn markdown_section<T>(
908 out: &mut String,
909 items: &[T],
910 title: &str,
911 format_lines: impl Fn(&T) -> Vec<String>,
912) {
913 if items.is_empty() {
914 return;
915 }
916 let _ = write!(out, "### {title} ({})\n\n", items.len());
917 for item in items {
918 for line in format_lines(item) {
919 out.push_str(&line);
920 out.push('\n');
921 }
922 }
923 out.push('\n');
924}
925
926fn markdown_grouped_section<'a, T>(
928 out: &mut String,
929 items: &'a [T],
930 title: &str,
931 root: &Path,
932 get_path: impl Fn(&'a T) -> &'a Path,
933 format_detail: impl Fn(&T) -> String,
934) {
935 if items.is_empty() {
936 return;
937 }
938 let _ = write!(out, "### {title} ({})\n\n", items.len());
939
940 let mut indices: Vec<usize> = (0..items.len()).collect();
941 indices.sort_by(|&a, &b| get_path(&items[a]).cmp(get_path(&items[b])));
942
943 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
944 let mut last_file = String::new();
945 for &i in &indices {
946 let item = &items[i];
947 let file_str = rel(get_path(item));
948 if file_str != last_file {
949 let _ = writeln!(out, "- `{file_str}`");
950 last_file = file_str;
951 }
952 let _ = writeln!(out, " - {}", format_detail(item));
953 }
954 out.push('\n');
955}
956
957pub(super) fn print_duplication_markdown(report: &DuplicationReport, root: &Path) {
958 outln!("{}", build_duplication_markdown(report, root));
959}
960
961#[must_use]
963pub fn build_duplication_markdown(report: &DuplicationReport, root: &Path) -> String {
964 let mut out = String::new();
965
966 if report.clone_groups.is_empty() {
967 out.push_str("## Fallow: no code duplication found\n");
968 return out;
969 }
970
971 let stats = &report.stats;
972 let _ = write!(
973 out,
974 "## Fallow: {} clone group{} found ({:.1}% duplication)\n\n",
975 stats.clone_groups,
976 plural(stats.clone_groups),
977 stats.duplication_percentage,
978 );
979
980 write_duplication_groups(&mut out, report, root);
981 write_duplication_families(&mut out, report, root);
982
983 let _ = writeln!(
984 out,
985 "**Summary:** {} duplicated lines ({:.1}%) across {} file{}",
986 stats.duplicated_lines,
987 stats.duplication_percentage,
988 stats.files_with_clones,
989 plural(stats.files_with_clones),
990 );
991
992 out
993}
994
995fn write_duplication_groups(out: &mut String, report: &DuplicationReport, root: &Path) {
997 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
998 out.push_str("### Duplicates\n\n");
999 for (i, group) in report.clone_groups.iter().enumerate() {
1000 let instance_count = group.instances.len();
1001 let _ = write!(
1002 out,
1003 "**Clone group {}** ({} lines, {instance_count} instance{})\n\n",
1004 i + 1,
1005 group.line_count,
1006 plural(instance_count)
1007 );
1008 for instance in &group.instances {
1009 let relative = rel(&instance.file);
1010 let _ = writeln!(
1011 out,
1012 "- `{relative}:{}-{}`",
1013 instance.start_line, instance.end_line
1014 );
1015 }
1016 out.push('\n');
1017 }
1018}
1019
1020fn write_duplication_families(out: &mut String, report: &DuplicationReport, root: &Path) {
1022 if report.clone_families.is_empty() {
1023 return;
1024 }
1025 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
1026 out.push_str("### Clone Families\n\n");
1027 for (i, family) in report.clone_families.iter().enumerate() {
1028 let file_names: Vec<_> = family.files.iter().map(|f| rel(f)).collect();
1029 let _ = write!(
1030 out,
1031 "**Family {}** ({} group{}, {} lines across {})\n\n",
1032 i + 1,
1033 family.groups.len(),
1034 plural(family.groups.len()),
1035 family.total_duplicated_lines,
1036 file_names
1037 .iter()
1038 .map(|s| format!("`{s}`"))
1039 .collect::<Vec<_>>()
1040 .join(", "),
1041 );
1042 for suggestion in &family.suggestions {
1043 let savings = if suggestion.estimated_savings > 0 {
1044 format!(" (~{} lines saved)", suggestion.estimated_savings)
1045 } else {
1046 String::new()
1047 };
1048 let _ = writeln!(out, "- {}{savings}", suggestion.description);
1049 }
1050 out.push('\n');
1051 }
1052}
1053
1054pub(super) fn print_health_markdown(report: &crate::health_types::HealthReport, root: &Path) {
1055 outln!("{}", build_health_markdown(report, root));
1056}
1057
1058#[must_use]
1060pub fn build_health_markdown(report: &crate::health_types::HealthReport, root: &Path) -> String {
1061 let mut out = String::new();
1062
1063 if let Some(ref hs) = report.health_score {
1064 let _ = writeln!(out, "## Health Score: {:.0} ({})\n", hs.score, hs.grade);
1065 }
1066
1067 write_trend_section(&mut out, report);
1068 write_vital_signs_section(&mut out, report);
1069
1070 if report.findings.is_empty()
1071 && report.file_scores.is_empty()
1072 && report.coverage_gaps.is_none()
1073 && report.hotspots.is_empty()
1074 && report.targets.is_empty()
1075 && report.runtime_coverage.is_none()
1076 && report.coverage_intelligence.is_none()
1077 && report.threshold_overrides.is_empty()
1078 && report.css_analytics.is_none()
1079 {
1080 if report.vital_signs.is_none() {
1081 let _ = write!(
1082 out,
1083 "## Fallow: no functions exceed complexity thresholds\n\n\
1084 **{}** functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {:.1})\n",
1085 report.summary.functions_analyzed,
1086 report.summary.max_cyclomatic_threshold,
1087 report.summary.max_cognitive_threshold,
1088 report.summary.max_crap_threshold,
1089 );
1090 }
1091 return out;
1092 }
1093
1094 write_findings_section(&mut out, report, root);
1095 write_threshold_overrides_section(&mut out, report, root);
1096 write_runtime_coverage_section(&mut out, report, root);
1097 write_coverage_intelligence_section(&mut out, report, root);
1098 write_coverage_gaps_section(&mut out, report, root);
1099 write_file_scores_section(&mut out, report, root);
1100 write_hotspots_section(&mut out, report, root);
1101 write_targets_section(&mut out, report, root);
1102 write_css_analytics_section(&mut out, report);
1103 write_metric_legend(&mut out, report);
1104
1105 out
1106}
1107
1108fn write_css_analytics_section(out: &mut String, report: &crate::health_types::HealthReport) {
1112 let Some(ref css) = report.css_analytics else {
1113 return;
1114 };
1115 let s = &css.summary;
1116 if !out.is_empty() && !out.ends_with("\n\n") {
1117 out.push('\n');
1118 }
1119 out.push_str("## CSS Health\n\n");
1120 let important_pct = if s.total_declarations > 0 {
1121 f64::from(s.important_declarations) / f64::from(s.total_declarations) * 100.0
1122 } else {
1123 0.0
1124 };
1125 let _ = writeln!(
1126 out,
1127 "- Stylesheets: {} | Rules: {} | !important: {important_pct:.1}% | Empty rules: {} | Max nesting: {}",
1128 s.files_analyzed, s.total_rules, s.empty_rules, s.max_nesting_depth,
1129 );
1130 let _ = writeln!(
1131 out,
1132 "- Value sprawl: {} colors | {} font sizes | {} z-index | {} shadows | {} radii | {} line-heights",
1133 s.unique_colors,
1134 s.unique_font_sizes,
1135 s.unique_z_indexes,
1136 s.unique_box_shadows,
1137 s.unique_border_radii,
1138 s.unique_line_heights,
1139 );
1140 let _ = writeln!(
1141 out,
1142 "- 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",
1143 s.keyframes_unreferenced,
1144 s.keyframes_undefined,
1145 s.duplicate_declaration_blocks,
1146 s.scoped_unused_classes,
1147 s.tailwind_arbitrary_values,
1148 s.unused_property_registrations,
1149 s.unused_layers,
1150 s.unresolved_class_references,
1151 s.unreferenced_css_classes,
1152 s.unused_font_faces,
1153 s.unused_theme_tokens,
1154 );
1155 write_css_candidate_details(out, css);
1156 out.push('\n');
1157}
1158
1159fn write_css_candidate_details(out: &mut String, css: &crate::health_types::CssAnalyticsReport) {
1160 write_css_keyframe_details(out, css);
1161 write_css_tailwind_details(out, css);
1162 write_css_class_candidate_details(out, css);
1163 write_css_font_candidate_details(out, css);
1164 write_css_font_size_mix_details(out, css);
1165}
1166
1167fn write_css_keyframe_details(out: &mut String, css: &crate::health_types::CssAnalyticsReport) {
1168 if !css.undefined_keyframes.is_empty() {
1169 let named: Vec<String> = css
1170 .undefined_keyframes
1171 .iter()
1172 .take(5)
1173 .map(|kf| format!("`{}` ({})", kf.name, kf.path))
1174 .collect();
1175 let _ = writeln!(
1176 out,
1177 "- Undefined @keyframes (candidates; likely typo or CSS-in-JS): {}",
1178 named.join(", "),
1179 );
1180 }
1181}
1182
1183fn write_css_tailwind_details(out: &mut String, css: &crate::health_types::CssAnalyticsReport) {
1184 if !css.tailwind_arbitrary_values.is_empty() {
1185 let named: Vec<String> = css
1186 .tailwind_arbitrary_values
1187 .iter()
1188 .take(5)
1189 .map(|a| format!("`{}` ({}x)", a.value, a.count))
1190 .collect();
1191 let _ = writeln!(out, "- Top Tailwind arbitrary values: {}", named.join(", "));
1192 }
1193}
1194
1195fn write_css_class_candidate_details(
1196 out: &mut String,
1197 css: &crate::health_types::CssAnalyticsReport,
1198) {
1199 if !css.unresolved_class_references.is_empty() {
1200 let named: Vec<String> = css
1201 .unresolved_class_references
1202 .iter()
1203 .take(5)
1204 .map(|u| {
1205 format!(
1206 "`{}` -> `{}` ({}:{})",
1207 u.class, u.suggestion, u.path, u.line
1208 )
1209 })
1210 .collect();
1211 let _ = writeln!(
1212 out,
1213 "- Likely class typos (candidates; verify, may be CSS-in-JS or external): {}",
1214 named.join(", "),
1215 );
1216 }
1217 if !css.unreferenced_css_classes.is_empty() {
1218 let named: Vec<String> = css
1219 .unreferenced_css_classes
1220 .iter()
1221 .take(5)
1222 .map(|u| format!("`.{}` ({}:{})", u.class, u.path, u.line))
1223 .collect();
1224 let _ = writeln!(
1225 out,
1226 "- Unreferenced global classes (candidates; verify no email / server / CMS / Markdown applies them): {}",
1227 named.join(", "),
1228 );
1229 }
1230}
1231
1232fn write_css_font_candidate_details(
1233 out: &mut String,
1234 css: &crate::health_types::CssAnalyticsReport,
1235) {
1236 if !css.unused_font_faces.is_empty() {
1237 let named: Vec<String> = css
1238 .unused_font_faces
1239 .iter()
1240 .take(5)
1241 .map(|u| format!("`{}` ({})", u.family, u.path))
1242 .collect();
1243 let _ = writeln!(
1244 out,
1245 "- Unused @font-face (dead web-font; candidates, may be set from JS/inline): {}",
1246 named.join(", "),
1247 );
1248 }
1249 if !css.unused_theme_tokens.is_empty() {
1250 let named: Vec<String> = css
1251 .unused_theme_tokens
1252 .iter()
1253 .take(5)
1254 .map(|u| format!("`{}` ({}:{})", u.token, u.path, u.line))
1255 .collect();
1256 let _ = writeln!(
1257 out,
1258 "- Unused @theme tokens (dead Tailwind v4 design tokens; candidates, may be consumed by a plugin or downstream repo): {}",
1259 named.join(", "),
1260 );
1261 }
1262}
1263
1264fn write_css_font_size_mix_details(
1265 out: &mut String,
1266 css: &crate::health_types::CssAnalyticsReport,
1267) {
1268 if let Some(mix) = &css.font_size_unit_mix {
1269 let breakdown: Vec<String> = mix
1270 .notations
1271 .iter()
1272 .map(|n| format!("{} {}", n.count, n.notation))
1273 .collect();
1274 let _ = writeln!(
1275 out,
1276 "- Font sizes mix {} units (candidate, standardize unless intentional): {}",
1277 mix.notations.len(),
1278 breakdown.join(", "),
1279 );
1280 }
1281}
1282
1283fn write_coverage_intelligence_section(
1284 out: &mut String,
1285 report: &crate::health_types::HealthReport,
1286 root: &Path,
1287) {
1288 let Some(ref intelligence) = report.coverage_intelligence else {
1289 return;
1290 };
1291 if !out.is_empty() && !out.ends_with("\n\n") {
1292 out.push('\n');
1293 }
1294 let _ = writeln!(
1295 out,
1296 "## Coverage Intelligence\n\n- Verdict: {}\n- Findings: {}\n- Ambiguous matches skipped: {}\n",
1297 intelligence.verdict,
1298 intelligence.summary.findings,
1299 intelligence.summary.skipped_ambiguous_matches,
1300 );
1301 if intelligence.findings.is_empty() {
1302 if intelligence.summary.skipped_ambiguous_matches > 0 {
1303 let match_phrase = if intelligence.summary.skipped_ambiguous_matches == 1 {
1304 "evidence match was"
1305 } else {
1306 "evidence matches were"
1307 };
1308 let _ = writeln!(
1309 out,
1310 "No actionable findings were emitted because {} ambiguous {match_phrase} skipped.\n",
1311 intelligence.summary.skipped_ambiguous_matches,
1312 );
1313 }
1314 return;
1315 }
1316 out.push_str("| ID | Path | Identity | Verdict | Recommendation | Confidence | Signals |\n");
1317 out.push_str("|:---|:-----|:---------|:--------|:---------------|:-----------|:--------|\n");
1318 for finding in &intelligence.findings {
1319 write_coverage_intelligence_row(out, finding, root);
1320 }
1321 out.push('\n');
1322}
1323
1324fn write_coverage_intelligence_row(
1326 out: &mut String,
1327 finding: &crate::health_types::CoverageIntelligenceFinding,
1328 root: &Path,
1329) {
1330 let path = escape_backticks(&normalize_uri(
1331 &relative_path(&finding.path, root).display().to_string(),
1332 ));
1333 let identity = finding
1334 .identity
1335 .as_deref()
1336 .map_or_else(|| "-".to_owned(), escape_backticks);
1337 let signals = finding
1338 .signals
1339 .iter()
1340 .map(ToString::to_string)
1341 .collect::<Vec<_>>()
1342 .join(", ");
1343 let _ = writeln!(
1344 out,
1345 "| `{}` | `{}`:{} | `{}` | {} | {} | {} | {} |",
1346 escape_backticks(&finding.id),
1347 path,
1348 finding.line,
1349 identity,
1350 finding.verdict,
1351 finding.recommendation,
1352 finding.confidence,
1353 signals,
1354 );
1355}
1356
1357fn write_runtime_coverage_section(
1358 out: &mut String,
1359 report: &crate::health_types::HealthReport,
1360 root: &Path,
1361) {
1362 let Some(ref production) = report.runtime_coverage else {
1363 return;
1364 };
1365 if !out.is_empty() && !out.ends_with("\n\n") {
1366 out.push('\n');
1367 }
1368 write_runtime_coverage_summary(out, production);
1369 write_runtime_coverage_findings(out, production, root);
1370 write_runtime_coverage_hot_paths(out, production, root);
1371}
1372
1373fn write_runtime_coverage_summary(
1375 out: &mut String,
1376 production: &crate::health_types::RuntimeCoverageReport,
1377) {
1378 let _ = writeln!(
1379 out,
1380 "## 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",
1381 production.verdict,
1382 production.summary.functions_tracked,
1383 production.summary.functions_hit,
1384 production.summary.functions_unhit,
1385 production.summary.functions_untracked,
1386 production.summary.coverage_percent,
1387 production.summary.trace_count,
1388 production.summary.period_days,
1389 production.summary.deployments_seen,
1390 );
1391 if let Some(watermark) = production.watermark {
1392 let _ = writeln!(out, "- Watermark: {watermark}\n");
1393 }
1394 if let Some(ref quality) = production.summary.capture_quality
1395 && quality.lazy_parse_warning
1396 {
1397 let window = super::human::health::format_window(quality.window_seconds);
1398 let _ = writeln!(
1399 out,
1400 "- Capture quality: short window ({} from {} instance(s), {:.1}% of functions untracked); lazy-parsed scripts may not appear.\n",
1401 window, quality.instances_observed, quality.untracked_ratio_percent,
1402 );
1403 }
1404}
1405
1406fn write_runtime_coverage_findings(
1408 out: &mut String,
1409 production: &crate::health_types::RuntimeCoverageReport,
1410 root: &Path,
1411) {
1412 if production.findings.is_empty() {
1413 return;
1414 }
1415 out.push_str("| ID | Path | Function | Verdict | Invocations | Confidence |\n");
1416 out.push_str("|:---|:-----|:---------|:--------|------------:|:-----------|\n");
1417 for finding in &production.findings {
1418 let invocations = finding
1419 .invocations
1420 .map_or_else(|| "-".to_owned(), |hits| hits.to_string());
1421 let _ = writeln!(
1422 out,
1423 "| `{}` | `{}`:{} | `{}` | {} | {} | {} |",
1424 escape_backticks(&finding.id),
1425 escape_backticks(&normalize_uri(
1426 &relative_path(&finding.path, root).display().to_string(),
1427 )),
1428 finding.line,
1429 escape_backticks(&finding.function),
1430 finding.verdict,
1431 invocations,
1432 finding.confidence,
1433 );
1434 }
1435 out.push('\n');
1436}
1437
1438fn write_runtime_coverage_hot_paths(
1440 out: &mut String,
1441 production: &crate::health_types::RuntimeCoverageReport,
1442 root: &Path,
1443) {
1444 if production.hot_paths.is_empty() {
1445 return;
1446 }
1447 out.push_str("| ID | Hot path | Function | Invocations | Percentile |\n");
1448 out.push_str("|:---|:---------|:---------|------------:|-----------:|\n");
1449 for entry in &production.hot_paths {
1450 let _ = writeln!(
1451 out,
1452 "| `{}` | `{}`:{} | `{}` | {} | {} |",
1453 escape_backticks(&entry.id),
1454 escape_backticks(&normalize_uri(
1455 &relative_path(&entry.path, root).display().to_string(),
1456 )),
1457 entry.line,
1458 escape_backticks(&entry.function),
1459 entry.invocations,
1460 entry.percentile,
1461 );
1462 }
1463 out.push('\n');
1464}
1465
1466fn write_trend_section(out: &mut String, report: &crate::health_types::HealthReport) {
1468 let Some(ref trend) = report.health_trend else {
1469 return;
1470 };
1471 let sha_str = trend
1472 .compared_to
1473 .git_sha
1474 .as_deref()
1475 .map_or(String::new(), |sha| format!(" ({sha})"));
1476 let _ = writeln!(
1477 out,
1478 "## Trend (vs {}{})\n",
1479 trend
1480 .compared_to
1481 .timestamp
1482 .get(..10)
1483 .unwrap_or(&trend.compared_to.timestamp),
1484 sha_str,
1485 );
1486 out.push_str("| Metric | Previous | Current | Delta | Direction |\n");
1487 out.push_str("|:-------|:---------|:--------|:------|:----------|\n");
1488 for m in &trend.metrics {
1489 write_trend_metric_row(out, m);
1490 }
1491 let md_sha = trend
1492 .compared_to
1493 .git_sha
1494 .as_deref()
1495 .map_or(String::new(), |sha| format!(" ({sha})"));
1496 let _ = writeln!(
1497 out,
1498 "\n*vs {}{} · {} {} available*\n",
1499 trend
1500 .compared_to
1501 .timestamp
1502 .get(..10)
1503 .unwrap_or(&trend.compared_to.timestamp),
1504 md_sha,
1505 trend.snapshots_loaded,
1506 if trend.snapshots_loaded == 1 {
1507 "snapshot"
1508 } else {
1509 "snapshots"
1510 },
1511 );
1512}
1513
1514fn write_trend_metric_row(out: &mut String, m: &crate::health_types::TrendMetric) {
1516 let fmt_val = |v: f64| -> String {
1517 if m.unit == "%" {
1518 format!("{v:.1}%")
1519 } else if (v - v.round()).abs() < 0.05 {
1520 format!("{v:.0}")
1521 } else {
1522 format!("{v:.1}")
1523 }
1524 };
1525 let prev = fmt_val(m.previous);
1526 let cur = fmt_val(m.current);
1527 let delta = if m.unit == "%" {
1528 format!("{:+.1}%", m.delta)
1529 } else if (m.delta - m.delta.round()).abs() < 0.05 {
1530 format!("{:+.0}", m.delta)
1531 } else {
1532 format!("{:+.1}", m.delta)
1533 };
1534 let _ = writeln!(
1535 out,
1536 "| {} | {} | {} | {} | {} {} |",
1537 m.label,
1538 prev,
1539 cur,
1540 delta,
1541 m.direction.arrow(),
1542 m.direction.label(),
1543 );
1544}
1545
1546fn write_vital_signs_section(out: &mut String, report: &crate::health_types::HealthReport) {
1548 let Some(ref vs) = report.vital_signs else {
1549 return;
1550 };
1551 out.push_str("## Vital Signs\n\n");
1552 out.push_str("| Metric | Value |\n");
1553 out.push_str("|:-------|------:|\n");
1554 if vs.total_loc > 0 {
1555 let _ = writeln!(out, "| Total LOC | {} |", vs.total_loc);
1556 }
1557 let _ = writeln!(out, "| Avg Cyclomatic | {:.1} |", vs.avg_cyclomatic);
1558 let _ = writeln!(out, "| P90 Cyclomatic | {} |", vs.p90_cyclomatic);
1559 if let Some(v) = vs.dead_file_pct {
1560 let _ = writeln!(out, "| Dead Files | {v:.1}% |");
1561 }
1562 if let Some(v) = vs.dead_export_pct {
1563 let _ = writeln!(out, "| Dead Exports | {v:.1}% |");
1564 }
1565 if let Some(v) = vs.maintainability_avg {
1566 let _ = writeln!(out, "| Maintainability (avg) | {v:.1} |");
1567 }
1568 if let Some(v) = vs.hotspot_count {
1569 let label = report.hotspot_summary.as_ref().map_or_else(
1570 || "Hotspots".to_string(),
1571 |summary| format!("Hotspots (since {})", summary.since),
1572 );
1573 let _ = writeln!(out, "| {label} | {v} |");
1574 }
1575 if let Some(v) = vs.circular_dep_count {
1576 let _ = writeln!(out, "| Circular Deps | {v} |");
1577 }
1578 if let Some(v) = vs.unused_dep_count {
1579 let _ = writeln!(out, "| Unused Deps | {v} |");
1580 }
1581 out.push('\n');
1582}
1583
1584fn write_findings_section(
1586 out: &mut String,
1587 report: &crate::health_types::HealthReport,
1588 root: &Path,
1589) {
1590 if report.findings.is_empty() {
1591 return;
1592 }
1593
1594 let has_synthetic = report
1595 .findings
1596 .iter()
1597 .any(|finding| matches!(finding.name.as_str(), "<template>" | "<component>"));
1598 write_findings_heading(out, report, has_synthetic);
1599 write_findings_table_header(out, has_synthetic);
1600
1601 for finding in &report.findings {
1602 write_findings_row(out, finding, report, root);
1603 }
1604
1605 let s = &report.summary;
1606 let _ = write!(
1607 out,
1608 "\n**{files}** files, **{funcs}** functions analyzed \
1609 (thresholds: cyclomatic > {cyc}, cognitive > {cog}, CRAP >= {crap:.1})\n",
1610 files = s.files_analyzed,
1611 funcs = s.functions_analyzed,
1612 cyc = s.max_cyclomatic_threshold,
1613 cog = s.max_cognitive_threshold,
1614 crap = s.max_crap_threshold,
1615 );
1616}
1617
1618fn write_findings_heading(
1620 out: &mut String,
1621 report: &crate::health_types::HealthReport,
1622 has_synthetic: bool,
1623) {
1624 let count = report.summary.functions_above_threshold;
1625 let shown = report.findings.len();
1626 let subject = if has_synthetic {
1627 "high complexity finding"
1628 } else {
1629 "high complexity function"
1630 };
1631 if shown < count {
1632 let _ = write!(
1633 out,
1634 "## Fallow: {count} {subject}{} ({shown} shown)\n\n",
1635 plural(count),
1636 );
1637 } else {
1638 let _ = write!(out, "## Fallow: {count} {subject}{}\n\n", plural(count));
1639 }
1640}
1641
1642fn write_findings_table_header(out: &mut String, has_synthetic: bool) {
1644 let name_header = if has_synthetic { "Entry" } else { "Function" };
1645 let _ = writeln!(
1646 out,
1647 "| File | {name_header} | Severity | Cyclomatic | Cognitive | CRAP | Lines |"
1648 );
1649 out.push_str("|:-----|:---------|:---------|:-----------|:----------|:-----|:------|\n");
1650}
1651
1652fn write_findings_row(
1654 out: &mut String,
1655 finding: &crate::health_types::HealthFinding,
1656 report: &crate::health_types::HealthReport,
1657 root: &Path,
1658) {
1659 let file_str = escape_backticks(&normalize_uri(
1660 &relative_path(&finding.path, root).display().to_string(),
1661 ));
1662 let thresholds =
1663 finding
1664 .effective_thresholds
1665 .unwrap_or(crate::health_types::HealthEffectiveThresholds {
1666 max_cyclomatic: report.summary.max_cyclomatic_threshold,
1667 max_cognitive: report.summary.max_cognitive_threshold,
1668 max_crap: report.summary.max_crap_threshold,
1669 });
1670 let cyc_marker = if finding.cyclomatic > thresholds.max_cyclomatic {
1671 " **!**"
1672 } else {
1673 ""
1674 };
1675 let cog_marker = if finding.cognitive > thresholds.max_cognitive {
1676 " **!**"
1677 } else {
1678 ""
1679 };
1680 let severity_label = match finding.severity {
1681 crate::health_types::FindingSeverity::Critical => "critical",
1682 crate::health_types::FindingSeverity::High => "high",
1683 crate::health_types::FindingSeverity::Moderate => "moderate",
1684 };
1685 let crap_cell = match finding.crap {
1686 Some(crap) => {
1687 let marker = if crap >= thresholds.max_crap {
1688 " **!**"
1689 } else {
1690 ""
1691 };
1692 format!("{crap:.1}{marker}")
1693 }
1694 None => "-".to_string(),
1695 };
1696 let _ = writeln!(
1697 out,
1698 "| `{file_str}:{line}` | `{name}` | {severity_label} | {cyc}{cyc_marker} | {cog}{cog_marker} | {crap_cell} | {lines} |",
1699 line = finding.line,
1700 name = escape_backticks(display_complexity_entry_name(&finding.name).as_ref()),
1701 cyc = finding.cyclomatic,
1702 cog = finding.cognitive,
1703 lines = finding.line_count,
1704 );
1705}
1706
1707fn write_threshold_overrides_section(
1708 out: &mut String,
1709 report: &crate::health_types::HealthReport,
1710 root: &Path,
1711) {
1712 if report.threshold_overrides.is_empty() {
1713 return;
1714 }
1715 if !out.is_empty() && !out.ends_with("\n\n") {
1716 out.push('\n');
1717 }
1718 out.push_str("## Health Threshold Overrides\n\n");
1719 out.push_str("| Override | Status | Target | Metrics |\n");
1720 out.push_str("|---------:|:-------|:-------|:--------|\n");
1721 for entry in &report.threshold_overrides {
1722 let status = match entry.status {
1723 crate::health_types::ThresholdOverrideStatus::Active => "active",
1724 crate::health_types::ThresholdOverrideStatus::Stale => "stale",
1725 crate::health_types::ThresholdOverrideStatus::NoMatch => "no_match",
1726 };
1727 let target = entry.path.as_ref().map_or_else(
1728 || "<no matching file or function>".to_string(),
1729 |path| {
1730 let display = escape_backticks(&normalize_uri(
1731 &relative_path(path, root).display().to_string(),
1732 ));
1733 entry.function.as_ref().map_or_else(
1734 || display.clone(),
1735 |name| format!("{display}:{}", escape_backticks(name)),
1736 )
1737 },
1738 );
1739 let metrics = entry.metrics.map_or_else(
1740 || "-".to_string(),
1741 |metrics| {
1742 let crap = metrics
1743 .crap
1744 .map_or(String::new(), |value| format!(", CRAP {value:.1}"));
1745 format!(
1746 "cyclomatic {}, cognitive {}{}",
1747 metrics.cyclomatic, metrics.cognitive, crap
1748 )
1749 },
1750 );
1751 let _ = writeln!(
1752 out,
1753 "| {} | {} | `{}` | {} |",
1754 entry.override_index, status, target, metrics
1755 );
1756 }
1757 out.push('\n');
1758}
1759
1760fn write_file_scores_section(
1762 out: &mut String,
1763 report: &crate::health_types::HealthReport,
1764 root: &Path,
1765) {
1766 if report.file_scores.is_empty() {
1767 return;
1768 }
1769
1770 let rel = |p: &Path| {
1771 escape_backticks(&normalize_uri(
1772 &relative_path(p, root).display().to_string(),
1773 ))
1774 };
1775
1776 out.push('\n');
1777 let _ = writeln!(
1778 out,
1779 "### File Health Scores ({} files)\n",
1780 report.file_scores.len(),
1781 );
1782 out.push_str("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density | Risk |\n");
1783 out.push_str("|:-----|:---------------|:-------|:--------|:----------|:--------|:-----|\n");
1784
1785 for score in &report.file_scores {
1786 let file_str = rel(&score.path);
1787 let _ = writeln!(
1788 out,
1789 "| `{file_str}` | {mi:.1} | {fi} | {fan_out} | {dead:.0}% | {density:.2} | {crap:.1} |",
1790 mi = score.maintainability_index,
1791 fi = score.fan_in,
1792 fan_out = score.fan_out,
1793 dead = score.dead_code_ratio * 100.0,
1794 density = score.complexity_density,
1795 crap = score.crap_max,
1796 );
1797 }
1798
1799 if let Some(avg) = report.summary.average_maintainability {
1800 let _ = write!(out, "\n**Average maintainability index:** {avg:.1}/100\n");
1801 }
1802}
1803
1804fn write_coverage_gaps_section(
1805 out: &mut String,
1806 report: &crate::health_types::HealthReport,
1807 root: &Path,
1808) {
1809 let Some(ref gaps) = report.coverage_gaps else {
1810 return;
1811 };
1812
1813 out.push('\n');
1814 let _ = writeln!(out, "### Coverage Gaps\n");
1815 let _ = writeln!(
1816 out,
1817 "*{} untested files · {} untested exports · {:.1}% file coverage*\n",
1818 gaps.summary.untested_files, gaps.summary.untested_exports, gaps.summary.file_coverage_pct,
1819 );
1820
1821 if gaps.files.is_empty() && gaps.exports.is_empty() {
1822 out.push_str("_No coverage gaps found in scope._\n");
1823 return;
1824 }
1825
1826 if !gaps.files.is_empty() {
1827 out.push_str("#### Files\n");
1828 for item in &gaps.files {
1829 let file_str = escape_backticks(&normalize_uri(
1830 &relative_path(&item.file.path, root).display().to_string(),
1831 ));
1832 let _ = writeln!(
1833 out,
1834 "- `{file_str}` ({count} value export{})",
1835 if item.file.value_export_count == 1 {
1836 ""
1837 } else {
1838 "s"
1839 },
1840 count = item.file.value_export_count,
1841 );
1842 }
1843 out.push('\n');
1844 }
1845
1846 if !gaps.exports.is_empty() {
1847 out.push_str("#### Exports\n");
1848 for item in &gaps.exports {
1849 let file_str = escape_backticks(&normalize_uri(
1850 &relative_path(&item.export.path, root).display().to_string(),
1851 ));
1852 let _ = writeln!(
1853 out,
1854 "- `{file_str}`:{} `{}`",
1855 item.export.line, item.export.export_name
1856 );
1857 }
1858 }
1859}
1860
1861fn ownership_md_cells(
1866 ownership: Option<&crate::health_types::OwnershipMetrics>,
1867) -> (String, String, String, String) {
1868 let Some(o) = ownership else {
1869 let dash = "\u{2013}".to_string();
1870 return (dash.clone(), dash.clone(), dash.clone(), dash);
1871 };
1872 let bus = o.bus_factor.to_string();
1873 let top = format!(
1874 "`{}` ({:.0}%)",
1875 o.top_contributor.identifier,
1876 o.top_contributor.share * 100.0,
1877 );
1878 let owner = o
1879 .declared_owner
1880 .as_deref()
1881 .map_or_else(|| "\u{2013}".to_string(), str::to_string);
1882 let mut notes: Vec<&str> = Vec::new();
1883 if o.unowned == Some(true) {
1884 notes.push("**unowned**");
1885 }
1886 if o.ownership_state == crate::health_types::OwnershipState::DeclaredInactive {
1887 notes.push("declared owner inactive");
1888 }
1889 if o.drift {
1890 notes.push("drift");
1891 }
1892 let notes_str = if notes.is_empty() {
1893 "\u{2013}".to_string()
1894 } else {
1895 notes.join(", ")
1896 };
1897 (bus, top, owner, notes_str)
1898}
1899
1900fn write_hotspots_section(
1901 out: &mut String,
1902 report: &crate::health_types::HealthReport,
1903 root: &Path,
1904) {
1905 if report.hotspots.is_empty() {
1906 return;
1907 }
1908
1909 out.push('\n');
1910 let header = report.hotspot_summary.as_ref().map_or_else(
1911 || format!("### Hotspots ({} files)\n", report.hotspots.len()),
1912 |summary| {
1913 format!(
1914 "### Hotspots ({} files, since {})\n",
1915 report.hotspots.len(),
1916 summary.since,
1917 )
1918 },
1919 );
1920 let _ = writeln!(out, "{header}");
1921 let any_ownership = report.hotspots.iter().any(|e| e.ownership.is_some());
1922 write_hotspots_table_header(out, any_ownership);
1923
1924 for entry in &report.hotspots {
1925 write_hotspots_row(out, entry, any_ownership, root);
1926 }
1927
1928 if let Some(ref summary) = report.hotspot_summary
1929 && summary.files_excluded > 0
1930 {
1931 let _ = write!(
1932 out,
1933 "\n*{} file{} excluded (< {} commits)*\n",
1934 summary.files_excluded,
1935 plural(summary.files_excluded),
1936 summary.min_commits,
1937 );
1938 }
1939}
1940
1941fn write_hotspots_table_header(out: &mut String, any_ownership: bool) {
1943 if any_ownership {
1944 out.push_str(
1945 "| File | Score | Commits | Churn | Density | Fan-in | Trend | Bus | Top | Owner | Notes |\n"
1946 );
1947 out.push_str(
1948 "|:-----|:------|:--------|:------|:--------|:-------|:------|:----|:----|:------|:------|\n"
1949 );
1950 } else {
1951 out.push_str("| File | Score | Commits | Churn | Density | Fan-in | Trend |\n");
1952 out.push_str("|:-----|:------|:--------|:------|:--------|:-------|:------|\n");
1953 }
1954}
1955
1956fn write_hotspots_row(
1958 out: &mut String,
1959 entry: &crate::health_types::HotspotFinding,
1960 any_ownership: bool,
1961 root: &Path,
1962) {
1963 let file_str = escape_backticks(&normalize_uri(
1964 &relative_path(&entry.path, root).display().to_string(),
1965 ));
1966 if any_ownership {
1967 let (bus, top, owner, notes) = ownership_md_cells(entry.ownership.as_ref());
1968 let _ = writeln!(
1969 out,
1970 "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} | {bus} | {top} | {owner} | {notes} |",
1971 score = entry.score,
1972 commits = entry.commits,
1973 churn = entry.lines_added + entry.lines_deleted,
1974 density = entry.complexity_density,
1975 fi = entry.fan_in,
1976 trend = entry.trend,
1977 );
1978 } else {
1979 let _ = writeln!(
1980 out,
1981 "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} |",
1982 score = entry.score,
1983 commits = entry.commits,
1984 churn = entry.lines_added + entry.lines_deleted,
1985 density = entry.complexity_density,
1986 fi = entry.fan_in,
1987 trend = entry.trend,
1988 );
1989 }
1990}
1991
1992fn write_targets_section(
1994 out: &mut String,
1995 report: &crate::health_types::HealthReport,
1996 root: &Path,
1997) {
1998 if report.targets.is_empty() {
1999 return;
2000 }
2001 let _ = write!(
2002 out,
2003 "\n### Refactoring Targets ({})\n\n",
2004 report.targets.len()
2005 );
2006 out.push_str("| Efficiency | Category | Effort / Confidence | File | Recommendation |\n");
2007 out.push_str("|:-----------|:---------|:--------------------|:-----|:---------------|\n");
2008 for target in &report.targets {
2009 let file_str = normalize_uri(&relative_path(&target.path, root).display().to_string());
2010 let category = target.category.label();
2011 let effort = target.effort.label();
2012 let confidence = target.confidence.label();
2013 let _ = writeln!(
2014 out,
2015 "| {:.1} | {category} | {effort} / {confidence} | `{file_str}` | {} |",
2016 target.efficiency, target.recommendation,
2017 );
2018 }
2019}
2020
2021fn write_metric_legend(out: &mut String, report: &crate::health_types::HealthReport) {
2023 let has_scores = !report.file_scores.is_empty();
2024 let has_coverage = report.coverage_gaps.is_some();
2025 let has_hotspots = !report.hotspots.is_empty();
2026 let has_targets = !report.targets.is_empty();
2027 if !has_scores && !has_coverage && !has_hotspots && !has_targets {
2028 return;
2029 }
2030 out.push_str("\n---\n\n<details><summary>Metric definitions</summary>\n\n");
2031 if has_scores {
2032 out.push_str("- **MI**: Maintainability Index (0\u{2013}100, higher is better)\n");
2033 out.push_str("- **Order**: risk-aware triage order using the larger of low-MI concern and CRAP risk\n");
2034 out.push_str("- **Fan-in**: files that import this file (blast radius)\n");
2035 out.push_str("- **Fan-out**: files this file imports (coupling)\n");
2036 out.push_str("- **Dead Code**: % of value exports with zero references\n");
2037 out.push_str("- **Density**: cyclomatic complexity / lines of code\n");
2038 out.push_str(
2039 "- **Risk**: max CRAP score for the file; low <15, moderate 15-30, high >=30\n",
2040 );
2041 }
2042 if has_coverage {
2043 out.push_str(
2044 "- **File coverage**: runtime files also reachable from a discovered test root\n",
2045 );
2046 out.push_str("- **Untested export**: export with no reference chain from any test-reachable module\n");
2047 }
2048 if has_hotspots {
2049 out.push_str("- **Score**: churn \u{00d7} complexity (0\u{2013}100, higher = riskier)\n");
2050 out.push_str("- **Commits**: commits in the analysis window\n");
2051 out.push_str("- **Churn**: total lines added + deleted\n");
2052 out.push_str("- **Trend**: accelerating / stable / cooling\n");
2053 }
2054 if has_targets {
2055 out.push_str(
2056 "- **Efficiency**: priority / effort (higher = better quick-win value, default sort)\n",
2057 );
2058 out.push_str("- **Category**: recommendation type (churn+complexity, high impact, dead code, complexity, coupling, circular dep)\n");
2059 out.push_str("- **Effort**: estimated effort (low / medium / high) based on file size, function count, and fan-in\n");
2060 out.push_str("- **Confidence**: recommendation reliability (high = deterministic analysis, medium = heuristic, low = git-dependent)\n");
2061 }
2062 out.push_str(
2063 "\n[Full metric reference](https://docs.fallow.tools/explanations/metrics)\n\n</details>\n",
2064 );
2065}
2066
2067#[cfg(test)]
2068mod tests {
2069 use super::*;
2070 use crate::report::test_helpers::sample_results;
2071 use fallow_core::duplicates::{
2072 CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
2073 RefactoringKind, RefactoringSuggestion,
2074 };
2075 use fallow_core::results::*;
2076 use std::path::PathBuf;
2077
2078 #[test]
2079 fn markdown_empty_results_no_issues() {
2080 let root = PathBuf::from("/project");
2081 let results = AnalysisResults::default();
2082 let md = build_markdown(&results, &root);
2083 assert_eq!(md, "## Fallow: no issues found\n");
2084 }
2085
2086 #[test]
2087 fn markdown_contains_header_with_count() {
2088 let root = PathBuf::from("/project");
2089 let results = sample_results(&root);
2090 let md = build_markdown(&results, &root);
2091 assert!(md.starts_with(&format!(
2092 "## Fallow: {} issues found\n",
2093 results.total_issues()
2094 )));
2095 }
2096
2097 #[test]
2098 fn markdown_contains_all_sections() {
2099 let root = PathBuf::from("/project");
2100 let results = sample_results(&root);
2101 let md = build_markdown(&results, &root);
2102
2103 assert!(md.contains("### Unused files (1)"));
2104 assert!(md.contains("### Unused exports (1)"));
2105 assert!(md.contains("### Unused type exports (1)"));
2106 assert!(md.contains("### Unused dependencies (1)"));
2107 assert!(md.contains("### Unused devDependencies (1)"));
2108 assert!(md.contains("### Unused enum members (1)"));
2109 assert!(md.contains("### Unused class members (1)"));
2110 assert!(md.contains("### Unresolved imports (1)"));
2111 assert!(md.contains("### Unlisted dependencies (1)"));
2112 assert!(md.contains("### Duplicate exports (1)"));
2113 assert!(md.contains("### Type-only dependencies"));
2114 assert!(md.contains("### Test-only production dependencies"));
2115 assert!(md.contains("### Circular dependencies (1)"));
2116 }
2117
2118 #[test]
2119 fn markdown_unused_file_format() {
2120 let root = PathBuf::from("/project");
2121 let mut results = AnalysisResults::default();
2122 results
2123 .unused_files
2124 .push(UnusedFileFinding::with_actions(UnusedFile {
2125 path: root.join("src/dead.ts"),
2126 }));
2127 let md = build_markdown(&results, &root);
2128 assert!(md.contains("- `src/dead.ts`"));
2129 }
2130
2131 #[test]
2132 fn markdown_unused_export_grouped_by_file() {
2133 let root = PathBuf::from("/project");
2134 let mut results = AnalysisResults::default();
2135 results
2136 .unused_exports
2137 .push(UnusedExportFinding::with_actions(UnusedExport {
2138 path: root.join("src/utils.ts"),
2139 export_name: "helperFn".to_string(),
2140 is_type_only: false,
2141 line: 10,
2142 col: 4,
2143 span_start: 120,
2144 is_re_export: false,
2145 }));
2146 let md = build_markdown(&results, &root);
2147 assert!(md.contains("- `src/utils.ts`"));
2148 assert!(md.contains(":10 `helperFn`"));
2149 }
2150
2151 #[test]
2152 fn markdown_re_export_tagged() {
2153 let root = PathBuf::from("/project");
2154 let mut results = AnalysisResults::default();
2155 results
2156 .unused_exports
2157 .push(UnusedExportFinding::with_actions(UnusedExport {
2158 path: root.join("src/index.ts"),
2159 export_name: "reExported".to_string(),
2160 is_type_only: false,
2161 line: 1,
2162 col: 0,
2163 span_start: 0,
2164 is_re_export: true,
2165 }));
2166 let md = build_markdown(&results, &root);
2167 assert!(md.contains("(re-export)"));
2168 }
2169
2170 #[test]
2171 fn markdown_unused_dep_format() {
2172 let root = PathBuf::from("/project");
2173 let mut results = AnalysisResults::default();
2174 results
2175 .unused_dependencies
2176 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2177 package_name: "lodash".to_string(),
2178 location: DependencyLocation::Dependencies,
2179 path: root.join("package.json"),
2180 line: 5,
2181 used_in_workspaces: Vec::new(),
2182 }));
2183 let md = build_markdown(&results, &root);
2184 assert!(md.contains("- `lodash`"));
2185 }
2186
2187 #[test]
2188 fn markdown_circular_dep_format() {
2189 let root = PathBuf::from("/project");
2190 let mut results = AnalysisResults::default();
2191 results
2192 .circular_dependencies
2193 .push(CircularDependencyFinding::with_actions(
2194 CircularDependency {
2195 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2196 length: 2,
2197 line: 3,
2198 col: 0,
2199 edges: Vec::new(),
2200 is_cross_package: false,
2201 },
2202 ));
2203 let md = build_markdown(&results, &root);
2204 assert!(md.contains("`src/a.ts`"));
2205 assert!(md.contains("`src/b.ts`"));
2206 assert!(md.contains("\u{2192}"));
2207 }
2208
2209 #[test]
2210 fn markdown_strips_root_prefix() {
2211 let root = PathBuf::from("/project");
2212 let mut results = AnalysisResults::default();
2213 results
2214 .unused_files
2215 .push(UnusedFileFinding::with_actions(UnusedFile {
2216 path: PathBuf::from("/project/src/deep/nested/file.ts"),
2217 }));
2218 let md = build_markdown(&results, &root);
2219 assert!(md.contains("`src/deep/nested/file.ts`"));
2220 assert!(!md.contains("/project/"));
2221 }
2222
2223 #[test]
2224 fn markdown_single_issue_no_plural() {
2225 let root = PathBuf::from("/project");
2226 let mut results = AnalysisResults::default();
2227 results
2228 .unused_files
2229 .push(UnusedFileFinding::with_actions(UnusedFile {
2230 path: root.join("src/dead.ts"),
2231 }));
2232 let md = build_markdown(&results, &root);
2233 assert!(md.starts_with("## Fallow: 1 issue found\n"));
2234 }
2235
2236 #[test]
2237 fn markdown_type_only_dep_format() {
2238 let root = PathBuf::from("/project");
2239 let mut results = AnalysisResults::default();
2240 results
2241 .type_only_dependencies
2242 .push(TypeOnlyDependencyFinding::with_actions(
2243 TypeOnlyDependency {
2244 package_name: "zod".to_string(),
2245 path: root.join("package.json"),
2246 line: 8,
2247 },
2248 ));
2249 let md = build_markdown(&results, &root);
2250 assert!(md.contains("### Type-only dependencies"));
2251 assert!(md.contains("- `zod`"));
2252 }
2253
2254 #[test]
2255 fn markdown_escapes_backticks_in_export_names() {
2256 let root = PathBuf::from("/project");
2257 let mut results = AnalysisResults::default();
2258 results
2259 .unused_exports
2260 .push(UnusedExportFinding::with_actions(UnusedExport {
2261 path: root.join("src/utils.ts"),
2262 export_name: "foo`bar".to_string(),
2263 is_type_only: false,
2264 line: 1,
2265 col: 0,
2266 span_start: 0,
2267 is_re_export: false,
2268 }));
2269 let md = build_markdown(&results, &root);
2270 assert!(md.contains("foo\\`bar"));
2271 assert!(!md.contains("foo`bar`"));
2272 }
2273
2274 #[test]
2275 fn markdown_escapes_backticks_in_package_names() {
2276 let root = PathBuf::from("/project");
2277 let mut results = AnalysisResults::default();
2278 results
2279 .unused_dependencies
2280 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2281 package_name: "pkg`name".to_string(),
2282 location: DependencyLocation::Dependencies,
2283 path: root.join("package.json"),
2284 line: 5,
2285 used_in_workspaces: Vec::new(),
2286 }));
2287 let md = build_markdown(&results, &root);
2288 assert!(md.contains("pkg\\`name"));
2289 }
2290
2291 #[test]
2292 fn duplication_markdown_empty() {
2293 let report = DuplicationReport::default();
2294 let root = PathBuf::from("/project");
2295 let md = build_duplication_markdown(&report, &root);
2296 assert_eq!(md, "## Fallow: no code duplication found\n");
2297 }
2298
2299 #[test]
2300 fn duplication_markdown_contains_groups() {
2301 let root = PathBuf::from("/project");
2302 let report = DuplicationReport {
2303 clone_groups: vec![CloneGroup {
2304 instances: vec![
2305 CloneInstance {
2306 file: root.join("src/a.ts"),
2307 start_line: 1,
2308 end_line: 10,
2309 start_col: 0,
2310 end_col: 0,
2311 fragment: String::new(),
2312 },
2313 CloneInstance {
2314 file: root.join("src/b.ts"),
2315 start_line: 5,
2316 end_line: 14,
2317 start_col: 0,
2318 end_col: 0,
2319 fragment: String::new(),
2320 },
2321 ],
2322 token_count: 50,
2323 line_count: 10,
2324 }],
2325 clone_families: vec![],
2326 mirrored_directories: vec![],
2327 stats: DuplicationStats {
2328 total_files: 10,
2329 files_with_clones: 2,
2330 total_lines: 500,
2331 duplicated_lines: 20,
2332 total_tokens: 2500,
2333 duplicated_tokens: 100,
2334 clone_groups: 1,
2335 clone_instances: 2,
2336 duplication_percentage: 4.0,
2337 clone_groups_below_min_occurrences: 0,
2338 },
2339 };
2340 let md = build_duplication_markdown(&report, &root);
2341 assert!(md.contains("**Clone group 1**"));
2342 assert!(md.contains("`src/a.ts:1-10`"));
2343 assert!(md.contains("`src/b.ts:5-14`"));
2344 assert!(md.contains("4.0% duplication"));
2345 }
2346
2347 #[test]
2348 fn duplication_markdown_contains_families() {
2349 let root = PathBuf::from("/project");
2350 let report = DuplicationReport {
2351 clone_groups: vec![CloneGroup {
2352 instances: vec![CloneInstance {
2353 file: root.join("src/a.ts"),
2354 start_line: 1,
2355 end_line: 5,
2356 start_col: 0,
2357 end_col: 0,
2358 fragment: String::new(),
2359 }],
2360 token_count: 30,
2361 line_count: 5,
2362 }],
2363 clone_families: vec![CloneFamily {
2364 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2365 groups: vec![],
2366 total_duplicated_lines: 20,
2367 total_duplicated_tokens: 100,
2368 suggestions: vec![RefactoringSuggestion {
2369 kind: RefactoringKind::ExtractFunction,
2370 description: "Extract shared utility function".to_string(),
2371 estimated_savings: 15,
2372 }],
2373 }],
2374 mirrored_directories: vec![],
2375 stats: DuplicationStats {
2376 clone_groups: 1,
2377 clone_instances: 1,
2378 duplication_percentage: 2.0,
2379 ..Default::default()
2380 },
2381 };
2382 let md = build_duplication_markdown(&report, &root);
2383 assert!(md.contains("### Clone Families"));
2384 assert!(md.contains("**Family 1**"));
2385 assert!(md.contains("Extract shared utility function"));
2386 assert!(md.contains("~15 lines saved"));
2387 }
2388
2389 #[test]
2390 fn health_markdown_empty_no_findings() {
2391 let root = PathBuf::from("/project");
2392 let report = crate::health_types::HealthReport {
2393 summary: crate::health_types::HealthSummary {
2394 files_analyzed: 10,
2395 functions_analyzed: 50,
2396 ..Default::default()
2397 },
2398 ..Default::default()
2399 };
2400 let md = build_health_markdown(&report, &root);
2401 assert!(md.contains("no functions exceed complexity thresholds"));
2402 assert!(md.contains("**50** functions analyzed"));
2403 }
2404
2405 #[test]
2406 fn health_markdown_table_format() {
2407 let root = PathBuf::from("/project");
2408 let report = crate::health_types::HealthReport {
2409 findings: vec![
2410 crate::health_types::ComplexityViolation {
2411 path: root.join("src/utils.ts"),
2412 name: "parseExpression".to_string(),
2413 line: 42,
2414 col: 0,
2415 cyclomatic: 25,
2416 cognitive: 30,
2417 line_count: 80,
2418 param_count: 0,
2419 react_hook_count: 0,
2420 react_jsx_max_depth: 0,
2421 react_prop_count: 0,
2422 react_hook_profile: None,
2423 exceeded: crate::health_types::ExceededThreshold::Both,
2424 severity: crate::health_types::FindingSeverity::High,
2425 crap: None,
2426 coverage_pct: None,
2427 coverage_tier: None,
2428 coverage_source: None,
2429 inherited_from: None,
2430 component_rollup: None,
2431 contributions: Vec::new(),
2432 effective_thresholds: None,
2433 threshold_source: None,
2434 }
2435 .into(),
2436 ],
2437 summary: crate::health_types::HealthSummary {
2438 files_analyzed: 10,
2439 functions_analyzed: 50,
2440 functions_above_threshold: 1,
2441 ..Default::default()
2442 },
2443 ..Default::default()
2444 };
2445 let md = build_health_markdown(&report, &root);
2446 assert!(md.contains("## Fallow: 1 high complexity function\n"));
2447 assert!(md.contains("| File | Function |"));
2448 assert!(md.contains("`src/utils.ts:42`"));
2449 assert!(md.contains("`parseExpression`"));
2450 assert!(md.contains("25 **!**"));
2451 assert!(md.contains("30 **!**"));
2452 assert!(md.contains("| 80 |"));
2453 assert!(md.contains("| - |"));
2454 }
2455
2456 #[test]
2457 fn health_markdown_labels_template_complexity_entries() {
2458 let root = PathBuf::from("/project");
2459 let report = crate::health_types::HealthReport {
2460 findings: vec![
2461 crate::health_types::ComplexityViolation {
2462 path: root.join("src/Card.vue"),
2463 name: "<template>".to_string(),
2464 line: 1,
2465 col: 0,
2466 cyclomatic: 8,
2467 cognitive: 12,
2468 line_count: 40,
2469 param_count: 0,
2470 react_hook_count: 0,
2471 react_jsx_max_depth: 0,
2472 react_prop_count: 0,
2473 react_hook_profile: None,
2474 exceeded: crate::health_types::ExceededThreshold::Cognitive,
2475 severity: crate::health_types::FindingSeverity::Moderate,
2476 crap: None,
2477 coverage_pct: None,
2478 coverage_tier: None,
2479 coverage_source: None,
2480 inherited_from: None,
2481 component_rollup: None,
2482 contributions: Vec::new(),
2483 effective_thresholds: None,
2484 threshold_source: None,
2485 }
2486 .into(),
2487 ],
2488 summary: crate::health_types::HealthSummary {
2489 files_analyzed: 1,
2490 functions_analyzed: 1,
2491 functions_above_threshold: 1,
2492 ..Default::default()
2493 },
2494 ..Default::default()
2495 };
2496 let md = build_health_markdown(&report, &root);
2497 assert!(md.contains("## Fallow: 1 high complexity finding\n"));
2498 assert!(md.contains("| File | Entry |"));
2499 assert!(md.contains("`<template> (template complexity)`"));
2500 }
2501
2502 #[test]
2503 fn health_markdown_includes_coverage_intelligence_and_ambiguity_summary() {
2504 use crate::health_types::{
2505 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2506 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2507 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2508 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2509 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2510 HealthReport, HealthSummary,
2511 };
2512
2513 let root = PathBuf::from("/project");
2514 let mut report = HealthReport {
2515 summary: HealthSummary {
2516 files_analyzed: 10,
2517 functions_analyzed: 50,
2518 ..Default::default()
2519 },
2520 coverage_intelligence: Some(CoverageIntelligenceReport {
2521 schema_version: CoverageIntelligenceSchemaVersion::V1,
2522 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2523 summary: CoverageIntelligenceSummary {
2524 findings: 1,
2525 high_confidence_deletes: 1,
2526 ..Default::default()
2527 },
2528 findings: vec![CoverageIntelligenceFinding {
2529 id: "fallow:coverage-intel:abc123".to_owned(),
2530 path: root.join("src/dead.ts"),
2531 identity: Some("deadPath".to_owned()),
2532 line: 9,
2533 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2534 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2535 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2536 confidence: CoverageIntelligenceConfidence::High,
2537 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2538 evidence: CoverageIntelligenceEvidence {
2539 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2540 ..Default::default()
2541 },
2542 actions: vec![CoverageIntelligenceAction {
2543 kind: "delete-after-confirming-owner".to_owned(),
2544 description: "Confirm ownership".to_owned(),
2545 auto_fixable: false,
2546 }],
2547 }],
2548 }),
2549 ..Default::default()
2550 };
2551
2552 let md = build_health_markdown(&report, &root);
2553 assert!(md.contains("## Coverage Intelligence"));
2554 assert!(md.contains("fallow:coverage-intel:abc123"));
2555 assert!(md.contains("delete-after-confirming-owner"));
2556 assert!(md.contains("runtime_cold"));
2557
2558 report.coverage_intelligence = Some(CoverageIntelligenceReport {
2559 schema_version: CoverageIntelligenceSchemaVersion::V1,
2560 verdict: CoverageIntelligenceVerdict::Clean,
2561 summary: CoverageIntelligenceSummary {
2562 skipped_ambiguous_matches: 2,
2563 ..Default::default()
2564 },
2565 findings: vec![],
2566 });
2567 let md = build_health_markdown(&report, &root);
2568 assert!(md.contains("2 ambiguous evidence matches were skipped"));
2569 assert!(!md.contains("| ID | Path |"));
2570 }
2571
2572 #[test]
2573 fn health_markdown_crap_column_shows_score_and_marker() {
2574 let root = PathBuf::from("/project");
2575 let report = crate::health_types::HealthReport {
2576 findings: vec![
2577 crate::health_types::ComplexityViolation {
2578 path: root.join("src/risky.ts"),
2579 name: "branchy".to_string(),
2580 line: 1,
2581 col: 0,
2582 cyclomatic: 67,
2583 cognitive: 10,
2584 line_count: 80,
2585 param_count: 1,
2586 react_hook_count: 0,
2587 react_jsx_max_depth: 0,
2588 react_prop_count: 0,
2589 react_hook_profile: None,
2590 exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
2591 severity: crate::health_types::FindingSeverity::Critical,
2592 crap: Some(182.0),
2593 coverage_pct: None,
2594 coverage_tier: None,
2595 coverage_source: None,
2596 inherited_from: None,
2597 component_rollup: None,
2598 contributions: Vec::new(),
2599 effective_thresholds: None,
2600 threshold_source: None,
2601 }
2602 .into(),
2603 ],
2604 summary: crate::health_types::HealthSummary {
2605 files_analyzed: 1,
2606 functions_analyzed: 1,
2607 functions_above_threshold: 1,
2608 ..Default::default()
2609 },
2610 ..Default::default()
2611 };
2612 let md = build_health_markdown(&report, &root);
2613 assert!(
2614 md.contains("| CRAP |"),
2615 "markdown table should have CRAP column header: {md}"
2616 );
2617 assert!(
2618 md.contains("182.0 **!**"),
2619 "CRAP value should be rendered with a threshold marker: {md}"
2620 );
2621 assert!(
2622 md.contains("CRAP >="),
2623 "trailing summary line should reference the CRAP threshold: {md}"
2624 );
2625 }
2626
2627 #[test]
2628 fn health_markdown_no_marker_when_below_threshold() {
2629 let root = PathBuf::from("/project");
2630 let report = crate::health_types::HealthReport {
2631 findings: vec![
2632 crate::health_types::ComplexityViolation {
2633 path: root.join("src/utils.ts"),
2634 name: "helper".to_string(),
2635 line: 10,
2636 col: 0,
2637 cyclomatic: 15,
2638 cognitive: 20,
2639 line_count: 30,
2640 param_count: 0,
2641 react_hook_count: 0,
2642 react_jsx_max_depth: 0,
2643 react_prop_count: 0,
2644 react_hook_profile: None,
2645 exceeded: crate::health_types::ExceededThreshold::Cognitive,
2646 severity: crate::health_types::FindingSeverity::High,
2647 crap: None,
2648 coverage_pct: None,
2649 coverage_tier: None,
2650 coverage_source: None,
2651 inherited_from: None,
2652 component_rollup: None,
2653 contributions: Vec::new(),
2654 effective_thresholds: None,
2655 threshold_source: None,
2656 }
2657 .into(),
2658 ],
2659 summary: crate::health_types::HealthSummary {
2660 files_analyzed: 5,
2661 functions_analyzed: 20,
2662 functions_above_threshold: 1,
2663 ..Default::default()
2664 },
2665 ..Default::default()
2666 };
2667 let md = build_health_markdown(&report, &root);
2668 assert!(md.contains("| 15 |"));
2669 assert!(md.contains("20 **!**"));
2670 }
2671
2672 #[test]
2673 fn health_markdown_with_targets() {
2674 use crate::health_types::*;
2675
2676 let root = PathBuf::from("/project");
2677 let report = HealthReport {
2678 summary: HealthSummary {
2679 files_analyzed: 10,
2680 functions_analyzed: 50,
2681 ..Default::default()
2682 },
2683 targets: vec![
2684 RefactoringTarget {
2685 path: PathBuf::from("/project/src/complex.ts"),
2686 priority: 82.5,
2687 efficiency: 27.5,
2688 recommendation: "Split high-impact file".into(),
2689 category: RecommendationCategory::SplitHighImpact,
2690 effort: crate::health_types::EffortEstimate::High,
2691 confidence: crate::health_types::Confidence::Medium,
2692 factors: vec![ContributingFactor {
2693 metric: "fan_in",
2694 value: 25.0,
2695 threshold: 10.0,
2696 detail: "25 files depend on this".into(),
2697 }],
2698 evidence: None,
2699 }
2700 .into(),
2701 RefactoringTarget {
2702 path: PathBuf::from("/project/src/legacy.ts"),
2703 priority: 45.0,
2704 efficiency: 45.0,
2705 recommendation: "Remove 5 unused exports".into(),
2706 category: RecommendationCategory::RemoveDeadCode,
2707 effort: crate::health_types::EffortEstimate::Low,
2708 confidence: crate::health_types::Confidence::High,
2709 factors: vec![],
2710 evidence: None,
2711 }
2712 .into(),
2713 ],
2714 ..Default::default()
2715 };
2716 let md = build_health_markdown(&report, &root);
2717
2718 assert!(
2719 md.contains("Refactoring Targets"),
2720 "should contain targets heading"
2721 );
2722 assert!(
2723 md.contains("src/complex.ts"),
2724 "should contain target file path"
2725 );
2726 assert!(md.contains("27.5"), "should contain efficiency score");
2727 assert!(
2728 md.contains("Split high-impact file"),
2729 "should contain recommendation"
2730 );
2731 assert!(md.contains("src/legacy.ts"), "should contain second target");
2732 }
2733
2734 #[test]
2735 fn health_markdown_with_coverage_gaps() {
2736 use crate::health_types::*;
2737
2738 let root = PathBuf::from("/project");
2739 let report = HealthReport {
2740 summary: HealthSummary {
2741 files_analyzed: 10,
2742 functions_analyzed: 50,
2743 ..Default::default()
2744 },
2745 coverage_gaps: Some(CoverageGaps {
2746 summary: CoverageGapSummary {
2747 runtime_files: 2,
2748 covered_files: 0,
2749 file_coverage_pct: 0.0,
2750 untested_files: 1,
2751 untested_exports: 1,
2752 },
2753 files: vec![UntestedFileFinding::with_actions(
2754 UntestedFile {
2755 path: root.join("src/app.ts"),
2756 value_export_count: 2,
2757 },
2758 &root,
2759 )],
2760 exports: vec![UntestedExportFinding::with_actions(
2761 UntestedExport {
2762 path: root.join("src/app.ts"),
2763 export_name: "loader".into(),
2764 line: 12,
2765 col: 4,
2766 },
2767 &root,
2768 )],
2769 }),
2770 ..Default::default()
2771 };
2772
2773 let md = build_health_markdown(&report, &root);
2774 assert!(md.contains("### Coverage Gaps"));
2775 assert!(md.contains("*1 untested files"));
2776 assert!(md.contains("`src/app.ts` (2 value exports)"));
2777 assert!(md.contains("`src/app.ts`:12 `loader`"));
2778 }
2779
2780 #[test]
2781 fn markdown_dep_in_workspace_shows_package_label() {
2782 let root = PathBuf::from("/project");
2783 let mut results = AnalysisResults::default();
2784 results
2785 .unused_dependencies
2786 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2787 package_name: "lodash".to_string(),
2788 location: DependencyLocation::Dependencies,
2789 path: root.join("packages/core/package.json"),
2790 line: 5,
2791 used_in_workspaces: Vec::new(),
2792 }));
2793 let md = build_markdown(&results, &root);
2794 assert!(md.contains("(packages/core/package.json)"));
2795 }
2796
2797 #[test]
2798 fn markdown_dep_at_root_no_extra_label() {
2799 let root = PathBuf::from("/project");
2800 let mut results = AnalysisResults::default();
2801 results
2802 .unused_dependencies
2803 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2804 package_name: "lodash".to_string(),
2805 location: DependencyLocation::Dependencies,
2806 path: root.join("package.json"),
2807 line: 5,
2808 used_in_workspaces: Vec::new(),
2809 }));
2810 let md = build_markdown(&results, &root);
2811 assert!(md.contains("- `lodash`"));
2812 assert!(!md.contains("(package.json)"));
2813 }
2814
2815 #[test]
2816 fn markdown_root_dep_with_cross_workspace_context_uses_context_label() {
2817 let root = PathBuf::from("/project");
2818 let mut results = AnalysisResults::default();
2819 results
2820 .unused_dependencies
2821 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2822 package_name: "lodash-es".to_string(),
2823 location: DependencyLocation::Dependencies,
2824 path: root.join("package.json"),
2825 line: 5,
2826 used_in_workspaces: vec![root.join("packages/consumer")],
2827 }));
2828 let md = build_markdown(&results, &root);
2829 assert!(md.contains("- `lodash-es` (imported in packages/consumer)"));
2830 assert!(!md.contains("(package.json; imported in packages/consumer)"));
2831 }
2832
2833 #[test]
2834 fn markdown_exports_grouped_by_file() {
2835 let root = PathBuf::from("/project");
2836 let mut results = AnalysisResults::default();
2837 results
2838 .unused_exports
2839 .push(UnusedExportFinding::with_actions(UnusedExport {
2840 path: root.join("src/utils.ts"),
2841 export_name: "alpha".to_string(),
2842 is_type_only: false,
2843 line: 5,
2844 col: 0,
2845 span_start: 0,
2846 is_re_export: false,
2847 }));
2848 results
2849 .unused_exports
2850 .push(UnusedExportFinding::with_actions(UnusedExport {
2851 path: root.join("src/utils.ts"),
2852 export_name: "beta".to_string(),
2853 is_type_only: false,
2854 line: 10,
2855 col: 0,
2856 span_start: 0,
2857 is_re_export: false,
2858 }));
2859 results
2860 .unused_exports
2861 .push(UnusedExportFinding::with_actions(UnusedExport {
2862 path: root.join("src/other.ts"),
2863 export_name: "gamma".to_string(),
2864 is_type_only: false,
2865 line: 1,
2866 col: 0,
2867 span_start: 0,
2868 is_re_export: false,
2869 }));
2870 let md = build_markdown(&results, &root);
2871 let utils_count = md.matches("- `src/utils.ts`").count();
2872 assert_eq!(utils_count, 1, "file header should appear once per file");
2873 assert!(md.contains(":5 `alpha`"));
2874 assert!(md.contains(":10 `beta`"));
2875 }
2876
2877 #[test]
2878 fn markdown_multiple_issues_plural() {
2879 let root = PathBuf::from("/project");
2880 let mut results = AnalysisResults::default();
2881 results
2882 .unused_files
2883 .push(UnusedFileFinding::with_actions(UnusedFile {
2884 path: root.join("src/a.ts"),
2885 }));
2886 results
2887 .unused_files
2888 .push(UnusedFileFinding::with_actions(UnusedFile {
2889 path: root.join("src/b.ts"),
2890 }));
2891 let md = build_markdown(&results, &root);
2892 assert!(md.starts_with("## Fallow: 2 issues found\n"));
2893 }
2894
2895 #[test]
2896 fn duplication_markdown_zero_savings_no_suffix() {
2897 let root = PathBuf::from("/project");
2898 let report = DuplicationReport {
2899 clone_groups: vec![CloneGroup {
2900 instances: vec![CloneInstance {
2901 file: root.join("src/a.ts"),
2902 start_line: 1,
2903 end_line: 5,
2904 start_col: 0,
2905 end_col: 0,
2906 fragment: String::new(),
2907 }],
2908 token_count: 30,
2909 line_count: 5,
2910 }],
2911 clone_families: vec![CloneFamily {
2912 files: vec![root.join("src/a.ts")],
2913 groups: vec![],
2914 total_duplicated_lines: 5,
2915 total_duplicated_tokens: 30,
2916 suggestions: vec![RefactoringSuggestion {
2917 kind: RefactoringKind::ExtractFunction,
2918 description: "Extract function".to_string(),
2919 estimated_savings: 0,
2920 }],
2921 }],
2922 mirrored_directories: vec![],
2923 stats: DuplicationStats {
2924 clone_groups: 1,
2925 clone_instances: 1,
2926 duplication_percentage: 1.0,
2927 ..Default::default()
2928 },
2929 };
2930 let md = build_duplication_markdown(&report, &root);
2931 assert!(md.contains("Extract function"));
2932 assert!(!md.contains("lines saved"));
2933 }
2934
2935 #[test]
2936 fn health_markdown_vital_signs_table() {
2937 let root = PathBuf::from("/project");
2938 let report = crate::health_types::HealthReport {
2939 summary: crate::health_types::HealthSummary {
2940 files_analyzed: 10,
2941 functions_analyzed: 50,
2942 ..Default::default()
2943 },
2944 vital_signs: Some(crate::health_types::VitalSigns {
2945 avg_cyclomatic: 3.5,
2946 p90_cyclomatic: 12,
2947 dead_file_pct: Some(5.0),
2948 dead_export_pct: Some(10.2),
2949 duplication_pct: None,
2950 maintainability_avg: Some(72.3),
2951 hotspot_count: Some(3),
2952 circular_dep_count: Some(1),
2953 unused_dep_count: Some(2),
2954 counts: None,
2955 unit_size_profile: None,
2956 unit_interfacing_profile: None,
2957 p95_fan_in: None,
2958 coupling_high_pct: None,
2959 total_loc: 15_200,
2960 ..Default::default()
2961 }),
2962 hotspot_summary: Some(crate::health_types::HotspotSummary {
2963 since: "6 months".to_string(),
2964 min_commits: 3,
2965 files_analyzed: 50,
2966 files_excluded: 0,
2967 shallow_clone: false,
2968 }),
2969 ..Default::default()
2970 };
2971 let md = build_health_markdown(&report, &root);
2972 assert!(md.contains("## Vital Signs"));
2973 assert!(md.contains("| Metric | Value |"));
2974 assert!(md.contains("| Total LOC | 15200 |"));
2975 assert!(md.contains("| Avg Cyclomatic | 3.5 |"));
2976 assert!(md.contains("| P90 Cyclomatic | 12 |"));
2977 assert!(md.contains("| Dead Files | 5.0% |"));
2978 assert!(md.contains("| Dead Exports | 10.2% |"));
2979 assert!(md.contains("| Maintainability (avg) | 72.3 |"));
2980 assert!(md.contains("| Hotspots (since 6 months) | 3 |"));
2981 assert!(md.contains("| Circular Deps | 1 |"));
2982 assert!(md.contains("| Unused Deps | 2 |"));
2983 }
2984
2985 #[test]
2986 fn health_markdown_hotspots_without_summary_omits_window() {
2987 let root = PathBuf::from("/project");
2988 let report = crate::health_types::HealthReport {
2989 vital_signs: Some(crate::health_types::VitalSigns {
2990 avg_cyclomatic: 2.0,
2991 p90_cyclomatic: 5,
2992 hotspot_count: Some(0),
2993 total_loc: 1_000,
2994 ..Default::default()
2995 }),
2996 hotspot_summary: None,
2997 ..Default::default()
2998 };
2999 let md = build_health_markdown(&report, &root);
3000 assert!(md.contains("| Hotspots | 0 |"));
3001 assert!(!md.contains("Hotspots (since"));
3002 }
3003
3004 #[test]
3005 fn health_markdown_file_scores_table() {
3006 let root = PathBuf::from("/project");
3007 let report = crate::health_types::HealthReport {
3008 findings: vec![
3009 crate::health_types::ComplexityViolation {
3010 path: root.join("src/dummy.ts"),
3011 name: "fn".to_string(),
3012 line: 1,
3013 col: 0,
3014 cyclomatic: 25,
3015 cognitive: 20,
3016 line_count: 50,
3017 param_count: 0,
3018 react_hook_count: 0,
3019 react_jsx_max_depth: 0,
3020 react_prop_count: 0,
3021 react_hook_profile: None,
3022 exceeded: crate::health_types::ExceededThreshold::Both,
3023 severity: crate::health_types::FindingSeverity::High,
3024 crap: None,
3025 coverage_pct: None,
3026 coverage_tier: None,
3027 coverage_source: None,
3028 inherited_from: None,
3029 component_rollup: None,
3030 contributions: Vec::new(),
3031 effective_thresholds: None,
3032 threshold_source: None,
3033 }
3034 .into(),
3035 ],
3036 summary: crate::health_types::HealthSummary {
3037 files_analyzed: 5,
3038 functions_analyzed: 10,
3039 functions_above_threshold: 1,
3040 files_scored: Some(1),
3041 average_maintainability: Some(65.0),
3042 ..Default::default()
3043 },
3044 file_scores: vec![crate::health_types::FileHealthScore {
3045 path: root.join("src/utils.ts"),
3046 fan_in: 5,
3047 fan_out: 3,
3048 dead_code_ratio: 0.25,
3049 complexity_density: 0.8,
3050 maintainability_index: 72.5,
3051 total_cyclomatic: 40,
3052 total_cognitive: 30,
3053 function_count: 10,
3054 lines: 200,
3055 crap_max: 0.0,
3056 crap_above_threshold: 0,
3057 }],
3058 ..Default::default()
3059 };
3060 let md = build_health_markdown(&report, &root);
3061 assert!(md.contains("### File Health Scores (1 files)"));
3062 assert!(md.contains("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density |"));
3063 assert!(md.contains("| `src/utils.ts` | 72.5 | 5 | 3 | 25% | 0.80 |"));
3064 assert!(md.contains("**Average maintainability index:** 65.0/100"));
3065 }
3066
3067 #[test]
3068 fn health_markdown_hotspots_table() {
3069 let root = PathBuf::from("/project");
3070 let report = crate::health_types::HealthReport {
3071 findings: vec![
3072 crate::health_types::ComplexityViolation {
3073 path: root.join("src/dummy.ts"),
3074 name: "fn".to_string(),
3075 line: 1,
3076 col: 0,
3077 cyclomatic: 25,
3078 cognitive: 20,
3079 line_count: 50,
3080 param_count: 0,
3081 react_hook_count: 0,
3082 react_jsx_max_depth: 0,
3083 react_prop_count: 0,
3084 react_hook_profile: None,
3085 exceeded: crate::health_types::ExceededThreshold::Both,
3086 severity: crate::health_types::FindingSeverity::High,
3087 crap: None,
3088 coverage_pct: None,
3089 coverage_tier: None,
3090 coverage_source: None,
3091 inherited_from: None,
3092 component_rollup: None,
3093 contributions: Vec::new(),
3094 effective_thresholds: None,
3095 threshold_source: None,
3096 }
3097 .into(),
3098 ],
3099 summary: crate::health_types::HealthSummary {
3100 files_analyzed: 5,
3101 functions_analyzed: 10,
3102 functions_above_threshold: 1,
3103 ..Default::default()
3104 },
3105 hotspots: vec![
3106 crate::health_types::HotspotEntry {
3107 path: root.join("src/hot.ts"),
3108 score: 85.0,
3109 commits: 42,
3110 weighted_commits: 35.0,
3111 lines_added: 500,
3112 lines_deleted: 200,
3113 complexity_density: 1.2,
3114 fan_in: 10,
3115 trend: fallow_core::churn::ChurnTrend::Accelerating,
3116 ownership: None,
3117 is_test_path: false,
3118 }
3119 .into(),
3120 ],
3121 hotspot_summary: Some(crate::health_types::HotspotSummary {
3122 since: "6 months".to_string(),
3123 min_commits: 3,
3124 files_analyzed: 50,
3125 files_excluded: 5,
3126 shallow_clone: false,
3127 }),
3128 ..Default::default()
3129 };
3130 let md = build_health_markdown(&report, &root);
3131 assert!(md.contains("### Hotspots (1 files, since 6 months)"));
3132 assert!(md.contains("| `src/hot.ts` | 85.0 | 42 | 700 | 1.20 | 10 | accelerating |"));
3133 assert!(md.contains("*5 files excluded (< 3 commits)*"));
3134 }
3135
3136 #[test]
3137 fn health_markdown_metric_legend_with_scores() {
3138 let root = PathBuf::from("/project");
3139 let report = crate::health_types::HealthReport {
3140 findings: vec![
3141 crate::health_types::ComplexityViolation {
3142 path: root.join("src/x.ts"),
3143 name: "f".to_string(),
3144 line: 1,
3145 col: 0,
3146 cyclomatic: 25,
3147 cognitive: 20,
3148 line_count: 10,
3149 param_count: 0,
3150 react_hook_count: 0,
3151 react_jsx_max_depth: 0,
3152 react_prop_count: 0,
3153 react_hook_profile: None,
3154 exceeded: crate::health_types::ExceededThreshold::Both,
3155 severity: crate::health_types::FindingSeverity::High,
3156 crap: None,
3157 coverage_pct: None,
3158 coverage_tier: None,
3159 coverage_source: None,
3160 inherited_from: None,
3161 component_rollup: None,
3162 contributions: Vec::new(),
3163 effective_thresholds: None,
3164 threshold_source: None,
3165 }
3166 .into(),
3167 ],
3168 summary: crate::health_types::HealthSummary {
3169 files_analyzed: 1,
3170 functions_analyzed: 1,
3171 functions_above_threshold: 1,
3172 files_scored: Some(1),
3173 average_maintainability: Some(70.0),
3174 ..Default::default()
3175 },
3176 file_scores: vec![crate::health_types::FileHealthScore {
3177 path: root.join("src/x.ts"),
3178 fan_in: 1,
3179 fan_out: 1,
3180 dead_code_ratio: 0.0,
3181 complexity_density: 0.5,
3182 maintainability_index: 80.0,
3183 total_cyclomatic: 10,
3184 total_cognitive: 8,
3185 function_count: 2,
3186 lines: 50,
3187 crap_max: 0.0,
3188 crap_above_threshold: 0,
3189 }],
3190 ..Default::default()
3191 };
3192 let md = build_health_markdown(&report, &root);
3193 assert!(md.contains("<details><summary>Metric definitions</summary>"));
3194 assert!(md.contains("**MI**: Maintainability Index"));
3195 assert!(md.contains("**Fan-in**"));
3196 assert!(md.contains("Full metric reference"));
3197 }
3198
3199 #[test]
3200 fn health_markdown_truncated_findings_shown_count() {
3201 let root = PathBuf::from("/project");
3202 let report = crate::health_types::HealthReport {
3203 findings: vec![
3204 crate::health_types::ComplexityViolation {
3205 path: root.join("src/x.ts"),
3206 name: "f".to_string(),
3207 line: 1,
3208 col: 0,
3209 cyclomatic: 25,
3210 cognitive: 20,
3211 line_count: 10,
3212 param_count: 0,
3213 react_hook_count: 0,
3214 react_jsx_max_depth: 0,
3215 react_prop_count: 0,
3216 react_hook_profile: None,
3217 exceeded: crate::health_types::ExceededThreshold::Both,
3218 severity: crate::health_types::FindingSeverity::High,
3219 crap: None,
3220 coverage_pct: None,
3221 coverage_tier: None,
3222 coverage_source: None,
3223 inherited_from: None,
3224 component_rollup: None,
3225 contributions: Vec::new(),
3226 effective_thresholds: None,
3227 threshold_source: None,
3228 }
3229 .into(),
3230 ],
3231 summary: crate::health_types::HealthSummary {
3232 files_analyzed: 10,
3233 functions_analyzed: 50,
3234 functions_above_threshold: 5, ..Default::default()
3236 },
3237 ..Default::default()
3238 };
3239 let md = build_health_markdown(&report, &root);
3240 assert!(md.contains("5 high complexity functions (1 shown)"));
3241 }
3242
3243 #[test]
3244 fn escape_backticks_handles_multiple() {
3245 assert_eq!(escape_backticks("a`b`c"), "a\\`b\\`c");
3246 }
3247
3248 #[test]
3249 fn escape_backticks_no_backticks_unchanged() {
3250 assert_eq!(escape_backticks("hello"), "hello");
3251 }
3252
3253 #[test]
3254 fn markdown_unresolved_import_grouped_by_file() {
3255 let root = PathBuf::from("/project");
3256 let mut results = AnalysisResults::default();
3257 results
3258 .unresolved_imports
3259 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
3260 path: root.join("src/app.ts"),
3261 specifier: "./missing".to_string(),
3262 line: 3,
3263 col: 0,
3264 specifier_col: 0,
3265 }));
3266 let md = build_markdown(&results, &root);
3267 assert!(md.contains("### Unresolved imports (1)"));
3268 assert!(md.contains("- `src/app.ts`"));
3269 assert!(md.contains(":3 `./missing`"));
3270 }
3271
3272 #[test]
3273 fn markdown_unused_optional_dep() {
3274 let root = PathBuf::from("/project");
3275 let mut results = AnalysisResults::default();
3276 results
3277 .unused_optional_dependencies
3278 .push(UnusedOptionalDependencyFinding::with_actions(
3279 UnusedDependency {
3280 package_name: "fsevents".to_string(),
3281 location: DependencyLocation::OptionalDependencies,
3282 path: root.join("package.json"),
3283 line: 12,
3284 used_in_workspaces: Vec::new(),
3285 },
3286 ));
3287 let md = build_markdown(&results, &root);
3288 assert!(md.contains("### Unused optionalDependencies (1)"));
3289 assert!(md.contains("- `fsevents`"));
3290 }
3291
3292 #[test]
3293 fn health_markdown_hotspots_no_excluded_message() {
3294 let root = PathBuf::from("/project");
3295 let report = crate::health_types::HealthReport {
3296 findings: vec![
3297 crate::health_types::ComplexityViolation {
3298 path: root.join("src/x.ts"),
3299 name: "f".to_string(),
3300 line: 1,
3301 col: 0,
3302 cyclomatic: 25,
3303 cognitive: 20,
3304 line_count: 10,
3305 param_count: 0,
3306 react_hook_count: 0,
3307 react_jsx_max_depth: 0,
3308 react_prop_count: 0,
3309 react_hook_profile: None,
3310 exceeded: crate::health_types::ExceededThreshold::Both,
3311 severity: crate::health_types::FindingSeverity::High,
3312 crap: None,
3313 coverage_pct: None,
3314 coverage_tier: None,
3315 coverage_source: None,
3316 inherited_from: None,
3317 component_rollup: None,
3318 contributions: Vec::new(),
3319 effective_thresholds: None,
3320 threshold_source: None,
3321 }
3322 .into(),
3323 ],
3324 summary: crate::health_types::HealthSummary {
3325 files_analyzed: 5,
3326 functions_analyzed: 10,
3327 functions_above_threshold: 1,
3328 ..Default::default()
3329 },
3330 hotspots: vec![
3331 crate::health_types::HotspotEntry {
3332 path: root.join("src/hot.ts"),
3333 score: 50.0,
3334 commits: 10,
3335 weighted_commits: 8.0,
3336 lines_added: 100,
3337 lines_deleted: 50,
3338 complexity_density: 0.5,
3339 fan_in: 3,
3340 trend: fallow_core::churn::ChurnTrend::Stable,
3341 ownership: None,
3342 is_test_path: false,
3343 }
3344 .into(),
3345 ],
3346 hotspot_summary: Some(crate::health_types::HotspotSummary {
3347 since: "6 months".to_string(),
3348 min_commits: 3,
3349 files_analyzed: 50,
3350 files_excluded: 0,
3351 shallow_clone: false,
3352 }),
3353 ..Default::default()
3354 };
3355 let md = build_health_markdown(&report, &root);
3356 assert!(!md.contains("files excluded"));
3357 }
3358
3359 #[test]
3360 fn duplication_markdown_single_group_no_plural() {
3361 let root = PathBuf::from("/project");
3362 let report = DuplicationReport {
3363 clone_groups: vec![CloneGroup {
3364 instances: vec![CloneInstance {
3365 file: root.join("src/a.ts"),
3366 start_line: 1,
3367 end_line: 5,
3368 start_col: 0,
3369 end_col: 0,
3370 fragment: String::new(),
3371 }],
3372 token_count: 30,
3373 line_count: 5,
3374 }],
3375 clone_families: vec![],
3376 mirrored_directories: vec![],
3377 stats: DuplicationStats {
3378 clone_groups: 1,
3379 clone_instances: 1,
3380 duplication_percentage: 2.0,
3381 ..Default::default()
3382 },
3383 };
3384 let md = build_duplication_markdown(&report, &root);
3385 assert!(md.contains("1 clone group found"));
3386 assert!(!md.contains("1 clone groups found"));
3387 }
3388
3389 #[test]
3394 fn display_complexity_entry_name_component_variant() {
3395 assert_eq!(
3396 display_complexity_entry_name("<component>"),
3397 "<component> (component rollup)"
3398 );
3399 }
3400
3401 #[test]
3406 fn markdown_private_type_leak_section() {
3407 use fallow_types::output_dead_code::PrivateTypeLeakFinding;
3408 use fallow_types::results::PrivateTypeLeak;
3409 let root = PathBuf::from("/project");
3410 let mut results = AnalysisResults::default();
3411 results
3412 .private_type_leaks
3413 .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
3414 path: root.join("src/api.ts"),
3415 export_name: "publicFn".to_string(),
3416 type_name: "InternalType".to_string(),
3417 line: 7,
3418 col: 0,
3419 span_start: 0,
3420 }));
3421 let md = build_markdown(&results, &root);
3422 assert!(md.contains("### Private type leaks (1)"));
3423 let normalized = md.replace('\\', "/");
3424 assert!(normalized.contains("`src/api.ts`"));
3425 assert!(normalized.contains("`publicFn` references private type `InternalType`"));
3426 }
3427
3428 #[test]
3433 fn markdown_circular_dep_cross_package_tag() {
3434 let root = PathBuf::from("/project");
3435 let mut results = AnalysisResults::default();
3436 results
3437 .circular_dependencies
3438 .push(CircularDependencyFinding::with_actions(
3439 CircularDependency {
3440 files: vec![root.join("pkg-a/src/a.ts"), root.join("pkg-b/src/b.ts")],
3441 length: 2,
3442 line: 1,
3443 col: 0,
3444 edges: Vec::new(),
3445 is_cross_package: true,
3446 },
3447 ));
3448 let md = build_markdown(&results, &root);
3449 assert!(md.contains("*(cross-package)*"));
3450 }
3451
3452 #[test]
3457 fn markdown_boundary_coverage_violation_format() {
3458 use fallow_types::output_dead_code::BoundaryCoverageViolationFinding;
3459 use fallow_types::results::BoundaryCoverageViolation;
3460 let root = PathBuf::from("/project");
3461 let mut results = AnalysisResults::default();
3462 results
3463 .boundary_coverage_violations
3464 .push(BoundaryCoverageViolationFinding::with_actions(
3465 BoundaryCoverageViolation {
3466 path: root.join("src/orphan.ts"),
3467 line: 1,
3468 col: 0,
3469 },
3470 ));
3471 let md = build_markdown(&results, &root);
3472 assert!(md.contains("### Boundary coverage (1)"));
3473 let normalized = md.replace('\\', "/");
3474 assert!(normalized.contains("src/orphan.ts"));
3475 assert!(normalized.contains("no matching boundary zone"));
3476 }
3477
3478 #[test]
3483 fn markdown_boundary_call_violation_format() {
3484 use fallow_types::output_dead_code::BoundaryCallViolationFinding;
3485 use fallow_types::results::BoundaryCallViolation;
3486 let root = PathBuf::from("/project");
3487 let mut results = AnalysisResults::default();
3488 results
3489 .boundary_call_violations
3490 .push(BoundaryCallViolationFinding::with_actions(
3491 BoundaryCallViolation {
3492 path: root.join("src/caller.ts"),
3493 line: 42,
3494 col: 0,
3495 callee: "dangerousCall".to_string(),
3496 zone: "public".to_string(),
3497 pattern: "dangerous*".to_string(),
3498 },
3499 ));
3500 let md = build_markdown(&results, &root);
3501 assert!(md.contains("### Boundary calls (1)"));
3502 let normalized = md.replace('\\', "/");
3503 assert!(normalized.contains("`dangerousCall` forbidden in zone `public`"));
3504 assert!(normalized.contains("pattern `dangerous*`"));
3505 }
3506
3507 #[test]
3512 fn markdown_policy_violation_without_message() {
3513 use fallow_types::output_dead_code::PolicyViolationFinding;
3514 use fallow_types::results::{PolicyRuleKind, PolicyViolation, PolicyViolationSeverity};
3515 let root = PathBuf::from("/project");
3516 let mut results = AnalysisResults::default();
3517 results
3518 .policy_violations
3519 .push(PolicyViolationFinding::with_actions(PolicyViolation {
3520 path: root.join("src/banned.ts"),
3521 line: 3,
3522 col: 0,
3523 matched: "eval".to_string(),
3524 pack: "security".to_string(),
3525 rule_id: "no-eval".to_string(),
3526 kind: PolicyRuleKind::BannedCall,
3527 severity: PolicyViolationSeverity::Error,
3528 message: None,
3529 }));
3530 let md = build_markdown(&results, &root);
3531 assert!(md.contains("### Policy violations (1)"));
3532 let normalized = md.replace('\\', "/");
3533 assert!(normalized.contains("`eval` banned by `security/no-eval`"));
3534 }
3535
3536 #[test]
3537 fn markdown_policy_violation_with_message() {
3538 use fallow_types::output_dead_code::PolicyViolationFinding;
3539 use fallow_types::results::{PolicyRuleKind, PolicyViolation, PolicyViolationSeverity};
3540 let root = PathBuf::from("/project");
3541 let mut results = AnalysisResults::default();
3542 results
3543 .policy_violations
3544 .push(PolicyViolationFinding::with_actions(PolicyViolation {
3545 path: root.join("src/banned.ts"),
3546 line: 8,
3547 col: 0,
3548 matched: "console.log".to_string(),
3549 pack: "style".to_string(),
3550 rule_id: "no-console".to_string(),
3551 kind: PolicyRuleKind::BannedCall,
3552 severity: PolicyViolationSeverity::Warn,
3553 message: Some("Use a logger instead".to_string()),
3554 }));
3555 let md = build_markdown(&results, &root);
3556 let normalized = md.replace('\\', "/");
3557 assert!(normalized.contains("(Use a logger instead)"));
3558 }
3559
3560 #[test]
3565 fn markdown_misplaced_directive_format() {
3566 use fallow_types::output_dead_code::MisplacedDirectiveFinding;
3567 use fallow_types::results::MisplacedDirective;
3568 let root = PathBuf::from("/project");
3569 let mut results = AnalysisResults::default();
3570 results
3571 .misplaced_directives
3572 .push(MisplacedDirectiveFinding::with_actions(
3573 MisplacedDirective {
3574 path: root.join("src/app.ts"),
3575 line: 10,
3576 col: 0,
3577 directive: "use client".to_string(),
3578 },
3579 ));
3580 let md = build_markdown(&results, &root);
3581 assert!(md.contains("### Misplaced directives (1)"));
3582 let normalized = md.replace('\\', "/");
3583 assert!(normalized.contains("src/app.ts"));
3584 assert!(normalized.contains("not in the leading position"));
3585 }
3586
3587 #[test]
3592 fn markdown_route_collision_format() {
3593 use fallow_types::output_dead_code::RouteCollisionFinding;
3594 use fallow_types::results::RouteCollision;
3595 let root = PathBuf::from("/project");
3596 let mut results = AnalysisResults::default();
3597 results
3598 .route_collisions
3599 .push(RouteCollisionFinding::with_actions(RouteCollision {
3600 path: root.join("app/dashboard/page.tsx"),
3601 url: "/dashboard".to_string(),
3602 conflicting_paths: vec![root.join("pages/dashboard.tsx")],
3603 line: 1,
3604 col: 0,
3605 }));
3606 let md = build_markdown(&results, &root);
3607 assert!(md.contains("### Route collisions (1)"));
3608 let normalized = md.replace('\\', "/");
3609 assert!(normalized.contains("/dashboard"));
3610 assert!(normalized.contains("dashboard"));
3611 }
3612
3613 #[test]
3618 fn markdown_dynamic_segment_name_conflict_format() {
3619 use fallow_types::output_dead_code::DynamicSegmentNameConflictFinding;
3620 use fallow_types::results::DynamicSegmentNameConflict;
3621 let root = PathBuf::from("/project");
3622 let mut results = AnalysisResults::default();
3623 results.dynamic_segment_name_conflicts.push(
3624 DynamicSegmentNameConflictFinding::with_actions(DynamicSegmentNameConflict {
3625 path: root.join("app/[slug]/page.tsx"),
3626 conflicting_segments: vec!["[slug]".to_string(), "[id]".to_string()],
3627 conflicting_paths: vec![root.join("app/[id]/page.tsx")],
3628 position: "/product".to_string(),
3629 line: 1,
3630 col: 0,
3631 }),
3632 );
3633 let md = build_markdown(&results, &root);
3634 assert!(md.contains("### Dynamic segment conflicts (1)"));
3635 let normalized = md.replace('\\', "/");
3636 assert!(normalized.contains("slug"));
3637 assert!(normalized.contains("id"));
3638 }
3639
3640 #[test]
3645 fn markdown_catalog_entry_with_hardcoded_consumers() {
3646 use fallow_types::output_dead_code::UnusedCatalogEntryFinding;
3647 use fallow_types::results::UnusedCatalogEntry;
3648 let root = PathBuf::from("/project");
3649 let mut results = AnalysisResults::default();
3650 results
3651 .unused_catalog_entries
3652 .push(UnusedCatalogEntryFinding::with_actions(
3653 UnusedCatalogEntry {
3654 entry_name: "lodash".to_string(),
3655 catalog_name: "default".to_string(),
3656 path: root.join("pnpm-workspace.yaml"),
3657 line: 4,
3658 hardcoded_consumers: vec![root.join("packages/legacy")],
3659 },
3660 ));
3661 let md = build_markdown(&results, &root);
3662 assert!(md.contains("### Unused catalog entries (1)"));
3663 let normalized = md.replace('\\', "/");
3664 assert!(normalized.contains("hardcoded in"));
3665 assert!(normalized.contains("packages/legacy"));
3666 }
3667
3668 #[test]
3673 fn markdown_unresolved_catalog_reference_with_alts() {
3674 use fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding;
3675 use fallow_types::results::UnresolvedCatalogReference;
3676 let root = PathBuf::from("/project");
3677 let mut results = AnalysisResults::default();
3678 results.unresolved_catalog_references.push(
3679 UnresolvedCatalogReferenceFinding::with_actions(UnresolvedCatalogReference {
3680 entry_name: "react".to_string(),
3681 catalog_name: "default".to_string(),
3682 path: root.join("packages/app/package.json"),
3683 line: 12,
3684 available_in_catalogs: vec!["shared".to_string()],
3685 }),
3686 );
3687 let md = build_markdown(&results, &root);
3688 assert!(md.contains("### Unresolved catalog references (1)"));
3689 let normalized = md.replace('\\', "/");
3690 assert!(normalized.contains("available in: `shared`"));
3691 }
3692
3693 #[test]
3698 fn health_markdown_vital_signs_all_optional_fields() {
3699 use crate::health_types::{HotspotSummary, VitalSigns};
3700 let root = PathBuf::from("/project");
3701 let report = crate::health_types::HealthReport {
3702 vital_signs: Some(VitalSigns {
3703 dead_file_pct: Some(2.5),
3704 dead_export_pct: Some(10.0),
3705 avg_cyclomatic: 3.2,
3706 critical_complexity_pct: None,
3707 p90_cyclomatic: 8,
3708 duplication_pct: None,
3709 hotspot_count: Some(4),
3710 hotspot_top_pct_count: None,
3711 maintainability_avg: Some(75.0),
3712 maintainability_low_pct: None,
3713 unused_dep_count: Some(3),
3714 unused_deps_per_k_files: None,
3715 circular_dep_count: Some(2),
3716 circular_deps_per_k_files: None,
3717 counts: None,
3718 unit_size_profile: None,
3719 functions_over_60_loc_per_k: None,
3720 unit_interfacing_profile: None,
3721 p95_fan_in: None,
3722 coupling_high_pct: None,
3723 prop_drilling_chain_count: None,
3724 prop_drilling_max_depth: None,
3725 p95_render_fan_in: None,
3726 render_fan_in_high_pct: None,
3727 max_render_fan_in: None,
3728 top_render_fan_in: Vec::new(),
3729 total_loc: 5000,
3730 }),
3731 hotspot_summary: Some(HotspotSummary {
3732 since: "3 months".to_string(),
3733 min_commits: 2,
3734 files_analyzed: 20,
3735 files_excluded: 0,
3736 shallow_clone: false,
3737 }),
3738 summary: crate::health_types::HealthSummary {
3739 files_analyzed: 10,
3740 functions_analyzed: 80,
3741 ..Default::default()
3742 },
3743 ..Default::default()
3744 };
3745 let md = build_health_markdown(&report, &root);
3746 assert!(md.contains("## Vital Signs"));
3747 assert!(md.contains("| Total LOC | 5000 |"));
3748 assert!(md.contains("| Avg Cyclomatic | 3.2 |"));
3749 assert!(md.contains("| Dead Files | 2.5% |"));
3750 assert!(md.contains("| Dead Exports | 10.0% |"));
3751 assert!(md.contains("| Maintainability (avg) | 75.0 |"));
3752 assert!(md.contains("| Hotspots (since 3 months) | 4 |"));
3753 assert!(md.contains("| Circular Deps | 2 |"));
3754 assert!(md.contains("| Unused Deps | 3 |"));
3755 }
3756
3757 #[test]
3758 fn health_markdown_vital_signs_hotspot_without_summary() {
3759 use crate::health_types::VitalSigns;
3760 let root = PathBuf::from("/project");
3761 let report = crate::health_types::HealthReport {
3762 vital_signs: Some(VitalSigns {
3763 avg_cyclomatic: 2.0,
3764 p90_cyclomatic: 5,
3765 total_loc: 0,
3766 dead_file_pct: None,
3767 dead_export_pct: None,
3768 critical_complexity_pct: None,
3769 duplication_pct: None,
3770 hotspot_count: Some(7),
3771 hotspot_top_pct_count: None,
3772 maintainability_avg: None,
3773 maintainability_low_pct: None,
3774 unused_dep_count: None,
3775 unused_deps_per_k_files: None,
3776 circular_dep_count: None,
3777 circular_deps_per_k_files: None,
3778 counts: None,
3779 unit_size_profile: None,
3780 functions_over_60_loc_per_k: None,
3781 unit_interfacing_profile: None,
3782 p95_fan_in: None,
3783 coupling_high_pct: None,
3784 prop_drilling_chain_count: None,
3785 prop_drilling_max_depth: None,
3786 p95_render_fan_in: None,
3787 render_fan_in_high_pct: None,
3788 max_render_fan_in: None,
3789 top_render_fan_in: Vec::new(),
3790 }),
3791 hotspot_summary: None,
3792 summary: crate::health_types::HealthSummary {
3793 files_analyzed: 5,
3794 functions_analyzed: 20,
3795 ..Default::default()
3796 },
3797 ..Default::default()
3798 };
3799 let md = build_health_markdown(&report, &root);
3800 assert!(md.contains("| Hotspots | 7 |"));
3802 assert!(!md.contains("since"));
3803 }
3804
3805 #[test]
3810 fn health_markdown_health_score_header() {
3811 use crate::health_types::{HealthScore, HealthScorePenalties};
3812 let root = PathBuf::from("/project");
3813 let report = crate::health_types::HealthReport {
3814 health_score: Some(HealthScore {
3815 formula_version: 2,
3816 score: 82.0,
3817 grade: "B",
3818 penalties: HealthScorePenalties {
3819 dead_files: None,
3820 dead_exports: None,
3821 complexity: 5.0,
3822 p90_complexity: 3.0,
3823 maintainability: None,
3824 hotspots: None,
3825 unused_deps: None,
3826 circular_deps: None,
3827 unit_size: None,
3828 coupling: None,
3829 duplication: None,
3830 prop_drilling: None,
3831 },
3832 }),
3833 summary: crate::health_types::HealthSummary {
3834 files_analyzed: 5,
3835 functions_analyzed: 20,
3836 ..Default::default()
3837 },
3838 ..Default::default()
3839 };
3840 let md = build_health_markdown(&report, &root);
3841 assert!(md.contains("## Health Score: 82 (B)"));
3842 }
3843
3844 #[test]
3849 fn health_markdown_threshold_overrides_active() {
3850 use crate::health_types::{
3851 HealthConfiguredThresholds, HealthEffectiveThresholds, ThresholdOverrideMetrics,
3852 ThresholdOverrideState, ThresholdOverrideStatus,
3853 };
3854 let root = PathBuf::from("/project");
3855 let report = crate::health_types::HealthReport {
3856 findings: vec![
3857 crate::health_types::ComplexityViolation {
3858 path: root.join("src/complex.ts"),
3859 name: "bigFn".to_string(),
3860 line: 10,
3861 col: 0,
3862 cyclomatic: 25,
3863 cognitive: 20,
3864 line_count: 50,
3865 param_count: 0,
3866 react_hook_count: 0,
3867 react_jsx_max_depth: 0,
3868 react_prop_count: 0,
3869 react_hook_profile: None,
3870 exceeded: crate::health_types::ExceededThreshold::Both,
3871 severity: crate::health_types::FindingSeverity::High,
3872 crap: None,
3873 coverage_pct: None,
3874 coverage_tier: None,
3875 coverage_source: None,
3876 inherited_from: None,
3877 component_rollup: None,
3878 contributions: Vec::new(),
3879 effective_thresholds: None,
3880 threshold_source: None,
3881 }
3882 .into(),
3883 ],
3884 summary: crate::health_types::HealthSummary {
3885 files_analyzed: 2,
3886 functions_analyzed: 5,
3887 functions_above_threshold: 1,
3888 ..Default::default()
3889 },
3890 threshold_overrides: vec![ThresholdOverrideState {
3891 status: ThresholdOverrideStatus::Active,
3892 override_index: 0,
3893 path: Some(root.join("src/complex.ts")),
3894 function: Some("bigFn".to_string()),
3895 configured_thresholds: HealthConfiguredThresholds {
3896 max_cyclomatic: Some(30),
3897 max_cognitive: Some(25),
3898 max_crap: None,
3899 },
3900 effective_thresholds: HealthEffectiveThresholds {
3901 max_cyclomatic: 30,
3902 max_cognitive: 25,
3903 max_crap: 30.0,
3904 },
3905 metrics: Some(ThresholdOverrideMetrics {
3906 cyclomatic: 25,
3907 cognitive: 20,
3908 crap: None,
3909 }),
3910 reason: None,
3911 }],
3912 ..Default::default()
3913 };
3914 let md = build_health_markdown(&report, &root);
3915 assert!(md.contains("## Health Threshold Overrides"));
3916 assert!(md.contains("| Override | Status |"));
3917 let normalized = md.replace('\\', "/");
3918 assert!(normalized.contains("active"));
3919 assert!(normalized.contains("src/complex.ts:bigFn"));
3920 assert!(normalized.contains("cyclomatic 25, cognitive 20"));
3921 }
3922
3923 #[test]
3924 fn health_markdown_threshold_overrides_stale_no_match() {
3925 use crate::health_types::{
3926 HealthConfiguredThresholds, HealthEffectiveThresholds, ThresholdOverrideState,
3927 ThresholdOverrideStatus,
3928 };
3929 let root = PathBuf::from("/project");
3930 let report = crate::health_types::HealthReport {
3931 findings: vec![
3932 crate::health_types::ComplexityViolation {
3933 path: root.join("src/x.ts"),
3934 name: "fn1".to_string(),
3935 line: 1,
3936 col: 0,
3937 cyclomatic: 22,
3938 cognitive: 18,
3939 line_count: 40,
3940 param_count: 0,
3941 react_hook_count: 0,
3942 react_jsx_max_depth: 0,
3943 react_prop_count: 0,
3944 react_hook_profile: None,
3945 exceeded: crate::health_types::ExceededThreshold::Both,
3946 severity: crate::health_types::FindingSeverity::High,
3947 crap: None,
3948 coverage_pct: None,
3949 coverage_tier: None,
3950 coverage_source: None,
3951 inherited_from: None,
3952 component_rollup: None,
3953 contributions: Vec::new(),
3954 effective_thresholds: None,
3955 threshold_source: None,
3956 }
3957 .into(),
3958 ],
3959 summary: crate::health_types::HealthSummary {
3960 files_analyzed: 1,
3961 functions_analyzed: 3,
3962 functions_above_threshold: 1,
3963 ..Default::default()
3964 },
3965 threshold_overrides: vec![
3966 ThresholdOverrideState {
3967 status: ThresholdOverrideStatus::Stale,
3968 override_index: 1,
3969 path: None,
3970 function: None,
3971 configured_thresholds: HealthConfiguredThresholds {
3972 max_cyclomatic: Some(40),
3973 max_cognitive: None,
3974 max_crap: None,
3975 },
3976 effective_thresholds: HealthEffectiveThresholds {
3977 max_cyclomatic: 40,
3978 max_cognitive: 15,
3979 max_crap: 30.0,
3980 },
3981 metrics: None,
3982 reason: None,
3983 },
3984 ThresholdOverrideState {
3985 status: ThresholdOverrideStatus::NoMatch,
3986 override_index: 2,
3987 path: None,
3988 function: None,
3989 configured_thresholds: HealthConfiguredThresholds {
3990 max_cyclomatic: None,
3991 max_cognitive: None,
3992 max_crap: Some(50.0),
3993 },
3994 effective_thresholds: HealthEffectiveThresholds {
3995 max_cyclomatic: 20,
3996 max_cognitive: 15,
3997 max_crap: 50.0,
3998 },
3999 metrics: None,
4000 reason: None,
4001 },
4002 ],
4003 ..Default::default()
4004 };
4005 let md = build_health_markdown(&report, &root);
4006 assert!(md.contains("stale"));
4007 assert!(md.contains("no_match"));
4008 assert!(md.contains("<no matching file or function>"));
4009 assert!(md.contains("| - |"));
4011 }
4012
4013 #[test]
4018 fn health_markdown_file_scores_section() {
4019 use crate::health_types::FileHealthScore;
4020 let root = PathBuf::from("/project");
4021 let report = crate::health_types::HealthReport {
4022 file_scores: vec![FileHealthScore {
4023 path: root.join("src/util.ts"),
4024 fan_in: 5,
4025 fan_out: 3,
4026 dead_code_ratio: 0.1,
4027 complexity_density: 0.25,
4028 maintainability_index: 68.0,
4029 total_cyclomatic: 40,
4030 total_cognitive: 30,
4031 function_count: 8,
4032 lines: 200,
4033 crap_max: 22.5,
4034 crap_above_threshold: 1,
4035 }],
4036 summary: crate::health_types::HealthSummary {
4037 files_analyzed: 5,
4038 functions_analyzed: 20,
4039 average_maintainability: Some(71.0),
4040 ..Default::default()
4041 },
4042 ..Default::default()
4043 };
4044 let md = build_health_markdown(&report, &root);
4045 assert!(md.contains("### File Health Scores (1 files)"));
4046 assert!(md.contains("| File | Maintainability |"));
4047 let normalized = md.replace('\\', "/");
4048 assert!(normalized.contains("`src/util.ts`"));
4049 assert!(normalized.contains("68.0"));
4050 assert!(md.contains("**Average maintainability index:** 71.0/100"));
4052 }
4053
4054 #[test]
4059 fn health_markdown_coverage_gaps_empty_files_and_exports() {
4060 use crate::health_types::{CoverageGapSummary, CoverageGaps};
4061 let root = PathBuf::from("/project");
4062 let report = crate::health_types::HealthReport {
4063 coverage_gaps: Some(CoverageGaps {
4064 summary: CoverageGapSummary {
4065 runtime_files: 5,
4066 covered_files: 5,
4067 file_coverage_pct: 100.0,
4068 untested_files: 0,
4069 untested_exports: 0,
4070 },
4071 files: vec![],
4072 exports: vec![],
4073 }),
4074 summary: crate::health_types::HealthSummary {
4075 files_analyzed: 5,
4076 functions_analyzed: 20,
4077 ..Default::default()
4078 },
4079 ..Default::default()
4080 };
4081 let md = build_health_markdown(&report, &root);
4082 assert!(md.contains("### Coverage Gaps"));
4083 assert!(md.contains("_No coverage gaps found in scope._"));
4084 }
4085
4086 #[test]
4091 fn health_markdown_hotspots_without_ownership() {
4092 let root = PathBuf::from("/project");
4093 let report = crate::health_types::HealthReport {
4094 findings: vec![
4095 crate::health_types::ComplexityViolation {
4096 path: root.join("src/x.ts"),
4097 name: "hotFn".to_string(),
4098 line: 5,
4099 col: 0,
4100 cyclomatic: 22,
4101 cognitive: 18,
4102 line_count: 60,
4103 param_count: 0,
4104 react_hook_count: 0,
4105 react_jsx_max_depth: 0,
4106 react_prop_count: 0,
4107 react_hook_profile: None,
4108 exceeded: crate::health_types::ExceededThreshold::Both,
4109 severity: crate::health_types::FindingSeverity::High,
4110 crap: None,
4111 coverage_pct: None,
4112 coverage_tier: None,
4113 coverage_source: None,
4114 inherited_from: None,
4115 component_rollup: None,
4116 contributions: Vec::new(),
4117 effective_thresholds: None,
4118 threshold_source: None,
4119 }
4120 .into(),
4121 ],
4122 summary: crate::health_types::HealthSummary {
4123 files_analyzed: 5,
4124 functions_analyzed: 10,
4125 functions_above_threshold: 1,
4126 ..Default::default()
4127 },
4128 hotspots: vec![
4129 crate::health_types::HotspotEntry {
4130 path: root.join("src/hot.ts"),
4131 score: 75.0,
4132 commits: 20,
4133 weighted_commits: 18.0,
4134 lines_added: 200,
4135 lines_deleted: 80,
4136 complexity_density: 0.7,
4137 fan_in: 8,
4138 trend: fallow_core::churn::ChurnTrend::Accelerating,
4139 ownership: None,
4140 is_test_path: false,
4141 }
4142 .into(),
4143 ],
4144 hotspot_summary: None,
4145 ..Default::default()
4146 };
4147 let md = build_health_markdown(&report, &root);
4148 assert!(md.contains("### Hotspots (1 files)"));
4149 assert!(!md.contains("since"));
4151 assert!(md.contains("| File | Score | Commits | Churn | Density | Fan-in | Trend |"));
4153 assert!(!md.contains("Bus"));
4154 let normalized = md.replace('\\', "/");
4155 assert!(normalized.contains("`src/hot.ts`"));
4156 assert!(md.contains("75.0"));
4157 assert!(md.contains("accelerating"));
4158 }
4159
4160 #[test]
4161 fn health_markdown_hotspots_with_summary_since_and_excluded() {
4162 let root = PathBuf::from("/project");
4163 let report = crate::health_types::HealthReport {
4164 findings: vec![
4165 crate::health_types::ComplexityViolation {
4166 path: root.join("src/z.ts"),
4167 name: "g".to_string(),
4168 line: 1,
4169 col: 0,
4170 cyclomatic: 22,
4171 cognitive: 18,
4172 line_count: 30,
4173 param_count: 0,
4174 react_hook_count: 0,
4175 react_jsx_max_depth: 0,
4176 react_prop_count: 0,
4177 react_hook_profile: None,
4178 exceeded: crate::health_types::ExceededThreshold::Both,
4179 severity: crate::health_types::FindingSeverity::High,
4180 crap: None,
4181 coverage_pct: None,
4182 coverage_tier: None,
4183 coverage_source: None,
4184 inherited_from: None,
4185 component_rollup: None,
4186 contributions: Vec::new(),
4187 effective_thresholds: None,
4188 threshold_source: None,
4189 }
4190 .into(),
4191 ],
4192 summary: crate::health_types::HealthSummary {
4193 files_analyzed: 5,
4194 functions_analyzed: 10,
4195 functions_above_threshold: 1,
4196 ..Default::default()
4197 },
4198 hotspots: vec![
4199 crate::health_types::HotspotEntry {
4200 path: root.join("src/churn.ts"),
4201 score: 60.0,
4202 commits: 15,
4203 weighted_commits: 12.0,
4204 lines_added: 150,
4205 lines_deleted: 40,
4206 complexity_density: 0.4,
4207 fan_in: 2,
4208 trend: fallow_core::churn::ChurnTrend::Cooling,
4209 ownership: None,
4210 is_test_path: false,
4211 }
4212 .into(),
4213 ],
4214 hotspot_summary: Some(crate::health_types::HotspotSummary {
4215 since: "12 months".to_string(),
4216 min_commits: 5,
4217 files_analyzed: 100,
4218 files_excluded: 3,
4219 shallow_clone: false,
4220 }),
4221 ..Default::default()
4222 };
4223 let md = build_health_markdown(&report, &root);
4224 assert!(md.contains("### Hotspots (1 files, since 12 months)"));
4225 assert!(md.contains("3 file"));
4227 assert!(md.contains("excluded"));
4228 assert!(md.contains("< 5 commits"));
4229 }
4230
4231 #[test]
4236 fn health_markdown_hotspots_with_ownership() {
4237 use crate::health_types::{
4238 ContributorEntry, ContributorIdentifierFormat, OwnershipMetrics, OwnershipState,
4239 };
4240 let root = PathBuf::from("/project");
4241 let report = crate::health_types::HealthReport {
4242 findings: vec![
4243 crate::health_types::ComplexityViolation {
4244 path: root.join("src/owned.ts"),
4245 name: "fn2".to_string(),
4246 line: 1,
4247 col: 0,
4248 cyclomatic: 22,
4249 cognitive: 18,
4250 line_count: 50,
4251 param_count: 0,
4252 react_hook_count: 0,
4253 react_jsx_max_depth: 0,
4254 react_prop_count: 0,
4255 react_hook_profile: None,
4256 exceeded: crate::health_types::ExceededThreshold::Both,
4257 severity: crate::health_types::FindingSeverity::High,
4258 crap: None,
4259 coverage_pct: None,
4260 coverage_tier: None,
4261 coverage_source: None,
4262 inherited_from: None,
4263 component_rollup: None,
4264 contributions: Vec::new(),
4265 effective_thresholds: None,
4266 threshold_source: None,
4267 }
4268 .into(),
4269 ],
4270 summary: crate::health_types::HealthSummary {
4271 files_analyzed: 2,
4272 functions_analyzed: 4,
4273 functions_above_threshold: 1,
4274 ..Default::default()
4275 },
4276 hotspots: vec![
4277 crate::health_types::HotspotEntry {
4278 path: root.join("src/owned.ts"),
4279 score: 80.0,
4280 commits: 25,
4281 weighted_commits: 22.0,
4282 lines_added: 300,
4283 lines_deleted: 100,
4284 complexity_density: 0.6,
4285 fan_in: 5,
4286 trend: fallow_core::churn::ChurnTrend::Stable,
4287 ownership: Some(OwnershipMetrics {
4288 bus_factor: 1,
4289 contributor_count: 2,
4290 top_contributor: ContributorEntry {
4291 identifier: "alice".to_string(),
4292 format: ContributorIdentifierFormat::Raw,
4293 share: 0.8,
4294 stale_days: 5,
4295 commits: 20,
4296 },
4297 recent_contributors: vec![],
4298 suggested_reviewers: vec![],
4299 declared_owner: Some("@team/core".to_string()),
4300 unowned: Some(false),
4301 ownership_state: OwnershipState::Active,
4302 drift: false,
4303 drift_reason: None,
4304 }),
4305 is_test_path: false,
4306 }
4307 .into(),
4308 ],
4309 hotspot_summary: None,
4310 ..Default::default()
4311 };
4312 let md = build_health_markdown(&report, &root);
4313 assert!(md.contains("| Bus | Top | Owner | Notes |"));
4315 assert!(md.contains("`alice` (80%)"));
4316 assert!(md.contains("@team/core"));
4317 }
4318
4319 #[test]
4324 fn health_markdown_trend_section() {
4325 use crate::health_types::{HealthTrend, TrendDirection, TrendMetric, TrendPoint};
4326 let root = PathBuf::from("/project");
4327 let report = crate::health_types::HealthReport {
4328 health_trend: Some(HealthTrend {
4329 compared_to: TrendPoint {
4330 timestamp: "2026-05-01T00:00:00Z".to_string(),
4331 git_sha: Some("abc1234".to_string()),
4332 score: Some(70.0),
4333 grade: Some("B".to_string()),
4334 coverage_model: None,
4335 snapshot_schema_version: None,
4336 },
4337 metrics: vec![TrendMetric {
4338 name: "score",
4339 label: "Health Score",
4340 previous: 70.0,
4341 current: 82.0,
4342 delta: 12.0,
4343 direction: TrendDirection::Improving,
4344 unit: "pts",
4345 previous_count: None,
4346 current_count: None,
4347 }],
4348 snapshots_loaded: 3,
4349 overall_direction: TrendDirection::Improving,
4350 }),
4351 summary: crate::health_types::HealthSummary {
4352 files_analyzed: 5,
4353 functions_analyzed: 20,
4354 ..Default::default()
4355 },
4356 ..Default::default()
4357 };
4358 let md = build_health_markdown(&report, &root);
4359 assert!(md.contains("## Trend (vs 2026-05-01 (abc1234))"));
4360 assert!(md.contains("| Metric | Previous | Current | Delta | Direction |"));
4361 assert!(md.contains("Health Score"));
4362 assert!(md.contains("+12"));
4363 assert!(md.contains("improving"));
4364 assert!(md.contains("3 snapshots available"));
4365 }
4366
4367 #[test]
4368 fn health_markdown_trend_single_snapshot_singular() {
4369 use crate::health_types::{HealthTrend, TrendDirection, TrendPoint};
4370 let root = PathBuf::from("/project");
4371 let report = crate::health_types::HealthReport {
4372 health_trend: Some(HealthTrend {
4373 compared_to: TrendPoint {
4374 timestamp: "2026-06-01T12:00:00Z".to_string(),
4375 git_sha: None,
4376 score: None,
4377 grade: None,
4378 coverage_model: None,
4379 snapshot_schema_version: None,
4380 },
4381 metrics: vec![],
4382 snapshots_loaded: 1,
4383 overall_direction: TrendDirection::Stable,
4384 }),
4385 summary: crate::health_types::HealthSummary {
4386 files_analyzed: 2,
4387 functions_analyzed: 5,
4388 ..Default::default()
4389 },
4390 ..Default::default()
4391 };
4392 let md = build_health_markdown(&report, &root);
4393 assert!(md.contains("1 snapshot available"));
4394 assert!(!md.contains("1 snapshots available"));
4395 }
4396
4397 #[test]
4402 fn health_markdown_trend_metric_percent_unit() {
4403 use crate::health_types::{HealthTrend, TrendDirection, TrendMetric, TrendPoint};
4404 let root = PathBuf::from("/project");
4405 let report = crate::health_types::HealthReport {
4406 health_trend: Some(HealthTrend {
4407 compared_to: TrendPoint {
4408 timestamp: "2026-04-01T00:00:00Z".to_string(),
4409 git_sha: None,
4410 score: None,
4411 grade: None,
4412 coverage_model: None,
4413 snapshot_schema_version: None,
4414 },
4415 metrics: vec![TrendMetric {
4416 name: "dead_file_pct",
4417 label: "Dead Files",
4418 previous: 5.0,
4419 current: 3.0,
4420 delta: -2.0,
4421 direction: TrendDirection::Improving,
4422 unit: "%",
4423 previous_count: None,
4424 current_count: None,
4425 }],
4426 snapshots_loaded: 2,
4427 overall_direction: TrendDirection::Improving,
4428 }),
4429 summary: crate::health_types::HealthSummary {
4430 files_analyzed: 5,
4431 functions_analyzed: 20,
4432 ..Default::default()
4433 },
4434 ..Default::default()
4435 };
4436 let md = build_health_markdown(&report, &root);
4437 assert!(md.contains("5.0%"));
4439 assert!(md.contains("3.0%"));
4440 assert!(md.contains("-2.0%"));
4441 }
4442
4443 #[test]
4448 fn health_markdown_runtime_coverage_with_watermark() {
4449 use crate::health_types::{
4450 RuntimeCoverageReport, RuntimeCoverageReportVerdict, RuntimeCoverageSchemaVersion,
4451 RuntimeCoverageSummary, RuntimeCoverageWatermark,
4452 };
4453 let root = PathBuf::from("/project");
4454 let report = crate::health_types::HealthReport {
4455 runtime_coverage: Some(RuntimeCoverageReport {
4456 schema_version: RuntimeCoverageSchemaVersion::V1,
4457 verdict: RuntimeCoverageReportVerdict::Clean,
4458 signals: vec![],
4459 summary: RuntimeCoverageSummary {
4460 functions_tracked: 100,
4461 functions_hit: 90,
4462 functions_unhit: 10,
4463 functions_untracked: 5,
4464 coverage_percent: 90.0,
4465 trace_count: 5000,
4466 period_days: 7,
4467 deployments_seen: 2,
4468 ..Default::default()
4469 },
4470 findings: vec![],
4471 hot_paths: vec![],
4472 blast_radius: vec![],
4473 importance: vec![],
4474 watermark: Some(RuntimeCoverageWatermark::TrialExpired),
4475 warnings: vec![],
4476 }),
4477 summary: crate::health_types::HealthSummary {
4478 files_analyzed: 5,
4479 functions_analyzed: 30,
4480 ..Default::default()
4481 },
4482 ..Default::default()
4483 };
4484 let md = build_health_markdown(&report, &root);
4485 assert!(md.contains("## Runtime Coverage"));
4486 assert!(md.contains("- Watermark: trial-expired"));
4487 }
4488
4489 #[test]
4494 fn health_markdown_runtime_coverage_hot_paths() {
4495 use crate::health_types::{
4496 RuntimeCoverageHotPath, RuntimeCoverageReport, RuntimeCoverageReportVerdict,
4497 RuntimeCoverageSchemaVersion, RuntimeCoverageSummary,
4498 };
4499 let root = PathBuf::from("/project");
4500 let report = crate::health_types::HealthReport {
4501 runtime_coverage: Some(RuntimeCoverageReport {
4502 schema_version: RuntimeCoverageSchemaVersion::V1,
4503 verdict: RuntimeCoverageReportVerdict::HotPathTouched,
4504 signals: vec![],
4505 summary: RuntimeCoverageSummary {
4506 functions_tracked: 50,
4507 functions_hit: 40,
4508 functions_unhit: 10,
4509 functions_untracked: 0,
4510 coverage_percent: 80.0,
4511 trace_count: 2000,
4512 period_days: 3,
4513 deployments_seen: 1,
4514 ..Default::default()
4515 },
4516 findings: vec![],
4517 hot_paths: vec![RuntimeCoverageHotPath {
4518 id: "fallow:hot:deadbeef".to_string(),
4519 stable_id: None,
4520 path: root.join("src/service.ts"),
4521 function: "handleRequest".to_string(),
4522 line: 42,
4523 end_line: 80,
4524 invocations: 12_345,
4525 percentile: 99,
4526 actions: vec![],
4527 }],
4528 blast_radius: vec![],
4529 importance: vec![],
4530 watermark: None,
4531 warnings: vec![],
4532 }),
4533 summary: crate::health_types::HealthSummary {
4534 files_analyzed: 5,
4535 functions_analyzed: 20,
4536 ..Default::default()
4537 },
4538 ..Default::default()
4539 };
4540 let md = build_health_markdown(&report, &root);
4541 assert!(md.contains("| ID | Hot path | Function |"));
4542 let normalized = md.replace('\\', "/");
4543 assert!(normalized.contains("fallow:hot:deadbeef"));
4544 assert!(normalized.contains("handleRequest"));
4545 assert!(normalized.contains("12345"));
4546 }
4547
4548 #[test]
4553 fn health_markdown_css_analytics_basic() {
4554 use crate::health_types::{CssAnalyticsReport, CssAnalyticsSummary};
4555 let root = PathBuf::from("/project");
4556 let report = crate::health_types::HealthReport {
4557 css_analytics: Some(CssAnalyticsReport {
4558 files: vec![],
4559 summary: CssAnalyticsSummary {
4560 files_analyzed: 3,
4561 total_rules: 120,
4562 total_declarations: 600,
4563 important_declarations: 10,
4564 empty_rules: 2,
4565 max_nesting_depth: 4,
4566 unique_colors: 15,
4567 unique_font_sizes: 8,
4568 unique_z_indexes: 3,
4569 unique_box_shadows: 2,
4570 unique_border_radii: 5,
4571 unique_line_heights: 4,
4572 custom_properties_defined: 20,
4573 custom_properties_unreferenced: 3,
4574 custom_properties_undefined: 1,
4575 keyframes_defined: 5,
4576 keyframes_unreferenced: 2,
4577 keyframes_undefined: 1,
4578 scoped_unused_classes: 4,
4579 duplicate_declaration_blocks: 1,
4580 duplicate_declarations_total: 8,
4581 tailwind_arbitrary_values: 3,
4582 tailwind_arbitrary_value_uses: 7,
4583 unused_property_registrations: 0,
4584 unused_layers: 1,
4585 unresolved_class_references: 2,
4586 unreferenced_css_classes: 0,
4587 unused_font_faces: 1,
4588 unused_theme_tokens: 0,
4589 font_size_units_used: 2,
4590 notable_truncated_files: 0,
4591 },
4592 scoped_unused: vec![],
4593 unreferenced_keyframes: vec![],
4594 undefined_keyframes: vec![],
4595 duplicate_declaration_blocks: vec![],
4596 tailwind_arbitrary_values: vec![],
4597 unused_at_rules: vec![],
4598 unresolved_class_references: vec![],
4599 unreferenced_css_classes: vec![],
4600 unused_font_faces: vec![],
4601 unused_theme_tokens: vec![],
4602 font_size_unit_mix: None,
4603 }),
4604 summary: crate::health_types::HealthSummary {
4605 files_analyzed: 5,
4606 functions_analyzed: 20,
4607 ..Default::default()
4608 },
4609 ..Default::default()
4610 };
4611 let md = build_health_markdown(&report, &root);
4612 assert!(md.contains("## CSS Health"));
4613 assert!(md.contains("Stylesheets: 3 | Rules: 120"));
4614 assert!(md.contains("Value sprawl:"));
4615 assert!(md.contains("Candidates:"));
4616 }
4617
4618 #[test]
4619 fn health_markdown_css_analytics_undefined_keyframes() {
4620 use crate::health_types::{
4621 CssAnalyticsReport, CssAnalyticsSummary, CssCandidateAction, CssCandidateActionType,
4622 UndefinedKeyframes,
4623 };
4624 let root = PathBuf::from("/project");
4625 let report = crate::health_types::HealthReport {
4626 css_analytics: Some(CssAnalyticsReport {
4627 files: vec![],
4628 summary: CssAnalyticsSummary {
4629 files_analyzed: 1,
4630 total_rules: 10,
4631 total_declarations: 50,
4632 important_declarations: 0,
4633 empty_rules: 0,
4634 max_nesting_depth: 2,
4635 unique_colors: 5,
4636 unique_font_sizes: 2,
4637 unique_z_indexes: 1,
4638 unique_box_shadows: 0,
4639 unique_border_radii: 2,
4640 unique_line_heights: 2,
4641 custom_properties_defined: 5,
4642 custom_properties_unreferenced: 0,
4643 custom_properties_undefined: 0,
4644 keyframes_defined: 2,
4645 keyframes_unreferenced: 0,
4646 keyframes_undefined: 1,
4647 scoped_unused_classes: 0,
4648 duplicate_declaration_blocks: 0,
4649 duplicate_declarations_total: 0,
4650 tailwind_arbitrary_values: 0,
4651 tailwind_arbitrary_value_uses: 0,
4652 unused_property_registrations: 0,
4653 unused_layers: 0,
4654 unresolved_class_references: 0,
4655 unreferenced_css_classes: 0,
4656 unused_font_faces: 0,
4657 unused_theme_tokens: 0,
4658 font_size_units_used: 1,
4659 notable_truncated_files: 0,
4660 },
4661 scoped_unused: vec![],
4662 unreferenced_keyframes: vec![],
4663 undefined_keyframes: vec![UndefinedKeyframes {
4664 name: "slide-in".to_string(),
4665 path: "src/styles.css".to_string(),
4666 actions: vec![CssCandidateAction {
4667 kind: CssCandidateActionType::VerifyUnused,
4668 auto_fixable: false,
4669 description: "Verify unused keyframe".to_string(),
4670 command: None,
4671 }],
4672 }],
4673 duplicate_declaration_blocks: vec![],
4674 tailwind_arbitrary_values: vec![],
4675 unused_at_rules: vec![],
4676 unresolved_class_references: vec![],
4677 unreferenced_css_classes: vec![],
4678 unused_font_faces: vec![],
4679 unused_theme_tokens: vec![],
4680 font_size_unit_mix: None,
4681 }),
4682 summary: crate::health_types::HealthSummary {
4683 files_analyzed: 2,
4684 functions_analyzed: 5,
4685 ..Default::default()
4686 },
4687 ..Default::default()
4688 };
4689 let md = build_health_markdown(&report, &root);
4690 assert!(md.contains("Undefined @keyframes"));
4691 assert!(md.contains("`slide-in`"));
4692 }
4693
4694 #[test]
4695 fn health_markdown_css_analytics_tailwind_and_class_candidates() {
4696 use crate::health_types::{
4697 CssAnalyticsReport, CssAnalyticsSummary, CssCandidateAction, CssCandidateActionType,
4698 TailwindArbitraryValue, UnreferencedCssClass, UnresolvedClassReference,
4699 };
4700 let root = PathBuf::from("/project");
4701 let report = crate::health_types::HealthReport {
4702 css_analytics: Some(CssAnalyticsReport {
4703 files: vec![],
4704 summary: CssAnalyticsSummary {
4705 files_analyzed: 1,
4706 total_rules: 5,
4707 total_declarations: 20,
4708 important_declarations: 0,
4709 empty_rules: 0,
4710 max_nesting_depth: 1,
4711 unique_colors: 2,
4712 unique_font_sizes: 1,
4713 unique_z_indexes: 0,
4714 unique_box_shadows: 0,
4715 unique_border_radii: 1,
4716 unique_line_heights: 1,
4717 custom_properties_defined: 0,
4718 custom_properties_unreferenced: 0,
4719 custom_properties_undefined: 0,
4720 keyframes_defined: 0,
4721 keyframes_unreferenced: 0,
4722 keyframes_undefined: 0,
4723 scoped_unused_classes: 0,
4724 duplicate_declaration_blocks: 0,
4725 duplicate_declarations_total: 0,
4726 tailwind_arbitrary_values: 2,
4727 tailwind_arbitrary_value_uses: 4,
4728 unused_property_registrations: 0,
4729 unused_layers: 0,
4730 unresolved_class_references: 1,
4731 unreferenced_css_classes: 1,
4732 unused_font_faces: 0,
4733 unused_theme_tokens: 0,
4734 font_size_units_used: 1,
4735 notable_truncated_files: 0,
4736 },
4737 scoped_unused: vec![],
4738 unreferenced_keyframes: vec![],
4739 undefined_keyframes: vec![],
4740 duplicate_declaration_blocks: vec![],
4741 tailwind_arbitrary_values: vec![TailwindArbitraryValue {
4742 value: "w-[42px]".to_string(),
4743 count: 3,
4744 path: "src/App.tsx".to_string(),
4745 line: 7,
4746 actions: vec![CssCandidateAction {
4747 kind: CssCandidateActionType::VerifyUnused,
4748 auto_fixable: false,
4749 description: "Replace with a scale token".to_string(),
4750 command: None,
4751 }],
4752 }],
4753 unused_at_rules: vec![],
4754 unresolved_class_references: vec![UnresolvedClassReference {
4755 class: "btn-primry".to_string(),
4756 suggestion: "btn-primary".to_string(),
4757 path: "src/index.html".to_string(),
4758 line: 15,
4759 actions: vec![],
4760 }],
4761 unreferenced_css_classes: vec![UnreferencedCssClass {
4762 class: "old-header".to_string(),
4763 path: "src/styles.css".to_string(),
4764 line: 22,
4765 actions: vec![],
4766 }],
4767 unused_font_faces: vec![],
4768 unused_theme_tokens: vec![],
4769 font_size_unit_mix: None,
4770 }),
4771 summary: crate::health_types::HealthSummary {
4772 files_analyzed: 2,
4773 functions_analyzed: 5,
4774 ..Default::default()
4775 },
4776 ..Default::default()
4777 };
4778 let md = build_health_markdown(&report, &root);
4779 assert!(md.contains("Top Tailwind arbitrary values"));
4780 assert!(md.contains("`w-[42px]` (3x)"));
4781 assert!(md.contains("Likely class typos"));
4782 assert!(md.contains("`btn-primry` -> `btn-primary`"));
4783 assert!(md.contains("Unreferenced global classes"));
4784 assert!(md.contains("`.old-header`"));
4785 }
4786
4787 #[test]
4788 fn health_markdown_css_analytics_font_candidates() {
4789 use crate::health_types::{
4790 CssAnalyticsReport, CssAnalyticsSummary, CssCandidateAction, CssCandidateActionType,
4791 CssNotationConsistency, CssNotationCount, UnusedFontFace, UnusedThemeToken,
4792 };
4793 let root = PathBuf::from("/project");
4794 let report = crate::health_types::HealthReport {
4795 css_analytics: Some(CssAnalyticsReport {
4796 files: vec![],
4797 summary: CssAnalyticsSummary {
4798 files_analyzed: 1,
4799 total_rules: 5,
4800 total_declarations: 20,
4801 important_declarations: 0,
4802 empty_rules: 0,
4803 max_nesting_depth: 1,
4804 unique_colors: 2,
4805 unique_font_sizes: 3,
4806 unique_z_indexes: 0,
4807 unique_box_shadows: 0,
4808 unique_border_radii: 1,
4809 unique_line_heights: 1,
4810 custom_properties_defined: 0,
4811 custom_properties_unreferenced: 0,
4812 custom_properties_undefined: 0,
4813 keyframes_defined: 0,
4814 keyframes_unreferenced: 0,
4815 keyframes_undefined: 0,
4816 scoped_unused_classes: 0,
4817 duplicate_declaration_blocks: 0,
4818 duplicate_declarations_total: 0,
4819 tailwind_arbitrary_values: 0,
4820 tailwind_arbitrary_value_uses: 0,
4821 unused_property_registrations: 0,
4822 unused_layers: 0,
4823 unresolved_class_references: 0,
4824 unreferenced_css_classes: 0,
4825 unused_font_faces: 1,
4826 unused_theme_tokens: 1,
4827 font_size_units_used: 2,
4828 notable_truncated_files: 0,
4829 },
4830 scoped_unused: vec![],
4831 unreferenced_keyframes: vec![],
4832 undefined_keyframes: vec![],
4833 duplicate_declaration_blocks: vec![],
4834 tailwind_arbitrary_values: vec![],
4835 unused_at_rules: vec![],
4836 unresolved_class_references: vec![],
4837 unreferenced_css_classes: vec![],
4838 unused_font_faces: vec![UnusedFontFace {
4839 family: "OldFont".to_string(),
4840 path: "src/fonts.css".to_string(),
4841 actions: vec![CssCandidateAction {
4842 kind: CssCandidateActionType::VerifyUnused,
4843 auto_fixable: false,
4844 description: "Check if used from JS".to_string(),
4845 command: None,
4846 }],
4847 }],
4848 unused_theme_tokens: vec![UnusedThemeToken {
4849 token: "--color-stale".to_string(),
4850 namespace: "color".to_string(),
4851 path: "src/theme.css".to_string(),
4852 line: 10,
4853 actions: vec![],
4854 }],
4855 font_size_unit_mix: Some(CssNotationConsistency {
4856 axis: "Font sizes".to_string(),
4857 notations: vec![
4858 CssNotationCount {
4859 notation: "rem".to_string(),
4860 count: 8,
4861 },
4862 CssNotationCount {
4863 notation: "px".to_string(),
4864 count: 3,
4865 },
4866 ],
4867 actions: vec![],
4868 }),
4869 }),
4870 summary: crate::health_types::HealthSummary {
4871 files_analyzed: 2,
4872 functions_analyzed: 5,
4873 ..Default::default()
4874 },
4875 ..Default::default()
4876 };
4877 let md = build_health_markdown(&report, &root);
4878 assert!(md.contains("Unused @font-face"));
4879 assert!(md.contains("`OldFont` (src/fonts.css)"));
4880 assert!(md.contains("Unused @theme tokens"));
4881 assert!(md.contains("`--color-stale` (src/theme.css:10)"));
4882 assert!(md.contains("Font sizes mix 2 units"));
4883 assert!(md.contains("8 rem"));
4884 assert!(md.contains("3 px"));
4885 }
4886
4887 #[test]
4892 fn health_markdown_metric_legend_shown_when_relevant() {
4893 use crate::health_types::FileHealthScore;
4894 let root = PathBuf::from("/project");
4895 let report = crate::health_types::HealthReport {
4896 file_scores: vec![FileHealthScore {
4897 path: root.join("src/x.ts"),
4898 fan_in: 1,
4899 fan_out: 1,
4900 dead_code_ratio: 0.0,
4901 complexity_density: 0.1,
4902 maintainability_index: 80.0,
4903 total_cyclomatic: 5,
4904 total_cognitive: 4,
4905 function_count: 2,
4906 lines: 50,
4907 crap_max: 5.0,
4908 crap_above_threshold: 0,
4909 }],
4910 summary: crate::health_types::HealthSummary {
4911 files_analyzed: 1,
4912 functions_analyzed: 2,
4913 ..Default::default()
4914 },
4915 ..Default::default()
4916 };
4917 let md = build_health_markdown(&report, &root);
4918 assert!(md.contains("<details><summary>Metric definitions</summary>"));
4919 assert!(md.contains("**MI**"));
4920 assert!(md.contains("[Full metric reference]"));
4921 }
4922
4923 #[test]
4924 fn health_markdown_metric_legend_not_shown_without_sections() {
4925 let root = PathBuf::from("/project");
4926 let report = crate::health_types::HealthReport {
4927 findings: vec![
4928 crate::health_types::ComplexityViolation {
4929 path: root.join("src/y.ts"),
4930 name: "noLegend".to_string(),
4931 line: 1,
4932 col: 0,
4933 cyclomatic: 22,
4934 cognitive: 18,
4935 line_count: 30,
4936 param_count: 0,
4937 react_hook_count: 0,
4938 react_jsx_max_depth: 0,
4939 react_prop_count: 0,
4940 react_hook_profile: None,
4941 exceeded: crate::health_types::ExceededThreshold::Both,
4942 severity: crate::health_types::FindingSeverity::High,
4943 crap: None,
4944 coverage_pct: None,
4945 coverage_tier: None,
4946 coverage_source: None,
4947 inherited_from: None,
4948 component_rollup: None,
4949 contributions: Vec::new(),
4950 effective_thresholds: None,
4951 threshold_source: None,
4952 }
4953 .into(),
4954 ],
4955 summary: crate::health_types::HealthSummary {
4956 files_analyzed: 1,
4957 functions_analyzed: 1,
4958 functions_above_threshold: 1,
4959 ..Default::default()
4960 },
4961 ..Default::default()
4962 };
4963 let md = build_health_markdown(&report, &root);
4964 assert!(!md.contains("<details>"));
4967 }
4968
4969 #[test]
4974 fn health_markdown_coverage_gaps_files_section_singular() {
4975 use crate::health_types::{
4976 CoverageGapSummary, CoverageGaps, UntestedFile, UntestedFileFinding,
4977 };
4978 let root = PathBuf::from("/project");
4979 let report = crate::health_types::HealthReport {
4980 coverage_gaps: Some(CoverageGaps {
4981 summary: CoverageGapSummary {
4982 runtime_files: 1,
4983 covered_files: 0,
4984 file_coverage_pct: 0.0,
4985 untested_files: 1,
4986 untested_exports: 0,
4987 },
4988 files: vec![UntestedFileFinding::with_actions(
4989 UntestedFile {
4990 path: root.join("src/single.ts"),
4991 value_export_count: 1,
4992 },
4993 &root,
4994 )],
4995 exports: vec![],
4996 }),
4997 summary: crate::health_types::HealthSummary {
4998 files_analyzed: 2,
4999 functions_analyzed: 5,
5000 ..Default::default()
5001 },
5002 ..Default::default()
5003 };
5004 let md = build_health_markdown(&report, &root);
5005 assert!(md.contains("#### Files"));
5006 let normalized = md.replace('\\', "/");
5007 assert!(normalized.contains("`src/single.ts` (1 value export)"));
5009 }
5010
5011 #[test]
5016 fn health_markdown_findings_subset_shown() {
5017 let root = PathBuf::from("/project");
5018 let report = crate::health_types::HealthReport {
5019 findings: vec![
5020 crate::health_types::ComplexityViolation {
5021 path: root.join("src/big.ts"),
5022 name: "bigFn".to_string(),
5023 line: 5,
5024 col: 0,
5025 cyclomatic: 30,
5026 cognitive: 25,
5027 line_count: 100,
5028 param_count: 0,
5029 react_hook_count: 0,
5030 react_jsx_max_depth: 0,
5031 react_prop_count: 0,
5032 react_hook_profile: None,
5033 exceeded: crate::health_types::ExceededThreshold::Both,
5034 severity: crate::health_types::FindingSeverity::High,
5035 crap: None,
5036 coverage_pct: None,
5037 coverage_tier: None,
5038 coverage_source: None,
5039 inherited_from: None,
5040 component_rollup: None,
5041 contributions: Vec::new(),
5042 effective_thresholds: None,
5043 threshold_source: None,
5044 }
5045 .into(),
5046 ],
5047 summary: crate::health_types::HealthSummary {
5048 files_analyzed: 2,
5049 functions_analyzed: 10,
5050 functions_above_threshold: 5,
5052 ..Default::default()
5053 },
5054 ..Default::default()
5055 };
5056 let md = build_health_markdown(&report, &root);
5057 assert!(md.contains("## Fallow: 5 high complexity functions (1 shown)"));
5059 }
5060
5061 #[test]
5066 fn markdown_unused_type_export_format() {
5067 let root = PathBuf::from("/project");
5068 let mut results = AnalysisResults::default();
5069 results
5070 .unused_types
5071 .push(UnusedTypeFinding::with_actions(UnusedExport {
5072 path: root.join("src/types.ts"),
5073 export_name: "MyInterface".to_string(),
5074 is_type_only: true,
5075 line: 3,
5076 col: 0,
5077 span_start: 0,
5078 is_re_export: false,
5079 }));
5080 let md = build_markdown(&results, &root);
5081 assert!(md.contains("### Unused type exports (1)"));
5082 let normalized = md.replace('\\', "/");
5083 assert!(normalized.contains("- `src/types.ts`"));
5084 assert!(normalized.contains(":3 `MyInterface`"));
5085 }
5086
5087 #[test]
5092 fn markdown_dep_with_multiple_workspace_consumers() {
5093 let root = PathBuf::from("/project");
5094 let mut results = AnalysisResults::default();
5095 results
5096 .unused_dependencies
5097 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
5098 package_name: "shared-utils".to_string(),
5099 location: DependencyLocation::Dependencies,
5100 path: root.join("packages/core/package.json"),
5101 line: 3,
5102 used_in_workspaces: vec![root.join("packages/app"), root.join("packages/admin")],
5103 }));
5104 let md = build_markdown(&results, &root);
5105 let normalized = md.replace('\\', "/");
5106 assert!(normalized.contains("imported in packages/app, packages/admin"));
5107 }
5108
5109 #[test]
5114 fn health_markdown_component_rollup_entry_label() {
5115 let root = PathBuf::from("/project");
5116 let report = crate::health_types::HealthReport {
5117 findings: vec![
5118 crate::health_types::ComplexityViolation {
5119 path: root.join("src/Card.vue"),
5120 name: "<component>".to_string(),
5121 line: 1,
5122 col: 0,
5123 cyclomatic: 10,
5124 cognitive: 14,
5125 line_count: 60,
5126 param_count: 0,
5127 react_hook_count: 0,
5128 react_jsx_max_depth: 0,
5129 react_prop_count: 0,
5130 react_hook_profile: None,
5131 exceeded: crate::health_types::ExceededThreshold::Cognitive,
5132 severity: crate::health_types::FindingSeverity::Moderate,
5133 crap: None,
5134 coverage_pct: None,
5135 coverage_tier: None,
5136 coverage_source: None,
5137 inherited_from: None,
5138 component_rollup: None,
5139 contributions: Vec::new(),
5140 effective_thresholds: None,
5141 threshold_source: None,
5142 }
5143 .into(),
5144 ],
5145 summary: crate::health_types::HealthSummary {
5146 files_analyzed: 1,
5147 functions_analyzed: 1,
5148 functions_above_threshold: 1,
5149 ..Default::default()
5150 },
5151 ..Default::default()
5152 };
5153 let md = build_health_markdown(&report, &root);
5154 assert!(md.contains("| File | Entry |"));
5156 assert!(md.contains("`<component> (component rollup)`"));
5157 }
5158}