use std::borrow::Cow;
use std::fmt::Write;
use std::path::Path;
use fallow_engine::duplicates::DuplicationReport;
use fallow_types::output_dead_code::*;
use fallow_types::results::{AnalysisResults, UnusedExport, UnusedMember};
use fallow_output::normalize_uri;
use crate::ResultGroup;
fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
path.strip_prefix(root).unwrap_or(path)
}
fn plural(count: usize) -> &'static str {
if count == 1 { "" } else { "s" }
}
fn format_window(seconds: u64) -> String {
if seconds < 60 {
return format!("{seconds} s");
}
let minutes = seconds / 60;
if minutes < 120 {
return format!("{minutes} min");
}
let hours = minutes / 60;
if hours < 48 {
format!("{hours} h")
} else {
format!("{} d", hours / 24)
}
}
fn escape_backticks(s: &str) -> String {
s.replace('`', "\\`")
}
fn display_complexity_entry_name(name: &str) -> Cow<'_, str> {
match name {
"<template>" => Cow::Borrowed("<template> (template complexity)"),
"<component>" => Cow::Borrowed("<component> (component rollup)"),
_ => Cow::Borrowed(name),
}
}
pub fn build_markdown(results: &AnalysisResults, root: &Path) -> String {
let total = results.total_issues();
let mut out = String::new();
if total == 0 {
out.push_str("## Fallow: no issues found\n");
return out;
}
let _ = write!(out, "## Fallow: {total} issue{} found\n\n", plural(total));
push_markdown_primary_sections(&mut out, results, root);
push_markdown_import_sections(&mut out, results, root);
push_markdown_dependency_detail_sections(&mut out, results, root);
push_markdown_graph_sections(&mut out, results, &|path| {
markdown_relative_path(path, root)
});
push_markdown_catalog_sections(&mut out, results, &|path| {
markdown_relative_path(path, root)
});
out
}
fn markdown_relative_path(path: &Path, root: &Path) -> String {
escape_backticks(&normalize_uri(
&relative_path(path, root).display().to_string(),
))
}
fn push_markdown_primary_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
markdown_section(out, &results.unused_files, "Unused files", |file| {
vec![format!(
"- `{}`",
markdown_relative_path(&file.file.path, root)
)]
});
markdown_grouped_section(
out,
&results.unused_exports,
"Unused exports",
root,
|e| e.export.path.as_path(),
|e: &UnusedExportFinding| format_export(&e.export),
);
markdown_grouped_section(
out,
&results.unused_types,
"Unused type exports",
root,
|e| e.export.path.as_path(),
|e: &UnusedTypeFinding| format_export(&e.export),
);
markdown_grouped_section(
out,
&results.private_type_leaks,
"Private type leaks",
root,
|e| e.leak.path.as_path(),
format_private_type_leak,
);
push_markdown_dependency_sections(out, results, root);
push_markdown_member_sections(out, results, root);
}
fn push_markdown_import_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
markdown_grouped_section(
out,
&results.unresolved_imports,
"Unresolved imports",
root,
|i| i.import.path.as_path(),
|i| {
format!(
":{} `{}`",
i.import.line,
escape_backticks(&i.import.specifier)
)
},
);
markdown_section(
out,
&results.unlisted_dependencies,
"Unlisted dependencies",
|dep| vec![format!("- `{}`", escape_backticks(&dep.dep.package_name))],
);
markdown_section(
out,
&results.duplicate_exports,
"Duplicate exports",
|dup| {
let locations: Vec<String> = dup
.export
.locations
.iter()
.map(|loc| format!("`{}`", markdown_relative_path(&loc.path, root)))
.collect();
vec![format!(
"- `{}` in {}",
escape_backticks(&dup.export.export_name),
locations.join(", ")
)]
},
);
}
fn push_markdown_dependency_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
markdown_section(
out,
&results.unused_dependencies,
"Unused dependencies",
|dep| {
format_dependency(
&dep.dep.package_name,
&dep.dep.path,
&dep.dep.used_in_workspaces,
root,
)
},
);
markdown_section(
out,
&results.unused_dev_dependencies,
"Unused devDependencies",
|dep| {
format_dependency(
&dep.dep.package_name,
&dep.dep.path,
&dep.dep.used_in_workspaces,
root,
)
},
);
markdown_section(
out,
&results.unused_optional_dependencies,
"Unused optionalDependencies",
|dep| {
format_dependency(
&dep.dep.package_name,
&dep.dep.path,
&dep.dep.used_in_workspaces,
root,
)
},
);
}
fn push_markdown_member_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
markdown_grouped_section(
out,
&results.unused_enum_members,
"Unused enum members",
root,
|m| m.member.path.as_path(),
|m: &UnusedEnumMemberFinding| format_member(&m.member),
);
markdown_grouped_section(
out,
&results.unused_class_members,
"Unused class members",
root,
|m| m.member.path.as_path(),
|m: &UnusedClassMemberFinding| format_member(&m.member),
);
markdown_grouped_section(
out,
&results.unused_store_members,
"Unused store members",
root,
|m| m.member.path.as_path(),
|m: &UnusedStoreMemberFinding| format_member(&m.member),
);
}
fn push_markdown_dependency_detail_sections(
out: &mut String,
results: &AnalysisResults,
root: &Path,
) {
markdown_section(
out,
&results.type_only_dependencies,
"Type-only dependencies (consider moving to devDependencies)",
|dep| format_dependency(&dep.dep.package_name, &dep.dep.path, &[], root),
);
markdown_section(
out,
&results.test_only_dependencies,
"Test-only production dependencies (consider moving to devDependencies)",
|dep| format_dependency(&dep.dep.package_name, &dep.dep.path, &[], root),
);
}
fn push_markdown_graph_sections(
out: &mut String,
results: &AnalysisResults,
rel: &dyn Fn(&Path) -> String,
) {
push_markdown_structure_sections(out, results, rel);
push_markdown_framework_sections(out, results, rel);
push_markdown_component_sections(out, results, rel);
push_markdown_suppression_sections(out, results, rel);
}
fn push_markdown_structure_sections(
out: &mut String,
results: &AnalysisResults,
rel: &dyn Fn(&Path) -> String,
) {
markdown_section(
out,
&results.circular_dependencies,
"Circular dependencies",
|cycle| format_markdown_circular_dependency(cycle, rel),
);
markdown_section(
out,
&results.re_export_cycles,
"Re-export cycles",
|cycle| format_markdown_re_export_cycle(cycle, rel),
);
markdown_section(
out,
&results.boundary_violations,
"Boundary violations",
|v| format_markdown_boundary_violation(v, rel),
);
markdown_section(
out,
&results.boundary_coverage_violations,
"Boundary coverage",
|v| format_markdown_boundary_coverage(v, rel),
);
markdown_section(
out,
&results.boundary_call_violations,
"Boundary calls",
|v| format_markdown_boundary_call(v, rel),
);
markdown_section(out, &results.policy_violations, "Policy violations", |v| {
format_markdown_policy_violation(v, rel)
});
}
fn push_markdown_framework_sections(
out: &mut String,
results: &AnalysisResults,
rel: &dyn Fn(&Path) -> String,
) {
markdown_section(
out,
&results.invalid_client_exports,
"Invalid client exports",
|e| format_markdown_invalid_client_export(e, rel),
);
markdown_section(
out,
&results.mixed_client_server_barrels,
"Mixed client/server barrels",
|b| format_markdown_mixed_client_server_barrel(b, rel),
);
markdown_section(
out,
&results.misplaced_directives,
"Misplaced directives",
|d| format_markdown_misplaced_directive(d, rel),
);
markdown_section(out, &results.route_collisions, "Route collisions", |c| {
format_markdown_route_collision(c, rel)
});
markdown_section(
out,
&results.dynamic_segment_name_conflicts,
"Dynamic segment conflicts",
|c| format_markdown_dynamic_segment_name_conflict(c, rel),
);
markdown_section(
out,
&results.unprovided_injects,
"Unprovided injects",
|i| format_markdown_unprovided_inject(i, rel),
);
}
fn push_markdown_component_sections(
out: &mut String,
results: &AnalysisResults,
rel: &dyn Fn(&Path) -> String,
) {
markdown_section(
out,
&results.unrendered_components,
"Unrendered components",
|c| format_markdown_unrendered_component(c, rel),
);
markdown_section(
out,
&results.unused_component_props,
"Unused component props",
|p| format_markdown_unused_component_prop(p, rel),
);
markdown_section(
out,
&results.unused_component_emits,
"Unused component emits",
|e| format_markdown_unused_component_emit(e, rel),
);
markdown_section(
out,
&results.unused_component_inputs,
"Unused component inputs",
|i| format_markdown_unused_component_input(i, rel),
);
markdown_section(
out,
&results.unused_component_outputs,
"Unused component outputs",
|o| format_markdown_unused_component_output(o, rel),
);
markdown_section(
out,
&results.unused_svelte_events,
"Unused Svelte events",
|e| format_markdown_unused_svelte_event(e, rel),
);
markdown_section(
out,
&results.unused_server_actions,
"Unused server actions",
|a| format_markdown_unused_server_action(a, rel),
);
markdown_section(
out,
&results.unused_load_data_keys,
"Unused load data keys",
|k| format_markdown_unused_load_data_key(k, rel),
);
}
fn push_markdown_suppression_sections(
out: &mut String,
results: &AnalysisResults,
rel: &dyn Fn(&Path) -> String,
) {
markdown_section(
out,
&results.stale_suppressions,
"Stale suppressions",
|s| {
vec![format!(
"- `{}`:{} `{}` ({})",
rel(&s.path),
s.line,
escape_backticks(&s.description()),
escape_backticks(&s.explanation()),
)]
},
);
}
fn format_markdown_circular_dependency(
cycle: &fallow_types::output_dead_code::CircularDependencyFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
let mut display_chain = chain.clone();
if let Some(first) = chain.first() {
display_chain.push(first.clone());
}
let cross_pkg_tag = if cycle.cycle.is_cross_package {
" *(cross-package)*"
} else {
""
};
vec![format!(
"- {}{}",
display_chain
.iter()
.map(|s| format!("`{s}`"))
.collect::<Vec<_>>()
.join(" \u{2192} "),
cross_pkg_tag
)]
}
fn format_markdown_re_export_cycle(
cycle: &fallow_types::output_dead_code::ReExportCycleFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
let kind_tag = match cycle.cycle.kind {
fallow_types::results::ReExportCycleKind::SelfLoop => " *(self-loop)*",
fallow_types::results::ReExportCycleKind::MultiNode => "",
};
vec![format!(
"- {}{}",
chain
.iter()
.map(|s| format!("`{s}`"))
.collect::<Vec<_>>()
.join(" <-> "),
kind_tag
)]
}
fn format_markdown_boundary_violation(
v: &fallow_types::output_dead_code::BoundaryViolationFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
vec![format!(
"- `{}`:{} \u{2192} `{}` ({} \u{2192} {})",
rel(&v.violation.from_path),
v.violation.line,
rel(&v.violation.to_path),
v.violation.from_zone,
v.violation.to_zone,
)]
}
fn format_markdown_boundary_coverage(
v: &fallow_types::output_dead_code::BoundaryCoverageViolationFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
vec![format!(
"- `{}`:{} no matching boundary zone",
rel(&v.violation.path),
v.violation.line,
)]
}
fn format_markdown_boundary_call(
v: &fallow_types::output_dead_code::BoundaryCallViolationFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
vec![format!(
"- `{}`:{} `{}` forbidden in zone `{}` (pattern `{}`)",
rel(&v.violation.path),
v.violation.line,
v.violation.callee,
v.violation.zone,
v.violation.pattern,
)]
}
fn format_markdown_policy_violation(
v: &fallow_types::output_dead_code::PolicyViolationFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
vec![format!(
"- `{}`:{} `{}` banned by `{}/{}`{}",
rel(&v.violation.path),
v.violation.line,
v.violation.matched,
v.violation.pack,
v.violation.rule_id,
v.violation
.message
.as_deref()
.map(|m| format!(" ({m})"))
.unwrap_or_default(),
)]
}
fn format_markdown_invalid_client_export(
e: &fallow_types::output_dead_code::InvalidClientExportFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
vec![format!(
"- `{}`:{} `{}` (from `\"{}\"`)",
rel(&e.export.path),
e.export.line,
e.export.export_name,
e.export.directive,
)]
}
fn format_markdown_mixed_client_server_barrel(
b: &fallow_types::output_dead_code::MixedClientServerBarrelFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
vec![format!(
"- `{}`:{} re-exports client `{}` and server-only `{}`",
rel(&b.barrel.path),
b.barrel.line,
b.barrel.client_origin,
b.barrel.server_origin,
)]
}
fn format_markdown_misplaced_directive(
d: &fallow_types::output_dead_code::MisplacedDirectiveFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
vec![format!(
"- `{}`:{} `\"{}\"` is not in the leading position and is ignored",
rel(&d.directive_site.path),
d.directive_site.line,
d.directive_site.directive,
)]
}
fn format_markdown_unprovided_inject(
i: &fallow_types::output_dead_code::UnprovidedInjectFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
vec![format!(
"- `{}`:{} `{}` has no matching provide(`{}`) in this project; at runtime it returns undefined",
rel(&i.inject.path),
i.inject.line,
escape_backticks(&i.inject.key_name),
escape_backticks(&i.inject.key_name),
)]
}
fn format_markdown_unrendered_component(
c: &fallow_types::output_dead_code::UnrenderedComponentFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
if c.component.framework == "lit" {
return vec![format!(
"- `{}`:{} `<{}>` is a registered custom element but rendered in no template (render it or remove it)",
rel(&c.component.path),
c.component.line,
escape_backticks(&c.component.component_name),
)];
}
vec![format!(
"- `{}`:{} `{}` is reachable but rendered nowhere in this project (render it somewhere or remove it)",
rel(&c.component.path),
c.component.line,
escape_backticks(&c.component.component_name),
)]
}
fn format_markdown_unused_component_prop(
p: &fallow_types::output_dead_code::UnusedComponentPropFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
vec![format!(
"- `{}`:{} `{}` is declared but referenced nowhere in this component (remove it or use it)",
rel(&p.prop.path),
p.prop.line,
escape_backticks(&p.prop.prop_name),
)]
}
fn format_markdown_unused_component_emit(
e: &fallow_types::output_dead_code::UnusedComponentEmitFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
vec![format!(
"- `{}`:{} `{}` is declared but emitted nowhere in this component (remove it or emit it)",
rel(&e.emit.path),
e.emit.line,
escape_backticks(&e.emit.emit_name),
)]
}
fn format_markdown_unused_svelte_event(
e: &fallow_types::output_dead_code::UnusedSvelteEventFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
vec![format!(
"- `{}`:{} `{}` is dispatched but listened to nowhere in the project (remove it or listen for it)",
rel(&e.event.path),
e.event.line,
escape_backticks(&e.event.event_name),
)]
}
fn format_markdown_unused_component_input(
i: &fallow_types::output_dead_code::UnusedComponentInputFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
vec![format!(
"- `{}`:{} `{}` is declared but referenced nowhere in this component (remove it or use it)",
rel(&i.input.path),
i.input.line,
escape_backticks(&i.input.input_name),
)]
}
fn format_markdown_unused_component_output(
o: &fallow_types::output_dead_code::UnusedComponentOutputFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
vec![format!(
"- `{}`:{} `{}` is declared but emitted nowhere in this component (remove it or emit it)",
rel(&o.output.path),
o.output.line,
escape_backticks(&o.output.output_name),
)]
}
fn format_markdown_unused_server_action(
a: &fallow_types::output_dead_code::UnusedServerActionFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
vec![format!(
"- `{}`:{} `{}` is exported from a \"use server\" file but no code in this project references it",
rel(&a.action.path),
a.action.line,
escape_backticks(&a.action.action_name),
)]
}
fn format_markdown_unused_load_data_key(
k: &fallow_types::output_dead_code::UnusedLoadDataKeyFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
vec![format!(
"- `{}`:{} `{}` is returned from load() but no consumer reads it",
rel(&k.key.path),
k.key.line,
escape_backticks(&k.key.key_name),
)]
}
fn format_markdown_route_collision(
c: &fallow_types::output_dead_code::RouteCollisionFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
vec![format!(
"- `{}` resolves to `{}` (shared with {} other route file(s))",
rel(&c.collision.path),
c.collision.url,
c.collision.conflicting_paths.len(),
)]
}
fn format_markdown_dynamic_segment_name_conflict(
c: &fallow_types::output_dead_code::DynamicSegmentNameConflictFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
vec![format!(
"- `{}` crashes at runtime: different slug names ({}) at the same dynamic path `{}`; \
`next build` passes but the route fails on its first request (rename to one consistent slug)",
rel(&c.conflict.path),
c.conflict.conflicting_segments.join(" vs "),
c.conflict.position,
)]
}
fn push_markdown_catalog_sections(
out: &mut String,
results: &AnalysisResults,
rel: &dyn Fn(&Path) -> String,
) {
markdown_section(
out,
&results.unused_catalog_entries,
"Unused catalog entries",
|entry| format_unused_catalog_entry(entry, rel),
);
markdown_section(
out,
&results.empty_catalog_groups,
"Empty catalog groups",
|group| {
vec![format!(
"- `{}` `{}`:{}",
escape_backticks(&group.group.catalog_name),
rel(&group.group.path),
group.group.line,
)]
},
);
markdown_section(
out,
&results.unresolved_catalog_references,
"Unresolved catalog references",
|finding| format_unresolved_catalog_reference(finding, rel),
);
markdown_section(
out,
&results.unused_dependency_overrides,
"Unused dependency overrides",
|finding| format_unused_dependency_override(finding, rel),
);
markdown_section(
out,
&results.misconfigured_dependency_overrides,
"Misconfigured dependency overrides",
|finding| {
vec![format!(
"- `{}` -> `{}` (`{}`) `{}`:{} ({})",
escape_backticks(&finding.entry.raw_key),
escape_backticks(&finding.entry.raw_value),
finding.entry.source.as_label(),
rel(&finding.entry.path),
finding.entry.line,
finding.entry.reason.describe(),
)]
},
);
}
fn format_unused_catalog_entry(
entry: &UnusedCatalogEntryFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
let mut row = format!(
"- `{}` (`{}`) `{}`:{}",
escape_backticks(&entry.entry.entry_name),
escape_backticks(&entry.entry.catalog_name),
rel(&entry.entry.path),
entry.entry.line,
);
if !entry.entry.hardcoded_consumers.is_empty() {
let consumers = entry
.entry
.hardcoded_consumers
.iter()
.map(|p| format!("`{}`", rel(p)))
.collect::<Vec<_>>()
.join(", ");
let _ = write!(row, " (hardcoded in {consumers})");
}
vec![row]
}
fn format_unresolved_catalog_reference(
finding: &UnresolvedCatalogReferenceFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
let mut row = format!(
"- `{}` (`{}`) `{}`:{}",
escape_backticks(&finding.reference.entry_name),
escape_backticks(&finding.reference.catalog_name),
rel(&finding.reference.path),
finding.reference.line,
);
if !finding.reference.available_in_catalogs.is_empty() {
let alts = finding
.reference
.available_in_catalogs
.iter()
.map(|c| format!("`{}`", escape_backticks(c)))
.collect::<Vec<_>>()
.join(", ");
let _ = write!(row, " (available in: {alts})");
}
vec![row]
}
fn format_unused_dependency_override(
finding: &UnusedDependencyOverrideFinding,
rel: &dyn Fn(&Path) -> String,
) -> Vec<String> {
let mut row = format!(
"- `{}` -> `{}` (`{}`) `{}`:{}",
escape_backticks(&finding.entry.raw_key),
escape_backticks(&finding.entry.version_range),
finding.entry.source.as_label(),
rel(&finding.entry.path),
finding.entry.line,
);
if let Some(hint) = &finding.entry.hint {
let _ = write!(row, " (hint: {})", escape_backticks(hint));
}
vec![row]
}
#[must_use]
pub fn build_grouped_markdown(groups: &[ResultGroup], root: &Path) -> String {
let total: usize = groups.iter().map(|g| g.results.total_issues()).sum();
let mut out = String::new();
if total == 0 {
out.push_str("## Fallow: no issues found\n");
return out;
}
let _ = writeln!(
out,
"## Fallow: {total} issue{} found (grouped)\n",
plural(total)
);
for group in groups {
let count = group.results.total_issues();
if count == 0 {
continue;
}
let _ = writeln!(
out,
"## {} ({count} issue{})\n",
escape_backticks(&group.key),
plural(count)
);
if let Some(ref owners) = group.owners
&& !owners.is_empty()
{
let joined = owners
.iter()
.map(|owner| escape_backticks(owner))
.collect::<Vec<_>>()
.join(" ");
let _ = writeln!(out, "Owners: {joined}\n");
}
let body = build_markdown(&group.results, root);
let sections = body
.strip_prefix("## Fallow: no issues found\n")
.or_else(|| body.find("\n\n").map(|pos| &body[pos + 2..]))
.unwrap_or(&body);
out.push_str(sections);
}
out
}
fn format_export(e: &UnusedExport) -> String {
let re = if e.is_re_export { " (re-export)" } else { "" };
format!(":{} `{}`{re}", e.line, escape_backticks(&e.export_name))
}
fn format_private_type_leak(
entry: &fallow_types::output_dead_code::PrivateTypeLeakFinding,
) -> String {
let e = &entry.leak;
format!(
":{} `{}` references private type `{}`",
e.line,
escape_backticks(&e.export_name),
escape_backticks(&e.type_name)
)
}
fn format_member(m: &UnusedMember) -> String {
format!(
":{} `{}.{}`",
m.line,
escape_backticks(&m.parent_name),
escape_backticks(&m.member_name)
)
}
fn format_dependency(
dep_name: &str,
pkg_path: &Path,
used_in_workspaces: &[std::path::PathBuf],
root: &Path,
) -> Vec<String> {
let name = escape_backticks(dep_name);
let pkg_label = relative_path(pkg_path, root).display().to_string();
let workspace_context = if used_in_workspaces.is_empty() {
String::new()
} else {
let workspaces = used_in_workspaces
.iter()
.map(|path| escape_backticks(&relative_path(path, root).display().to_string()))
.collect::<Vec<_>>()
.join(", ");
format!("; imported in {workspaces}")
};
if pkg_label == "package.json" && workspace_context.is_empty() {
vec![format!("- `{name}`")]
} else {
let label = if pkg_label == "package.json" {
workspace_context.trim_start_matches("; ").to_string()
} else {
format!("{}{workspace_context}", escape_backticks(&pkg_label))
};
vec![format!("- `{name}` ({label})")]
}
}
fn markdown_section<T>(
out: &mut String,
items: &[T],
title: &str,
format_lines: impl Fn(&T) -> Vec<String>,
) {
if items.is_empty() {
return;
}
let _ = write!(out, "### {title} ({})\n\n", items.len());
for item in items {
for line in format_lines(item) {
out.push_str(&line);
out.push('\n');
}
}
out.push('\n');
}
fn markdown_grouped_section<'a, T>(
out: &mut String,
items: &'a [T],
title: &str,
root: &Path,
get_path: impl Fn(&'a T) -> &'a Path,
format_detail: impl Fn(&T) -> String,
) {
if items.is_empty() {
return;
}
let _ = write!(out, "### {title} ({})\n\n", items.len());
let mut indices: Vec<usize> = (0..items.len()).collect();
indices.sort_by(|&a, &b| get_path(&items[a]).cmp(get_path(&items[b])));
let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
let mut last_file = String::new();
for &i in &indices {
let item = &items[i];
let file_str = rel(get_path(item));
if file_str != last_file {
let _ = writeln!(out, "- `{file_str}`");
last_file = file_str;
}
let _ = writeln!(out, " - {}", format_detail(item));
}
out.push('\n');
}
#[must_use]
pub fn build_duplication_markdown(report: &DuplicationReport, root: &Path) -> String {
let mut out = String::new();
if report.clone_groups.is_empty() {
out.push_str("## Fallow: no code duplication found\n");
return out;
}
let stats = &report.stats;
let _ = write!(
out,
"## Fallow: {} clone group{} found ({:.1}% duplication)\n\n",
stats.clone_groups,
plural(stats.clone_groups),
stats.duplication_percentage,
);
write_duplication_groups(&mut out, report, root);
write_duplication_families(&mut out, report, root);
let _ = writeln!(
out,
"**Summary:** {} duplicated lines ({:.1}%) across {} file{}",
stats.duplicated_lines,
stats.duplication_percentage,
stats.files_with_clones,
plural(stats.files_with_clones),
);
out
}
fn write_duplication_groups(out: &mut String, report: &DuplicationReport, root: &Path) {
let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
out.push_str("### Duplicates\n\n");
for (i, group) in report.clone_groups.iter().enumerate() {
let instance_count = group.instances.len();
let _ = write!(
out,
"**Clone group {}** ({} lines, {instance_count} instance{})\n\n",
i + 1,
group.line_count,
plural(instance_count)
);
for instance in &group.instances {
let relative = rel(&instance.file);
let _ = writeln!(
out,
"- `{relative}:{}-{}`",
instance.start_line, instance.end_line
);
}
out.push('\n');
}
}
fn write_duplication_families(out: &mut String, report: &DuplicationReport, root: &Path) {
if report.clone_families.is_empty() {
return;
}
let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
out.push_str("### Clone Families\n\n");
for (i, family) in report.clone_families.iter().enumerate() {
let file_names: Vec<_> = family.files.iter().map(|f| rel(f)).collect();
let _ = write!(
out,
"**Family {}** ({} group{}, {} lines across {})\n\n",
i + 1,
family.groups.len(),
plural(family.groups.len()),
family.total_duplicated_lines,
file_names
.iter()
.map(|s| format!("`{s}`"))
.collect::<Vec<_>>()
.join(", "),
);
for suggestion in &family.suggestions {
let savings = if suggestion.estimated_savings > 0 {
format!(" (~{} lines saved)", suggestion.estimated_savings)
} else {
String::new()
};
let _ = writeln!(out, "- {}{savings}", suggestion.description);
}
out.push('\n');
}
}
#[must_use]
pub fn build_health_markdown(report: &fallow_output::HealthReport, root: &Path) -> String {
let mut out = String::new();
if let Some(ref hs) = report.health_score {
let _ = writeln!(out, "## Health Score: {:.0} ({})\n", hs.score, hs.grade);
}
write_trend_section(&mut out, report);
write_vital_signs_section(&mut out, report);
if report.findings.is_empty()
&& report.file_scores.is_empty()
&& report.coverage_gaps.is_none()
&& report.hotspots.is_empty()
&& report.targets.is_empty()
&& report.runtime_coverage.is_none()
&& report.coverage_intelligence.is_none()
&& report.threshold_overrides.is_empty()
&& report.css_analytics.is_none()
{
if report.vital_signs.is_none() {
let _ = write!(
out,
"## Fallow: no functions exceed complexity thresholds\n\n\
**{}** functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {:.1})\n",
report.summary.functions_analyzed,
report.summary.max_cyclomatic_threshold,
report.summary.max_cognitive_threshold,
report.summary.max_crap_threshold,
);
}
return out;
}
write_findings_section(&mut out, report, root);
write_threshold_overrides_section(&mut out, report, root);
write_runtime_coverage_section(&mut out, report, root);
write_coverage_intelligence_section(&mut out, report, root);
write_coverage_gaps_section(&mut out, report, root);
write_file_scores_section(&mut out, report, root);
write_hotspots_section(&mut out, report, root);
write_targets_section(&mut out, report, root);
write_css_analytics_section(&mut out, report);
write_metric_legend(&mut out, report);
out
}
fn write_css_analytics_section(out: &mut String, report: &fallow_output::HealthReport) {
let Some(ref css) = report.css_analytics else {
return;
};
let s = &css.summary;
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str("## CSS Health\n\n");
let important_pct = if s.total_declarations > 0 {
f64::from(s.important_declarations) / f64::from(s.total_declarations) * 100.0
} else {
0.0
};
let _ = writeln!(
out,
"- Stylesheets: {} | Rules: {} | !important: {important_pct:.1}% | Empty rules: {} | Max nesting: {}",
s.files_analyzed, s.total_rules, s.empty_rules, s.max_nesting_depth,
);
let _ = writeln!(
out,
"- Value sprawl: {} colors | {} font sizes | {} z-index | {} shadows | {} radii | {} line-heights",
s.unique_colors,
s.unique_font_sizes,
s.unique_z_indexes,
s.unique_box_shadows,
s.unique_border_radii,
s.unique_line_heights,
);
let _ = writeln!(
out,
"- 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",
s.keyframes_unreferenced,
s.keyframes_undefined,
s.duplicate_declaration_blocks,
s.scoped_unused_classes,
s.tailwind_arbitrary_values,
s.unused_property_registrations,
s.unused_layers,
s.unresolved_class_references,
s.unreferenced_css_classes,
s.unused_font_faces,
s.unused_theme_tokens,
);
write_css_candidate_details(out, css);
out.push('\n');
}
fn write_css_candidate_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
write_css_keyframe_details(out, css);
write_css_tailwind_details(out, css);
write_css_class_candidate_details(out, css);
write_css_font_candidate_details(out, css);
write_css_font_size_mix_details(out, css);
}
fn write_css_keyframe_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
if !css.undefined_keyframes.is_empty() {
let named: Vec<String> = css
.undefined_keyframes
.iter()
.take(5)
.map(|kf| format!("`{}` ({})", kf.name, kf.path))
.collect();
let _ = writeln!(
out,
"- Undefined @keyframes (candidates; likely typo or CSS-in-JS): {}",
named.join(", "),
);
}
}
fn write_css_tailwind_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
if !css.tailwind_arbitrary_values.is_empty() {
let named: Vec<String> = css
.tailwind_arbitrary_values
.iter()
.take(5)
.map(|a| format!("`{}` ({}x)", a.value, a.count))
.collect();
let _ = writeln!(out, "- Top Tailwind arbitrary values: {}", named.join(", "));
}
}
fn write_css_class_candidate_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
if !css.unresolved_class_references.is_empty() {
let named: Vec<String> = css
.unresolved_class_references
.iter()
.take(5)
.map(|u| {
format!(
"`{}` -> `{}` ({}:{})",
u.class, u.suggestion, u.path, u.line
)
})
.collect();
let _ = writeln!(
out,
"- Likely class typos (candidates; verify, may be CSS-in-JS or external): {}",
named.join(", "),
);
}
if !css.unreferenced_css_classes.is_empty() {
let named: Vec<String> = css
.unreferenced_css_classes
.iter()
.take(5)
.map(|u| format!("`.{}` ({}:{})", u.class, u.path, u.line))
.collect();
let _ = writeln!(
out,
"- Unreferenced global classes (candidates; verify no email / server / CMS / Markdown applies them): {}",
named.join(", "),
);
}
}
fn write_css_font_candidate_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
if !css.unused_font_faces.is_empty() {
let named: Vec<String> = css
.unused_font_faces
.iter()
.take(5)
.map(|u| format!("`{}` ({})", u.family, u.path))
.collect();
let _ = writeln!(
out,
"- Unused @font-face (dead web-font; candidates, may be set from JS/inline): {}",
named.join(", "),
);
}
if !css.unused_theme_tokens.is_empty() {
let named: Vec<String> = css
.unused_theme_tokens
.iter()
.take(5)
.map(|u| format!("`{}` ({}:{})", u.token, u.path, u.line))
.collect();
let _ = writeln!(
out,
"- Unused @theme tokens (dead Tailwind v4 design tokens; candidates, may be consumed by a plugin or downstream repo): {}",
named.join(", "),
);
}
}
fn write_css_font_size_mix_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
if let Some(mix) = &css.font_size_unit_mix {
let breakdown: Vec<String> = mix
.notations
.iter()
.map(|n| format!("{} {}", n.count, n.notation))
.collect();
let _ = writeln!(
out,
"- Font sizes mix {} units (candidate, standardize unless intentional): {}",
mix.notations.len(),
breakdown.join(", "),
);
}
}
fn write_coverage_intelligence_section(
out: &mut String,
report: &fallow_output::HealthReport,
root: &Path,
) {
let Some(ref intelligence) = report.coverage_intelligence else {
return;
};
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
let _ = writeln!(
out,
"## Coverage Intelligence\n\n- Verdict: {}\n- Findings: {}\n- Ambiguous matches skipped: {}\n",
intelligence.verdict,
intelligence.summary.findings,
intelligence.summary.skipped_ambiguous_matches,
);
if intelligence.findings.is_empty() {
if intelligence.summary.skipped_ambiguous_matches > 0 {
let match_phrase = if intelligence.summary.skipped_ambiguous_matches == 1 {
"evidence match was"
} else {
"evidence matches were"
};
let _ = writeln!(
out,
"No actionable findings were emitted because {} ambiguous {match_phrase} skipped.\n",
intelligence.summary.skipped_ambiguous_matches,
);
}
return;
}
out.push_str("| ID | Path | Identity | Verdict | Recommendation | Confidence | Signals |\n");
out.push_str("|:---|:-----|:---------|:--------|:---------------|:-----------|:--------|\n");
for finding in &intelligence.findings {
write_coverage_intelligence_row(out, finding, root);
}
out.push('\n');
}
fn write_coverage_intelligence_row(
out: &mut String,
finding: &fallow_output::CoverageIntelligenceFinding,
root: &Path,
) {
let path = escape_backticks(&normalize_uri(
&relative_path(&finding.path, root).display().to_string(),
));
let identity = finding
.identity
.as_deref()
.map_or_else(|| "-".to_owned(), escape_backticks);
let signals = finding
.signals
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(
out,
"| `{}` | `{}`:{} | `{}` | {} | {} | {} | {} |",
escape_backticks(&finding.id),
path,
finding.line,
identity,
finding.verdict,
finding.recommendation,
finding.confidence,
signals,
);
}
fn write_runtime_coverage_section(
out: &mut String,
report: &fallow_output::HealthReport,
root: &Path,
) {
let Some(ref production) = report.runtime_coverage else {
return;
};
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
write_runtime_coverage_summary(out, production);
write_runtime_coverage_findings(out, production, root);
write_runtime_coverage_hot_paths(out, production, root);
}
fn write_runtime_coverage_summary(
out: &mut String,
production: &fallow_output::RuntimeCoverageReport,
) {
let _ = writeln!(
out,
"## 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",
production.verdict,
production.summary.functions_tracked,
production.summary.functions_hit,
production.summary.functions_unhit,
production.summary.functions_untracked,
production.summary.coverage_percent,
production.summary.trace_count,
production.summary.period_days,
production.summary.deployments_seen,
);
if let Some(watermark) = production.watermark {
let _ = writeln!(out, "- Watermark: {watermark}\n");
}
if let Some(ref quality) = production.summary.capture_quality
&& quality.lazy_parse_warning
{
let window = format_window(quality.window_seconds);
let _ = writeln!(
out,
"- Capture quality: short window ({} from {} instance(s), {:.1}% of functions untracked); lazy-parsed scripts may not appear.\n",
window, quality.instances_observed, quality.untracked_ratio_percent,
);
}
}
fn write_runtime_coverage_findings(
out: &mut String,
production: &fallow_output::RuntimeCoverageReport,
root: &Path,
) {
if production.findings.is_empty() {
return;
}
out.push_str("| ID | Path | Function | Verdict | Invocations | Confidence |\n");
out.push_str("|:---|:-----|:---------|:--------|------------:|:-----------|\n");
for finding in &production.findings {
let invocations = finding
.invocations
.map_or_else(|| "-".to_owned(), |hits| hits.to_string());
let _ = writeln!(
out,
"| `{}` | `{}`:{} | `{}` | {} | {} | {} |",
escape_backticks(&finding.id),
escape_backticks(&normalize_uri(
&relative_path(&finding.path, root).display().to_string(),
)),
finding.line,
escape_backticks(&finding.function),
finding.verdict,
invocations,
finding.confidence,
);
}
out.push('\n');
}
fn write_runtime_coverage_hot_paths(
out: &mut String,
production: &fallow_output::RuntimeCoverageReport,
root: &Path,
) {
if production.hot_paths.is_empty() {
return;
}
out.push_str("| ID | Hot path | Function | Invocations | Percentile |\n");
out.push_str("|:---|:---------|:---------|------------:|-----------:|\n");
for entry in &production.hot_paths {
let _ = writeln!(
out,
"| `{}` | `{}`:{} | `{}` | {} | {} |",
escape_backticks(&entry.id),
escape_backticks(&normalize_uri(
&relative_path(&entry.path, root).display().to_string(),
)),
entry.line,
escape_backticks(&entry.function),
entry.invocations,
entry.percentile,
);
}
out.push('\n');
}
fn write_trend_section(out: &mut String, report: &fallow_output::HealthReport) {
let Some(ref trend) = report.health_trend else {
return;
};
let sha_str = trend
.compared_to
.git_sha
.as_deref()
.map_or(String::new(), |sha| format!(" ({sha})"));
let _ = writeln!(
out,
"## Trend (vs {}{})\n",
trend
.compared_to
.timestamp
.get(..10)
.unwrap_or(&trend.compared_to.timestamp),
sha_str,
);
out.push_str("| Metric | Previous | Current | Delta | Direction |\n");
out.push_str("|:-------|:---------|:--------|:------|:----------|\n");
for m in &trend.metrics {
write_trend_metric_row(out, m);
}
let md_sha = trend
.compared_to
.git_sha
.as_deref()
.map_or(String::new(), |sha| format!(" ({sha})"));
let _ = writeln!(
out,
"\n*vs {}{} · {} {} available*\n",
trend
.compared_to
.timestamp
.get(..10)
.unwrap_or(&trend.compared_to.timestamp),
md_sha,
trend.snapshots_loaded,
if trend.snapshots_loaded == 1 {
"snapshot"
} else {
"snapshots"
},
);
}
fn write_trend_metric_row(out: &mut String, m: &fallow_output::TrendMetric) {
let fmt_val = |v: f64| -> String {
if m.unit == "%" {
format!("{v:.1}%")
} else if (v - v.round()).abs() < 0.05 {
format!("{v:.0}")
} else {
format!("{v:.1}")
}
};
let prev = fmt_val(m.previous);
let cur = fmt_val(m.current);
let delta = if m.unit == "%" {
format!("{:+.1}%", m.delta)
} else if (m.delta - m.delta.round()).abs() < 0.05 {
format!("{:+.0}", m.delta)
} else {
format!("{:+.1}", m.delta)
};
let _ = writeln!(
out,
"| {} | {} | {} | {} | {} {} |",
m.label,
prev,
cur,
delta,
m.direction.arrow(),
m.direction.label(),
);
}
fn write_vital_signs_section(out: &mut String, report: &fallow_output::HealthReport) {
let Some(ref vs) = report.vital_signs else {
return;
};
out.push_str("## Vital Signs\n\n");
out.push_str("| Metric | Value |\n");
out.push_str("|:-------|------:|\n");
if vs.total_loc > 0 {
let _ = writeln!(out, "| Total LOC | {} |", vs.total_loc);
}
let _ = writeln!(out, "| Avg Cyclomatic | {:.1} |", vs.avg_cyclomatic);
let _ = writeln!(out, "| P90 Cyclomatic | {} |", vs.p90_cyclomatic);
if let Some(v) = vs.dead_file_pct {
let _ = writeln!(out, "| Dead Files | {v:.1}% |");
}
if let Some(v) = vs.dead_export_pct {
let _ = writeln!(out, "| Dead Exports | {v:.1}% |");
}
if let Some(v) = vs.maintainability_avg {
let _ = writeln!(out, "| Maintainability (avg) | {v:.1} |");
}
if let Some(v) = vs.hotspot_count {
let label = report.hotspot_summary.as_ref().map_or_else(
|| "Hotspots".to_string(),
|summary| format!("Hotspots (since {})", summary.since),
);
let _ = writeln!(out, "| {label} | {v} |");
}
if let Some(v) = vs.circular_dep_count {
let _ = writeln!(out, "| Circular Deps | {v} |");
}
if let Some(v) = vs.unused_dep_count {
let _ = writeln!(out, "| Unused Deps | {v} |");
}
out.push('\n');
}
fn write_findings_section(out: &mut String, report: &fallow_output::HealthReport, root: &Path) {
if report.findings.is_empty() {
return;
}
let has_synthetic = report
.findings
.iter()
.any(|finding| matches!(finding.name.as_str(), "<template>" | "<component>"));
write_findings_heading(out, report, has_synthetic);
write_findings_table_header(out, has_synthetic);
for finding in &report.findings {
write_findings_row(out, finding, report, root);
}
let s = &report.summary;
let _ = write!(
out,
"\n**{files}** files, **{funcs}** functions analyzed \
(thresholds: cyclomatic > {cyc}, cognitive > {cog}, CRAP >= {crap:.1})\n",
files = s.files_analyzed,
funcs = s.functions_analyzed,
cyc = s.max_cyclomatic_threshold,
cog = s.max_cognitive_threshold,
crap = s.max_crap_threshold,
);
}
fn write_findings_heading(
out: &mut String,
report: &fallow_output::HealthReport,
has_synthetic: bool,
) {
let count = report.summary.functions_above_threshold;
let shown = report.findings.len();
let subject = if has_synthetic {
"high complexity finding"
} else {
"high complexity function"
};
if shown < count {
let _ = write!(
out,
"## Fallow: {count} {subject}{} ({shown} shown)\n\n",
plural(count),
);
} else {
let _ = write!(out, "## Fallow: {count} {subject}{}\n\n", plural(count));
}
}
fn write_findings_table_header(out: &mut String, has_synthetic: bool) {
let name_header = if has_synthetic { "Entry" } else { "Function" };
let _ = writeln!(
out,
"| File | {name_header} | Severity | Cyclomatic | Cognitive | CRAP | Lines |"
);
out.push_str("|:-----|:---------|:---------|:-----------|:----------|:-----|:------|\n");
}
fn write_findings_row(
out: &mut String,
finding: &fallow_output::HealthFinding,
report: &fallow_output::HealthReport,
root: &Path,
) {
let file_str = escape_backticks(&normalize_uri(
&relative_path(&finding.path, root).display().to_string(),
));
let thresholds =
finding
.effective_thresholds
.unwrap_or(fallow_output::HealthEffectiveThresholds {
max_cyclomatic: report.summary.max_cyclomatic_threshold,
max_cognitive: report.summary.max_cognitive_threshold,
max_crap: report.summary.max_crap_threshold,
});
let cyc_marker = if finding.cyclomatic > thresholds.max_cyclomatic {
" **!**"
} else {
""
};
let cog_marker = if finding.cognitive > thresholds.max_cognitive {
" **!**"
} else {
""
};
let severity_label = match finding.severity {
fallow_output::FindingSeverity::Critical => "critical",
fallow_output::FindingSeverity::High => "high",
fallow_output::FindingSeverity::Moderate => "moderate",
};
let crap_cell = match finding.crap {
Some(crap) => {
let marker = if crap >= thresholds.max_crap {
" **!**"
} else {
""
};
format!("{crap:.1}{marker}")
}
None => "-".to_string(),
};
let _ = writeln!(
out,
"| `{file_str}:{line}` | `{name}` | {severity_label} | {cyc}{cyc_marker} | {cog}{cog_marker} | {crap_cell} | {lines} |",
line = finding.line,
name = escape_backticks(display_complexity_entry_name(&finding.name).as_ref()),
cyc = finding.cyclomatic,
cog = finding.cognitive,
lines = finding.line_count,
);
}
fn write_threshold_overrides_section(
out: &mut String,
report: &fallow_output::HealthReport,
root: &Path,
) {
if report.threshold_overrides.is_empty() {
return;
}
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str("## Health Threshold Overrides\n\n");
out.push_str("| Override | Status | Target | Metrics |\n");
out.push_str("|---------:|:-------|:-------|:--------|\n");
for entry in &report.threshold_overrides {
let status = match entry.status {
fallow_output::ThresholdOverrideStatus::Active => "active",
fallow_output::ThresholdOverrideStatus::Stale => "stale",
fallow_output::ThresholdOverrideStatus::NoMatch => "no_match",
};
let target = entry.path.as_ref().map_or_else(
|| "<no matching file or function>".to_string(),
|path| {
let display = escape_backticks(&normalize_uri(
&relative_path(path, root).display().to_string(),
));
entry.function.as_ref().map_or_else(
|| display.clone(),
|name| format!("{display}:{}", escape_backticks(name)),
)
},
);
let metrics = entry.metrics.map_or_else(
|| "-".to_string(),
|metrics| {
let crap = metrics
.crap
.map_or(String::new(), |value| format!(", CRAP {value:.1}"));
format!(
"cyclomatic {}, cognitive {}{}",
metrics.cyclomatic, metrics.cognitive, crap
)
},
);
let _ = writeln!(
out,
"| {} | {} | `{}` | {} |",
entry.override_index, status, target, metrics
);
}
out.push('\n');
}
fn write_file_scores_section(out: &mut String, report: &fallow_output::HealthReport, root: &Path) {
if report.file_scores.is_empty() {
return;
}
let rel = |p: &Path| {
escape_backticks(&normalize_uri(
&relative_path(p, root).display().to_string(),
))
};
out.push('\n');
let _ = writeln!(
out,
"### File Health Scores ({} files)\n",
report.file_scores.len(),
);
out.push_str("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density | Risk |\n");
out.push_str("|:-----|:---------------|:-------|:--------|:----------|:--------|:-----|\n");
for score in &report.file_scores {
let file_str = rel(&score.path);
let _ = writeln!(
out,
"| `{file_str}` | {mi:.1} | {fi} | {fan_out} | {dead:.0}% | {density:.2} | {crap:.1} |",
mi = score.maintainability_index,
fi = score.fan_in,
fan_out = score.fan_out,
dead = score.dead_code_ratio * 100.0,
density = score.complexity_density,
crap = score.crap_max,
);
}
if let Some(avg) = report.summary.average_maintainability {
let _ = write!(out, "\n**Average maintainability index:** {avg:.1}/100\n");
}
}
fn write_coverage_gaps_section(
out: &mut String,
report: &fallow_output::HealthReport,
root: &Path,
) {
let Some(ref gaps) = report.coverage_gaps else {
return;
};
out.push('\n');
let _ = writeln!(out, "### Coverage Gaps\n");
let _ = writeln!(
out,
"*{} untested files · {} untested exports · {:.1}% file coverage*\n",
gaps.summary.untested_files, gaps.summary.untested_exports, gaps.summary.file_coverage_pct,
);
if gaps.files.is_empty() && gaps.exports.is_empty() {
out.push_str("_No coverage gaps found in scope._\n");
return;
}
if !gaps.files.is_empty() {
out.push_str("#### Files\n");
for item in &gaps.files {
let file_str = escape_backticks(&normalize_uri(
&relative_path(&item.file.path, root).display().to_string(),
));
let _ = writeln!(
out,
"- `{file_str}` ({count} value export{})",
if item.file.value_export_count == 1 {
""
} else {
"s"
},
count = item.file.value_export_count,
);
}
out.push('\n');
}
if !gaps.exports.is_empty() {
out.push_str("#### Exports\n");
for item in &gaps.exports {
let file_str = escape_backticks(&normalize_uri(
&relative_path(&item.export.path, root).display().to_string(),
));
let _ = writeln!(
out,
"- `{file_str}`:{} `{}`",
item.export.line, item.export.export_name
);
}
}
}
fn ownership_md_cells(
ownership: Option<&fallow_output::OwnershipMetrics>,
) -> (String, String, String, String) {
let Some(o) = ownership else {
let dash = "\u{2013}".to_string();
return (dash.clone(), dash.clone(), dash.clone(), dash);
};
let bus = o.bus_factor.to_string();
let top = format!(
"`{}` ({:.0}%)",
o.top_contributor.identifier,
o.top_contributor.share * 100.0,
);
let owner = o
.declared_owner
.as_deref()
.map_or_else(|| "\u{2013}".to_string(), str::to_string);
let mut notes: Vec<&str> = Vec::new();
if o.unowned == Some(true) {
notes.push("**unowned**");
}
if o.ownership_state == fallow_output::OwnershipState::DeclaredInactive {
notes.push("declared owner inactive");
}
if o.drift {
notes.push("drift");
}
let notes_str = if notes.is_empty() {
"\u{2013}".to_string()
} else {
notes.join(", ")
};
(bus, top, owner, notes_str)
}
fn write_hotspots_section(out: &mut String, report: &fallow_output::HealthReport, root: &Path) {
if report.hotspots.is_empty() {
return;
}
out.push('\n');
let header = report.hotspot_summary.as_ref().map_or_else(
|| format!("### Hotspots ({} files)\n", report.hotspots.len()),
|summary| {
format!(
"### Hotspots ({} files, since {})\n",
report.hotspots.len(),
summary.since,
)
},
);
let _ = writeln!(out, "{header}");
let any_ownership = report.hotspots.iter().any(|e| e.ownership.is_some());
write_hotspots_table_header(out, any_ownership);
for entry in &report.hotspots {
write_hotspots_row(out, entry, any_ownership, root);
}
if let Some(ref summary) = report.hotspot_summary
&& summary.files_excluded > 0
{
let _ = write!(
out,
"\n*{} file{} excluded (< {} commits)*\n",
summary.files_excluded,
plural(summary.files_excluded),
summary.min_commits,
);
}
}
fn write_hotspots_table_header(out: &mut String, any_ownership: bool) {
if any_ownership {
out.push_str(
"| File | Score | Commits | Churn | Density | Fan-in | Trend | Bus | Top | Owner | Notes |\n"
);
out.push_str(
"|:-----|:------|:--------|:------|:--------|:-------|:------|:----|:----|:------|:------|\n"
);
} else {
out.push_str("| File | Score | Commits | Churn | Density | Fan-in | Trend |\n");
out.push_str("|:-----|:------|:--------|:------|:--------|:-------|:------|\n");
}
}
fn write_hotspots_row(
out: &mut String,
entry: &fallow_output::HotspotFinding,
any_ownership: bool,
root: &Path,
) {
let file_str = escape_backticks(&normalize_uri(
&relative_path(&entry.path, root).display().to_string(),
));
if any_ownership {
let (bus, top, owner, notes) = ownership_md_cells(entry.ownership.as_ref());
let _ = writeln!(
out,
"| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} | {bus} | {top} | {owner} | {notes} |",
score = entry.score,
commits = entry.commits,
churn = entry.lines_added + entry.lines_deleted,
density = entry.complexity_density,
fi = entry.fan_in,
trend = entry.trend,
);
} else {
let _ = writeln!(
out,
"| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} |",
score = entry.score,
commits = entry.commits,
churn = entry.lines_added + entry.lines_deleted,
density = entry.complexity_density,
fi = entry.fan_in,
trend = entry.trend,
);
}
}
fn write_targets_section(out: &mut String, report: &fallow_output::HealthReport, root: &Path) {
if report.targets.is_empty() {
return;
}
let _ = write!(
out,
"\n### Refactoring Targets ({})\n\n",
report.targets.len()
);
out.push_str("| Efficiency | Category | Effort / Confidence | File | Recommendation |\n");
out.push_str("|:-----------|:---------|:--------------------|:-----|:---------------|\n");
for target in &report.targets {
let file_str = normalize_uri(&relative_path(&target.path, root).display().to_string());
let category = target.category.label();
let effort = target.effort.label();
let confidence = target.confidence.label();
let _ = writeln!(
out,
"| {:.1} | {category} | {effort} / {confidence} | `{file_str}` | {} |",
target.efficiency, target.recommendation,
);
}
}
fn write_metric_legend(out: &mut String, report: &fallow_output::HealthReport) {
let has_scores = !report.file_scores.is_empty();
let has_coverage = report.coverage_gaps.is_some();
let has_hotspots = !report.hotspots.is_empty();
let has_targets = !report.targets.is_empty();
if !has_scores && !has_coverage && !has_hotspots && !has_targets {
return;
}
out.push_str("\n---\n\n<details><summary>Metric definitions</summary>\n\n");
if has_scores {
out.push_str("- **MI**: Maintainability Index (0\u{2013}100, higher is better)\n");
out.push_str("- **Order**: risk-aware triage order using the larger of low-MI concern and CRAP risk\n");
out.push_str("- **Fan-in**: files that import this file (blast radius)\n");
out.push_str("- **Fan-out**: files this file imports (coupling)\n");
out.push_str("- **Dead Code**: % of value exports with zero references\n");
out.push_str("- **Density**: cyclomatic complexity / lines of code\n");
out.push_str(
"- **Risk**: max CRAP score for the file; low <15, moderate 15-30, high >=30\n",
);
}
if has_coverage {
out.push_str(
"- **File coverage**: runtime files also reachable from a discovered test root\n",
);
out.push_str("- **Untested export**: export with no reference chain from any test-reachable module\n");
}
if has_hotspots {
out.push_str("- **Score**: churn \u{00d7} complexity (0\u{2013}100, higher = riskier)\n");
out.push_str("- **Commits**: commits in the analysis window\n");
out.push_str("- **Churn**: total lines added + deleted\n");
out.push_str("- **Trend**: accelerating / stable / cooling\n");
}
if has_targets {
out.push_str(
"- **Efficiency**: priority / effort (higher = better quick-win value, default sort)\n",
);
out.push_str("- **Category**: recommendation type (churn+complexity, high impact, dead code, complexity, coupling, circular dep)\n");
out.push_str("- **Effort**: estimated effort (low / medium / high) based on file size, function count, and fan-in\n");
out.push_str("- **Confidence**: recommendation reliability (high = deterministic analysis, medium = heuristic, low = git-dependent)\n");
}
out.push_str(
"\n[Full metric reference](https://docs.fallow.tools/explanations/metrics)\n\n</details>\n",
);
}