use std::path::Path;
use rustc_hash::FxHashSet;
use fallow_types::output_dead_code::{CaveatedFinding, ReachabilityCaveat};
use fallow_types::workspace::WorkspaceDiagnostic;
use crate::extract::ModuleInfo;
use crate::graph::ModuleGraph;
use crate::results::AnalysisResults;
const DEPENDENCY_CAVEATS: [ReachabilityCaveat; 1] = [ReachabilityCaveat::IncompleteImportGraph];
pub(super) struct GraphConfidenceContext<'a> {
incomplete_paths: FxHashSet<&'a Path>,
graph_incomplete: bool,
}
impl<'a> GraphConfidenceContext<'a> {
pub(super) fn new(
graph: &'a ModuleGraph,
modules: &[ModuleInfo],
diagnostics: &'a [WorkspaceDiagnostic],
) -> Self {
let mut incomplete_paths = FxHashSet::default();
let mut graph_incomplete = false;
for module in modules {
if module.parse_error_count == 0 {
continue;
}
let Some(node) = graph.modules.get(module.file_id.0 as usize) else {
continue;
};
incomplete_paths.insert(node.path.as_path());
graph_incomplete = graph_incomplete || node.is_reachable() || node.is_entry_point();
}
for diagnostic in diagnostics {
if !diagnostic.kind.source_never_analyzed() {
continue;
}
incomplete_paths.insert(diagnostic.path.as_path());
graph_incomplete = true;
}
Self {
incomplete_paths,
graph_incomplete,
}
}
fn is_clean(&self) -> bool {
self.incomplete_paths.is_empty() && !self.graph_incomplete
}
fn caveats_for(&self, path: &Path) -> Vec<ReachabilityCaveat> {
let mut caveats = Vec::new();
if self.incomplete_paths.contains(path) {
caveats.push(ReachabilityCaveat::IncompleteFileAnalysis);
}
if self.graph_incomplete {
caveats.push(ReachabilityCaveat::IncompleteImportGraph);
}
caveats
}
fn member_caveats_for(&self, path: &Path) -> Vec<ReachabilityCaveat> {
let mut caveats = Vec::new();
if self.incomplete_paths.contains(path) {
caveats.push(ReachabilityCaveat::IncompleteFileAnalysis);
}
caveats.push(ReachabilityCaveat::IncompleteImportGraph);
caveats
}
pub(super) fn annotate(&self, results: &mut AnalysisResults) {
if self.is_clean() {
return;
}
for finding in &mut results.unused_files {
let caveats = self.caveats_for(&finding.file.path);
finding.set_reachability_caveats(caveats);
}
for finding in &mut results.unused_exports {
let caveats = self.caveats_for(&finding.export.path);
finding.set_reachability_caveats(caveats);
}
for finding in &mut results.unused_types {
let caveats = self.caveats_for(&finding.export.path);
finding.set_reachability_caveats(caveats);
}
for finding in &mut results.unused_enum_members {
let caveats = self.member_caveats_for(&finding.member.path);
finding.set_reachability_caveats(caveats);
}
for finding in &mut results.unused_class_members {
let caveats = self.member_caveats_for(&finding.member.path);
finding.set_reachability_caveats(caveats);
}
for finding in &mut results.unused_store_members {
let caveats = self.member_caveats_for(&finding.member.path);
finding.set_reachability_caveats(caveats);
}
for finding in &mut results.unused_dependencies {
finding.set_reachability_caveats(DEPENDENCY_CAVEATS.to_vec());
}
for finding in &mut results.unused_dev_dependencies {
finding.set_reachability_caveats(DEPENDENCY_CAVEATS.to_vec());
}
for finding in &mut results.unused_optional_dependencies {
finding.set_reachability_caveats(DEPENDENCY_CAVEATS.to_vec());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
use crate::resolve::ResolvedModule;
use crate::results::{UnusedDependency, UnusedExport, UnusedFile, UnusedMember};
use fallow_types::extract::MemberKind;
use fallow_types::output::IssueAction;
use fallow_types::output_dead_code::{
UnusedClassMemberFinding, UnusedDependencyFinding, UnusedEnumMemberFinding,
UnusedExportFinding, UnusedFileFinding, UnusedStoreMemberFinding, UnusedTypeFinding,
};
use fallow_types::results::DependencyLocation;
use fallow_types::workspace::WorkspaceDiagnosticKind;
use std::path::PathBuf;
const ROOT: &str = "/p";
const INDEX: &str = "/p/src/index.ts";
const HELPER: &str = "/p/src/helper.ts";
const ORPHAN: &str = "/p/src/orphan.ts";
fn graph() -> ModuleGraph {
let paths = [INDEX, HELPER, ORPHAN];
let files: Vec<DiscoveredFile> = paths
.iter()
.enumerate()
.map(|(index, path)| DiscoveredFile {
id: FileId(u32::try_from(index).expect("test file count fits u32")),
path: PathBuf::from(path),
size_bytes: 0,
})
.collect();
let entry_points = vec![EntryPoint {
path: PathBuf::from(INDEX),
source: EntryPointSource::ManualEntry,
}];
let resolved: Vec<ResolvedModule> = files
.iter()
.map(|file| ResolvedModule {
file_id: file.id,
path: file.path.clone(),
..Default::default()
})
.collect();
ModuleGraph::build(&resolved, &entry_points, &files)
}
fn modules(error_counts: [u32; 3]) -> Vec<ModuleInfo> {
error_counts
.into_iter()
.enumerate()
.map(|(index, errors)| ModuleInfo {
parse_error_count: errors,
..ModuleInfo::empty(FileId(
u32::try_from(index).expect("test file count fits u32"),
))
})
.collect()
}
fn unused(paths: &[&str]) -> AnalysisResults {
AnalysisResults {
unused_files: paths
.iter()
.map(|path| {
UnusedFileFinding::with_actions(UnusedFile {
path: PathBuf::from(path),
})
})
.collect(),
..AnalysisResults::default()
}
}
fn with_unused_enum_member(mut results: AnalysisResults) -> AnalysisResults {
results
.unused_enum_members
.push(UnusedEnumMemberFinding::with_actions(UnusedMember {
path: PathBuf::from(INDEX),
parent_name: "Color".to_string(),
member_name: "Blue".to_string(),
kind: MemberKind::EnumMember,
line: 3,
col: 2,
}));
results
}
fn with_unused_class_member(mut results: AnalysisResults) -> AnalysisResults {
results
.unused_class_members
.push(UnusedClassMemberFinding::with_actions(UnusedMember {
path: PathBuf::from(INDEX),
parent_name: "Widget".to_string(),
member_name: "onlyUsedInBigFile".to_string(),
kind: MemberKind::ClassMethod,
line: 6,
col: 2,
}));
results
}
fn with_unused_store_member(mut results: AnalysisResults) -> AnalysisResults {
results
.unused_store_members
.push(UnusedStoreMemberFinding::with_actions(UnusedMember {
path: PathBuf::from(INDEX),
parent_name: "useCounterStore".to_string(),
member_name: "onlyUsedInBigFile".to_string(),
kind: MemberKind::StoreMember,
line: 7,
col: 2,
}));
results
}
fn with_unused_export_and_type(mut results: AnalysisResults) -> AnalysisResults {
let export = |name: &str| UnusedExport {
path: PathBuf::from(HELPER),
export_name: name.to_string(),
is_type_only: false,
line: 1,
col: 0,
span_start: 0,
is_re_export: false,
};
results
.unused_exports
.push(UnusedExportFinding::with_actions(export("helper")));
results
.unused_types
.push(UnusedTypeFinding::with_actions(export("Shape")));
results
}
fn with_unused_dependency(mut results: AnalysisResults) -> AnalysisResults {
results
.unused_dependencies
.push(UnusedDependencyFinding::with_actions(UnusedDependency {
package_name: "lodash".to_string(),
location: DependencyLocation::Dependencies,
path: PathBuf::from("/p/package.json"),
line: 5,
used_in_workspaces: Vec::new(),
}));
results
}
#[test]
fn a_run_that_parsed_cleanly_flags_nothing() {
let graph = graph();
let mut results = unused(&[HELPER, ORPHAN]);
GraphConfidenceContext::new(&graph, &modules([0, 0, 0]), &[]).annotate(&mut results);
assert!(
results
.unused_files
.iter()
.all(|finding| finding.reachability_caveats.is_empty()),
"a healthy project must carry no marker at all"
);
}
#[test]
fn a_degraded_reachable_module_flags_the_findings_its_lost_imports_could_reach() {
let graph = graph();
let mut results = unused(&[HELPER, ORPHAN]);
GraphConfidenceContext::new(&graph, &modules([3, 0, 0]), &[]).annotate(&mut results);
for finding in &results.unused_files {
assert_eq!(
finding.reachability_caveats,
vec![ReachabilityCaveat::IncompleteImportGraph],
"{} should carry the incomplete-graph caveat",
finding.file.path.display()
);
}
}
#[test]
fn a_degraded_unreachable_module_flags_only_itself() {
let graph = graph();
let mut results = unused(&[HELPER, ORPHAN]);
GraphConfidenceContext::new(&graph, &modules([0, 2, 0]), &[]).annotate(&mut results);
let helper = results
.unused_files
.iter()
.find(|finding| finding.file.path.ends_with("helper.ts"))
.expect("helper finding present");
assert_eq!(
helper.reachability_caveats,
vec![ReachabilityCaveat::IncompleteFileAnalysis],
"the degraded file's own truncated export list is the only caveat"
);
let orphan = results
.unused_files
.iter()
.find(|finding| finding.file.path.ends_with("orphan.ts"))
.expect("orphan finding present");
assert!(
orphan.reachability_caveats.is_empty(),
"an unreachable degraded module must not cast a caveat over the whole run"
);
}
#[test]
fn a_clean_run_leaves_a_dependency_finding_unmarked() {
let graph = graph();
let mut results = with_unused_dependency(unused(&[]));
GraphConfidenceContext::new(&graph, &modules([0, 0, 0]), &[]).annotate(&mut results);
assert!(
results.unused_dependencies[0]
.reachability_caveats
.is_empty(),
"a project that parses cleanly must keep the previous wire shape"
);
}
#[test]
fn an_unreachable_degraded_module_still_caveats_every_dependency() {
let graph = graph();
let mut results = with_unused_dependency(unused(&[HELPER, ORPHAN]));
GraphConfidenceContext::new(&graph, &modules([0, 2, 0]), &[]).annotate(&mut results);
assert_eq!(
results.unused_dependencies[0].reachability_caveats,
vec![ReachabilityCaveat::IncompleteImportGraph],
"an unreachable degraded module can still hide a package import"
);
assert!(
results
.unused_files
.iter()
.find(|finding| finding.file.path.ends_with("orphan.ts"))
.expect("orphan finding present")
.reachability_caveats
.is_empty(),
"the dependency widening must not leak into the reachability verdicts"
);
}
#[test]
fn a_dependency_finding_never_claims_its_own_file_parsed_degraded() {
let graph = graph();
let mut results = with_unused_dependency(unused(&[]));
GraphConfidenceContext::new(&graph, &modules([3, 2, 1]), &[]).annotate(&mut results);
assert!(
!results.unused_dependencies[0]
.reachability_caveats
.contains(&ReachabilityCaveat::IncompleteFileAnalysis),
"package.json is not a parsed source module"
);
}
fn never_analyzed_kinds() -> Vec<WorkspaceDiagnosticKind> {
vec![
WorkspaceDiagnosticKind::SkippedLargeFile {
size_bytes: 6 * 1024 * 1024,
},
WorkspaceDiagnosticKind::SkippedMinifiedFile {
size_bytes: 2 * 1024 * 1024,
},
WorkspaceDiagnosticKind::SkippedSourceDotdir,
WorkspaceDiagnosticKind::SourceReadFailure {
error: "permission denied".to_owned(),
},
]
}
fn diagnostic(path: &str, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnostic {
WorkspaceDiagnostic::new(Path::new(ROOT), PathBuf::from(path), kind)
}
#[test]
fn every_never_analyzed_file_caveats_every_reachability_verdict() {
for kind in never_analyzed_kinds() {
let id = kind.id();
assert!(
kind.source_never_analyzed(),
"{id} must be classified as a file the run never analyzed"
);
let graph = graph();
let mut results = with_unused_dependency(unused(&[HELPER, ORPHAN]));
let diagnostics = vec![diagnostic("/p/src/huge.ts", kind)];
GraphConfidenceContext::new(&graph, &modules([0, 0, 0]), &diagnostics)
.annotate(&mut results);
for finding in &results.unused_files {
assert_eq!(
finding.reachability_caveats,
vec![ReachabilityCaveat::IncompleteImportGraph],
"{id}: {} rests on an import graph missing an unread file's edges",
finding.file.path.display()
);
}
assert_eq!(
results.unused_dependencies[0].reachability_caveats,
vec![ReachabilityCaveat::IncompleteImportGraph],
"{id}: an unread file can hide the import that credits a package"
);
}
}
#[test]
fn an_unread_file_is_never_narrowed_away_by_reachability() {
let graph = graph();
let mut results = unused(&[ORPHAN]);
let diagnostics = vec![diagnostic(
"/p/src/huge.ts",
WorkspaceDiagnosticKind::SkippedLargeFile {
size_bytes: 6 * 1024 * 1024,
},
)];
GraphConfidenceContext::new(&graph, &modules([0, 0, 0]), &diagnostics)
.annotate(&mut results);
assert_eq!(
results.unused_files[0].reachability_caveats,
vec![ReachabilityCaveat::IncompleteImportGraph],
"no degraded module is reachable, yet the unread file still taints the verdict"
);
}
#[test]
fn an_unreadable_file_reported_unused_carries_its_own_caveat() {
let graph = graph();
let mut results = unused(&[HELPER, ORPHAN]);
let diagnostics = vec![diagnostic(
HELPER,
WorkspaceDiagnosticKind::SourceReadFailure {
error: "permission denied".to_owned(),
},
)];
GraphConfidenceContext::new(&graph, &modules([0, 0, 0]), &diagnostics)
.annotate(&mut results);
let helper = results
.unused_files
.iter()
.find(|finding| finding.file.path.ends_with("helper.ts"))
.expect("helper finding present");
assert_eq!(
helper.reachability_caveats,
vec![
ReachabilityCaveat::IncompleteFileAnalysis,
ReachabilityCaveat::IncompleteImportGraph,
],
"nothing was extracted from a file that could not be read"
);
}
#[test]
fn a_diagnostic_outside_the_class_flags_nothing() {
let graph = graph();
let mut results = with_unused_dependency(unused(&[HELPER, ORPHAN]));
let diagnostics = vec![
diagnostic(
"/p/node_modules",
WorkspaceDiagnosticKind::NodeModulesMissing,
),
diagnostic("/p", WorkspaceDiagnosticKind::BoundariesNotConfigured),
];
GraphConfidenceContext::new(&graph, &modules([0, 0, 0]), &diagnostics)
.annotate(&mut results);
assert!(
results
.unused_files
.iter()
.all(|finding| finding.reachability_caveats.is_empty())
&& results.unused_dependencies[0]
.reachability_caveats
.is_empty(),
"only a file the run failed to read may raise a caveat"
);
}
#[test]
fn an_unread_file_caveats_an_enum_member_verdict_and_withholds_its_removal() {
let graph = graph();
let mut results = with_unused_enum_member(unused(&[]));
let diagnostics = vec![diagnostic(
"/p/src/huge.ts",
WorkspaceDiagnosticKind::SkippedLargeFile {
size_bytes: 6 * 1024 * 1024,
},
)];
GraphConfidenceContext::new(&graph, &modules([0, 0, 0]), &diagnostics)
.annotate(&mut results);
let member = &results.unused_enum_members[0];
assert_eq!(
member.reachability_caveats,
vec![ReachabilityCaveat::IncompleteImportGraph],
"the unread file may hold the only access to this member"
);
assert!(
!member.actions.iter().any(IssueAction::is_auto_fixable),
"the remove-enum-member action must not advertise itself as applicable"
);
}
#[test]
fn a_degraded_unreachable_module_caveats_a_member_but_not_a_file() {
let graph = graph();
let mut results = with_unused_class_member(with_unused_enum_member(unused(&[ORPHAN])));
GraphConfidenceContext::new(&graph, &modules([0, 2, 0]), &[]).annotate(&mut results);
assert!(
results.unused_files[0].reachability_caveats.is_empty(),
"an unreachable degraded module cannot change a reachability verdict"
);
assert_eq!(
results.unused_enum_members[0].reachability_caveats,
vec![ReachabilityCaveat::IncompleteImportGraph],
"it can still hold the member access that credits this member"
);
assert_eq!(
results.unused_class_members[0].reachability_caveats,
results.unused_enum_members[0].reachability_caveats,
"a class member is the same verdict off the same access walk, so it must not \
render with more confidence than an enum member in the same file"
);
}
#[test]
fn an_unread_file_caveats_a_store_member_verdict_with_no_mutation_to_withhold() {
let graph = graph();
let mut results = with_unused_store_member(unused(&[]));
let diagnostics = vec![diagnostic(
"/p/src/big.ts",
WorkspaceDiagnosticKind::SkippedLargeFile {
size_bytes: 6 * 1024 * 1024,
},
)];
GraphConfidenceContext::new(&graph, &modules([0, 0, 0]), &diagnostics)
.annotate(&mut results);
let member = &results.unused_store_members[0];
assert_eq!(
member.reachability_caveats,
vec![ReachabilityCaveat::IncompleteImportGraph],
"the unread file may hold the only access to this store member"
);
assert!(
!member.actions.iter().any(IssueAction::is_auto_fixable),
"a store member must not advertise an applicable mutation"
);
}
#[test]
fn a_clean_run_leaves_a_store_member_uncaveated() {
let graph = graph();
let mut results = with_unused_store_member(unused(&[]));
GraphConfidenceContext::new(&graph, &modules([0, 0, 0]), &[]).annotate(&mut results);
assert!(
results.unused_store_members[0]
.reachability_caveats
.is_empty()
);
}
#[test]
fn an_unread_file_caveats_a_class_member_verdict_and_withholds_its_removal() {
let graph = graph();
let mut results = with_unused_class_member(unused(&[]));
let diagnostics = vec![diagnostic(
"/p/src/big.ts",
WorkspaceDiagnosticKind::SkippedLargeFile {
size_bytes: 6 * 1024 * 1024,
},
)];
GraphConfidenceContext::new(&graph, &modules([0, 0, 0]), &diagnostics)
.annotate(&mut results);
let member = &results.unused_class_members[0];
assert_eq!(
member.reachability_caveats,
vec![ReachabilityCaveat::IncompleteImportGraph],
"the unread file may hold the only call to this member"
);
assert!(
!member.actions.iter().any(IssueAction::is_auto_fixable),
"the remove-class-member action must not advertise itself as applicable"
);
}
#[test]
fn a_type_export_carries_the_same_caveat_as_a_value_export() {
let graph = graph();
let mut results = with_unused_export_and_type(unused(&[]));
let diagnostics = vec![diagnostic(
"/p/src/huge.ts",
WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
)];
GraphConfidenceContext::new(&graph, &modules([0, 0, 0]), &diagnostics)
.annotate(&mut results);
assert_eq!(
results.unused_types[0].reachability_caveats,
results.unused_exports[0].reachability_caveats,
"an unused type and an unused export in the same file must agree"
);
assert!(
!results.unused_types[0]
.actions
.iter()
.any(IssueAction::is_auto_fixable),
"and the type's removal is withheld the same way"
);
}
#[test]
fn a_clean_run_leaves_the_new_arrays_untouched() {
let graph = graph();
let mut results = with_unused_enum_member(with_unused_export_and_type(unused(&[ORPHAN])));
GraphConfidenceContext::new(&graph, &modules([0, 0, 0]), &[]).annotate(&mut results);
assert!(
results.unused_enum_members[0]
.reachability_caveats
.is_empty()
&& results.unused_types[0].reachability_caveats.is_empty(),
"a complete run stamps nothing"
);
assert!(
results.unused_enum_members[0]
.actions
.iter()
.any(IssueAction::is_auto_fixable),
"and withholds nothing"
);
}
}