use rustc_hash::{FxHashMap, FxHashSet};
use std::collections::BTreeMap;
use std::path::Path;
use crate::duplicates::DuplicationReport;
fn relative_path(path: &Path, root: &Path) -> String {
match path.strip_prefix(root) {
Ok(relative) => relative.to_string_lossy().replace('\\', "/"),
Err(_) => {
tracing::debug!(
path = %path.display(),
root = %root.display(),
"baseline key: path is not under project root, using absolute path as key"
);
path.to_string_lossy().replace('\\', "/")
}
}
}
fn package_json_dependency_key(package_name: &str, path: &Path, root: &Path) -> String {
format!("{}:{package_name}", relative_path(path, root))
}
fn baseline_contains_dependency(
baseline_keys: &FxHashSet<&str>,
package_name: &str,
path_key: &str,
) -> bool {
baseline_keys.contains(path_key) || baseline_keys.contains(package_name)
}
fn retain_new_by_keys<T>(
items: &mut Vec<T>,
baseline_keys: &[String],
root: &Path,
key_builder: fn(&[T], &Path) -> Vec<String>,
) {
let baseline_keys: FxHashSet<&str> = baseline_keys.iter().map(String::as_str).collect();
let item_keys = key_builder(items, root);
let mut key_iter = item_keys.into_iter();
items.retain(|_| match key_iter.next() {
Some(key) => !baseline_keys.contains(key.as_str()),
None => true,
});
}
const STALE_WARN_PERCENT: usize = 25;
#[must_use]
pub const fn stale_share_warrants_warning(baseline_entries: usize, stale_entries: usize) -> bool {
stale_entries > 0 && stale_entries * 100 >= baseline_entries * STALE_WARN_PERCENT
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BaselineStaleness {
pub entries: usize,
pub matched: usize,
pub current_findings: usize,
pub change_scoped: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BaselineStalenessWarning {
None,
ZeroOverlap,
Partial,
}
impl BaselineStaleness {
#[must_use]
pub const fn stale_entries(&self) -> usize {
self.entries.saturating_sub(self.matched)
}
#[must_use]
pub const fn warning(&self) -> BaselineStalenessWarning {
if self.change_scoped || self.entries == 0 || self.current_findings == 0 {
return BaselineStalenessWarning::None;
}
if self.matched == 0 {
return BaselineStalenessWarning::ZeroOverlap;
}
if stale_share_warrants_warning(self.entries, self.stale_entries()) {
return BaselineStalenessWarning::Partial;
}
BaselineStalenessWarning::None
}
#[must_use]
pub const fn trips_gate(&self) -> bool {
stale_baseline_gate_trips(self.entries, self.matched, self.change_scoped)
}
#[must_use]
pub fn to_envelope(
&self,
moved_entries: usize,
scope_reasons: fallow_output::BaselineScopeReasons,
unrecognised_format: bool,
) -> fallow_output::BaselineStaleness {
debug_assert_eq!(
self.change_scoped,
!scope_reasons.is_empty(),
"change_scoped and scope_reasons must be derived from the same predicate"
);
let warning = self.warning();
fallow_output::BaselineStaleness {
baseline_entries: self.entries,
matched_entries: self.matched,
stale_entries: self.stale_entries(),
current_findings: self.current_findings,
change_scoped: self.change_scoped,
stale: warning != BaselineStalenessWarning::None,
warning: match warning {
BaselineStalenessWarning::None => fallow_output::BaselineStalenessAdvisory::None,
BaselineStalenessWarning::ZeroOverlap => {
fallow_output::BaselineStalenessAdvisory::ZeroOverlap
}
BaselineStalenessWarning::Partial => {
fallow_output::BaselineStalenessAdvisory::Partial
}
},
gate_trips: self.trips_gate() || unrecognised_format,
moved_entries,
unrecognised_format,
saved_by: None,
scope_reasons,
}
}
}
#[must_use]
pub const fn stale_baseline_gate_trips(
entries: usize,
matched: usize,
change_scoped: bool,
) -> bool {
!change_scoped && entries > 0 && matched < entries
}
#[must_use]
pub fn declares_baseline_format(json: &str, declared_keys: &[&str]) -> bool {
let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
return false;
};
value_declares_baseline_format(&value, declared_keys)
}
fn value_declares_baseline_format(value: &serde_json::Value, declared_keys: &[&str]) -> bool {
let Some(object) = value.as_object() else {
return false;
};
declared_keys.iter().any(|key| object.contains_key(*key))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BaselineKind {
DeadCode,
Dupes,
Health,
}
impl BaselineKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::DeadCode => "dead-code",
Self::Dupes => "dupes",
Self::Health => "health",
}
}
#[must_use]
pub fn from_token(token: &str) -> Option<Self> {
[Self::DeadCode, Self::Dupes, Self::Health]
.into_iter()
.find(|kind| kind.as_str() == token)
}
#[must_use]
pub const fn declared_keys(self) -> &'static [&'static str] {
match self {
Self::DeadCode => BaselineData::REQUIRED_KEYS,
Self::Dupes => DuplicationBaselineData::DECLARED_KEYS,
Self::Health => HealthBaselineData::DECLARED_KEYS,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BaselineFileKind {
Own,
Foreign(String),
Unrecognised,
NotAnObject,
}
impl BaselineFileKind {
#[must_use]
pub fn saved_by(&self) -> Option<BaselineKind> {
match self {
Self::Foreign(token) => BaselineKind::from_token(token),
Self::Own | Self::Unrecognised | Self::NotAnObject => None,
}
}
}
#[must_use]
pub fn refuse_baseline_kind_overwrite(save_path: &Path, saving: BaselineKind) -> Option<String> {
let existing = std::fs::read_to_string(save_path).ok()?;
let BaselineFileKind::Foreign(found) = classify_baseline_file(&existing, saving) else {
return None;
};
Some(format!(
"refusing to overwrite the baseline at {}: it was saved by `fallow {found}` and this is a \
`fallow {}` save, which would destroy it. Save each command's baseline to its own path.",
save_path.display(),
saving.as_str(),
))
}
#[must_use]
pub fn classify_baseline_file(json: &str, expected: BaselineKind) -> BaselineFileKind {
let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
return BaselineFileKind::NotAnObject;
};
classify_baseline_value(&value, expected)
}
#[must_use]
pub fn classify_baseline_value(
value: &serde_json::Value,
expected: BaselineKind,
) -> BaselineFileKind {
let Some(object) = value.as_object() else {
return BaselineFileKind::NotAnObject;
};
match object.get("kind").and_then(serde_json::Value::as_str) {
Some(token) if token == expected.as_str() => BaselineFileKind::Own,
Some(token) => BaselineFileKind::Foreign(token.to_owned()),
None => {
if value_declares_baseline_format(value, expected.declared_keys()) {
BaselineFileKind::Own
} else {
BaselineFileKind::Unrecognised
}
}
}
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct BaselineData {
#[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
kind: Option<BaselineKind>,
#[serde(default)]
analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
unused_files: Vec<String>,
unused_exports: Vec<String>,
unused_types: Vec<String>,
#[serde(default)]
private_type_leaks: Vec<String>,
#[serde(default)]
deprecated_exports_in_use: Vec<String>,
unused_dependencies: Vec<String>,
unused_dev_dependencies: Vec<String>,
#[serde(default)]
circular_dependencies: Vec<String>,
#[serde(default)]
re_export_cycles: Vec<String>,
#[serde(default)]
unused_optional_dependencies: Vec<String>,
#[serde(default)]
unused_enum_members: Vec<String>,
#[serde(default)]
unused_class_members: Vec<String>,
#[serde(default)]
unused_store_members: Vec<String>,
#[serde(default)]
unprovided_injects: Vec<String>,
#[serde(default)]
unrendered_components: Vec<String>,
#[serde(default)]
unused_component_props: Vec<String>,
#[serde(default)]
unused_component_emits: Vec<String>,
#[serde(default)]
unused_component_inputs: Vec<String>,
#[serde(default)]
unused_component_outputs: Vec<String>,
#[serde(default)]
unused_svelte_events: Vec<String>,
#[serde(default)]
unused_server_actions: Vec<String>,
#[serde(default)]
unused_load_data_keys: Vec<String>,
#[serde(default)]
unresolved_imports: Vec<String>,
#[serde(default)]
unlisted_dependencies: Vec<String>,
#[serde(default)]
duplicate_exports: Vec<String>,
#[serde(default)]
type_only_dependencies: Vec<String>,
#[serde(default)]
test_only_dependencies: Vec<String>,
#[serde(default)]
dev_dependencies_in_production: Vec<String>,
#[serde(default)]
boundary_violations: Vec<String>,
#[serde(default)]
boundary_coverage_violations: Vec<String>,
#[serde(default)]
boundary_call_violations: Vec<String>,
#[serde(default)]
policy_violations: Vec<String>,
#[serde(default)]
stale_suppressions: Vec<String>,
#[serde(default)]
unused_catalog_entries: Vec<String>,
#[serde(default)]
empty_catalog_groups: Vec<String>,
#[serde(default)]
unresolved_catalog_references: Vec<String>,
#[serde(default)]
unused_dependency_overrides: Vec<String>,
#[serde(default)]
misconfigured_dependency_overrides: Vec<String>,
#[serde(default)]
invalid_client_exports: Vec<String>,
#[serde(default)]
mixed_client_server_barrels: Vec<String>,
#[serde(default)]
misplaced_directives: Vec<String>,
#[serde(default)]
route_collisions: Vec<String>,
#[serde(default)]
dynamic_segment_name_conflicts: Vec<String>,
}
impl BaselineData {
pub const REQUIRED_KEYS: &'static [&'static str] = &[
"unused_files",
"unused_exports",
"unused_types",
"unused_dependencies",
"unused_dev_dependencies",
];
pub fn from_results(results: &crate::results::AnalysisResults, root: &Path) -> Self {
Self::from_results_with_identity(
results,
root,
fallow_types::semantic::SemanticAnalysisIdentity::syntactic(),
)
}
pub fn from_results_with_identity(
results: &crate::results::AnalysisResults,
root: &Path,
analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity,
) -> Self {
let file_exports = baseline_file_export_keys(results, root);
let member_imports = baseline_member_import_keys(results, root);
let dependencies = baseline_dependency_keys(results, root);
let graph = baseline_graph_keys(results, root);
let catalog = baseline_catalog_keys(results, root);
Self {
kind: Some(BaselineKind::DeadCode),
analysis_identity,
unused_files: file_exports.unused_files,
unused_exports: file_exports.unused_exports,
unused_types: file_exports.unused_types,
private_type_leaks: file_exports.private_type_leaks,
deprecated_exports_in_use: file_exports.deprecated_exports_in_use,
unused_dependencies: dependencies.unused,
unused_dev_dependencies: dependencies.unused_dev,
circular_dependencies: graph.circular_dependencies,
re_export_cycles: graph.re_export_cycles,
unused_optional_dependencies: dependencies.unused_optional,
unused_enum_members: member_imports.unused_enum_members,
unused_class_members: member_imports.unused_class_members,
unused_store_members: member_imports.unused_store_members,
unprovided_injects: member_imports.unprovided_injects,
unrendered_components: member_imports.unrendered_components,
unused_component_props: member_imports.unused_component_props,
unused_component_emits: member_imports.unused_component_emits,
unused_component_inputs: member_imports.unused_component_inputs,
unused_component_outputs: member_imports.unused_component_outputs,
unused_svelte_events: member_imports.unused_svelte_events,
unused_server_actions: member_imports.unused_server_actions,
unused_load_data_keys: member_imports.unused_load_data_keys,
unresolved_imports: member_imports.unresolved_imports,
unlisted_dependencies: dependencies.unlisted,
duplicate_exports: member_imports.duplicate_exports,
type_only_dependencies: dependencies.type_only,
test_only_dependencies: dependencies.test_only,
dev_dependencies_in_production: dependencies.dev_in_prod,
boundary_violations: graph.boundary_violations,
boundary_coverage_violations: graph.boundary_coverage_violations,
boundary_call_violations: graph.boundary_call_violations,
policy_violations: graph.policy_violations,
stale_suppressions: member_imports.stale_suppressions,
unused_catalog_entries: catalog.unused_catalog_entries,
empty_catalog_groups: catalog.empty_catalog_groups,
unresolved_catalog_references: catalog.unresolved_catalog_references,
unused_dependency_overrides: catalog.unused_dependency_overrides,
misconfigured_dependency_overrides: catalog.misconfigured_dependency_overrides,
invalid_client_exports: file_exports.invalid_client_exports,
mixed_client_server_barrels: file_exports.mixed_client_server_barrels,
misplaced_directives: file_exports.misplaced_directives,
route_collisions: file_exports.route_collisions,
dynamic_segment_name_conflicts: file_exports.dynamic_segment_name_conflicts,
}
}
#[must_use]
pub const fn analysis_identity(&self) -> &fallow_types::semantic::SemanticAnalysisIdentity {
&self.analysis_identity
}
pub fn total_entries(&self) -> usize {
self.unused_files.len()
+ self.unused_exports.len()
+ self.unused_types.len()
+ self.private_type_leaks.len()
+ self.deprecated_exports_in_use.len()
+ self.unused_dependencies.len()
+ self.unused_dev_dependencies.len()
+ self.circular_dependencies.len()
+ self.re_export_cycles.len()
+ self.unused_optional_dependencies.len()
+ self.unused_enum_members.len()
+ self.unused_class_members.len()
+ self.unused_store_members.len()
+ self.unprovided_injects.len()
+ self.unrendered_components.len()
+ self.unused_component_props.len()
+ self.unused_component_emits.len()
+ self.unused_component_inputs.len()
+ self.unused_component_outputs.len()
+ self.unused_svelte_events.len()
+ self.unused_server_actions.len()
+ self.unused_load_data_keys.len()
+ self.unresolved_imports.len()
+ self.unlisted_dependencies.len()
+ self.duplicate_exports.len()
+ self.type_only_dependencies.len()
+ self.test_only_dependencies.len()
+ self.dev_dependencies_in_production.len()
+ self.boundary_violations.len()
+ self.boundary_coverage_violations.len()
+ self.boundary_call_violations.len()
+ self.policy_violations.len()
+ self.stale_suppressions.len()
+ self.unused_catalog_entries.len()
+ self.empty_catalog_groups.len()
+ self.unresolved_catalog_references.len()
+ self.unused_dependency_overrides.len()
+ self.misconfigured_dependency_overrides.len()
+ self.invalid_client_exports.len()
+ self.mixed_client_server_barrels.len()
+ self.misplaced_directives.len()
+ self.route_collisions.len()
+ self.dynamic_segment_name_conflicts.len()
}
}
struct BaselineFileExportKeys {
unused_files: Vec<String>,
unused_exports: Vec<String>,
unused_types: Vec<String>,
private_type_leaks: Vec<String>,
deprecated_exports_in_use: Vec<String>,
invalid_client_exports: Vec<String>,
mixed_client_server_barrels: Vec<String>,
misplaced_directives: Vec<String>,
route_collisions: Vec<String>,
dynamic_segment_name_conflicts: Vec<String>,
}
fn baseline_file_export_keys(
results: &crate::results::AnalysisResults,
root: &Path,
) -> BaselineFileExportKeys {
BaselineFileExportKeys {
unused_files: results
.unused_files
.iter()
.map(|f| relative_path(&f.file.path, root))
.collect(),
unused_exports: unused_export_baseline_keys(&results.unused_exports, root),
unused_types: unused_type_baseline_keys(&results.unused_types, root),
private_type_leaks: private_type_leak_baseline_keys(&results.private_type_leaks, root),
deprecated_exports_in_use: results
.deprecated_exports_in_use
.iter()
.map(|e| deprecated_export_key(&e.export, root))
.collect(),
invalid_client_exports: invalid_client_export_baseline_keys(
&results.invalid_client_exports,
root,
),
mixed_client_server_barrels: barrel_baseline_keys(
&results.mixed_client_server_barrels,
root,
),
misplaced_directives: directive_baseline_keys(&results.misplaced_directives, root),
route_collisions: route_collision_baseline_keys(&results.route_collisions, root),
dynamic_segment_name_conflicts: results
.dynamic_segment_name_conflicts
.iter()
.map(|c| {
format!(
"{}:{}",
relative_path(&c.conflict.path, root),
c.conflict.position
)
})
.collect(),
}
}
fn unused_export_baseline_keys(
items: &[crate::results::UnusedExportFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|e| {
format!(
"{}:{}",
relative_path(&e.export.path, root),
e.export.export_name
)
})
.collect()
}
fn unused_type_baseline_keys(
items: &[crate::results::UnusedTypeFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|e| {
format!(
"{}:{}",
relative_path(&e.export.path, root),
e.export.export_name
)
})
.collect()
}
fn invalid_client_export_baseline_keys(
items: &[crate::results::InvalidClientExportFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|e| {
format!(
"{}:{}",
relative_path(&e.export.path, root),
e.export.export_name
)
})
.collect()
}
fn private_type_leak_baseline_keys(
items: &[crate::results::PrivateTypeLeakFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|e| {
format!(
"{}:{}->{}",
relative_path(&e.leak.path, root),
e.leak.export_name,
e.leak.type_name
)
})
.collect()
}
fn barrel_baseline_keys(
items: &[crate::results::MixedClientServerBarrelFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|b| {
format!(
"{}:{}:{}",
relative_path(&b.barrel.path, root),
b.barrel.client_origin,
b.barrel.server_origin
)
})
.collect()
}
fn directive_baseline_keys(
items: &[crate::results::MisplacedDirectiveFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|d| {
format!(
"{}:{}:{}",
relative_path(&d.directive_site.path, root),
d.directive_site.line,
d.directive_site.directive
)
})
.collect()
}
fn route_collision_baseline_keys(
items: &[crate::results::RouteCollisionFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|c| {
format!(
"{}:{}",
relative_path(&c.collision.path, root),
c.collision.url
)
})
.collect()
}
struct BaselineMemberImportKeys {
unused_enum_members: Vec<String>,
unused_class_members: Vec<String>,
unused_store_members: Vec<String>,
unprovided_injects: Vec<String>,
unrendered_components: Vec<String>,
unused_component_props: Vec<String>,
unused_component_emits: Vec<String>,
unused_component_inputs: Vec<String>,
unused_component_outputs: Vec<String>,
unused_svelte_events: Vec<String>,
unused_server_actions: Vec<String>,
unused_load_data_keys: Vec<String>,
unresolved_imports: Vec<String>,
duplicate_exports: Vec<String>,
stale_suppressions: Vec<String>,
}
fn baseline_member_import_keys(
results: &crate::results::AnalysisResults,
root: &Path,
) -> BaselineMemberImportKeys {
BaselineMemberImportKeys {
unused_enum_members: enum_member_baseline_keys(&results.unused_enum_members, root),
unused_class_members: class_member_baseline_keys(&results.unused_class_members, root),
unused_store_members: store_member_baseline_keys(&results.unused_store_members, root),
unprovided_injects: inject_baseline_keys(&results.unprovided_injects, root),
unrendered_components: component_baseline_keys(&results.unrendered_components, root),
unused_component_props: component_prop_baseline_keys(&results.unused_component_props, root),
unused_component_emits: component_emit_baseline_keys(&results.unused_component_emits, root),
unused_component_inputs: component_input_baseline_keys(
&results.unused_component_inputs,
root,
),
unused_component_outputs: component_output_baseline_keys(
&results.unused_component_outputs,
root,
),
unused_svelte_events: svelte_event_baseline_keys(&results.unused_svelte_events, root),
unused_server_actions: server_action_baseline_keys(&results.unused_server_actions, root),
unused_load_data_keys: load_data_key_baseline_keys(&results.unused_load_data_keys, root),
unresolved_imports: unresolved_import_baseline_keys(&results.unresolved_imports, root),
duplicate_exports: results
.duplicate_exports
.iter()
.map(|d| duplicate_export_key(&d.export, root))
.collect(),
stale_suppressions: results
.stale_suppressions
.iter()
.map(|s| stale_suppression_baseline_key(s, root))
.collect(),
}
}
fn stale_suppression_baseline_key(
suppression: &crate::results::StaleSuppression,
root: &Path,
) -> String {
let rule_id = if suppression.missing_reason {
"missing-suppression-reason"
} else {
"stale-suppression"
};
format!(
"{rule_id}:{}:{}",
relative_path(&suppression.path, root),
suppression.line
)
}
fn enum_member_baseline_keys(
items: &[crate::results::UnusedEnumMemberFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|m| unused_member_baseline_key(&m.member, root))
.collect()
}
fn class_member_baseline_keys(
items: &[crate::results::UnusedClassMemberFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|m| unused_member_baseline_key(&m.member, root))
.collect()
}
fn store_member_baseline_keys(
items: &[crate::results::UnusedStoreMemberFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|m| unused_member_baseline_key(&m.member, root))
.collect()
}
fn unused_member_baseline_key(member: &crate::results::UnusedMember, root: &Path) -> String {
format!(
"{}:{}.{}",
relative_path(&member.path, root),
member.parent_name,
member.member_name
)
}
fn inject_baseline_keys(
items: &[crate::results::UnprovidedInjectFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|f| {
format!(
"{}:{}",
relative_path(&f.inject.path, root),
f.inject.key_name
)
})
.collect()
}
fn component_baseline_keys(
items: &[crate::results::UnrenderedComponentFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|c| {
format!(
"{}:{}",
relative_path(&c.component.path, root),
c.component.component_name
)
})
.collect()
}
fn component_prop_baseline_keys(
items: &[crate::results::UnusedComponentPropFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|p| format!("{}:{}", relative_path(&p.prop.path, root), p.prop.prop_name))
.collect()
}
fn component_emit_baseline_keys(
items: &[crate::results::UnusedComponentEmitFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|e| format!("{}:{}", relative_path(&e.emit.path, root), e.emit.emit_name))
.collect()
}
fn component_input_baseline_keys(
items: &[crate::results::UnusedComponentInputFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|i| {
format!(
"{}:{}",
relative_path(&i.input.path, root),
i.input.input_name
)
})
.collect()
}
fn component_output_baseline_keys(
items: &[crate::results::UnusedComponentOutputFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|o| {
format!(
"{}:{}",
relative_path(&o.output.path, root),
o.output.output_name
)
})
.collect()
}
fn svelte_event_baseline_keys(
items: &[crate::results::UnusedSvelteEventFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|e| {
format!(
"{}:{}",
relative_path(&e.event.path, root),
e.event.event_name
)
})
.collect()
}
fn server_action_baseline_keys(
items: &[crate::results::UnusedServerActionFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|a| {
format!(
"{}:{}",
relative_path(&a.action.path, root),
a.action.action_name
)
})
.collect()
}
fn load_data_key_baseline_keys(
items: &[crate::results::UnusedLoadDataKeyFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|k| format!("{}:{}", relative_path(&k.key.path, root), k.key.key_name))
.collect()
}
fn unresolved_import_baseline_keys(
items: &[crate::results::UnresolvedImportFinding],
root: &Path,
) -> Vec<String> {
items
.iter()
.map(|i| {
format!(
"{}:{}",
relative_path(&i.import.path, root),
i.import.specifier
)
})
.collect()
}
struct BaselineDependencyKeys {
unused: Vec<String>,
unused_dev: Vec<String>,
unused_optional: Vec<String>,
unlisted: Vec<String>,
type_only: Vec<String>,
test_only: Vec<String>,
dev_in_prod: Vec<String>,
}
fn baseline_dependency_keys(
results: &crate::results::AnalysisResults,
root: &Path,
) -> BaselineDependencyKeys {
BaselineDependencyKeys {
unused: results
.unused_dependencies
.iter()
.map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
.collect(),
unused_dev: results
.unused_dev_dependencies
.iter()
.map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
.collect(),
unused_optional: results
.unused_optional_dependencies
.iter()
.map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
.collect(),
unlisted: results
.unlisted_dependencies
.iter()
.map(|d| d.dep.package_name.clone())
.collect(),
type_only: results
.type_only_dependencies
.iter()
.map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
.collect(),
test_only: results
.test_only_dependencies
.iter()
.map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
.collect(),
dev_in_prod: results
.dev_dependencies_in_production
.iter()
.map(|d| package_json_dependency_key(&d.dep.package_name, &d.dep.path, root))
.collect(),
}
}
struct BaselineGraphKeys {
circular_dependencies: Vec<String>,
re_export_cycles: Vec<String>,
boundary_violations: Vec<String>,
boundary_coverage_violations: Vec<String>,
boundary_call_violations: Vec<String>,
policy_violations: Vec<String>,
}
fn baseline_graph_keys(
results: &crate::results::AnalysisResults,
root: &Path,
) -> BaselineGraphKeys {
BaselineGraphKeys {
circular_dependencies: results
.circular_dependencies
.iter()
.map(|c| circular_dep_key(&c.cycle, root))
.collect(),
re_export_cycles: results
.re_export_cycles
.iter()
.map(|c| re_export_cycle_key(&c.cycle, root))
.collect(),
boundary_violations: results
.boundary_violations
.iter()
.map(|v| boundary_violation_key(&v.violation, root))
.collect(),
boundary_coverage_violations: results
.boundary_coverage_violations
.iter()
.map(|v| relative_path(&v.violation.path, root))
.collect(),
boundary_call_violations: results
.boundary_call_violations
.iter()
.map(|v| boundary_call_violation_key(&v.violation, root))
.collect(),
policy_violations: results
.policy_violations
.iter()
.map(|v| policy_violation_key(&v.violation, root))
.collect(),
}
}
struct BaselineCatalogKeys {
unused_catalog_entries: Vec<String>,
empty_catalog_groups: Vec<String>,
unresolved_catalog_references: Vec<String>,
unused_dependency_overrides: Vec<String>,
misconfigured_dependency_overrides: Vec<String>,
}
fn baseline_catalog_keys(
results: &crate::results::AnalysisResults,
root: &Path,
) -> BaselineCatalogKeys {
BaselineCatalogKeys {
unused_catalog_entries: results
.unused_catalog_entries
.iter()
.map(|e| format!("{}:{}", e.entry.catalog_name, e.entry.entry_name))
.collect(),
empty_catalog_groups: results
.empty_catalog_groups
.iter()
.map(|g| g.group.catalog_name.clone())
.collect(),
unresolved_catalog_references: results
.unresolved_catalog_references
.iter()
.map(|r| {
format!(
"{}:{}:{}:{}",
relative_path(&r.reference.path, root),
r.reference.line,
r.reference.catalog_name,
r.reference.entry_name,
)
})
.collect(),
unused_dependency_overrides: results
.unused_dependency_overrides
.iter()
.map(|o| format!("{}:{}", o.entry.source, o.entry.raw_key))
.collect(),
misconfigured_dependency_overrides: results
.misconfigured_dependency_overrides
.iter()
.map(|o| format!("{}:{}", o.entry.source, o.entry.raw_key))
.collect(),
}
}
fn boundary_violation_key(v: &crate::results::BoundaryViolation, root: &Path) -> String {
format!(
"{}->{}",
relative_path(&v.from_path, root),
relative_path(&v.to_path, root),
)
}
fn boundary_call_violation_key(v: &crate::results::BoundaryCallViolation, root: &Path) -> String {
format!("{}:{}", relative_path(&v.path, root), v.callee)
}
fn policy_violation_key(v: &crate::results::PolicyViolation, root: &Path) -> String {
format!(
"{}:{}/{}:{}",
relative_path(&v.path, root),
v.pack,
v.rule_id,
v.matched
)
}
fn duplicate_export_key(dup: &crate::results::DuplicateExport, root: &Path) -> String {
let mut locs: Vec<String> = dup
.locations
.iter()
.map(|l| relative_path(&l.path, root))
.collect();
locs.sort();
format!("{}|{}", dup.export_name, locs.join("|"))
}
fn circular_dep_key(dep: &crate::results::CircularDependency, root: &Path) -> String {
let mut paths: Vec<String> = dep.files.iter().map(|f| relative_path(f, root)).collect();
paths.sort();
paths.join("->")
}
fn re_export_cycle_key(cycle: &crate::results::ReExportCycle, root: &Path) -> String {
let kind = match cycle.kind {
crate::results::ReExportCycleKind::MultiNode => "multi-node",
crate::results::ReExportCycleKind::SelfLoop => "self-loop",
};
let mut paths: Vec<String> = cycle.files.iter().map(|f| relative_path(f, root)).collect();
paths.sort();
format!("{kind}:{}", paths.join("<->"))
}
fn private_type_leak_key(leak: &crate::results::PrivateTypeLeak, root: &Path) -> String {
format!(
"{}:{}->{}",
relative_path(&leak.path, root),
leak.export_name,
leak.type_name
)
}
fn deprecated_export_key(
export: &fallow_types::results::DeprecatedExportInUse,
root: &Path,
) -> String {
format!(
"{}:{}",
relative_path(&export.path, root),
export.export_name
)
}
fn filter_private_type_leaks(
leaks: &mut Vec<fallow_types::output_dead_code::PrivateTypeLeakFinding>,
baseline_keys: &[String],
root: &Path,
) {
let baseline_private_type_leaks: FxHashSet<&str> =
baseline_keys.iter().map(String::as_str).collect();
leaks.retain(|entry| {
let key = private_type_leak_key(&entry.leak, root);
!baseline_private_type_leaks.contains(key.as_str())
});
}
struct BaselineFilterContext<'a> {
baseline: &'a BaselineData,
root: &'a Path,
}
impl BaselineFilterContext<'_> {
fn filter_cycles_and_members(&self, results: &mut crate::results::AnalysisResults) {
let baseline_circular: FxHashSet<&str> = self
.baseline
.circular_dependencies
.iter()
.map(String::as_str)
.collect();
results.circular_dependencies.retain(|cycle| {
let key = circular_dep_key(&cycle.cycle, self.root);
!baseline_circular.contains(key.as_str())
});
let baseline_re_export_cycles: FxHashSet<&str> = self
.baseline
.re_export_cycles
.iter()
.map(String::as_str)
.collect();
results.re_export_cycles.retain(|cycle| {
let key = re_export_cycle_key(&cycle.cycle, self.root);
!baseline_re_export_cycles.contains(key.as_str())
});
self.filter_unused_members(results);
self.filter_unresolved_and_exports(results);
}
fn filter_unused_members(&self, results: &mut crate::results::AnalysisResults) {
self.filter_enum_class_store_members(results);
self.filter_component_surface_members(results);
self.filter_route_action_members(results);
}
fn filter_enum_class_store_members(&self, results: &mut crate::results::AnalysisResults) {
let baseline_enum_members: FxHashSet<&str> = self
.baseline
.unused_enum_members
.iter()
.map(String::as_str)
.collect();
results.unused_enum_members.retain(|member| {
let key = format!(
"{}:{}.{}",
relative_path(&member.member.path, self.root),
member.member.parent_name,
member.member.member_name
);
!baseline_enum_members.contains(key.as_str())
});
let baseline_class_members: FxHashSet<&str> = self
.baseline
.unused_class_members
.iter()
.map(String::as_str)
.collect();
results.unused_class_members.retain(|member| {
let key = format!(
"{}:{}.{}",
relative_path(&member.member.path, self.root),
member.member.parent_name,
member.member.member_name
);
!baseline_class_members.contains(key.as_str())
});
let baseline_store_members: FxHashSet<&str> = self
.baseline
.unused_store_members
.iter()
.map(String::as_str)
.collect();
results.unused_store_members.retain(|member| {
let key = format!(
"{}:{}.{}",
relative_path(&member.member.path, self.root),
member.member.parent_name,
member.member.member_name
);
!baseline_store_members.contains(key.as_str())
});
}
fn filter_component_surface_members(&self, results: &mut crate::results::AnalysisResults) {
retain_new_by_keys(
&mut results.unprovided_injects,
&self.baseline.unprovided_injects,
self.root,
inject_baseline_keys,
);
retain_new_by_keys(
&mut results.unrendered_components,
&self.baseline.unrendered_components,
self.root,
component_baseline_keys,
);
retain_new_by_keys(
&mut results.unused_component_props,
&self.baseline.unused_component_props,
self.root,
component_prop_baseline_keys,
);
retain_new_by_keys(
&mut results.unused_component_emits,
&self.baseline.unused_component_emits,
self.root,
component_emit_baseline_keys,
);
retain_new_by_keys(
&mut results.unused_component_inputs,
&self.baseline.unused_component_inputs,
self.root,
component_input_baseline_keys,
);
retain_new_by_keys(
&mut results.unused_component_outputs,
&self.baseline.unused_component_outputs,
self.root,
component_output_baseline_keys,
);
retain_new_by_keys(
&mut results.unused_svelte_events,
&self.baseline.unused_svelte_events,
self.root,
svelte_event_baseline_keys,
);
}
fn filter_route_action_members(&self, results: &mut crate::results::AnalysisResults) {
let baseline_unused_server_actions: FxHashSet<&str> = self
.baseline
.unused_server_actions
.iter()
.map(String::as_str)
.collect();
results.unused_server_actions.retain(|finding| {
let key = format!(
"{}:{}",
relative_path(&finding.action.path, self.root),
finding.action.action_name
);
!baseline_unused_server_actions.contains(key.as_str())
});
let baseline_unused_load_data_keys: FxHashSet<&str> = self
.baseline
.unused_load_data_keys
.iter()
.map(String::as_str)
.collect();
results.unused_load_data_keys.retain(|finding| {
let key = format!(
"{}:{}",
relative_path(&finding.key.path, self.root),
finding.key.key_name
);
!baseline_unused_load_data_keys.contains(key.as_str())
});
}
fn filter_unresolved_and_exports(&self, results: &mut crate::results::AnalysisResults) {
let baseline_unresolved: FxHashSet<&str> = self
.baseline
.unresolved_imports
.iter()
.map(String::as_str)
.collect();
results.unresolved_imports.retain(|import| {
let key = format!(
"{}:{}",
relative_path(&import.import.path, self.root),
import.import.specifier
);
!baseline_unresolved.contains(key.as_str())
});
let baseline_unlisted: FxHashSet<&str> = self
.baseline
.unlisted_dependencies
.iter()
.map(String::as_str)
.collect();
results
.unlisted_dependencies
.retain(|dep| !baseline_unlisted.contains(dep.dep.package_name.as_str()));
let baseline_dup_exports: FxHashSet<&str> = self
.baseline
.duplicate_exports
.iter()
.map(String::as_str)
.collect();
results.duplicate_exports.retain(|duplicate| {
let key = duplicate_export_key(&duplicate.export, self.root);
!baseline_dup_exports.contains(key.as_str())
});
}
fn filter_dependency_variants(&self, results: &mut crate::results::AnalysisResults) {
let baseline_optional_deps: FxHashSet<&str> = self
.baseline
.unused_optional_dependencies
.iter()
.map(String::as_str)
.collect();
results.unused_optional_dependencies.retain(|dep| {
let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
!baseline_contains_dependency(
&baseline_optional_deps,
&dep.dep.package_name,
key.as_str(),
)
});
self.filter_type_and_test_only_dependencies(results);
}
fn filter_type_and_test_only_dependencies(
&self,
results: &mut crate::results::AnalysisResults,
) {
let baseline_type_only: FxHashSet<&str> = self
.baseline
.type_only_dependencies
.iter()
.map(String::as_str)
.collect();
results.type_only_dependencies.retain(|dep| {
let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
!baseline_contains_dependency(&baseline_type_only, &dep.dep.package_name, key.as_str())
});
let baseline_test_only: FxHashSet<&str> = self
.baseline
.test_only_dependencies
.iter()
.map(String::as_str)
.collect();
results.test_only_dependencies.retain(|dep| {
let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
!baseline_contains_dependency(&baseline_test_only, &dep.dep.package_name, key.as_str())
});
let baseline_dev_in_prod: FxHashSet<&str> = self
.baseline
.dev_dependencies_in_production
.iter()
.map(String::as_str)
.collect();
results.dev_dependencies_in_production.retain(|dep| {
let key = package_json_dependency_key(&dep.dep.package_name, &dep.dep.path, self.root);
!baseline_contains_dependency(
&baseline_dev_in_prod,
&dep.dep.package_name,
key.as_str(),
)
});
}
fn filter_boundaries_and_suppressions(&self, results: &mut crate::results::AnalysisResults) {
let baseline_boundary: FxHashSet<&str> = self
.baseline
.boundary_violations
.iter()
.map(String::as_str)
.collect();
results.boundary_violations.retain(|violation| {
let key = boundary_violation_key(&violation.violation, self.root);
!baseline_boundary.contains(key.as_str())
});
self.filter_boundary_details(results);
self.filter_stale_suppressions(results);
self.filter_invalid_client_exports(results);
self.filter_mixed_client_server_barrels(results);
self.filter_misplaced_directives(results);
self.filter_route_collisions(results);
self.filter_dynamic_segment_name_conflicts(results);
}
fn filter_invalid_client_exports(&self, results: &mut crate::results::AnalysisResults) {
let baseline_invalid: FxHashSet<&str> = self
.baseline
.invalid_client_exports
.iter()
.map(String::as_str)
.collect();
results.invalid_client_exports.retain(|finding| {
let key = format!(
"{}:{}",
relative_path(&finding.export.path, self.root),
finding.export.export_name
);
!baseline_invalid.contains(key.as_str())
});
}
fn filter_mixed_client_server_barrels(&self, results: &mut crate::results::AnalysisResults) {
let baseline_barrels: FxHashSet<&str> = self
.baseline
.mixed_client_server_barrels
.iter()
.map(String::as_str)
.collect();
results.mixed_client_server_barrels.retain(|finding| {
let key = format!(
"{}:{}:{}",
relative_path(&finding.barrel.path, self.root),
finding.barrel.client_origin,
finding.barrel.server_origin
);
!baseline_barrels.contains(key.as_str())
});
}
fn filter_misplaced_directives(&self, results: &mut crate::results::AnalysisResults) {
let baseline_directives: FxHashSet<&str> = self
.baseline
.misplaced_directives
.iter()
.map(String::as_str)
.collect();
results.misplaced_directives.retain(|finding| {
let key = format!(
"{}:{}:{}",
relative_path(&finding.directive_site.path, self.root),
finding.directive_site.line,
finding.directive_site.directive
);
!baseline_directives.contains(key.as_str())
});
}
fn filter_route_collisions(&self, results: &mut crate::results::AnalysisResults) {
let baseline_collisions: FxHashSet<&str> = self
.baseline
.route_collisions
.iter()
.map(String::as_str)
.collect();
results.route_collisions.retain(|finding| {
let key = format!(
"{}:{}",
relative_path(&finding.collision.path, self.root),
finding.collision.url
);
!baseline_collisions.contains(key.as_str())
});
}
fn filter_dynamic_segment_name_conflicts(&self, results: &mut crate::results::AnalysisResults) {
let baseline_conflicts: FxHashSet<&str> = self
.baseline
.dynamic_segment_name_conflicts
.iter()
.map(String::as_str)
.collect();
results.dynamic_segment_name_conflicts.retain(|finding| {
let key = format!(
"{}:{}",
relative_path(&finding.conflict.path, self.root),
finding.conflict.position
);
!baseline_conflicts.contains(key.as_str())
});
}
fn filter_boundary_details(&self, results: &mut crate::results::AnalysisResults) {
let baseline_boundary_coverage: FxHashSet<&str> = self
.baseline
.boundary_coverage_violations
.iter()
.map(String::as_str)
.collect();
results.boundary_coverage_violations.retain(|violation| {
let key = relative_path(&violation.violation.path, self.root);
!baseline_boundary_coverage.contains(key.as_str())
});
let baseline_boundary_calls: FxHashSet<&str> = self
.baseline
.boundary_call_violations
.iter()
.map(String::as_str)
.collect();
results.boundary_call_violations.retain(|violation| {
let key = boundary_call_violation_key(&violation.violation, self.root);
!baseline_boundary_calls.contains(key.as_str())
});
}
fn filter_stale_suppressions(&self, results: &mut crate::results::AnalysisResults) {
let baseline_stale: FxHashSet<&str> = self
.baseline
.stale_suppressions
.iter()
.map(String::as_str)
.collect();
results.stale_suppressions.retain(|suppression| {
let key = stale_suppression_baseline_key(suppression, self.root);
let legacy_key = format!(
"{}:{}",
relative_path(&suppression.path, self.root),
suppression.line
);
!baseline_stale.contains(key.as_str()) && !baseline_stale.contains(legacy_key.as_str())
});
}
fn filter_pnpm_entries(&self, results: &mut crate::results::AnalysisResults) {
let baseline_catalog: FxHashSet<&str> = self
.baseline
.unused_catalog_entries
.iter()
.map(String::as_str)
.collect();
results.unused_catalog_entries.retain(|entry| {
let key = format!("{}:{}", entry.entry.catalog_name, entry.entry.entry_name);
!baseline_catalog.contains(key.as_str())
});
let baseline_empty_catalog_groups: FxHashSet<&str> = self
.baseline
.empty_catalog_groups
.iter()
.map(String::as_str)
.collect();
results.empty_catalog_groups.retain(|group| {
!baseline_empty_catalog_groups.contains(group.group.catalog_name.as_str())
});
self.filter_pnpm_references_and_overrides(results);
}
fn filter_pnpm_references_and_overrides(&self, results: &mut crate::results::AnalysisResults) {
let baseline_unresolved: FxHashSet<&str> = self
.baseline
.unresolved_catalog_references
.iter()
.map(String::as_str)
.collect();
results.unresolved_catalog_references.retain(|reference| {
let key = format!(
"{}:{}:{}:{}",
relative_path(&reference.reference.path, self.root),
reference.reference.line,
reference.reference.catalog_name,
reference.reference.entry_name,
);
!baseline_unresolved.contains(key.as_str())
});
self.filter_pnpm_overrides(results);
}
fn filter_pnpm_overrides(&self, results: &mut crate::results::AnalysisResults) {
let baseline_unused_overrides: FxHashSet<&str> = self
.baseline
.unused_dependency_overrides
.iter()
.map(String::as_str)
.collect();
results
.unused_dependency_overrides
.retain(|override_entry| {
let key = format!(
"{}:{}",
override_entry.entry.source, override_entry.entry.raw_key
);
!baseline_unused_overrides.contains(key.as_str())
});
let baseline_misconfigured_overrides: FxHashSet<&str> = self
.baseline
.misconfigured_dependency_overrides
.iter()
.map(String::as_str)
.collect();
results
.misconfigured_dependency_overrides
.retain(|override_entry| {
let key = format!(
"{}:{}",
override_entry.entry.source, override_entry.entry.raw_key
);
!baseline_misconfigured_overrides.contains(key.as_str())
});
}
}
pub fn filter_new_issues(
mut results: crate::results::AnalysisResults,
baseline: &BaselineData,
root: &Path,
) -> crate::results::AnalysisResults {
let baseline_files: FxHashSet<&str> =
baseline.unused_files.iter().map(String::as_str).collect();
let baseline_exports: FxHashSet<&str> =
baseline.unused_exports.iter().map(String::as_str).collect();
let baseline_types: FxHashSet<&str> =
baseline.unused_types.iter().map(String::as_str).collect();
let baseline_deps: FxHashSet<&str> = baseline
.unused_dependencies
.iter()
.map(String::as_str)
.collect();
let baseline_dev_deps: FxHashSet<&str> = baseline
.unused_dev_dependencies
.iter()
.map(String::as_str)
.collect();
results
.unused_files
.retain(|f| !baseline_files.contains(relative_path(&f.file.path, root).as_str()));
results.unused_exports.retain(|e| {
let key = format!(
"{}:{}",
relative_path(&e.export.path, root),
e.export.export_name
);
!baseline_exports.contains(key.as_str())
});
results.unused_types.retain(|e| {
let key = format!(
"{}:{}",
relative_path(&e.export.path, root),
e.export.export_name
);
!baseline_types.contains(key.as_str())
});
filter_private_type_leaks(
&mut results.private_type_leaks,
&baseline.private_type_leaks,
root,
);
let baseline_deprecated: FxHashSet<&str> = baseline
.deprecated_exports_in_use
.iter()
.map(String::as_str)
.collect();
results
.deprecated_exports_in_use
.retain(|e| !baseline_deprecated.contains(deprecated_export_key(&e.export, root).as_str()));
results.unused_dependencies.retain(|d| {
let key = package_json_dependency_key(&d.dep.package_name, &d.dep.path, root);
!baseline_contains_dependency(&baseline_deps, &d.dep.package_name, key.as_str())
});
results.unused_dev_dependencies.retain(|d| {
let key = package_json_dependency_key(&d.dep.package_name, &d.dep.path, root);
!baseline_contains_dependency(&baseline_dev_deps, &d.dep.package_name, key.as_str())
});
let filter = BaselineFilterContext { baseline, root };
filter.filter_cycles_and_members(&mut results);
filter.filter_dependency_variants(&mut results);
filter.filter_boundaries_and_suppressions(&mut results);
filter.filter_pnpm_entries(&mut results);
results
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeadCodeBaselineOutcome {
Applied(BaselineStaleness),
NotDeadCode {
staleness: BaselineStaleness,
saved_by: Option<BaselineKind>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeadCodeBaselineError {
Parse(String),
IncompatibleIdentity(Vec<&'static str>),
}
pub fn apply_dead_code_baseline(
results: &mut crate::results::AnalysisResults,
content: &str,
root: &Path,
identity: &fallow_types::semantic::SemanticAnalysisIdentity,
change_scoped: bool,
) -> Result<DeadCodeBaselineOutcome, DeadCodeBaselineError> {
let parsed = serde_json::from_str::<serde_json::Value>(content)
.map_err(|err| DeadCodeBaselineError::Parse(err.to_string()))?;
let saved_by = match classify_baseline_value(&parsed, BaselineKind::DeadCode) {
BaselineFileKind::Own | BaselineFileKind::NotAnObject => None,
foreign @ BaselineFileKind::Foreign(_) => Some(foreign.saved_by()),
BaselineFileKind::Unrecognised => Some(None),
};
if let Some(saved_by) = saved_by {
return Ok(DeadCodeBaselineOutcome::NotDeadCode {
staleness: BaselineStaleness {
entries: 0,
matched: 0,
current_findings: results.total_issues(),
change_scoped,
},
saved_by,
});
}
let baseline = serde_json::from_value::<BaselineData>(parsed)
.map_err(|err| DeadCodeBaselineError::Parse(err.to_string()))?;
let incompatible = baseline.analysis_identity().incompatible_fields(identity);
if !incompatible.is_empty() {
return Err(DeadCodeBaselineError::IncompatibleIdentity(incompatible));
}
let before = results.total_issues();
*results = filter_new_issues(std::mem::take(results), &baseline, root);
Ok(DeadCodeBaselineOutcome::Applied(BaselineStaleness {
entries: baseline.total_entries(),
matched: before.saturating_sub(results.total_issues()),
current_findings: before,
change_scoped,
}))
}
#[derive(Default, serde::Serialize, serde::Deserialize)]
pub struct DuplicationBaselineData {
#[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
kind: Option<BaselineKind>,
#[serde(default)]
pub clone_groups: Vec<String>,
#[serde(default)]
pub clone_fingerprints: Vec<String>,
#[serde(default)]
pub normalized_clone_fingerprints: Vec<String>,
}
impl DuplicationBaselineData {
pub const DECLARED_KEYS: &'static [&'static str] = &[
"clone_groups",
"clone_fingerprints",
"normalized_clone_fingerprints",
];
pub fn from_report(report: &DuplicationReport, root: &Path) -> Self {
let fingerprints =
crate::duplicates::CloneFingerprintSet::from_groups(&report.clone_groups);
Self {
kind: Some(BaselineKind::Dupes),
clone_groups: report
.clone_groups
.iter()
.map(|g| clone_group_key(g, root))
.collect(),
clone_fingerprints: report
.clone_groups
.iter()
.map(legacy_clone_group_fingerprint_key)
.collect(),
normalized_clone_fingerprints: report
.clone_groups
.iter()
.map(|group| clone_group_fingerprint_key(group, &fingerprints))
.collect(),
}
}
#[must_use]
pub fn entry_count(&self) -> usize {
if !self.normalized_clone_fingerprints.is_empty() {
self.normalized_clone_fingerprints.len()
} else if !self.clone_fingerprints.is_empty() {
self.clone_fingerprints.len()
} else {
self.clone_groups.len()
}
}
}
fn clone_group_key(group: &crate::duplicates::CloneGroup, root: &Path) -> String {
let mut parts: Vec<String> = group
.instances
.iter()
.map(|i| {
format!(
"{}:{}-{}",
relative_path(&i.file, root),
i.start_line,
i.end_line
)
})
.collect();
parts.sort();
parts.join("|")
}
fn clone_group_fingerprint_key(
group: &crate::duplicates::CloneGroup,
fingerprints: &crate::duplicates::CloneFingerprintSet,
) -> String {
fingerprints.ignored_clone_key_for_group(group)
}
fn legacy_clone_group_fingerprint_key(group: &crate::duplicates::CloneGroup) -> String {
let representative = group
.instances
.iter()
.min_by(|a, b| (a.file.as_path(), a.start_line).cmp(&(b.file.as_path(), b.start_line)))
.map_or("", |i| i.fragment.as_str());
let hash = if representative.as_bytes().contains(&b'\r') {
xxhash_rust::xxh3::xxh3_64(representative.replace('\r', "").as_bytes())
} else {
xxhash_rust::xxh3::xxh3_64(representative.as_bytes())
};
format!(
"{}{:08x}:{}",
crate::duplicates::FINGERPRINT_PREFIX,
hash as u32,
group.instances.len()
)
}
fn consume_baseline_key(remaining: &mut FxHashMap<&str, usize>, key: &str) -> bool {
match remaining.get_mut(key) {
Some(count) if *count > 0 => {
*count -= 1;
true
}
_ => false,
}
}
pub fn filter_new_clone_groups(
mut report: DuplicationReport,
baseline: &DuplicationBaselineData,
root: &Path,
) -> DuplicationReport {
if !baseline.normalized_clone_fingerprints.is_empty() {
let fingerprints =
crate::duplicates::CloneFingerprintSet::from_groups(&report.clone_groups);
let mut remaining: FxHashMap<&str, usize> = FxHashMap::default();
for key in &baseline.normalized_clone_fingerprints {
*remaining.entry(key.as_str()).or_insert(0) += 1;
}
report.clone_groups.retain(|group| {
let key = clone_group_fingerprint_key(group, &fingerprints);
!consume_baseline_key(&mut remaining, &key)
});
} else if baseline.clone_fingerprints.is_empty() {
let baseline_keys: FxHashSet<&str> =
baseline.clone_groups.iter().map(String::as_str).collect();
report.clone_groups.retain(|g| {
let key = clone_group_key(g, root);
!baseline_keys.contains(key.as_str())
});
} else {
let mut remaining: FxHashMap<&str, usize> = FxHashMap::default();
for key in &baseline.clone_fingerprints {
*remaining.entry(key.as_str()).or_insert(0) += 1;
}
report.clone_groups.retain(|group| {
let key = legacy_clone_group_fingerprint_key(group);
!consume_baseline_key(&mut remaining, &key)
});
}
crate::duplicates::refresh_clone_families(&mut report, root);
report.stats = recompute_stats(&report);
report
}
pub fn recompute_stats(report: &DuplicationReport) -> crate::duplicates::DuplicationStats {
crate::duplicates::recompute_stats(report)
}
#[derive(Default, serde::Serialize, serde::Deserialize)]
pub struct HealthBaselineData {
#[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
pub(crate) kind: Option<BaselineKind>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) findings: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub(crate) finding_counts: HealthFindingCountMap,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub(crate) identity_finding_counts: HealthFindingCountMap,
#[serde(default)]
pub(crate) runtime_coverage_findings: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) runtime_coverage_source_hashes: Vec<String>,
#[serde(default)]
pub(crate) target_keys: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct HealthBaselineCount {
count: usize,
}
type HealthFindingCountMap = BTreeMap<String, BTreeMap<String, HealthBaselineCount>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HealthBaselineMode {
#[default]
Count,
Identity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HealthFindingDimension {
Complexity,
Crap,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct HealthFindingCategory {
dimension: HealthFindingDimension,
severity: fallow_output::FindingSeverity,
}
impl HealthFindingCategory {
const fn key(self) -> &'static str {
match (self.dimension, self.severity) {
(HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Moderate) => {
"complexity_moderate"
}
(HealthFindingDimension::Complexity, fallow_output::FindingSeverity::High) => {
"complexity_high"
}
(HealthFindingDimension::Complexity, fallow_output::FindingSeverity::Critical) => {
"complexity_critical"
}
(HealthFindingDimension::Crap, fallow_output::FindingSeverity::Moderate) => {
"crap_moderate"
}
(HealthFindingDimension::Crap, fallow_output::FindingSeverity::High) => "crap_high",
(HealthFindingDimension::Crap, fallow_output::FindingSeverity::Critical) => {
"crap_critical"
}
}
}
}
const HEALTH_FINDING_DIMENSIONS: [HealthFindingDimension; 2] = [
HealthFindingDimension::Complexity,
HealthFindingDimension::Crap,
];
impl HealthBaselineData {
pub const DECLARED_KEYS: &'static [&'static str] = &[
"findings",
"finding_counts",
"identity_finding_counts",
"runtime_coverage_findings",
"runtime_coverage_source_hashes",
"target_keys",
];
pub(crate) fn from_findings(
findings: &[fallow_output::ComplexityViolation],
runtime_coverage_findings: &[fallow_output::RuntimeCoverageFinding],
targets: &[fallow_output::RefactoringTarget],
root: &Path,
) -> Self {
Self {
kind: Some(BaselineKind::Health),
findings: Vec::new(),
finding_counts: health_finding_counts(findings, root, HealthBaselineMode::Count),
identity_finding_counts: HealthFindingCountMap::new(),
runtime_coverage_findings: runtime_coverage_findings
.iter()
.map(|f| runtime_coverage_finding_key(f, root))
.collect(),
runtime_coverage_source_hashes: runtime_coverage_findings
.iter()
.filter_map(|f| runtime_coverage_source_hash_key(f, root))
.collect(),
target_keys: targets
.iter()
.map(|t| target_baseline_key(t, root))
.collect(),
}
}
pub(crate) fn finding_entry_count(&self) -> usize {
if !self.finding_counts.is_empty() {
self.finding_counts
.values()
.flat_map(BTreeMap::values)
.map(|entry| entry.count)
.sum()
} else {
self.findings.len()
}
}
#[must_use]
pub(crate) fn with_identity(
mut self,
findings: &[fallow_output::ComplexityViolation],
root: &Path,
) -> Self {
self.identity_finding_counts =
health_finding_counts(findings, root, HealthBaselineMode::Identity);
self
}
pub(crate) fn lacks_identity_data(&self) -> bool {
self.identity_finding_counts.is_empty()
&& (!self.finding_counts.is_empty() || !self.findings.is_empty())
}
fn counts_for(&self, mode: HealthBaselineMode) -> &HealthFindingCountMap {
match mode {
HealthBaselineMode::Count => &self.finding_counts,
HealthBaselineMode::Identity => &self.identity_finding_counts,
}
}
pub(crate) fn overlap_entries(
&self,
findings: &[fallow_output::ComplexityViolation],
root: &Path,
mode: HealthBaselineMode,
) -> HealthBaselineOverlap {
let baseline_counts = self.counts_for(mode);
if !baseline_counts.is_empty() {
let current_counts = health_finding_counts(findings, root, mode);
let direct = health_overlap_entry_count(¤t_counts, baseline_counts);
let remapped = (mode == HealthBaselineMode::Identity)
.then(|| {
identity_counts_with_move_tolerance(baseline_counts, ¤t_counts, root)
})
.flatten();
match remapped {
Some(remapped_counts) => {
let matched = health_overlap_entry_count(¤t_counts, &remapped_counts);
HealthBaselineOverlap {
matched_entries: matched,
moved_entries: matched.saturating_sub(direct),
}
}
None => HealthBaselineOverlap {
matched_entries: direct,
moved_entries: 0,
},
}
} else {
let baseline_keys: FxHashSet<&str> = self.findings.iter().map(String::as_str).collect();
HealthBaselineOverlap {
matched_entries: findings
.iter()
.filter(|finding| {
baseline_keys.contains(health_finding_key(finding, root).as_str())
})
.count(),
moved_entries: 0,
}
}
}
}
pub(crate) struct HealthBaselineOverlap {
pub(crate) matched_entries: usize,
pub(crate) moved_entries: usize,
}
fn target_baseline_key(target: &fallow_output::RefactoringTarget, root: &Path) -> String {
format!(
"{}:{}",
relative_path(&target.path, root),
target.category.label()
)
}
fn health_finding_key(finding: &fallow_output::ComplexityViolation, root: &Path) -> String {
format!(
"{}:{}:{}",
relative_path(&finding.path, root),
finding.name,
finding.line
)
}
fn health_bucket_key(
finding: &fallow_output::ComplexityViolation,
root: &Path,
mode: HealthBaselineMode,
) -> String {
let path = relative_path(&finding.path, root);
match mode {
HealthBaselineMode::Count => path,
HealthBaselineMode::Identity => format!("{path}\0{}", finding.name),
}
}
const ANONYMOUS_FUNCTION_NAME: &str = "<anonymous>";
fn identity_bucket_parts(key: &str) -> Option<(&str, &str)> {
key.split_once('\0')
}
fn moved_identity_bucket_remaps(
baseline_counts: &HealthFindingCountMap,
current_counts: &HealthFindingCountMap,
root: &Path,
) -> Vec<(String, String)> {
let mut candidates_by_name: FxHashMap<&str, Vec<&str>> = FxHashMap::default();
for key in current_counts.keys() {
if baseline_counts.contains_key(key) {
continue;
}
if let Some((_, name)) = identity_bucket_parts(key)
&& name != ANONYMOUS_FUNCTION_NAME
{
candidates_by_name.entry(name).or_default().push(key);
}
}
let mut proposals: Vec<(&str, &str)> = Vec::new();
let mut claims: FxHashMap<&str, usize> = FxHashMap::default();
for key in baseline_counts.keys() {
if current_counts.contains_key(key.as_str()) {
continue;
}
let Some((path, name)) = identity_bucket_parts(key) else {
continue;
};
if name == ANONYMOUS_FUNCTION_NAME || root.join(path).exists() {
continue;
}
if let Some(candidates) = candidates_by_name.get(name)
&& let [only_candidate] = candidates.as_slice()
{
proposals.push((key.as_str(), only_candidate));
*claims.entry(only_candidate).or_default() += 1;
}
}
proposals
.into_iter()
.filter(|(_, candidate)| claims.get(candidate) == Some(&1))
.map(|(old, new)| (old.to_string(), new.to_string()))
.collect()
}
fn identity_counts_with_move_tolerance(
baseline_counts: &HealthFindingCountMap,
current_counts: &HealthFindingCountMap,
root: &Path,
) -> Option<HealthFindingCountMap> {
let remaps = moved_identity_bucket_remaps(baseline_counts, current_counts, root);
if remaps.is_empty() {
return None;
}
let mut remapped = baseline_counts.clone();
for (old_key, new_key) in remaps {
if let Some(entry) = remapped.remove(&old_key) {
remapped.insert(new_key, entry);
}
}
Some(remapped)
}
fn health_finding_counts(
findings: &[fallow_output::ComplexityViolation],
root: &Path,
mode: HealthBaselineMode,
) -> HealthFindingCountMap {
let mut counts = BTreeMap::new();
for finding in findings {
let bucket = health_bucket_key(finding, root, mode);
let file_counts = counts.entry(bucket).or_insert_with(BTreeMap::new);
for category in health_finding_categories(finding).into_iter().flatten() {
file_counts
.entry(category.key().to_string())
.and_modify(|entry: &mut HealthBaselineCount| entry.count += 1)
.or_insert(HealthBaselineCount { count: 1 });
}
}
counts
}
fn health_finding_categories(
finding: &fallow_output::ComplexityViolation,
) -> [Option<HealthFindingCategory>; 2] {
let complexity_category = HealthFindingCategory {
dimension: HealthFindingDimension::Complexity,
severity: finding.severity,
};
let crap_category = HealthFindingCategory {
dimension: HealthFindingDimension::Crap,
severity: finding.severity,
};
let has_complexity =
finding.exceeded.includes_cyclomatic() || finding.exceeded.includes_cognitive();
let has_crap = finding.exceeded.includes_crap();
[
has_complexity.then_some(complexity_category),
has_crap.then_some(crap_category),
]
}
fn severity_index(severity: fallow_output::FindingSeverity) -> usize {
match severity {
fallow_output::FindingSeverity::Moderate => 0,
fallow_output::FindingSeverity::High => 1,
fallow_output::FindingSeverity::Critical => 2,
}
}
fn severity_counts_for_dimension(
file_counts: Option<&BTreeMap<String, HealthBaselineCount>>,
dimension: HealthFindingDimension,
) -> [usize; 3] {
let mut counts = [0; 3];
for severity in [
fallow_output::FindingSeverity::Moderate,
fallow_output::FindingSeverity::High,
fallow_output::FindingSeverity::Critical,
] {
let category = HealthFindingCategory {
dimension,
severity,
};
counts[severity_index(severity)] = file_counts
.and_then(|entries| entries.get(category.key()))
.map_or(0, |entry| entry.count);
}
counts
}
fn overflowing_severities(current: [usize; 3], baseline: [usize; 3]) -> [bool; 3] {
let mut available = baseline;
let mut overflow = [false; 3];
for severity_idx in 0..3 {
let compatible = available[severity_idx..].iter().sum::<usize>();
overflow[severity_idx] = compatible < current[severity_idx];
let mut matched = current[severity_idx].min(compatible);
for slot in available.iter_mut().skip(severity_idx) {
let taken = matched.min(*slot);
*slot -= taken;
matched -= taken;
if matched == 0 {
break;
}
}
}
overflow
}
fn health_overflow_categories(
current_counts: &HealthFindingCountMap,
baseline_counts: &HealthFindingCountMap,
) -> FxHashMap<String, FxHashSet<&'static str>> {
let mut overflow_by_path = FxHashMap::default();
for (path, current_file_counts) in current_counts {
let mut overflow_categories: FxHashSet<&'static str> = FxHashSet::default();
let baseline_file_counts = baseline_counts.get(path);
for dimension in HEALTH_FINDING_DIMENSIONS {
let current = severity_counts_for_dimension(Some(current_file_counts), dimension);
let baseline = severity_counts_for_dimension(baseline_file_counts, dimension);
let overflow = overflowing_severities(current, baseline);
for severity in [
fallow_output::FindingSeverity::Moderate,
fallow_output::FindingSeverity::High,
fallow_output::FindingSeverity::Critical,
] {
if overflow[severity_index(severity)] {
overflow_categories.insert(
HealthFindingCategory {
dimension,
severity,
}
.key(),
);
}
}
}
if !overflow_categories.is_empty() {
overflow_by_path.insert(path.clone(), overflow_categories);
}
}
overflow_by_path
}
fn health_overlap_entry_count(
current_counts: &HealthFindingCountMap,
baseline_counts: &HealthFindingCountMap,
) -> usize {
let mut overlap = 0;
for (path, baseline_file_counts) in baseline_counts {
let current_file_counts = current_counts.get(path);
for dimension in HEALTH_FINDING_DIMENSIONS {
let current_total: usize =
severity_counts_for_dimension(current_file_counts, dimension)
.into_iter()
.sum();
let baseline_total: usize =
severity_counts_for_dimension(Some(baseline_file_counts), dimension)
.into_iter()
.sum();
overlap += current_total.min(baseline_total);
}
}
overlap
}
fn runtime_coverage_finding_key(
finding: &fallow_output::RuntimeCoverageFinding,
_root: &Path,
) -> String {
finding
.stable_id
.clone()
.unwrap_or_else(|| finding.id.clone())
}
fn runtime_coverage_source_hash_key(
finding: &fallow_output::RuntimeCoverageFinding,
root: &Path,
) -> Option<String> {
finding.source_hash.as_deref().map(|hash| {
format!(
"{}\0{}\0{}",
relative_path(&finding.path, root),
finding.function,
hash
)
})
}
pub(crate) fn filter_new_health_findings(
mut findings: Vec<fallow_output::ComplexityViolation>,
baseline: &HealthBaselineData,
root: &Path,
mode: HealthBaselineMode,
) -> Vec<fallow_output::ComplexityViolation> {
let baseline_counts = baseline.counts_for(mode);
if !baseline_counts.is_empty() {
let current_counts = health_finding_counts(&findings, root, mode);
let remapped = (mode == HealthBaselineMode::Identity)
.then(|| identity_counts_with_move_tolerance(baseline_counts, ¤t_counts, root))
.flatten();
let overflow_categories = health_overflow_categories(
¤t_counts,
remapped.as_ref().unwrap_or(baseline_counts),
);
findings.retain(|finding| {
let bucket = health_bucket_key(finding, root, mode);
overflow_categories.get(&bucket).is_some_and(|categories| {
health_finding_categories(finding)
.into_iter()
.flatten()
.any(|category| categories.contains(category.key()))
})
});
return findings;
}
let baseline_keys: FxHashSet<&str> = baseline.findings.iter().map(String::as_str).collect();
findings.retain(|f| {
let key = health_finding_key(f, root);
!baseline_keys.contains(key.as_str())
});
findings
}
pub(crate) fn filter_new_runtime_coverage_findings(
mut findings: Vec<fallow_output::RuntimeCoverageFinding>,
baseline: &HealthBaselineData,
root: &Path,
) -> Vec<fallow_output::RuntimeCoverageFinding> {
let baseline_keys: FxHashSet<&str> = baseline
.runtime_coverage_findings
.iter()
.map(String::as_str)
.collect();
let baseline_source_hash_keys: FxHashSet<&str> = baseline
.runtime_coverage_source_hashes
.iter()
.map(String::as_str)
.collect();
findings.retain(|finding| {
let suppressed_by_stable_id = finding
.stable_id
.as_deref()
.is_some_and(|id| baseline_keys.contains(id));
let suppressed_by_legacy_id = baseline_keys.contains(finding.id.as_str());
let suppressed_by_source_hash = runtime_coverage_source_hash_key(finding, root)
.is_some_and(|key| baseline_source_hash_keys.contains(key.as_str()));
!(suppressed_by_stable_id || suppressed_by_legacy_id || suppressed_by_source_hash)
});
findings
}
pub(crate) fn filter_new_health_targets(
mut targets: Vec<fallow_output::RefactoringTarget>,
baseline: &HealthBaselineData,
root: &Path,
) -> Vec<fallow_output::RefactoringTarget> {
let baseline_keys: FxHashSet<&str> = baseline.target_keys.iter().map(String::as_str).collect();
targets.retain(|t| {
let key = target_baseline_key(t, root);
!baseline_keys.contains(key.as_str())
});
targets
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CategoryDelta {
pub current: usize,
pub baseline: usize,
pub delta: i64,
}
#[derive(Debug, Clone)]
pub struct BaselineDeltas {
pub total_delta: i64,
pub per_category: Vec<(String, CategoryDelta)>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
use crate::results::{
AnalysisResults, BoundaryViolationFinding, CircularDependencyFinding, DependencyLocation,
UnusedDependency, UnusedDependencyFinding, UnusedDevDependencyFinding, UnusedExport,
UnusedFile,
};
use fallow_types::output_dead_code::{
UnusedExportFinding, UnusedFileFinding, UnusedTypeFinding,
};
use std::path::PathBuf;
#[test]
fn stale_share_threshold_matches_the_documented_quarter() {
for (baseline_entries, stale_entries, expected) in [
(100, 24, false),
(100, 25, true),
(4, 2, true),
(29, 2, false),
(5, 1, false),
(0, 0, false),
] {
assert_eq!(
stale_share_warrants_warning(baseline_entries, stale_entries),
expected,
"{stale_entries} of {baseline_entries} entries"
);
}
}
const fn staleness(
entries: usize,
matched: usize,
current_findings: usize,
) -> BaselineStaleness {
BaselineStaleness {
entries,
matched,
current_findings,
change_scoped: false,
}
}
#[test]
fn warning_is_silent_when_the_run_found_nothing() {
let staleness = staleness(4, 0, 0);
assert_eq!(staleness.stale_entries(), 4);
assert_eq!(staleness.warning(), BaselineStalenessWarning::None);
}
#[test]
fn zero_overlap_warns_when_the_run_has_findings() {
assert_eq!(
staleness(4, 0, 4).warning(),
BaselineStalenessWarning::ZeroOverlap
);
}
#[test]
fn partial_warning_needs_the_documented_quarter() {
assert_eq!(
staleness(100, 76, 100).warning(),
BaselineStalenessWarning::None
);
assert_eq!(
staleness(100, 75, 100).warning(),
BaselineStalenessWarning::Partial
);
}
#[test]
fn empty_baseline_never_warns() {
assert_eq!(staleness(0, 0, 3).warning(), BaselineStalenessWarning::None);
}
#[test]
fn change_scoped_run_never_warns_and_never_trips_the_gate() {
let scoped = BaselineStaleness {
change_scoped: true,
..staleness(8, 2, 8)
};
assert_eq!(scoped.warning(), BaselineStalenessWarning::None);
assert!(!scoped.trips_gate());
}
#[test]
fn gate_trips_on_one_stale_entry_the_warning_ignores() {
let staleness = staleness(20, 19, 19);
assert_eq!(staleness.warning(), BaselineStalenessWarning::None);
assert!(staleness.trips_gate());
}
#[test]
fn gate_trips_on_a_cleaned_project_the_warning_stays_silent_about() {
let staleness = staleness(4, 0, 0);
assert_eq!(staleness.warning(), BaselineStalenessWarning::None);
assert!(staleness.trips_gate());
}
#[test]
fn gate_is_inert_on_an_empty_baseline() {
assert!(!staleness(0, 0, 0).trips_gate());
}
#[test]
fn gate_is_inert_when_every_entry_matched() {
assert!(!staleness(4, 4, 4).trips_gate());
}
fn make_results() -> AnalysisResults {
AnalysisResults {
unused_files: vec![
UnusedFileFinding::with_actions(UnusedFile {
path: PathBuf::from("src/old.ts"),
}),
UnusedFileFinding::with_actions(UnusedFile {
path: PathBuf::from("src/dead.ts"),
}),
],
unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
path: PathBuf::from("src/utils.ts"),
export_name: "helperA".to_string(),
is_type_only: false,
line: 5,
col: 0,
span_start: 40,
is_re_export: false,
deprecated: false,
deprecated_reason: None,
})],
unused_types: vec![UnusedTypeFinding::with_actions(UnusedExport {
path: PathBuf::from("src/types.ts"),
export_name: "OldType".to_string(),
is_type_only: true,
line: 10,
col: 0,
span_start: 100,
is_re_export: false,
deprecated: false,
deprecated_reason: None,
})],
unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
package_name: "lodash".to_string(),
location: DependencyLocation::Dependencies,
path: PathBuf::from("package.json"),
line: 5,
used_in_workspaces: Vec::new(),
})],
unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
UnusedDependency {
package_name: "jest".to_string(),
location: DependencyLocation::DevDependencies,
path: PathBuf::from("package.json"),
line: 5,
used_in_workspaces: Vec::new(),
},
)],
..Default::default()
}
}
#[test]
fn baseline_from_results_captures_all_fields() {
let results = make_results();
let baseline = BaselineData::from_results(&results, Path::new(""));
assert_eq!(baseline.unused_files.len(), 2);
assert!(baseline.unused_files.contains(&"src/old.ts".to_string()));
assert!(baseline.unused_files.contains(&"src/dead.ts".to_string()));
assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helperA"]);
assert_eq!(baseline.unused_types, vec!["src/types.ts:OldType"]);
assert_eq!(baseline.unused_dependencies, vec!["package.json:lodash"]);
assert_eq!(baseline.unused_dev_dependencies, vec!["package.json:jest"]);
}
#[test]
fn dependency_baseline_keys_include_package_json_path() {
let root = Path::new("/repo");
let results = AnalysisResults {
unused_dependencies: vec![
UnusedDependencyFinding::with_actions(UnusedDependency {
package_name: "lodash-es".to_string(),
location: DependencyLocation::Dependencies,
path: PathBuf::from("/repo/packages/app-a/package.json"),
line: 5,
used_in_workspaces: Vec::new(),
}),
UnusedDependencyFinding::with_actions(UnusedDependency {
package_name: "lodash-es".to_string(),
location: DependencyLocation::Dependencies,
path: PathBuf::from("/repo/packages/app-b/package.json"),
line: 5,
used_in_workspaces: Vec::new(),
}),
],
..Default::default()
};
let baseline = BaselineData::from_results(&results, root);
assert_eq!(
baseline.unused_dependencies,
vec![
"packages/app-a/package.json:lodash-es",
"packages/app-b/package.json:lodash-es"
]
);
}
#[test]
fn dependency_baseline_filter_matches_path_before_package_name() {
let root = Path::new("/repo");
let results = AnalysisResults {
unused_dependencies: vec![
UnusedDependencyFinding::with_actions(UnusedDependency {
package_name: "lodash-es".to_string(),
location: DependencyLocation::Dependencies,
path: PathBuf::from("/repo/packages/app-a/package.json"),
line: 5,
used_in_workspaces: Vec::new(),
}),
UnusedDependencyFinding::with_actions(UnusedDependency {
package_name: "lodash-es".to_string(),
location: DependencyLocation::Dependencies,
path: PathBuf::from("/repo/packages/app-b/package.json"),
line: 5,
used_in_workspaces: Vec::new(),
}),
],
..Default::default()
};
let baseline = BaselineData {
unused_dependencies: vec!["packages/app-a/package.json:lodash-es".to_string()],
..BaselineData::from_results(&AnalysisResults::default(), root)
};
let filtered = filter_new_issues(results, &baseline, root);
assert_eq!(filtered.unused_dependencies.len(), 1);
assert_eq!(
filtered.unused_dependencies[0].dep.path,
PathBuf::from("/repo/packages/app-b/package.json")
);
}
#[test]
fn dependency_baseline_filter_supports_legacy_package_only_keys() {
let root = Path::new("/repo");
let results = AnalysisResults {
unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
package_name: "lodash-es".to_string(),
location: DependencyLocation::Dependencies,
path: PathBuf::from("/repo/packages/app/package.json"),
line: 5,
used_in_workspaces: Vec::new(),
})],
..Default::default()
};
let baseline = BaselineData {
unused_dependencies: vec!["lodash-es".to_string()],
..BaselineData::from_results(&AnalysisResults::default(), root)
};
let filtered = filter_new_issues(results, &baseline, root);
assert!(filtered.unused_dependencies.is_empty());
}
#[test]
fn baseline_serialization_roundtrip() {
let results = make_results();
let baseline = BaselineData::from_results(&results, Path::new(""));
let json = serde_json::to_string(&baseline).unwrap();
let deserialized: BaselineData = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.unused_files, baseline.unused_files);
assert_eq!(deserialized.unused_exports, baseline.unused_exports);
assert_eq!(deserialized.unused_types, baseline.unused_types);
assert_eq!(
deserialized.unused_dependencies,
baseline.unused_dependencies
);
assert_eq!(
deserialized.unused_dev_dependencies,
baseline.unused_dev_dependencies
);
}
#[test]
fn filter_removes_baseline_issues() {
let results = make_results();
let baseline = BaselineData::from_results(&results, Path::new(""));
let filtered = filter_new_issues(results, &baseline, Path::new(""));
assert!(
filtered.unused_files.is_empty(),
"all files were in baseline"
);
assert!(
filtered.unused_exports.is_empty(),
"all exports were in baseline"
);
assert!(
filtered.unused_types.is_empty(),
"all types were in baseline"
);
assert!(
filtered.unused_dependencies.is_empty(),
"all deps were in baseline"
);
assert!(
filtered.unused_dev_dependencies.is_empty(),
"all dev deps were in baseline"
);
}
#[test]
fn filter_keeps_new_issues_not_in_baseline() {
let baseline = BaselineData {
kind: None,
analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
unused_files: vec!["src/old.ts".to_string()],
unused_exports: vec![],
unused_types: vec![],
private_type_leaks: vec![],
deprecated_exports_in_use: vec![],
unused_dependencies: vec![],
unused_dev_dependencies: vec![],
circular_dependencies: vec![],
re_export_cycles: vec![],
unused_optional_dependencies: vec![],
unused_enum_members: vec![],
unused_class_members: vec![],
unused_store_members: vec![],
unprovided_injects: vec![],
unrendered_components: vec![],
unused_component_props: vec![],
unused_component_emits: vec![],
unused_component_inputs: vec![],
unused_component_outputs: vec![],
unused_svelte_events: vec![],
unused_server_actions: vec![],
unused_load_data_keys: vec![],
unresolved_imports: vec![],
unlisted_dependencies: vec![],
duplicate_exports: vec![],
type_only_dependencies: vec![],
test_only_dependencies: vec![],
dev_dependencies_in_production: vec![],
boundary_violations: vec![],
boundary_coverage_violations: vec![],
boundary_call_violations: vec![],
policy_violations: vec![],
stale_suppressions: vec![],
unused_catalog_entries: vec![],
empty_catalog_groups: vec![],
unresolved_catalog_references: vec![],
unused_dependency_overrides: vec![],
misconfigured_dependency_overrides: vec![],
invalid_client_exports: vec![],
mixed_client_server_barrels: vec![],
misplaced_directives: vec![],
route_collisions: vec![],
dynamic_segment_name_conflicts: vec![],
};
let results = AnalysisResults {
unused_files: vec![
UnusedFileFinding::with_actions(UnusedFile {
path: PathBuf::from("src/old.ts"),
}),
UnusedFileFinding::with_actions(UnusedFile {
path: PathBuf::from("src/new-dead.ts"),
}),
],
..Default::default()
};
let filtered = filter_new_issues(results, &baseline, Path::new(""));
assert_eq!(filtered.unused_files.len(), 1);
assert_eq!(
filtered.unused_files[0].file.path,
PathBuf::from("src/new-dead.ts")
);
}
#[test]
fn filter_with_empty_baseline_keeps_all() {
let baseline = BaselineData {
kind: None,
analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
unused_files: vec![],
unused_exports: vec![],
unused_types: vec![],
private_type_leaks: vec![],
deprecated_exports_in_use: vec![],
unused_dependencies: vec![],
unused_dev_dependencies: vec![],
circular_dependencies: vec![],
re_export_cycles: vec![],
unused_optional_dependencies: vec![],
unused_enum_members: vec![],
unused_class_members: vec![],
unused_store_members: vec![],
unprovided_injects: vec![],
unrendered_components: vec![],
unused_component_props: vec![],
unused_component_emits: vec![],
unused_component_inputs: vec![],
unused_component_outputs: vec![],
unused_svelte_events: vec![],
unused_server_actions: vec![],
unused_load_data_keys: vec![],
unresolved_imports: vec![],
unlisted_dependencies: vec![],
duplicate_exports: vec![],
type_only_dependencies: vec![],
test_only_dependencies: vec![],
dev_dependencies_in_production: vec![],
boundary_violations: vec![],
boundary_coverage_violations: vec![],
boundary_call_violations: vec![],
policy_violations: vec![],
stale_suppressions: vec![],
unused_catalog_entries: vec![],
empty_catalog_groups: vec![],
unresolved_catalog_references: vec![],
unused_dependency_overrides: vec![],
misconfigured_dependency_overrides: vec![],
invalid_client_exports: vec![],
mixed_client_server_barrels: vec![],
misplaced_directives: vec![],
route_collisions: vec![],
dynamic_segment_name_conflicts: vec![],
};
let results = make_results();
let filtered = filter_new_issues(results, &baseline, Path::new(""));
assert_eq!(filtered.unused_files.len(), 2);
assert_eq!(filtered.unused_exports.len(), 1);
}
#[test]
fn filter_new_exports_by_file_and_name() {
let baseline = BaselineData {
kind: None,
analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
unused_files: vec![],
unused_exports: vec!["src/utils.ts:helperA".to_string()],
unused_types: vec![],
private_type_leaks: vec![],
deprecated_exports_in_use: vec![],
unused_dependencies: vec![],
unused_dev_dependencies: vec![],
circular_dependencies: vec![],
re_export_cycles: vec![],
unused_optional_dependencies: vec![],
unused_enum_members: vec![],
unused_class_members: vec![],
unused_store_members: vec![],
unprovided_injects: vec![],
unrendered_components: vec![],
unused_component_props: vec![],
unused_component_emits: vec![],
unused_component_inputs: vec![],
unused_component_outputs: vec![],
unused_svelte_events: vec![],
unused_server_actions: vec![],
unused_load_data_keys: vec![],
unresolved_imports: vec![],
unlisted_dependencies: vec![],
duplicate_exports: vec![],
type_only_dependencies: vec![],
test_only_dependencies: vec![],
dev_dependencies_in_production: vec![],
boundary_violations: vec![],
boundary_coverage_violations: vec![],
boundary_call_violations: vec![],
policy_violations: vec![],
stale_suppressions: vec![],
unused_catalog_entries: vec![],
empty_catalog_groups: vec![],
unresolved_catalog_references: vec![],
unused_dependency_overrides: vec![],
misconfigured_dependency_overrides: vec![],
invalid_client_exports: vec![],
mixed_client_server_barrels: vec![],
misplaced_directives: vec![],
route_collisions: vec![],
dynamic_segment_name_conflicts: vec![],
};
let results = AnalysisResults {
unused_exports: vec![
UnusedExportFinding::with_actions(UnusedExport {
path: PathBuf::from("src/utils.ts"),
export_name: "helperA".to_string(),
is_type_only: false,
line: 5,
col: 0,
span_start: 40,
is_re_export: false,
deprecated: false,
deprecated_reason: None,
}),
UnusedExportFinding::with_actions(UnusedExport {
path: PathBuf::from("src/utils.ts"),
export_name: "helperB".to_string(),
is_type_only: false,
line: 10,
col: 0,
span_start: 80,
is_re_export: false,
deprecated: false,
deprecated_reason: None,
}),
],
..Default::default()
};
let filtered = filter_new_issues(results, &baseline, Path::new(""));
assert_eq!(filtered.unused_exports.len(), 1);
assert_eq!(filtered.unused_exports[0].export.export_name, "helperB");
}
fn make_clone_group(instances: Vec<(&str, usize, usize)>) -> CloneGroup {
let mut files: Vec<&str> = instances.iter().map(|(file, _, _)| *file).collect();
files.sort_unstable();
let fragment = format!("const source = '{}';", files.join(","));
make_clone_group_with_fragment(&fragment, instances)
}
fn make_clone_group_with_fragment(
fragment: &str,
instances: Vec<(&str, usize, usize)>,
) -> CloneGroup {
CloneGroup {
instances: instances
.into_iter()
.map(|(file, start, end)| CloneInstance {
file: PathBuf::from(file),
start_line: start,
end_line: end,
start_col: 0,
end_col: 0,
fragment: fragment.to_string(),
})
.collect(),
token_count: 50,
line_count: 10,
similarity: None,
}
}
fn make_duplication_report(groups: Vec<CloneGroup>) -> DuplicationReport {
DuplicationReport {
clone_groups: groups,
clone_families: vec![],
mirrored_directories: vec![],
stats: DuplicationStats {
total_files: 10,
files_with_clones: 2,
total_lines: 1000,
duplicated_lines: 100,
total_tokens: 5000,
duplicated_tokens: 500,
clone_groups: 1,
clone_families: 0,
clone_instances: 2,
duplication_percentage: 10.0,
clone_groups_below_min_occurrences: 0,
clone_groups_ignored: 0,
near_candidates_skipped: 0,
},
}
}
fn normalized_clone_group_key(group: &CloneGroup) -> String {
let fingerprints =
crate::duplicates::CloneFingerprintSet::from_groups(std::slice::from_ref(group));
clone_group_fingerprint_key(group, &fingerprints)
}
#[test]
fn clone_group_key_is_deterministic() {
let root = Path::new("/project");
let group = make_clone_group(vec![
("/project/src/a.ts", 1, 10),
("/project/src/b.ts", 5, 15),
]);
let key1 = clone_group_key(&group, root);
let key2 = clone_group_key(&group, root);
assert_eq!(key1, key2);
}
#[test]
fn clone_group_key_is_sorted() {
let root = Path::new("/project");
let group_ab = make_clone_group(vec![
("/project/src/a.ts", 1, 10),
("/project/src/b.ts", 5, 15),
]);
let group_ba = make_clone_group(vec![
("/project/src/b.ts", 5, 15),
("/project/src/a.ts", 1, 10),
]);
assert_eq!(
clone_group_key(&group_ab, root),
clone_group_key(&group_ba, root),
"key should be stable regardless of instance order"
);
}
#[test]
fn duplication_baseline_roundtrip() {
let root = Path::new("/project");
let group = make_clone_group(vec![
("/project/src/a.ts", 1, 10),
("/project/src/b.ts", 5, 15),
]);
let report = make_duplication_report(vec![group]);
let baseline = DuplicationBaselineData::from_report(&report, root);
let json = serde_json::to_string(&baseline).unwrap();
let deserialized: DuplicationBaselineData = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.clone_groups, baseline.clone_groups);
assert_eq!(deserialized.clone_fingerprints, baseline.clone_fingerprints);
assert_eq!(
deserialized.normalized_clone_fingerprints,
baseline.normalized_clone_fingerprints
);
assert_eq!(
baseline.normalized_clone_fingerprints.len(),
1,
"a saved baseline carries a normalized key per clone group"
);
}
#[test]
fn the_declared_keys_are_every_key_each_format_writes() {
let duplication = DuplicationBaselineData {
kind: Some(BaselineKind::Dupes),
clone_groups: vec!["src/a.ts:1-10".to_owned()],
clone_fingerprints: vec!["abc".to_owned()],
normalized_clone_fingerprints: vec!["def".to_owned()],
};
assert_eq!(
serialized_keys_without_kind(&duplication),
DuplicationBaselineData::DECLARED_KEYS
);
let counts: HealthFindingCountMap = std::iter::once((
"src/a.ts".to_owned(),
std::iter::once(("complexity".to_owned(), HealthBaselineCount { count: 1 })).collect(),
))
.collect();
let health = HealthBaselineData {
kind: Some(BaselineKind::Health),
findings: vec!["src/a.ts:run:1".to_owned()],
finding_counts: counts.clone(),
identity_finding_counts: counts,
runtime_coverage_findings: vec!["src/a.ts:run".to_owned()],
runtime_coverage_source_hashes: vec!["src/a.ts\0run\0hash".to_owned()],
target_keys: vec!["src/a.ts:complexity".to_owned()],
};
assert_eq!(
serialized_keys_without_kind(&health),
HealthBaselineData::DECLARED_KEYS
);
for list in [
DuplicationBaselineData::DECLARED_KEYS,
HealthBaselineData::DECLARED_KEYS,
BaselineData::REQUIRED_KEYS,
] {
assert!(
!list.contains(&"kind"),
"no format may declare the key every format writes: {list:?}"
);
}
}
#[test]
fn the_required_dead_code_keys_are_the_ones_without_a_serde_default() {
let json = serde_json::to_string(&BaselineData::from_results(
&crate::results::AnalysisResults::default(),
Path::new("/project"),
))
.expect("baseline serializes");
let serde_json::Value::Object(object) =
serde_json::from_str::<serde_json::Value>(&json).expect("object")
else {
panic!("a baseline serializes as an object");
};
for key in object.keys() {
let mut without = object.clone();
without.remove(key);
let loads =
serde_json::from_value::<BaselineData>(serde_json::Value::Object(without)).is_ok();
assert_eq!(
!loads,
BaselineData::REQUIRED_KEYS.contains(&key.as_str()),
"REQUIRED_KEYS must list exactly the keys a load cannot do without, and {key} \
disagrees"
);
}
}
#[test]
fn every_saved_baseline_names_the_command_that_wrote_it() {
let dead_code = serde_json::to_string(&BaselineData::from_results(
&crate::results::AnalysisResults::default(),
Path::new("/project"),
))
.expect("baseline serializes");
let dupes = serde_json::to_string(&DuplicationBaselineData::from_report(
&make_duplication_report(Vec::new()),
Path::new("/project"),
))
.expect("baseline serializes");
let health = serde_json::to_string(&HealthBaselineData::from_findings(
&[],
&[],
&[],
Path::new("/project"),
))
.expect("baseline serializes");
for (json, kind) in [
(&dead_code, BaselineKind::DeadCode),
(&dupes, BaselineKind::Dupes),
(&health, BaselineKind::Health),
] {
assert_eq!(
serde_json::from_str::<serde_json::Value>(json).expect("object")["kind"],
serde_json::json!(kind.as_str()),
"a saved baseline states which command wrote it: {json}"
);
assert_eq!(classify_baseline_file(json, kind), BaselineFileKind::Own);
for other in [
BaselineKind::DeadCode,
BaselineKind::Dupes,
BaselineKind::Health,
] {
if other == kind {
continue;
}
assert_eq!(
classify_baseline_file(json, other),
BaselineFileKind::Foreign(kind.as_str().to_owned()),
"and every other command reads it as that command's: {json}"
);
}
}
}
#[test]
fn a_baseline_without_a_kind_is_classified_by_its_keys() {
let dupes =
r#"{"clone_groups":[],"clone_fingerprints":[],"normalized_clone_fingerprints":[]}"#;
let health = r#"{"runtime_coverage_findings":[],"target_keys":[]}"#;
let dead_code = r#"{"unused_files":[],"unused_exports":[],"unused_types":[],"unused_dependencies":[],"unused_dev_dependencies":[]}"#;
for (json, own) in [
(dupes, BaselineKind::Dupes),
(health, BaselineKind::Health),
(dead_code, BaselineKind::DeadCode),
] {
assert_eq!(classify_baseline_file(json, own), BaselineFileKind::Own);
for other in [
BaselineKind::DeadCode,
BaselineKind::Dupes,
BaselineKind::Health,
] {
if other == own {
continue;
}
assert_eq!(
classify_baseline_file(json, other),
BaselineFileKind::Unrecognised,
"a file with no kind and none of this format's keys is unrecognised, not \
attributed to a command it never named: {json}"
);
}
}
}
#[test]
fn an_unreadable_kind_never_becomes_a_parse_error() {
assert_eq!(
classify_baseline_file(r#"{"kind":"security"}"#, BaselineKind::Dupes),
BaselineFileKind::Foreign("security".to_owned())
);
assert_eq!(
classify_baseline_file(r#"{"kind":7,"clone_groups":[]}"#, BaselineKind::Dupes),
BaselineFileKind::Own
);
assert_eq!(
classify_baseline_file("[]", BaselineKind::Dupes),
BaselineFileKind::NotAnObject
);
assert_eq!(
classify_baseline_file("not json", BaselineKind::DeadCode),
BaselineFileKind::NotAnObject
);
assert!(
serde_json::from_str::<DuplicationBaselineData>(r#"{"kind":"security"}"#).is_ok(),
"a kind only a newer fallow writes must not break a load"
);
}
#[test]
fn a_save_is_refused_only_over_another_commands_baseline() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("baseline.json");
assert!(
refuse_baseline_kind_overwrite(&path, BaselineKind::Dupes).is_none(),
"there is nothing to destroy yet"
);
std::fs::write(
&path,
serde_json::to_string(&DuplicationBaselineData::from_report(
&make_duplication_report(Vec::new()),
Path::new("/project"),
))
.expect("baseline serializes"),
)
.expect("write");
assert!(
refuse_baseline_kind_overwrite(&path, BaselineKind::Dupes).is_none(),
"re-saving over its own file is the documented workflow"
);
let message = refuse_baseline_kind_overwrite(&path, BaselineKind::Health)
.expect("a health save over a duplication baseline is refused");
assert!(message.contains("`fallow dupes`"), "{message}");
assert!(message.contains("`fallow health`"), "{message}");
assert!(message.contains(&path.display().to_string()), "{message}");
std::fs::write(&path, "{}").expect("write");
assert!(
refuse_baseline_kind_overwrite(&path, BaselineKind::Health).is_none(),
"a file with nothing to identify it carries no claim to protect"
);
}
fn serialized_keys_without_kind<T: serde::Serialize>(value: &T) -> Vec<String> {
let serde_json::Value::Object(object) =
serde_json::to_value(value).expect("baseline serializes")
else {
panic!("a baseline serializes as an object");
};
object
.keys()
.filter(|key| key.as_str() != "kind")
.cloned()
.collect()
}
#[test]
fn a_baseline_saved_from_a_clean_project_still_declares_its_format() {
let empty = serde_json::to_string(&DuplicationBaselineData::default())
.expect("baseline serializes");
assert!(
declares_baseline_format(&empty, DuplicationBaselineData::DECLARED_KEYS),
"an empty duplication baseline is still a duplication baseline: {empty}"
);
assert!(
!declares_baseline_format(&empty, HealthBaselineData::DECLARED_KEYS),
"and it is not a health one: {empty}"
);
assert!(!declares_baseline_format(
"{}",
HealthBaselineData::DECLARED_KEYS
));
assert!(!declares_baseline_format(
"[]",
HealthBaselineData::DECLARED_KEYS
));
assert!(!declares_baseline_format(
"not json",
HealthBaselineData::DECLARED_KEYS
));
}
#[test]
fn filter_new_clone_groups_matches_shifted_clone() {
let root = Path::new("/project");
let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
"const total = a + b;",
vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
)]);
let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
let shifted = make_duplication_report(vec![make_clone_group_with_fragment(
"const total = a + b;",
vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 30, 40)],
)]);
let filtered = filter_new_clone_groups(shifted, &baseline, root);
assert!(
filtered.clone_groups.is_empty(),
"an unrelated line shift must not resurface a baselined clone"
);
}
#[test]
fn filter_new_clone_groups_reports_extra_copy() {
let root = Path::new("/project");
let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
"const total = a + b;",
vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
)]);
let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
let with_third_copy = make_duplication_report(vec![make_clone_group_with_fragment(
"const total = a + b;",
vec![
("/project/src/a.ts", 10, 20),
("/project/src/b.ts", 30, 40),
("/project/src/c.ts", 5, 15),
],
)]);
let filtered = filter_new_clone_groups(with_third_copy, &baseline, root);
assert_eq!(
filtered.clone_groups.len(),
1,
"a fresh copy in a third file is a new finding"
);
}
#[test]
fn filter_new_clone_groups_reads_legacy_baseline() {
let root = Path::new("/project");
let legacy_json = r#"{"clone_groups":["src/a.ts:10-20|src/b.ts:30-40"]}"#;
let baseline: DuplicationBaselineData = serde_json::from_str(legacy_json).unwrap();
assert_eq!(baseline.entry_count(), 1);
let unchanged = make_duplication_report(vec![make_clone_group_with_fragment(
"const total = a + b;",
vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
)]);
assert!(
filter_new_clone_groups(unchanged, &baseline, root)
.clone_groups
.is_empty(),
"a legacy baseline still matches on locations"
);
let shifted = make_duplication_report(vec![make_clone_group_with_fragment(
"const total = a + b;",
vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 30, 40)],
)]);
assert_eq!(
filter_new_clone_groups(shifted, &baseline, root)
.clone_groups
.len(),
1,
"legacy behavior is unchanged: a shift stops matching"
);
}
#[test]
fn clone_group_fingerprint_key_survives_file_rename() {
let before = make_clone_group_with_fragment(
"const total = a + b;",
vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
);
let after = make_clone_group_with_fragment(
"const total = a + b;",
vec![
("/project/src/renamed.ts", 10, 20),
("/project/src/b.ts", 30, 40),
],
);
assert_eq!(
normalized_clone_group_key(&before),
normalized_clone_group_key(&after),
"renaming a file must not resurface a baselined clone"
);
}
#[test]
fn clone_group_fingerprint_key_does_not_follow_representative_order() {
let mut before = make_clone_group_with_fragment(
"const total = a + b;",
vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
);
before.instances[1].fragment = "const total = a + b;".to_string();
let mut after = before.clone();
after.instances[0].file = PathBuf::from("/project/src/z.ts");
assert_eq!(
normalized_clone_group_key(&before),
normalized_clone_group_key(&after),
"normalized group identity is independent of representative order"
);
}
#[test]
fn filter_new_clone_groups_matches_legacy_raw_fingerprint_key() {
let root = Path::new("/project");
let group = make_clone_group_with_fragment(
"const total = a + b;",
vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
);
let legacy_key = legacy_clone_group_fingerprint_key(&group);
let baseline = DuplicationBaselineData {
kind: None,
clone_groups: Vec::new(),
clone_fingerprints: vec![legacy_key.clone()],
normalized_clone_fingerprints: Vec::new(),
};
let filtered = filter_new_clone_groups(
make_duplication_report(vec![group.clone()]),
&baseline,
root,
);
assert!(filtered.clone_groups.is_empty());
let current =
DuplicationBaselineData::from_report(&make_duplication_report(vec![group]), root);
assert_eq!(current.clone_fingerprints, vec![legacy_key]);
assert_ne!(
current.normalized_clone_fingerprints,
current.clone_fingerprints
);
}
#[test]
fn normalized_baseline_survives_formatting_only_edits() {
let root = Path::new("/project");
let baseline_report = make_duplication_report(vec![make_clone_group_with_fragment(
"const total = left + right;",
vec![("/project/src/a.ts", 10, 20), ("/project/src/b.ts", 30, 40)],
)]);
let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
let formatted = make_duplication_report(vec![make_clone_group_with_fragment(
"/* reviewed */\r\nconst total=left + right;",
vec![("/project/src/a.ts", 18, 28), ("/project/src/b.ts", 35, 45)],
)]);
assert!(
filter_new_clone_groups(formatted, &baseline, root)
.clone_groups
.is_empty()
);
}
#[test]
fn filter_new_clone_groups_removes_baseline() {
let root = Path::new("/project");
let group = make_clone_group(vec![
("/project/src/a.ts", 1, 10),
("/project/src/b.ts", 5, 15),
]);
let report = make_duplication_report(vec![group]);
let baseline = DuplicationBaselineData::from_report(&report, root);
let filtered = filter_new_clone_groups(report, &baseline, root);
assert!(
filtered.clone_groups.is_empty(),
"baseline group should be filtered out"
);
}
#[test]
fn filter_new_clone_groups_keeps_new_groups() {
let root = Path::new("/project");
let baseline_group = make_clone_group(vec![
("/project/src/a.ts", 1, 10),
("/project/src/b.ts", 5, 15),
]);
let new_group = make_clone_group(vec![
("/project/src/c.ts", 20, 30),
("/project/src/d.ts", 25, 35),
]);
let baseline_report = make_duplication_report(vec![baseline_group]);
let baseline = DuplicationBaselineData::from_report(&baseline_report, root);
let report = make_duplication_report(vec![
make_clone_group(vec![
("/project/src/a.ts", 1, 10),
("/project/src/b.ts", 5, 15),
]),
new_group,
]);
let filtered = filter_new_clone_groups(report, &baseline, root);
assert_eq!(
filtered.clone_groups.len(),
1,
"only the new group should remain"
);
}
#[test]
fn recompute_stats_after_filtering() {
let root = Path::new("/project");
let group = make_clone_group(vec![
("/project/src/a.ts", 1, 10),
("/project/src/b.ts", 5, 15),
]);
let report = make_duplication_report(vec![group]);
let baseline = DuplicationBaselineData::from_report(&report, root);
let filtered = filter_new_clone_groups(report, &baseline, root);
assert_eq!(filtered.stats.clone_groups, 0);
assert_eq!(filtered.stats.clone_instances, 0);
assert_eq!(filtered.stats.duplicated_lines, 0);
}
#[test]
fn recompute_stats_zero_total_lines() {
let report = DuplicationReport {
clone_groups: vec![],
clone_families: vec![],
mirrored_directories: vec![],
stats: DuplicationStats {
total_files: 0,
files_with_clones: 0,
total_lines: 0,
duplicated_lines: 0,
total_tokens: 0,
duplicated_tokens: 0,
clone_groups: 0,
clone_families: 0,
clone_instances: 0,
duplication_percentage: 0.0,
clone_groups_below_min_occurrences: 0,
clone_groups_ignored: 0,
near_candidates_skipped: 0,
},
};
let stats = super::recompute_stats(&report);
assert!((stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
}
fn filter_new_health_findings(
findings: Vec<fallow_output::ComplexityViolation>,
baseline: &HealthBaselineData,
root: &Path,
) -> Vec<fallow_output::ComplexityViolation> {
super::filter_new_health_findings(findings, baseline, root, HealthBaselineMode::Count)
}
fn make_health_finding(
root: &Path,
name: &str,
line: u32,
) -> fallow_output::ComplexityViolation {
make_health_finding_with(
root,
name,
line,
fallow_output::ExceededThreshold::Both,
fallow_output::FindingSeverity::High,
)
}
fn make_health_finding_with(
root: &Path,
name: &str,
line: u32,
exceeded: fallow_output::ExceededThreshold,
severity: fallow_output::FindingSeverity,
) -> fallow_output::ComplexityViolation {
fallow_output::ComplexityViolation {
path: root.join("src/utils.ts"),
name: name.to_string(),
line,
col: 0,
cyclomatic: 25,
cognitive: 30,
line_count: 80,
param_count: 0,
react_hook_count: 0,
react_jsx_max_depth: 0,
react_prop_count: 0,
react_hook_profile: None,
exceeded,
severity,
effective_severity: None,
crap: None,
coverage_pct: None,
coverage_tier: None,
coverage_source: None,
inherited_from: None,
component_rollup: None,
contributions: Vec::new(),
effective_thresholds: None,
threshold_source: None,
}
}
#[test]
fn health_baseline_roundtrip() {
let root = PathBuf::from("/project");
let findings = vec![make_health_finding(&root, "parseExpression", 42)];
let baseline = HealthBaselineData::from_findings(&findings, &[], &[], &root);
let json = serde_json::to_string(&baseline).unwrap();
let deserialized: HealthBaselineData = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.findings, baseline.findings);
assert_eq!(baseline.findings, Vec::<String>::new());
assert_eq!(
deserialized.finding_counts["src/utils.ts"]["complexity_high"].count,
1
);
assert!(!json.contains("parseExpression"));
}
#[test]
fn health_baseline_filters_known_findings() {
let root = PathBuf::from("/project");
let mut findings = vec![
make_health_finding(&root, "parseExpression", 42),
make_health_finding(&root, "newFunction", 100),
];
findings[1].path = root.join("src/other.ts");
let baseline = HealthBaselineData::from_findings(&findings[..1], &[], &[], &root);
let filtered = filter_new_health_findings(findings, &baseline, &root);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "newFunction");
}
#[test]
fn health_baseline_filters_shifted_lines_with_same_category_count() {
let root = PathBuf::from("/project");
let baseline = HealthBaselineData::from_findings(
&[make_health_finding(&root, "parseExpression", 42)],
&[],
&[],
&root,
);
let filtered = filter_new_health_findings(
vec![make_health_finding(&root, "parseExpression", 43)],
&baseline,
&root,
);
assert!(filtered.is_empty());
}
#[test]
fn health_baseline_reports_full_category_when_count_increases() {
let root = PathBuf::from("/project");
let baseline = HealthBaselineData::from_findings(
&[make_health_finding(&root, "parseExpression", 42)],
&[],
&[],
&root,
);
let filtered = filter_new_health_findings(
vec![
make_health_finding(&root, "parseExpression", 43),
make_health_finding(&root, "newFunction", 100),
],
&baseline,
&root,
);
assert_eq!(filtered.len(), 2);
}
#[test]
fn health_baseline_legacy_findings_still_load() {
let root = PathBuf::from("/project");
let baseline = HealthBaselineData {
kind: None,
findings: vec!["src/utils.ts:parseExpression:42".to_owned()],
finding_counts: BTreeMap::new(),
identity_finding_counts: BTreeMap::new(),
target_keys: vec![],
runtime_coverage_findings: vec![],
runtime_coverage_source_hashes: vec![],
};
let filtered = filter_new_health_findings(
vec![make_health_finding(&root, "parseExpression", 42)],
&baseline,
&root,
);
assert!(filtered.is_empty());
}
#[test]
fn health_baseline_keeps_crap_categories_separate_from_complexity() {
let root = PathBuf::from("/project");
let baseline = HealthBaselineData::from_findings(
&[make_health_finding_with(
&root,
"parseExpression",
42,
fallow_output::ExceededThreshold::Crap,
fallow_output::FindingSeverity::High,
)],
&[],
&[],
&root,
);
let filtered = filter_new_health_findings(
vec![
make_health_finding_with(
&root,
"parseExpression",
43,
fallow_output::ExceededThreshold::Crap,
fallow_output::FindingSeverity::High,
),
make_health_finding(&root, "newComplexityOnlyFunction", 100),
],
&baseline,
&root,
);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "newComplexityOnlyFunction");
}
#[test]
fn health_baseline_suppresses_findings_that_only_improve_in_severity() {
let root = PathBuf::from("/project");
let baseline = HealthBaselineData::from_findings(
&[make_health_finding_with(
&root,
"parseExpression",
42,
fallow_output::ExceededThreshold::Both,
fallow_output::FindingSeverity::Critical,
)],
&[],
&[],
&root,
);
let filtered = filter_new_health_findings(
vec![make_health_finding_with(
&root,
"parseExpression",
42,
fallow_output::ExceededThreshold::Both,
fallow_output::FindingSeverity::High,
)],
&baseline,
&root,
);
assert!(filtered.is_empty());
}
#[test]
fn health_baseline_still_reports_worse_current_severity_as_new() {
let root = PathBuf::from("/project");
let baseline = HealthBaselineData::from_findings(
&[make_health_finding_with(
&root,
"parseExpression",
42,
fallow_output::ExceededThreshold::Both,
fallow_output::FindingSeverity::High,
)],
&[],
&[],
&root,
);
let filtered = filter_new_health_findings(
vec![make_health_finding_with(
&root,
"parseExpression",
42,
fallow_output::ExceededThreshold::Both,
fallow_output::FindingSeverity::Critical,
)],
&baseline,
&root,
);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "parseExpression");
assert!(matches!(
filtered[0].severity,
fallow_output::FindingSeverity::Critical
));
}
#[test]
fn health_baseline_overlap_counts_partial_category_overflow() {
let root = PathBuf::from("/project");
let baseline = HealthBaselineData::from_findings(
&[make_health_finding(&root, "parseExpression", 42)],
&[],
&[],
&root,
);
let overlap = baseline.overlap_entries(
&[
make_health_finding(&root, "parseExpression", 42),
make_health_finding(&root, "newFunction", 100),
],
&root,
HealthBaselineMode::Count,
);
assert_eq!(overlap.matched_entries, 1);
assert_eq!(overlap.moved_entries, 0);
}
fn identity_baseline(
findings: &[fallow_output::ComplexityViolation],
root: &Path,
) -> HealthBaselineData {
HealthBaselineData::from_findings(findings, &[], &[], root).with_identity(findings, root)
}
#[test]
fn health_identity_baseline_reports_replacement_hotspot() {
let root = PathBuf::from("/project");
let baseline = identity_baseline(&[make_health_finding(&root, "firstHotspot", 3)], &root);
let replacement = vec![make_health_finding(&root, "replacementHotspot", 3)];
assert!(
filter_new_health_findings(replacement.clone(), &baseline, &root).is_empty(),
"count mode keeps the per-file allowance and suppresses the replacement"
);
let filtered = super::filter_new_health_findings(
replacement,
&baseline,
&root,
HealthBaselineMode::Identity,
);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "replacementHotspot");
}
#[test]
fn health_identity_baseline_survives_line_moves() {
let root = PathBuf::from("/project");
let baseline =
identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
let filtered = super::filter_new_health_findings(
vec![make_health_finding(&root, "parseExpression", 512)],
&baseline,
&root,
HealthBaselineMode::Identity,
);
assert!(filtered.is_empty());
}
#[test]
fn health_identity_baseline_suppresses_severity_improvement() {
let root = PathBuf::from("/project");
let baseline = identity_baseline(
&[make_health_finding_with(
&root,
"parseExpression",
42,
fallow_output::ExceededThreshold::Both,
fallow_output::FindingSeverity::Critical,
)],
&root,
);
let filtered = super::filter_new_health_findings(
vec![make_health_finding_with(
&root,
"parseExpression",
42,
fallow_output::ExceededThreshold::Both,
fallow_output::FindingSeverity::Moderate,
)],
&baseline,
&root,
HealthBaselineMode::Identity,
);
assert!(filtered.is_empty());
}
#[test]
fn health_identity_baseline_reports_added_finding_for_known_function() {
let root = PathBuf::from("/project");
let baseline =
identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
let filtered = super::filter_new_health_findings(
vec![
make_health_finding(&root, "parseExpression", 42),
make_health_finding(&root, "parseStatement", 90),
],
&baseline,
&root,
HealthBaselineMode::Identity,
);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "parseStatement");
}
#[test]
fn health_identity_buckets_are_written_only_in_identity_mode() {
let root = PathBuf::from("/project");
let findings = [make_health_finding(&root, "parseExpression", 42)];
let count_only = HealthBaselineData::from_findings(&findings, &[], &[], &root);
let json = serde_json::to_string(&count_only).unwrap();
assert!(!json.contains("identity_finding_counts"));
assert!(count_only.lacks_identity_data());
let identity = identity_baseline(&findings, &root);
assert!(!identity.lacks_identity_data());
assert_eq!(
identity.identity_finding_counts["src/utils.ts\0parseExpression"]["complexity_high"]
.count,
1
);
assert!(
!identity.finding_counts.is_empty(),
"an identity baseline stays readable in count mode"
);
}
fn moved_finding(root: &Path, path: &str, name: &str) -> fallow_output::ComplexityViolation {
let mut finding = make_health_finding(root, name, 42);
finding.path = root.join(path);
finding
}
#[test]
fn health_identity_baseline_follows_file_move() {
let root = PathBuf::from("/project");
let baseline =
identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
let moved = vec![moved_finding(
&root,
"src/parser/utils.ts",
"parseExpression",
)];
let overlap = baseline.overlap_entries(&moved, &root, HealthBaselineMode::Identity);
assert_eq!(
overlap.matched_entries, 1,
"a followed move counts as matched, not stale"
);
assert_eq!(
overlap.moved_entries, 1,
"the followed move stays observable as a moved entry"
);
let filtered = super::filter_new_health_findings(
moved,
&baseline,
&root,
HealthBaselineMode::Identity,
);
assert!(filtered.is_empty());
}
#[test]
fn health_identity_move_is_not_followed_when_candidates_are_ambiguous() {
let root = PathBuf::from("/project");
let baseline =
identity_baseline(&[make_health_finding(&root, "parseExpression", 42)], &root);
let filtered = super::filter_new_health_findings(
vec![
moved_finding(&root, "src/a.ts", "parseExpression"),
moved_finding(&root, "src/b.ts", "parseExpression"),
],
&baseline,
&root,
HealthBaselineMode::Identity,
);
assert_eq!(filtered.len(), 2);
}
#[test]
fn health_identity_move_is_not_followed_when_candidate_is_claimed_twice() {
let root = PathBuf::from("/project");
let baseline = identity_baseline(
&[
moved_finding(&root, "src/a.ts", "parseExpression"),
moved_finding(&root, "src/b.ts", "parseExpression"),
],
&root,
);
let filtered = super::filter_new_health_findings(
vec![moved_finding(&root, "src/c.ts", "parseExpression")],
&baseline,
&root,
HealthBaselineMode::Identity,
);
assert_eq!(filtered.len(), 1);
}
#[test]
fn health_identity_move_is_not_followed_when_old_path_still_exists() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let baseline = identity_baseline(
&[moved_finding(&root, "src/baseline.rs", "parseExpression")],
&root,
);
let filtered = super::filter_new_health_findings(
vec![moved_finding(&root, "src/moved.ts", "parseExpression")],
&baseline,
&root,
HealthBaselineMode::Identity,
);
assert_eq!(filtered.len(), 1);
}
#[test]
fn health_identity_move_is_not_followed_for_anonymous_functions() {
let root = PathBuf::from("/project");
let baseline = identity_baseline(&[make_health_finding(&root, "<anonymous>", 42)], &root);
let filtered = super::filter_new_health_findings(
vec![moved_finding(&root, "src/moved.ts", "<anonymous>")],
&baseline,
&root,
HealthBaselineMode::Identity,
);
assert_eq!(filtered.len(), 1);
}
#[test]
fn health_count_baseline_does_not_follow_file_moves() {
let root = PathBuf::from("/project");
let baseline = HealthBaselineData::from_findings(
&[make_health_finding(&root, "parseExpression", 42)],
&[],
&[],
&root,
);
let filtered = filter_new_health_findings(
vec![moved_finding(
&root,
"src/parser/utils.ts",
"parseExpression",
)],
&baseline,
&root,
);
assert_eq!(filtered.len(), 1);
}
#[test]
fn health_identity_data_is_absent_for_legacy_and_empty_baselines() {
let legacy = HealthBaselineData {
findings: vec!["src/utils.ts:parseExpression:42".to_string()],
..HealthBaselineData::default()
};
assert!(legacy.lacks_identity_data());
assert!(!HealthBaselineData::default().lacks_identity_data());
}
#[test]
fn health_baseline_empty_keeps_all() {
let root = PathBuf::from("/project");
let findings = vec![make_health_finding(&root, "parseExpression", 42)];
let baseline = HealthBaselineData {
kind: None,
findings: vec![],
finding_counts: BTreeMap::new(),
identity_finding_counts: BTreeMap::new(),
target_keys: vec![],
runtime_coverage_findings: vec![],
runtime_coverage_source_hashes: vec![],
};
let filtered = filter_new_health_findings(findings, &baseline, &root);
assert_eq!(filtered.len(), 1);
}
#[test]
fn circular_dep_key_is_order_independent() {
use crate::results::CircularDependency;
let dep_ab = CircularDependencyFinding::with_actions(CircularDependency {
files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
length: 2,
line: 1,
col: 0,
edges: Vec::new(),
is_cross_package: false,
});
let dep_ba = CircularDependencyFinding::with_actions(CircularDependency {
files: vec![PathBuf::from("src/b.ts"), PathBuf::from("src/a.ts")],
length: 2,
line: 1,
col: 0,
edges: Vec::new(),
is_cross_package: false,
});
assert_eq!(
super::circular_dep_key(&dep_ab.cycle, Path::new("")),
super::circular_dep_key(&dep_ba.cycle, Path::new("")),
"same files in different order should produce identical keys"
);
}
#[test]
fn circular_dep_key_different_files_different_keys() {
use crate::results::CircularDependency;
let dep1 = CircularDependencyFinding::with_actions(CircularDependency {
files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
length: 2,
line: 1,
col: 0,
edges: Vec::new(),
is_cross_package: false,
});
let dep2 = CircularDependencyFinding::with_actions(CircularDependency {
files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/c.ts")],
length: 2,
line: 1,
col: 0,
edges: Vec::new(),
is_cross_package: false,
});
assert_ne!(
super::circular_dep_key(&dep1.cycle, Path::new("")),
super::circular_dep_key(&dep2.cycle, Path::new("")),
);
}
#[test]
fn circular_dep_key_three_files_order_independent() {
use crate::results::CircularDependency;
let dep_abc = CircularDependencyFinding::with_actions(CircularDependency {
files: vec![
PathBuf::from("src/a.ts"),
PathBuf::from("src/b.ts"),
PathBuf::from("src/c.ts"),
],
length: 3,
line: 1,
col: 0,
edges: Vec::new(),
is_cross_package: false,
});
let dep_cab = CircularDependencyFinding::with_actions(CircularDependency {
files: vec![
PathBuf::from("src/c.ts"),
PathBuf::from("src/a.ts"),
PathBuf::from("src/b.ts"),
],
length: 3,
line: 1,
col: 0,
edges: Vec::new(),
is_cross_package: false,
});
assert_eq!(
super::circular_dep_key(&dep_abc.cycle, Path::new("")),
super::circular_dep_key(&dep_cab.cycle, Path::new("")),
);
}
#[expect(
clippy::too_many_lines,
reason = "test fixture; linear setup/assert, length is not a maintainability concern"
)]
fn make_full_results() -> AnalysisResults {
use crate::results::*;
use crate::source::MemberKind;
let mut r = make_results();
r.circular_dependencies
.push(CircularDependencyFinding::with_actions(
CircularDependency {
files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
length: 2,
line: 1,
col: 0,
edges: Vec::new(),
is_cross_package: false,
},
));
r.unused_optional_dependencies
.push(UnusedOptionalDependencyFinding::with_actions(
UnusedDependency {
package_name: "fsevents".to_string(),
location: DependencyLocation::OptionalDependencies,
path: PathBuf::from("package.json"),
line: 15,
used_in_workspaces: Vec::new(),
},
));
r.unused_enum_members
.push(UnusedEnumMemberFinding::with_actions(UnusedMember {
path: PathBuf::from("src/enums.ts"),
parent_name: "Status".to_string(),
member_name: "Deprecated".to_string(),
kind: MemberKind::EnumMember,
line: 8,
col: 0,
}));
r.unused_class_members
.push(UnusedClassMemberFinding::with_actions(UnusedMember {
path: PathBuf::from("src/service.ts"),
parent_name: "UserService".to_string(),
member_name: "legacy".to_string(),
kind: MemberKind::ClassMethod,
line: 42,
col: 0,
}));
r.unused_store_members
.push(UnusedStoreMemberFinding::with_actions(UnusedMember {
path: PathBuf::from("src/store.ts"),
parent_name: "useStore".to_string(),
member_name: "legacyAction".to_string(),
kind: MemberKind::StoreMember,
line: 17,
col: 0,
}));
r.unresolved_imports.push(
fallow_types::output_dead_code::UnresolvedImportFinding::with_actions(
crate::results::UnresolvedImport {
path: PathBuf::from("src/app.ts"),
specifier: "./missing".to_string(),
line: 3,
col: 0,
specifier_col: 0,
},
),
);
r.unlisted_dependencies
.push(crate::results::UnlistedDependencyFinding::with_actions(
UnlistedDependency {
package_name: "chalk".to_string(),
imported_from: vec![],
},
));
r.duplicate_exports
.push(crate::results::DuplicateExportFinding::with_actions(
crate::results::DuplicateExport {
export_name: "Config".to_string(),
locations: vec![
crate::results::DuplicateLocation {
path: PathBuf::from("src/a.ts"),
line: 1,
col: 0,
},
crate::results::DuplicateLocation {
path: PathBuf::from("src/b.ts"),
line: 5,
col: 0,
},
],
},
));
r.type_only_dependencies
.push(crate::results::TypeOnlyDependencyFinding::with_actions(
TypeOnlyDependency {
package_name: "zod".to_string(),
path: PathBuf::from("package.json"),
line: 8,
},
));
r.test_only_dependencies
.push(crate::results::TestOnlyDependencyFinding::with_actions(
TestOnlyDependency {
package_name: "vitest".to_string(),
path: PathBuf::from("package.json"),
line: 10,
},
));
r.boundary_violations.push(
fallow_types::output_dead_code::BoundaryViolationFinding::with_actions(
crate::results::BoundaryViolation {
from_path: PathBuf::from("src/ui/btn.ts"),
to_path: PathBuf::from("src/db/query.ts"),
from_zone: "ui".to_string(),
to_zone: "db".to_string(),
import_specifier: "../db/query".to_string(),
line: 1,
col: 0,
},
),
);
r
}
#[test]
fn baseline_from_results_captures_all_extended_fields() {
let results = make_full_results();
let baseline = BaselineData::from_results(&results, Path::new(""));
assert_eq!(baseline.circular_dependencies.len(), 1);
assert_eq!(
baseline.unused_optional_dependencies,
vec!["package.json:fsevents"]
);
assert_eq!(baseline.unused_enum_members.len(), 1);
assert!(baseline.unused_enum_members[0].contains("Status.Deprecated"));
assert_eq!(baseline.unused_class_members.len(), 1);
assert!(baseline.unused_class_members[0].contains("UserService.legacy"));
assert_eq!(baseline.unused_store_members.len(), 1);
assert!(baseline.unused_store_members[0].contains("useStore.legacyAction"));
assert_eq!(baseline.unresolved_imports.len(), 1);
assert!(baseline.unresolved_imports[0].contains("./missing"));
assert_eq!(baseline.unlisted_dependencies, vec!["chalk"]);
assert_eq!(baseline.duplicate_exports.len(), 1);
assert!(baseline.duplicate_exports[0].starts_with("Config|"));
assert_eq!(baseline.type_only_dependencies, vec!["package.json:zod"]);
assert_eq!(baseline.test_only_dependencies, vec!["package.json:vitest"]);
assert_eq!(baseline.boundary_violations.len(), 1);
assert!(baseline.boundary_violations[0].contains("->"));
}
#[test]
fn filter_removes_all_extended_baseline_issues() {
let results = make_full_results();
let baseline = BaselineData::from_results(&results, Path::new(""));
let filtered = filter_new_issues(results, &baseline, Path::new(""));
assert!(filtered.circular_dependencies.is_empty());
assert!(filtered.unused_optional_dependencies.is_empty());
assert!(filtered.unused_enum_members.is_empty());
assert!(filtered.unused_class_members.is_empty());
assert!(filtered.unused_store_members.is_empty());
assert!(filtered.unresolved_imports.is_empty());
assert!(filtered.unlisted_dependencies.is_empty());
assert!(filtered.duplicate_exports.is_empty());
assert!(filtered.type_only_dependencies.is_empty());
assert!(filtered.test_only_dependencies.is_empty());
assert!(filtered.boundary_violations.is_empty());
}
#[test]
fn filter_keeps_new_circular_deps() {
use crate::results::CircularDependency;
let baseline = BaselineData {
circular_dependencies: vec!["src/a.ts->src/b.ts".to_string()],
..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
};
let mut results = AnalysisResults::default();
results
.circular_dependencies
.push(CircularDependencyFinding::with_actions(
CircularDependency {
files: vec![PathBuf::from("src/a.ts"), PathBuf::from("src/b.ts")],
length: 2,
line: 1,
col: 0,
edges: Vec::new(),
is_cross_package: false,
},
));
results
.circular_dependencies
.push(CircularDependencyFinding::with_actions(
CircularDependency {
files: vec![PathBuf::from("src/x.ts"), PathBuf::from("src/y.ts")],
length: 2,
line: 5,
col: 0,
edges: Vec::new(),
is_cross_package: false,
},
));
let filtered = filter_new_issues(results, &baseline, Path::new(""));
assert_eq!(filtered.circular_dependencies.len(), 1);
}
#[test]
fn filter_keeps_new_boundary_violations() {
use crate::results::BoundaryViolation;
let baseline = BaselineData {
boundary_violations: vec!["src/a.ts->src/b.ts".to_string()],
boundary_coverage_violations: vec![],
boundary_call_violations: vec![],
policy_violations: vec![],
..BaselineData::from_results(&AnalysisResults::default(), Path::new(""))
};
let mut results = AnalysisResults::default();
results
.boundary_violations
.push(BoundaryViolationFinding::with_actions(BoundaryViolation {
from_path: PathBuf::from("src/a.ts"),
to_path: PathBuf::from("src/b.ts"),
from_zone: "a".to_string(),
to_zone: "b".to_string(),
import_specifier: "../b".to_string(),
line: 1,
col: 0,
}));
results
.boundary_violations
.push(BoundaryViolationFinding::with_actions(BoundaryViolation {
from_path: PathBuf::from("src/new.ts"),
to_path: PathBuf::from("src/secret.ts"),
from_zone: "new".to_string(),
to_zone: "secret".to_string(),
import_specifier: "../secret".to_string(),
line: 1,
col: 0,
}));
let filtered = filter_new_issues(results, &baseline, Path::new(""));
assert_eq!(filtered.boundary_violations.len(), 1);
}
#[test]
fn health_targets_baseline_filters_known() {
let root = PathBuf::from("/project");
let targets = vec![
fallow_output::RefactoringTarget {
path: root.join("src/complex.ts"),
priority: 80.0,
efficiency: 40.0,
recommendation: "Split file".to_string(),
category: fallow_output::RecommendationCategory::SplitHighImpact,
effort: fallow_output::EffortEstimate::Medium,
confidence: fallow_output::Confidence::Medium,
factors: vec![],
evidence: None,
},
fallow_output::RefactoringTarget {
path: root.join("src/new-issue.ts"),
priority: 60.0,
efficiency: 30.0,
recommendation: "Extract function".to_string(),
category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
effort: fallow_output::EffortEstimate::Low,
confidence: fallow_output::Confidence::High,
factors: vec![],
evidence: None,
},
];
let baseline = HealthBaselineData::from_findings(&[], &[], &targets[..1], &root);
let filtered = filter_new_health_targets(targets, &baseline, &root);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].path, root.join("src/new-issue.ts"));
}
#[test]
fn duplicate_export_key_is_sorted() {
use crate::results::{DuplicateExport, DuplicateLocation};
let dup_ab = DuplicateExport {
export_name: "foo".to_string(),
locations: vec![
DuplicateLocation {
path: PathBuf::from("src/a.ts"),
line: 1,
col: 0,
},
DuplicateLocation {
path: PathBuf::from("src/b.ts"),
line: 5,
col: 0,
},
],
};
let dup_ba = DuplicateExport {
export_name: "foo".to_string(),
locations: vec![
DuplicateLocation {
path: PathBuf::from("src/b.ts"),
line: 5,
col: 0,
},
DuplicateLocation {
path: PathBuf::from("src/a.ts"),
line: 1,
col: 0,
},
],
};
assert_eq!(
super::duplicate_export_key(&dup_ab, Path::new("")),
super::duplicate_export_key(&dup_ba, Path::new("")),
);
}
#[test]
fn boundary_violation_key_format() {
use crate::results::BoundaryViolation;
let v = BoundaryViolation {
from_path: PathBuf::from("src/ui/btn.ts"),
to_path: PathBuf::from("src/db/query.ts"),
from_zone: "ui".to_string(),
to_zone: "db".to_string(),
import_specifier: "../db/query".to_string(),
line: 1,
col: 0,
};
let key = super::boundary_violation_key(&v, Path::new(""));
assert_eq!(key, "src/ui/btn.ts->src/db/query.ts");
}
fn make_absolute_results(root: &str) -> AnalysisResults {
use crate::results::*;
use crate::source::MemberKind;
let p = |rel: &str| PathBuf::from(format!("{root}/{rel}"));
AnalysisResults {
unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
path: p("src/old.ts"),
})],
unused_exports: vec![UnusedExportFinding::with_actions(UnusedExport {
path: p("src/utils.ts"),
export_name: "helper".to_string(),
is_type_only: false,
line: 5,
col: 0,
span_start: 40,
is_re_export: false,
deprecated: false,
deprecated_reason: None,
})],
unused_dependencies: vec![UnusedDependencyFinding::with_actions(UnusedDependency {
package_name: "lodash-es".to_string(),
location: DependencyLocation::Dependencies,
path: p("packages/app/package.json"),
line: 5,
used_in_workspaces: Vec::new(),
})],
circular_dependencies: vec![CircularDependencyFinding::with_actions(
CircularDependency {
files: vec![p("src/a.ts"), p("src/b.ts")],
length: 2,
line: 1,
col: 0,
edges: Vec::new(),
is_cross_package: false,
},
)],
unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(UnusedMember {
path: p("src/enums.ts"),
parent_name: "Status".to_string(),
member_name: "Deprecated".to_string(),
kind: MemberKind::EnumMember,
line: 8,
col: 0,
})],
unused_class_members: vec![UnusedClassMemberFinding::with_actions(UnusedMember {
path: p("src/service.ts"),
parent_name: "UserService".to_string(),
member_name: "legacy".to_string(),
kind: MemberKind::ClassMethod,
line: 42,
col: 0,
})],
unused_store_members: vec![UnusedStoreMemberFinding::with_actions(UnusedMember {
path: p("src/store.ts"),
parent_name: "useStore".to_string(),
member_name: "legacyAction".to_string(),
kind: MemberKind::StoreMember,
line: 17,
col: 0,
})],
unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
path: p("src/app.ts"),
specifier: "./missing".to_string(),
line: 3,
col: 0,
specifier_col: 0,
})],
duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
export_name: "Config".to_string(),
locations: vec![
DuplicateLocation {
path: p("src/a.ts"),
line: 1,
col: 0,
},
DuplicateLocation {
path: p("src/b.ts"),
line: 5,
col: 0,
},
],
})],
boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
from_path: p("src/ui/btn.ts"),
to_path: p("src/db/query.ts"),
from_zone: "ui".to_string(),
to_zone: "db".to_string(),
import_specifier: "../db/query".to_string(),
line: 1,
col: 0,
})],
..Default::default()
}
}
#[test]
fn baseline_keys_are_relative_to_root() {
let local_root = Path::new("/Users/dev/project");
let results = make_absolute_results("/Users/dev/project");
let baseline = BaselineData::from_results(&results, local_root);
assert_eq!(baseline.unused_files, vec!["src/old.ts"]);
assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helper"]);
assert_eq!(
baseline.unused_dependencies,
vec!["packages/app/package.json:lodash-es"]
);
assert_eq!(
baseline.boundary_violations,
vec!["src/ui/btn.ts->src/db/query.ts"]
);
assert_eq!(baseline.circular_dependencies, vec!["src/a.ts->src/b.ts"]);
assert_eq!(
baseline.unused_enum_members,
vec!["src/enums.ts:Status.Deprecated"]
);
assert_eq!(
baseline.unused_class_members,
vec!["src/service.ts:UserService.legacy"]
);
assert_eq!(
baseline.unused_store_members,
vec!["src/store.ts:useStore.legacyAction"]
);
assert_eq!(baseline.unresolved_imports, vec!["src/app.ts:./missing"]);
assert_eq!(baseline.duplicate_exports, vec!["Config|src/a.ts|src/b.ts"]);
let ci_root = Path::new("/home/runner/work/project/project");
let ci_results = make_absolute_results("/home/runner/work/project/project");
let filtered = filter_new_issues(ci_results, &baseline, ci_root);
assert!(filtered.unused_files.is_empty(), "unused files");
assert!(filtered.unused_exports.is_empty(), "unused exports");
assert!(filtered.unused_dependencies.is_empty(), "unused deps");
assert!(
filtered.boundary_violations.is_empty(),
"boundary violations"
);
assert!(filtered.circular_dependencies.is_empty(), "circular deps");
assert!(filtered.unused_enum_members.is_empty(), "enum members");
assert!(filtered.unused_class_members.is_empty(), "class members");
assert!(filtered.unused_store_members.is_empty(), "store members");
assert!(filtered.unresolved_imports.is_empty(), "unresolved imports");
assert!(filtered.duplicate_exports.is_empty(), "duplicate exports");
}
#[test]
fn stale_suppression_baseline_keys_include_missing_reason_state() {
let root = Path::new("/project");
let stale = crate::results::StaleSuppression {
path: root.join("src/file.ts"),
line: 1,
col: 0,
origin: crate::results::SuppressionOrigin::Comment {
issue_kind: Some("unused-export".to_string()),
reason: None,
is_file_level: false,
kind_known: true,
},
missing_reason: false,
actions: crate::results::StaleSuppression::actions_for(false),
effective_severity: None,
};
let missing = crate::results::StaleSuppression {
missing_reason: true,
actions: crate::results::StaleSuppression::actions_for(true),
..stale.clone()
};
let results = AnalysisResults {
stale_suppressions: vec![stale, missing],
..Default::default()
};
let baseline = BaselineData::from_results(&results, root);
assert_eq!(
baseline.stale_suppressions,
vec![
"stale-suppression:src/file.ts:1",
"missing-suppression-reason:src/file.ts:1",
]
);
let mut legacy_baseline = BaselineData::from_results(&AnalysisResults::default(), root);
legacy_baseline.stale_suppressions = vec!["src/file.ts:1".to_string()];
let filtered = filter_new_issues(results, &legacy_baseline, root);
assert!(filtered.stale_suppressions.is_empty());
}
fn runtime_finding(
id: &str,
stable_id: Option<&str>,
line: u32,
source_hash: Option<&str>,
) -> fallow_output::RuntimeCoverageFinding {
fallow_output::RuntimeCoverageFinding {
id: id.to_owned(),
stable_id: stable_id.map(str::to_owned),
source_hash: source_hash.map(str::to_owned),
path: PathBuf::from("src/a.ts"),
function: "alpha".to_owned(),
line,
verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
invocations: Some(0),
confidence: fallow_output::RuntimeCoverageConfidence::Medium,
evidence: fallow_output::RuntimeCoverageEvidence {
static_status: "used".to_owned(),
test_coverage: "not_covered".to_owned(),
test_only_reference: None,
v8_tracking: "tracked".to_owned(),
untracked_reason: None,
observation_days: 1,
deployments_observed: 1,
},
actions: vec![],
discriminators: None,
}
}
#[test]
fn legacy_prod_baseline_still_suppresses_finding() {
let baseline = HealthBaselineData {
runtime_coverage_findings: vec!["fallow:prod:deadbeef".to_owned()],
..HealthBaselineData::default()
};
let findings = vec![runtime_finding(
"fallow:prod:deadbeef",
Some("fallow:fn:00000001"),
14,
None,
)];
let filtered =
filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
assert!(filtered.is_empty(), "legacy prod id must still suppress");
}
#[test]
fn source_hash_baseline_survives_line_move() {
let root = Path::new("/repo");
let baselined = runtime_finding(
"fallow:prod:deadbeef",
Some("fallow:fn:00000001"),
14,
Some("0123456789abcdef"),
);
let baseline = HealthBaselineData::from_findings(&[], &[baselined], &[], root);
assert_eq!(baseline.runtime_coverage_source_hashes.len(), 1);
let findings = vec![runtime_finding(
"fallow:prod:99999999",
Some("fallow:fn:cafe0002"),
40,
Some("0123456789abcdef"),
)];
let filtered = filter_new_runtime_coverage_findings(findings, &baseline, root);
assert!(
filtered.is_empty(),
"source_hash baseline must survive a line move despite a changed stable_id and id"
);
}
#[test]
fn unbaselined_finding_is_reported() {
let baseline = HealthBaselineData {
runtime_coverage_findings: vec!["fallow:fn:00000001".to_owned()],
..HealthBaselineData::default()
};
let findings = vec![runtime_finding(
"fallow:prod:abc1234d",
Some("fallow:fn:beefcafe"),
7,
None,
)];
let filtered =
filter_new_runtime_coverage_findings(findings, &baseline, Path::new("/repo"));
assert_eq!(filtered.len(), 1, "a brand-new finding must be reported");
}
}