#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
#![cfg_attr(
test,
allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "tests use unwrap and expect to keep fixture setup concise"
)
)]
pub mod analyze;
pub mod cache;
pub mod churn;
pub mod cross_reference;
pub mod discover;
pub mod duplicates;
pub(crate) mod errors;
mod external_style_usage;
pub mod extract;
pub mod git_env;
mod package_assets;
pub mod plugins;
pub(crate) mod progress;
pub mod results;
pub(crate) mod scripts;
pub(crate) mod spawn;
pub mod suppress;
pub mod trace;
pub mod trace_chain;
pub use fallow_graph::cache as graph_cache;
pub use fallow_graph::graph;
pub use fallow_graph::project;
pub use fallow_graph::resolve;
use std::path::{Path, PathBuf};
use std::time::Instant;
use errors::FallowError;
use fallow_config::{
EntryPointRole, PackageJson, ResolvedConfig, discover_workspaces,
find_undeclared_workspaces_with_ignores,
};
use rayon::prelude::*;
use results::AnalysisResults;
use rustc_hash::FxHashSet;
use trace::PipelineTimings;
const UNDECLARED_WORKSPACE_WARNING_PREVIEW: usize = 5;
type LoadedWorkspacePackage<'a> = (&'a fallow_config::WorkspaceInfo, PackageJson);
fn record_graph_package_usage(
graph: &mut graph::ModuleGraph,
package_name: &str,
file_id: discover::FileId,
is_type_only: bool,
) {
graph
.package_usage
.entry(package_name.to_owned())
.or_default()
.push(file_id);
if is_type_only {
graph
.type_only_package_usage
.entry(package_name.to_owned())
.or_default()
.push(file_id);
}
}
fn workspace_package_name<'a>(
source: &str,
workspace_names: &'a FxHashSet<&str>,
) -> Option<&'a str> {
if !resolve::is_bare_specifier(source) {
return None;
}
let package_name = resolve::extract_package_name(source);
workspace_names.get(package_name.as_str()).copied()
}
fn credit_workspace_package_usage(
graph: &mut graph::ModuleGraph,
resolved: &[resolve::ResolvedModule],
workspaces: &[fallow_config::WorkspaceInfo],
) {
if workspaces.is_empty() {
return;
}
let workspace_names: FxHashSet<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
for module in resolved {
for import in module.all_resolved_imports() {
if matches!(import.target, resolve::ResolveResult::InternalModule(_))
&& let Some(package_name) =
workspace_package_name(&import.info.source, &workspace_names)
{
record_graph_package_usage(
graph,
package_name,
module.file_id,
import.info.is_type_only,
);
}
}
for re_export in &module.re_exports {
if matches!(re_export.target, resolve::ResolveResult::InternalModule(_))
&& let Some(package_name) =
workspace_package_name(&re_export.info.source, &workspace_names)
{
record_graph_package_usage(
graph,
package_name,
module.file_id,
re_export.info.is_type_only,
);
}
}
}
}
fn credit_package_path_references(graph: &mut graph::ModuleGraph, modules: &[extract::ModuleInfo]) {
for module in modules {
for package_name in &module.package_path_references {
record_graph_package_usage(graph, package_name, module.file_id, false);
}
}
}
#[doc(hidden)]
pub struct AnalysisOutput {
pub results: AnalysisResults,
pub timings: Option<PipelineTimings>,
pub graph: Option<graph::ModuleGraph>,
pub modules: Option<Vec<extract::ModuleInfo>>,
pub files: Option<Vec<discover::DiscoveredFile>>,
pub script_used_packages: rustc_hash::FxHashSet<String>,
pub file_hashes: rustc_hash::FxHashMap<std::path::PathBuf, u64>,
}
#[derive(Debug, Clone, Copy)]
#[doc(hidden)]
pub struct AnalysisParseMetrics {
pub parse_ms: f64,
pub cache_ms: f64,
pub cache_hits: usize,
pub cache_misses: usize,
pub parse_cpu_ms: f64,
}
fn update_cache(
store: &mut cache::CacheStore,
modules: &[extract::ModuleInfo],
files: &[discover::DiscoveredFile],
) -> bool {
let mut dirty = false;
for module in modules {
if let Some(file) = files.get(module.file_id.0 as usize) {
let fingerprint = file_fingerprint(&file.path);
if let Some(cached) = store.get_by_path_only(&file.path)
&& cached.content_hash == module.content_hash
{
if cached.source_fingerprint() != fingerprint {
let preserved_last_access = cached.last_access_secs;
let mut refreshed = cache::module_to_cached(module, fingerprint);
refreshed.last_access_secs = preserved_last_access;
store.insert(&file.path, refreshed);
dirty = true;
}
continue;
}
store.insert(&file.path, cache::module_to_cached(module, fingerprint));
dirty = true;
}
}
let removed_stale_paths = store.retain_paths(files);
dirty || removed_stale_paths
}
#[must_use]
pub fn resolve_cache_max_size_bytes(config: &ResolvedConfig) -> usize {
config
.cache_max_size_mb
.map_or(cache::DEFAULT_CACHE_MAX_SIZE, |mb| {
(mb as usize).saturating_mul(1024 * 1024)
})
}
fn file_fingerprint(path: &std::path::Path) -> fallow_types::source_fingerprint::SourceFingerprint {
std::fs::metadata(path).map_or(
fallow_types::source_fingerprint::SourceFingerprint::new(0, 0),
|metadata| fallow_types::source_fingerprint::SourceFingerprint::from_metadata(&metadata),
)
}
fn format_undeclared_workspace_warning(
root: &Path,
undeclared: &[fallow_config::WorkspaceDiagnostic],
) -> Option<String> {
if undeclared.is_empty() {
return None;
}
let preview = undeclared
.iter()
.take(UNDECLARED_WORKSPACE_WARNING_PREVIEW)
.map(|diag| {
diag.path
.strip_prefix(root)
.unwrap_or(&diag.path)
.display()
.to_string()
.replace('\\', "/")
})
.collect::<Vec<_>>();
let remaining = undeclared
.len()
.saturating_sub(UNDECLARED_WORKSPACE_WARNING_PREVIEW);
let tail = if remaining > 0 {
format!(" (and {remaining} more)")
} else {
String::new()
};
let noun = if undeclared.len() == 1 {
"directory with package.json is"
} else {
"directories with package.json are"
};
let guidance = if undeclared.len() == 1 {
"Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
} else {
"Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
};
Some(format!(
"{} {} not declared as {}: {}{}. {}",
undeclared.len(),
noun,
if undeclared.len() == 1 {
"a workspace"
} else {
"workspaces"
},
preview.join(", "),
tail,
guidance
))
}
fn warn_undeclared_workspaces(
root: &Path,
workspaces_vec: &[fallow_config::WorkspaceInfo],
ignore_patterns: &globset::GlobSet,
quiet: bool,
) {
let undeclared = find_undeclared_workspaces_with_ignores(root, workspaces_vec, ignore_patterns);
if undeclared.is_empty() {
return;
}
let existing = fallow_config::workspace_diagnostics_for(root);
let already_flagged: rustc_hash::FxHashSet<PathBuf> = existing
.iter()
.map(|d| dunce::canonicalize(&d.path).unwrap_or_else(|_| d.path.clone()))
.collect();
let undeclared: Vec<_> = undeclared
.into_iter()
.filter(|diag| {
let canonical = dunce::canonicalize(&diag.path).unwrap_or_else(|_| diag.path.clone());
!already_flagged.contains(&canonical)
})
.collect();
if undeclared.is_empty() {
return;
}
fallow_config::append_workspace_diagnostics(root, undeclared.clone());
if !quiet && let Some(message) = format_undeclared_workspace_warning(root, &undeclared) {
tracing::warn!("{message}");
}
}
#[doc(hidden)]
#[deprecated(
since = "2.76.0",
note = "fallow_core is internal; use fallow_api::run_dead_code for typed output; serialize with fallow_api::serialize_dead_code_programmatic_json for JSON output. See docs/fallow-core-migration.md and ADR-008."
)]
pub fn analyze(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
let output = analyze_full(config, false, false, false, false)?;
Ok(output.results)
}
#[doc(hidden)]
#[deprecated(
since = "2.76.0",
note = "fallow_core is internal; use fallow_api::run_dead_code for public typed output. NOTE: export-usage collection is not exposed in the programmatic surface today. See docs/fallow-core-migration.md and ADR-008."
)]
pub fn analyze_with_usages(config: &ResolvedConfig) -> Result<AnalysisResults, FallowError> {
let output = analyze_full(config, false, true, false, false)?;
Ok(output.results)
}
#[doc(hidden)]
#[deprecated(
since = "2.76.0",
note = "fallow_core is internal; use fallow_api::run_dead_code for public typed output. NOTE: trace timings are not exposed in the programmatic surface today; use `fallow dead-code --performance` for CLI-side timings. See docs/fallow-core-migration.md and ADR-008."
)]
pub fn analyze_with_trace(config: &ResolvedConfig) -> Result<AnalysisOutput, FallowError> {
analyze_full(config, true, false, false, false)
}
#[doc(hidden)]
#[deprecated(
since = "2.76.0",
note = "fallow_core is internal; use fallow_api::run_dead_code for public typed output. NOTE: combined-mode module retention is not exposed in the programmatic surface today. See docs/fallow-core-migration.md and ADR-008."
)]
pub fn analyze_retaining_modules(
config: &ResolvedConfig,
need_complexity: bool,
retain_graph: bool,
) -> Result<AnalysisOutput, FallowError> {
analyze_full(config, retain_graph, false, need_complexity, true)
}
fn new_analysis_progress(config: &ResolvedConfig) -> progress::AnalysisProgress {
let show_progress = !config.quiet
&& std::io::IsTerminal::is_terminal(&std::io::stderr())
&& matches!(
config.output,
fallow_config::OutputFormat::Human
| fallow_config::OutputFormat::Compact
| fallow_config::OutputFormat::Markdown
);
progress::AnalysisProgress::new(show_progress)
}
fn warn_missing_node_modules(config: &ResolvedConfig) {
if config.root.join("node_modules").is_dir() {
return;
}
tracing::warn!(
"node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
);
}
fn discover_analysis_workspaces(
config: &ResolvedConfig,
) -> (Vec<fallow_config::WorkspaceInfo>, f64) {
let t = Instant::now();
let workspaces = discover_workspaces(&config.root);
let workspaces_ms = t.elapsed().as_secs_f64() * 1000.0;
if !workspaces.is_empty() {
tracing::info!(count = workspaces.len(), "workspaces discovered");
}
warn_undeclared_workspaces(
&config.root,
&workspaces,
&config.ignore_patterns,
config.quiet,
);
(workspaces, workspaces_ms)
}
struct AnalysisSetup {
progress: progress::AnalysisProgress,
project: project::ProjectState,
root_pkg: Option<PackageJson>,
config_candidates: Vec<std::path::PathBuf>,
discover_ms: f64,
workspaces_ms: f64,
}
#[derive(Debug, Clone)]
#[doc(hidden)]
pub struct AnalysisDiscovery {
files: Vec<discover::DiscoveredFile>,
workspaces: Vec<fallow_config::WorkspaceInfo>,
root_pkg: Option<PackageJson>,
config_candidates: Vec<std::path::PathBuf>,
discover_ms: f64,
workspaces_ms: f64,
}
impl AnalysisDiscovery {
#[must_use]
pub fn from_parts(
files: Vec<discover::DiscoveredFile>,
workspaces: Vec<fallow_config::WorkspaceInfo>,
root_pkg: Option<PackageJson>,
config_candidates: Vec<std::path::PathBuf>,
discover_ms: f64,
workspaces_ms: f64,
) -> Self {
Self {
files,
workspaces,
root_pkg,
config_candidates,
discover_ms,
workspaces_ms,
}
}
#[must_use]
pub fn files(&self) -> &[discover::DiscoveredFile] {
&self.files
}
#[must_use]
pub fn workspaces(&self) -> &[fallow_config::WorkspaceInfo] {
&self.workspaces
}
#[must_use]
pub fn into_files(self) -> Vec<discover::DiscoveredFile> {
self.files
}
}
pub(crate) struct AnalysisSession<'a> {
config: &'a ResolvedConfig,
pipeline_start: Instant,
progress: progress::AnalysisProgress,
project: project::ProjectState,
root_pkg: Option<PackageJson>,
config_candidates: Vec<std::path::PathBuf>,
discover_ms: f64,
workspaces_ms: f64,
}
impl<'a> AnalysisSession<'a> {
fn new(config: &'a ResolvedConfig) -> Self {
let pipeline_start = Instant::now();
let AnalysisSetup {
progress,
project,
root_pkg,
config_candidates,
discover_ms,
workspaces_ms,
} = run_analysis_setup(config);
Self {
config,
pipeline_start,
progress,
project,
root_pkg,
config_candidates,
discover_ms,
workspaces_ms,
}
}
fn files(&self) -> &[discover::DiscoveredFile] {
self.project.files()
}
fn workspaces(&self) -> &[fallow_config::WorkspaceInfo] {
self.project.workspaces()
}
fn load_workspace_packages(&self) -> Vec<LoadedWorkspacePackage<'_>> {
load_workspace_packages(self.workspaces())
}
fn run_plugins_and_scripts(
&self,
workspace_pkgs: &[LoadedWorkspacePackage<'_>],
) -> Result<(plugins::AggregatedPluginResult, f64, f64), FallowError> {
run_plugins_and_scripts(&PluginScriptInput {
config: self.config,
progress: &self.progress,
files: self.files(),
workspaces: self.workspaces(),
root_pkg: self.root_pkg.as_ref(),
workspace_pkgs,
config_candidates: &self.config_candidates,
})
}
fn prelude_timings(&self, plugins_ms: f64, scripts_ms: f64) -> PreludeTimings {
PreludeTimings {
discover_ms: self.discover_ms,
workspaces_ms: self.workspaces_ms,
plugins_ms,
scripts_ms,
}
}
fn parse_modules(&self, need_complexity: bool) -> AnalysisParseOutput {
let t = Instant::now();
self.progress
.set_stage(&format!("parsing {} files...", self.files().len()));
parse_analysis_modules(self.config, self.files(), need_complexity, t)
}
fn run_owned_core(
&self,
workspace_pkgs: &[LoadedWorkspacePackage<'_>],
plugin_result: &plugins::AggregatedPluginResult,
mut modules: Vec<extract::ModuleInfo>,
collect_usages: bool,
) -> OwnedAnalysisCore {
let shared = AnalysisCoreSharedInput {
config: self.config,
progress: &self.progress,
files: self.files(),
workspaces: self.workspaces(),
root_pkg: self.root_pkg.as_ref(),
workspace_pkgs,
plugin_result,
};
let entry_points = discover_analysis_entry_points(&shared);
let (resolved, graph) = if let Some(hit) =
try_load_analysis_graph_cache(&shared, &entry_points, &modules)
{
(
TimedResolvedModules {
resolved: hit.resolved,
elapsed_ms: 0.0,
},
TimedGraph {
graph: hit.graph,
elapsed_ms: hit.elapsed_ms,
},
)
} else {
let resolved = resolve_analysis_imports_timed(&shared, &modules);
let graph =
build_analysis_graph_timed(&shared, &resolved.resolved, &entry_points, &modules);
(resolved, graph)
};
release_resolution_payloads(&mut modules);
let analysis = analyze_dead_code_timed(
&shared,
&graph.graph,
&resolved.resolved,
&modules,
collect_usages,
entry_points.summary,
);
OwnedAnalysisCore {
result: analysis.result,
graph: graph.graph,
modules,
entry_point_count: entry_points.count,
entry_points_ms: entry_points.elapsed_ms,
resolve_ms: resolved.elapsed_ms,
graph_ms: graph.elapsed_ms,
analyze_ms: analysis.elapsed_ms,
}
}
fn run_full(
self,
retain: bool,
collect_usages: bool,
need_complexity: bool,
retain_modules: bool,
) -> Result<AnalysisOutput, FallowError> {
let workspace_pkgs = self.load_workspace_packages();
let (plugin_result, plugins_ms, scripts_ms) =
self.run_plugins_and_scripts(&workspace_pkgs)?;
let AnalysisParseOutput { modules, metrics } = self.parse_modules(need_complexity);
let core = self.run_owned_core(&workspace_pkgs, &plugin_result, modules, collect_usages);
self.progress.finish();
let profile = full_analysis_pipeline_profile(
&self.prelude_timings(plugins_ms, scripts_ms),
self.pipeline_start,
self.files(),
self.workspaces(),
&core,
&metrics,
);
trace_pipeline_profile(&profile);
Ok(assemble_full_output(
core,
plugin_result,
&profile,
self.files(),
retain,
retain_modules,
))
}
}
fn run_analysis_setup(config: &ResolvedConfig) -> AnalysisSetup {
let progress = new_analysis_progress(config);
warn_missing_node_modules(config);
let (workspaces_vec, workspaces_ms) = discover_analysis_workspaces(config);
let root_pkg = load_root_package_json(config);
let discovery_hidden_dir_scopes =
discover::collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces_vec);
let t = Instant::now();
progress.set_stage("discovering files...");
let (discovered_files, config_candidates) =
discover::discover_files_and_config_candidates(config, &discovery_hidden_dir_scopes);
let discover_ms = t.elapsed().as_secs_f64() * 1000.0;
let project = project::ProjectState::new(discovered_files, workspaces_vec);
AnalysisSetup {
progress,
project,
root_pkg,
config_candidates,
discover_ms,
workspaces_ms,
}
}
struct PluginScriptInput<'a> {
config: &'a ResolvedConfig,
progress: &'a progress::AnalysisProgress,
files: &'a [discover::DiscoveredFile],
workspaces: &'a [fallow_config::WorkspaceInfo],
root_pkg: Option<&'a PackageJson>,
workspace_pkgs: &'a [LoadedWorkspacePackage<'a>],
config_candidates: &'a [std::path::PathBuf],
}
fn run_plugins_and_scripts(
input: &PluginScriptInput<'_>,
) -> Result<(plugins::AggregatedPluginResult, f64, f64), FallowError> {
let t = Instant::now();
input.progress.set_stage("detecting plugins...");
let mut plugin_result = run_plugins(
input.config,
input.files,
input.workspaces,
input.root_pkg,
input.workspace_pkgs,
input.config_candidates,
)?;
let plugins_ms = t.elapsed().as_secs_f64() * 1000.0;
let t = Instant::now();
analyze_all_scripts(
input.config,
input.workspaces,
input.root_pkg,
input.workspace_pkgs,
&mut plugin_result,
);
let scripts_ms = t.elapsed().as_secs_f64() * 1000.0;
Ok((plugin_result, plugins_ms, scripts_ms))
}
#[derive(Debug, Clone, Copy)]
#[doc(hidden)]
pub struct DeadCodePreludeTimings {
pub discover_ms: f64,
pub workspaces_ms: f64,
pub plugins_ms: f64,
pub scripts_ms: f64,
}
#[doc(hidden)]
pub struct DeadCodeBackendPrelude<'a> {
config: &'a ResolvedConfig,
pipeline_start: Instant,
progress: progress::AnalysisProgress,
discovery: &'a AnalysisDiscovery,
workspace_pkgs: Vec<LoadedWorkspacePackage<'a>>,
plugin_result: plugins::AggregatedPluginResult,
plugins_ms: f64,
scripts_ms: f64,
}
impl DeadCodeBackendPrelude<'_> {
#[must_use]
pub fn timings(&self) -> DeadCodePreludeTimings {
DeadCodePreludeTimings {
discover_ms: self.discovery.discover_ms,
workspaces_ms: self.discovery.workspaces_ms,
plugins_ms: self.plugins_ms,
scripts_ms: self.scripts_ms,
}
}
#[must_use]
pub fn elapsed_ms(&self) -> f64 {
self.pipeline_start.elapsed().as_secs_f64() * 1000.0
}
#[must_use]
pub fn script_used_packages(&self) -> FxHashSet<String> {
self.plugin_result.script_used_packages.clone()
}
pub fn finish(&self) {
self.progress.finish();
}
}
#[doc(hidden)]
pub struct DeadCodeEntryPoints {
inner: TimedEntryPoints,
}
impl DeadCodeEntryPoints {
#[must_use]
pub fn count(&self) -> usize {
self.inner.count
}
#[must_use]
pub fn elapsed_ms(&self) -> f64 {
self.inner.elapsed_ms
}
}
#[doc(hidden)]
pub struct DeadCodeResolvedModules {
pub resolved: Vec<resolve::ResolvedModule>,
pub elapsed_ms: f64,
}
#[doc(hidden)]
pub struct DeadCodeGraphRun {
pub graph: graph::ModuleGraph,
pub elapsed_ms: f64,
}
#[doc(hidden)]
pub struct DeadCodeDetectorRun {
pub results: AnalysisResults,
pub elapsed_ms: f64,
}
pub fn prepare_dead_code_backend_prelude<'a>(
config: &'a ResolvedConfig,
discovery: &'a AnalysisDiscovery,
) -> Result<DeadCodeBackendPrelude<'a>, FallowError> {
let progress = new_analysis_progress(config);
let pipeline_start = Instant::now();
let workspace_pkgs = load_workspace_packages(&discovery.workspaces);
let (plugin_result, plugins_ms, scripts_ms) = run_plugins_and_scripts(&PluginScriptInput {
config,
progress: &progress,
files: discovery.files(),
workspaces: &discovery.workspaces,
root_pkg: discovery.root_pkg.as_ref(),
workspace_pkgs: &workspace_pkgs,
config_candidates: &discovery.config_candidates,
})?;
Ok(DeadCodeBackendPrelude {
config,
pipeline_start,
progress,
discovery,
workspace_pkgs,
plugin_result,
plugins_ms,
scripts_ms,
})
}
#[must_use]
pub fn discover_dead_code_entry_points(
prelude: &DeadCodeBackendPrelude<'_>,
) -> DeadCodeEntryPoints {
let shared = prelude.shared_input();
DeadCodeEntryPoints {
inner: discover_analysis_entry_points(&shared),
}
}
#[must_use]
pub fn try_load_dead_code_graph_cache(
prelude: &DeadCodeBackendPrelude<'_>,
entry_points: &DeadCodeEntryPoints,
modules: &[extract::ModuleInfo],
) -> Option<(DeadCodeResolvedModules, DeadCodeGraphRun)> {
let shared = prelude.shared_input();
try_load_analysis_graph_cache(&shared, &entry_points.inner, modules).map(|hit| {
(
DeadCodeResolvedModules {
resolved: hit.resolved,
elapsed_ms: 0.0,
},
DeadCodeGraphRun {
graph: hit.graph,
elapsed_ms: hit.elapsed_ms,
},
)
})
}
#[must_use]
pub fn resolve_dead_code_imports(
prelude: &DeadCodeBackendPrelude<'_>,
modules: &[extract::ModuleInfo],
) -> DeadCodeResolvedModules {
let shared = prelude.shared_input();
let resolved = resolve_analysis_imports_timed(&shared, modules);
DeadCodeResolvedModules {
resolved: resolved.resolved,
elapsed_ms: resolved.elapsed_ms,
}
}
#[must_use]
pub fn build_dead_code_graph(
prelude: &DeadCodeBackendPrelude<'_>,
resolved: &[resolve::ResolvedModule],
entry_points: &DeadCodeEntryPoints,
modules: &[extract::ModuleInfo],
) -> DeadCodeGraphRun {
let shared = prelude.shared_input();
let graph = build_analysis_graph_timed(&shared, resolved, &entry_points.inner, modules);
DeadCodeGraphRun {
graph: graph.graph,
elapsed_ms: graph.elapsed_ms,
}
}
#[must_use]
pub fn run_dead_code_detectors(
prelude: &DeadCodeBackendPrelude<'_>,
graph: &graph::ModuleGraph,
resolved: &[resolve::ResolvedModule],
modules: &[extract::ModuleInfo],
collect_usages: bool,
entry_points: &DeadCodeEntryPoints,
) -> DeadCodeDetectorRun {
let shared = prelude.shared_input();
let analysis = analyze_dead_code_timed(
&shared,
graph,
resolved,
modules,
collect_usages,
entry_points.inner.summary.clone(),
);
DeadCodeDetectorRun {
results: analysis.result,
elapsed_ms: analysis.elapsed_ms,
}
}
impl<'a> DeadCodeBackendPrelude<'a> {
fn shared_input(&'a self) -> AnalysisCoreSharedInput<'a> {
AnalysisCoreSharedInput {
config: self.config,
progress: &self.progress,
files: self.discovery.files(),
workspaces: &self.discovery.workspaces,
root_pkg: self.discovery.root_pkg.as_ref(),
workspace_pkgs: &self.workspace_pkgs,
plugin_result: &self.plugin_result,
}
}
}
struct PreludeMetrics {
discover_ms: f64,
workspaces_ms: f64,
plugins_ms: f64,
scripts_ms: f64,
total_ms: f64,
file_count: usize,
workspace_count: usize,
module_count: usize,
}
#[expect(
clippy::struct_field_names,
reason = "timings are all milliseconds; the _ms suffix is the unit"
)]
struct PreludeTimings {
discover_ms: f64,
workspaces_ms: f64,
plugins_ms: f64,
scripts_ms: f64,
}
fn prelude_metrics(
timings: &PreludeTimings,
pipeline_start: Instant,
files: &[discover::DiscoveredFile],
workspaces: &[fallow_config::WorkspaceInfo],
module_count: usize,
) -> PreludeMetrics {
PreludeMetrics {
discover_ms: timings.discover_ms,
workspaces_ms: timings.workspaces_ms,
plugins_ms: timings.plugins_ms,
scripts_ms: timings.scripts_ms,
total_ms: pipeline_start.elapsed().as_secs_f64() * 1000.0,
file_count: files.len(),
workspace_count: workspaces.len(),
module_count,
}
}
struct AnalysisCoreSharedInput<'a> {
config: &'a ResolvedConfig,
progress: &'a progress::AnalysisProgress,
files: &'a [discover::DiscoveredFile],
workspaces: &'a [fallow_config::WorkspaceInfo],
root_pkg: Option<&'a PackageJson>,
workspace_pkgs: &'a [LoadedWorkspacePackage<'a>],
plugin_result: &'a plugins::AggregatedPluginResult,
}
struct TimedEntryPoints {
entry_points: discover::CategorizedEntryPoints,
summary: results::EntryPointSummary,
count: usize,
elapsed_ms: f64,
}
struct TimedResolvedModules {
resolved: Vec<resolve::ResolvedModule>,
elapsed_ms: f64,
}
struct TimedGraph {
graph: graph::ModuleGraph,
elapsed_ms: f64,
}
struct GraphCacheHit {
graph: graph::ModuleGraph,
resolved: Vec<resolve::ResolvedModule>,
elapsed_ms: f64,
}
struct TimedAnalysis {
result: AnalysisResults,
elapsed_ms: f64,
}
fn discover_analysis_entry_points(input: &AnalysisCoreSharedInput<'_>) -> TimedEntryPoints {
let t = Instant::now();
let entry_points = discover_all_entry_points(
input.config,
input.files,
input.workspaces,
input.root_pkg,
input.workspace_pkgs,
input.plugin_result,
);
let elapsed_ms = t.elapsed().as_secs_f64() * 1000.0;
let summary = summarize_entry_points(&entry_points.all);
let count = entry_points.all.len();
TimedEntryPoints {
entry_points,
summary,
count,
elapsed_ms,
}
}
fn try_load_analysis_graph_cache(
input: &AnalysisCoreSharedInput<'_>,
entry_points: &TimedEntryPoints,
modules: &[extract::ModuleInfo],
) -> Option<GraphCacheHit> {
if input.config.no_cache {
return None;
}
let t = Instant::now();
input.progress.set_stage("loading module graph cache...");
let current = build_graph_cache_manifest(
input.config,
input.plugin_result,
&entry_points.entry_points,
input.files,
);
let store = graph_cache::GraphCacheStore::load(&input.config.cache_dir)?;
if store.manifest.matches_inputs(¤t) {
let resolved = graph_cache::restore_resolved_modules(
&input.config.root,
modules,
input.files,
&store.resolved_modules,
)?;
tracing::debug!("Graph cache hit: skipping import resolution and graph build");
return Some(GraphCacheHit {
graph: store.graph,
resolved,
elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
});
}
if !store.manifest.matches_resolution_inputs(¤t) {
return None;
}
let resolved = graph_cache::restore_resolved_modules(
&input.config.root,
modules,
input.files,
&store.resolved_modules,
)?;
tracing::debug!("Graph resolver cache hit: skipping import resolution and rebuilding graph");
let graph = build_analysis_graph_timed(input, &resolved, entry_points, modules);
Some(GraphCacheHit {
graph: graph.graph,
resolved,
elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
})
}
fn resolve_analysis_imports_timed(
input: &AnalysisCoreSharedInput<'_>,
modules: &[extract::ModuleInfo],
) -> TimedResolvedModules {
let t = Instant::now();
input.progress.set_stage("resolving imports...");
let resolved = resolve_analysis_imports(
modules,
input.files,
input.workspaces,
input.plugin_result,
input.config,
);
TimedResolvedModules {
resolved,
elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
}
}
fn build_analysis_graph_timed(
input: &AnalysisCoreSharedInput<'_>,
resolved: &[resolve::ResolvedModule],
entry_points: &TimedEntryPoints,
modules: &[extract::ModuleInfo],
) -> TimedGraph {
let t = Instant::now();
input.progress.set_stage("building module graph...");
let graph = build_analysis_graph(&BuildAnalysisGraphInput {
config: input.config,
plugin_result: input.plugin_result,
resolved,
entry_points: &entry_points.entry_points,
files: input.files,
modules,
workspaces: input.workspaces,
});
TimedGraph {
graph,
elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
}
}
fn release_resolution_payloads(modules: &mut [extract::ModuleInfo]) {
for module in modules {
module.release_resolution_payload();
}
}
fn analyze_dead_code_timed(
input: &AnalysisCoreSharedInput<'_>,
graph: &graph::ModuleGraph,
resolved: &[resolve::ResolvedModule],
modules: &[extract::ModuleInfo],
collect_usages: bool,
entry_point_summary: results::EntryPointSummary,
) -> TimedAnalysis {
let t = Instant::now();
input.progress.set_stage("analyzing...");
#[expect(
deprecated,
reason = "ADR-008 keeps workspace path-dependency calls while warning external fallow-core consumers"
)]
let mut result = analyze::find_dead_code_full(
graph,
input.config,
resolved,
Some(input.plugin_result),
input.workspaces,
modules,
collect_usages,
);
result.entry_point_summary = Some(entry_point_summary);
TimedAnalysis {
result,
elapsed_ms: t.elapsed().as_secs_f64() * 1000.0,
}
}
fn analyze_full(
config: &ResolvedConfig,
retain: bool,
collect_usages: bool,
need_complexity: bool,
retain_modules: bool,
) -> Result<AnalysisOutput, FallowError> {
let _span = tracing::info_span!("fallow_analyze").entered();
AnalysisSession::new(config).run_full(retain, collect_usages, need_complexity, retain_modules)
}
fn full_analysis_pipeline_profile(
timings: &PreludeTimings,
pipeline_start: Instant,
files: &[discover::DiscoveredFile],
workspaces: &[fallow_config::WorkspaceInfo],
core: &OwnedAnalysisCore,
metrics: &ParseMetrics,
) -> PipelineProfile {
let prelude = prelude_metrics(
timings,
pipeline_start,
files,
workspaces,
core.modules.len(),
);
full_pipeline_profile(&prelude, core, metrics)
}
fn assemble_full_output(
core: OwnedAnalysisCore,
plugin_result: plugins::AggregatedPluginResult,
profile: &PipelineProfile,
files: &[discover::DiscoveredFile],
retain: bool,
retain_modules: bool,
) -> AnalysisOutput {
let file_hashes = collect_file_hashes(&core.modules, files);
AnalysisOutput {
results: core.result,
timings: retained_pipeline_timings(retain, profile),
graph: if retain { Some(core.graph) } else { None },
modules: if retain_modules {
Some(core.modules)
} else {
None
},
files: if retain_modules {
Some(files.to_vec())
} else {
None
},
script_used_packages: plugin_result.script_used_packages,
file_hashes,
}
}
struct OwnedAnalysisCore {
result: AnalysisResults,
graph: graph::ModuleGraph,
modules: Vec<extract::ModuleInfo>,
entry_point_count: usize,
entry_points_ms: f64,
resolve_ms: f64,
graph_ms: f64,
analyze_ms: f64,
}
fn full_pipeline_profile(
prelude: &PreludeMetrics,
core: &OwnedAnalysisCore,
parse: &ParseMetrics,
) -> PipelineProfile {
PipelineProfile {
discover_ms: prelude.discover_ms,
workspaces_ms: prelude.workspaces_ms,
plugins_ms: prelude.plugins_ms,
scripts_ms: prelude.scripts_ms,
parse_ms: parse.parse_ms,
cache_ms: parse.cache_ms,
entry_points_ms: core.entry_points_ms,
resolve_ms: core.resolve_ms,
graph_ms: core.graph_ms,
analyze_ms: core.analyze_ms,
total_ms: prelude.total_ms,
file_count: prelude.file_count,
workspace_count: prelude.workspace_count,
module_count: prelude.module_count,
entry_point_count: core.entry_point_count,
cache_hits: parse.cache_hits,
cache_misses: parse.cache_misses,
parse_cpu_ms: parse.parse_cpu_ms,
}
}
#[derive(Clone, Copy)]
struct PipelineProfile {
discover_ms: f64,
workspaces_ms: f64,
plugins_ms: f64,
scripts_ms: f64,
parse_ms: f64,
cache_ms: f64,
entry_points_ms: f64,
resolve_ms: f64,
graph_ms: f64,
analyze_ms: f64,
total_ms: f64,
file_count: usize,
workspace_count: usize,
module_count: usize,
entry_point_count: usize,
cache_hits: usize,
cache_misses: usize,
parse_cpu_ms: f64,
}
struct AnalysisParseOutput {
modules: Vec<extract::ModuleInfo>,
metrics: ParseMetrics,
}
struct ParseMetrics {
parse_ms: f64,
cache_ms: f64,
cache_hits: usize,
cache_misses: usize,
parse_cpu_ms: f64,
}
impl From<AnalysisParseMetrics> for ParseMetrics {
fn from(metrics: AnalysisParseMetrics) -> Self {
Self {
parse_ms: metrics.parse_ms,
cache_ms: metrics.cache_ms,
cache_hits: metrics.cache_hits,
cache_misses: metrics.cache_misses,
parse_cpu_ms: metrics.parse_cpu_ms,
}
}
}
fn parse_analysis_modules(
config: &ResolvedConfig,
files: &[discover::DiscoveredFile],
need_complexity: bool,
start: Instant,
) -> AnalysisParseOutput {
let cache_max_size_bytes = resolve_cache_max_size_bytes(config);
let mut cache_store = if config.no_cache {
None
} else {
cache::CacheStore::load(
&config.cache_dir,
config.cache_config_hash,
cache_max_size_bytes,
)
};
let parse_result = extract::parse_all_files(files, cache_store.as_ref(), need_complexity);
let modules = parse_result.modules;
let parse_ms = start.elapsed().as_secs_f64() * 1000.0;
let cache_ms = update_parse_cache_if_enabled(
config,
&mut cache_store,
&modules,
files,
cache_max_size_bytes,
);
AnalysisParseOutput {
modules,
metrics: ParseMetrics {
parse_ms,
cache_ms,
cache_hits: parse_result.cache_hits,
cache_misses: parse_result.cache_misses,
parse_cpu_ms: parse_result.parse_cpu_ms,
},
}
}
fn retained_pipeline_timings(retain: bool, profile: &PipelineProfile) -> Option<PipelineTimings> {
retain.then_some(PipelineTimings {
discover_files_ms: profile.discover_ms,
file_count: profile.file_count,
workspaces_ms: profile.workspaces_ms,
workspace_count: profile.workspace_count,
plugins_ms: profile.plugins_ms,
script_analysis_ms: profile.scripts_ms,
parse_extract_ms: profile.parse_ms,
parse_cpu_ms: profile.parse_cpu_ms,
module_count: profile.module_count,
cache_hits: profile.cache_hits,
cache_misses: profile.cache_misses,
cache_update_ms: profile.cache_ms,
entry_points_ms: profile.entry_points_ms,
entry_point_count: profile.entry_point_count,
resolve_imports_ms: profile.resolve_ms,
build_graph_ms: profile.graph_ms,
analyze_ms: profile.analyze_ms,
duplication_ms: None,
total_ms: profile.total_ms,
})
}
fn update_parse_cache_if_enabled(
config: &ResolvedConfig,
cache_store: &mut Option<cache::CacheStore>,
modules: &[extract::ModuleInfo],
files: &[discover::DiscoveredFile],
cache_max_size_bytes: usize,
) -> f64 {
let t = Instant::now();
if !config.no_cache {
let store = cache_store.get_or_insert_with(cache::CacheStore::new);
if update_cache(store, modules, files)
&& let Err(error) = store.save(
&config.cache_dir,
config.cache_config_hash,
cache_max_size_bytes,
)
{
tracing::warn!("Failed to save cache: {error}");
}
}
t.elapsed().as_secs_f64() * 1000.0
}
fn resolve_analysis_imports(
modules: &[extract::ModuleInfo],
files: &[discover::DiscoveredFile],
workspaces: &[fallow_config::WorkspaceInfo],
plugin_result: &plugins::AggregatedPluginResult,
config: &ResolvedConfig,
) -> Vec<resolve::ResolvedModule> {
let mut resolved = resolve::resolve_all_imports(&resolve::ResolveAllImportsInput {
modules,
files,
workspaces,
active_plugins: &plugin_result.active_plugins,
path_aliases: &plugin_result.path_aliases,
auto_imports: &plugin_result.auto_imports,
scss_include_paths: &plugin_result.scss_include_paths,
static_dir_mappings: &plugin_result.static_dir_mappings,
root: &config.root,
extra_conditions: &config.resolve.conditions,
});
external_style_usage::augment_external_style_package_usage(
&mut resolved,
config,
workspaces,
plugin_result,
);
resolved
}
struct BuildAnalysisGraphInput<'a> {
config: &'a ResolvedConfig,
plugin_result: &'a plugins::AggregatedPluginResult,
resolved: &'a [resolve::ResolvedModule],
entry_points: &'a discover::CategorizedEntryPoints,
files: &'a [discover::DiscoveredFile],
modules: &'a [extract::ModuleInfo],
workspaces: &'a [fallow_config::WorkspaceInfo],
}
fn build_analysis_graph(input: &BuildAnalysisGraphInput<'_>) -> graph::ModuleGraph {
let caching_enabled = !input.config.no_cache;
let current_manifest = caching_enabled.then(|| {
build_graph_cache_manifest(
input.config,
input.plugin_result,
input.entry_points,
input.files,
)
});
let mut graph = graph::ModuleGraph::build_with_reachability_roots(
input.resolved,
&input.entry_points.all,
&input.entry_points.runtime,
&input.entry_points.test,
input.files,
);
credit_package_path_references(&mut graph, input.modules);
credit_workspace_package_usage(&mut graph, input.resolved, input.workspaces);
if let Some(manifest) = current_manifest {
let Some(resolved_modules) =
graph_cache::cache_resolved_modules(&input.config.root, input.files, input.resolved)
else {
return graph;
};
let store = graph_cache::GraphCacheStore {
version: graph_cache::GRAPH_CACHE_VERSION,
manifest,
graph,
resolved_modules,
};
store.save(&input.config.cache_dir);
return store.graph;
}
graph
}
fn build_graph_cache_manifest(
config: &ResolvedConfig,
plugin_result: &plugins::AggregatedPluginResult,
entry_points: &discover::CategorizedEntryPoints,
files: &[discover::DiscoveredFile],
) -> graph_cache::GraphCacheManifest {
let mode = graph_cache::GraphCacheMode::new(
resolver_options_hash(config),
entry_points_hash(entry_points),
plugin_config_hash(plugin_result),
);
graph_cache::GraphCacheManifest::from_discovered_files(&config.root, files, mode, |path| {
std::fs::metadata(path).map_or(
fallow_types::source_fingerprint::SourceFingerprint::new(0, 0),
|metadata| {
fallow_types::source_fingerprint::SourceFingerprint::from_metadata(&metadata)
},
)
})
}
fn resolver_options_hash(config: &ResolvedConfig) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = rustc_hash::FxHasher::default();
config.root.hash(&mut hasher);
config.cache_config_hash.hash(&mut hasher);
config.resolve.conditions.hash(&mut hasher);
hasher.finish()
}
fn entry_points_hash(entry_points: &discover::CategorizedEntryPoints) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = rustc_hash::FxHasher::default();
for role in [&entry_points.all, &entry_points.runtime, &entry_points.test] {
let mut paths: Vec<&std::path::Path> = role.iter().map(|ep| ep.path.as_path()).collect();
paths.sort_unstable();
paths.len().hash(&mut hasher);
for path in paths {
path.hash(&mut hasher);
}
}
hasher.finish()
}
fn plugin_config_hash(plugin_result: &plugins::AggregatedPluginResult) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = rustc_hash::FxHasher::default();
let mut active: Vec<&str> = plugin_result
.active_plugins
.iter()
.map(String::as_str)
.collect();
active.sort_unstable();
active.len().hash(&mut hasher);
for name in active {
name.hash(&mut hasher);
}
let mut aliases: Vec<(&str, &str)> = plugin_result
.path_aliases
.iter()
.map(|(prefix, replacement)| (prefix.as_str(), replacement.as_str()))
.collect();
aliases.sort_unstable();
aliases.len().hash(&mut hasher);
for (prefix, replacement) in aliases {
prefix.hash(&mut hasher);
replacement.hash(&mut hasher);
}
let mut auto_imports: Vec<(&str, &std::path::Path, fallow_config::AutoImportKind)> =
plugin_result
.auto_imports
.iter()
.map(|rule| (rule.name.as_str(), rule.source.as_path(), rule.kind))
.collect();
auto_imports.sort_unstable_by(|a, b| {
a.0.cmp(b.0)
.then_with(|| a.1.cmp(b.1))
.then_with(|| auto_import_kind_rank(a.2).cmp(&auto_import_kind_rank(b.2)))
});
auto_imports.len().hash(&mut hasher);
for (name, source, kind) in auto_imports {
name.hash(&mut hasher);
source.hash(&mut hasher);
auto_import_kind_rank(kind).hash(&mut hasher);
}
let mut scss_include_paths: Vec<&std::path::Path> = plugin_result
.scss_include_paths
.iter()
.map(std::path::PathBuf::as_path)
.collect();
scss_include_paths.sort_unstable();
scss_include_paths.len().hash(&mut hasher);
for path in scss_include_paths {
path.hash(&mut hasher);
}
let mut static_dir_mappings: Vec<(&std::path::Path, &str)> = plugin_result
.static_dir_mappings
.iter()
.map(|(from_dir, mount)| (from_dir.as_path(), mount.as_str()))
.collect();
static_dir_mappings.sort_unstable();
static_dir_mappings.len().hash(&mut hasher);
for (from_dir, mount) in static_dir_mappings {
from_dir.hash(&mut hasher);
mount.hash(&mut hasher);
}
hasher.finish()
}
fn auto_import_kind_rank(kind: fallow_config::AutoImportKind) -> u8 {
match kind {
fallow_config::AutoImportKind::Named => 0,
fallow_config::AutoImportKind::Default => 1,
fallow_config::AutoImportKind::DefaultComponent => 2,
}
}
fn collect_file_hashes(
modules: &[extract::ModuleInfo],
files: &[discover::DiscoveredFile],
) -> rustc_hash::FxHashMap<std::path::PathBuf, u64> {
modules
.iter()
.filter_map(|module| {
files
.get(module.file_id.0 as usize)
.map(|file| (file.path.clone(), module.content_hash))
})
.collect()
}
fn trace_pipeline_profile(profile: &PipelineProfile) {
let PipelineProfile {
discover_ms,
workspaces_ms,
plugins_ms,
scripts_ms,
parse_ms,
cache_ms,
entry_points_ms,
resolve_ms,
graph_ms,
analyze_ms,
total_ms,
file_count,
module_count,
entry_point_count,
cache_hits,
cache_misses,
..
} = *profile;
let cache_summary = if cache_hits > 0 {
format!(" ({cache_hits} cached, {cache_misses} parsed)")
} else {
String::new()
};
tracing::debug!(
"\n┌─ Pipeline Profile ─────────────────────────────\n\
│ discover files: {:>8.1}ms ({} files)\n\
│ workspaces: {:>8.1}ms\n\
│ plugins: {:>8.1}ms\n\
│ script analysis: {:>8.1}ms\n\
│ parse/extract: {:>8.1}ms ({} modules{})\n\
│ cache update: {:>8.1}ms\n\
│ entry points: {:>8.1}ms ({} entries)\n\
│ resolve imports: {:>8.1}ms\n\
│ build graph: {:>8.1}ms\n\
│ analyze: {:>8.1}ms\n\
│ ────────────────────────────────────────────\n\
│ TOTAL: {:>8.1}ms\n\
└─────────────────────────────────────────────────",
discover_ms,
file_count,
workspaces_ms,
plugins_ms,
scripts_ms,
parse_ms,
module_count,
cache_summary,
cache_ms,
entry_points_ms,
entry_point_count,
resolve_ms,
graph_ms,
analyze_ms,
total_ms,
);
}
fn load_root_package_json(config: &ResolvedConfig) -> Option<PackageJson> {
PackageJson::load(&config.root.join("package.json")).ok()
}
fn load_workspace_packages(
workspaces: &[fallow_config::WorkspaceInfo],
) -> Vec<LoadedWorkspacePackage<'_>> {
workspaces
.iter()
.filter_map(|ws| {
PackageJson::load(&ws.root.join("package.json"))
.ok()
.map(|pkg| (ws, pkg))
})
.collect()
}
fn analyze_all_scripts(
config: &ResolvedConfig,
workspaces: &[fallow_config::WorkspaceInfo],
root_pkg: Option<&PackageJson>,
workspace_pkgs: &[LoadedWorkspacePackage<'_>],
plugin_result: &mut plugins::AggregatedPluginResult,
) {
let all_dep_names = collect_all_dependency_names(root_pkg, workspace_pkgs);
let all_dep_set: FxHashSet<String> = all_dep_names.iter().cloned().collect();
let all_script_names = collect_all_script_names(root_pkg, workspace_pkgs);
let nm_roots = collect_node_modules_roots(config, workspaces);
let bin_map = scripts::build_bin_to_package_map(&nm_roots, &all_dep_names);
analyze_root_scripts(config, root_pkg, &bin_map, &all_dep_set, plugin_result);
analyze_workspace_scripts(
config,
workspace_pkgs,
&bin_map,
&all_dep_set,
plugin_result,
);
analyze_ci_scripts(
config,
&bin_map,
&all_dep_set,
&all_script_names,
plugin_result,
);
plugin_result
.entry_point_roles
.entry("scripts".to_string())
.or_insert(EntryPointRole::Support);
}
fn collect_all_dependency_names(
root_pkg: Option<&PackageJson>,
workspace_pkgs: &[LoadedWorkspacePackage<'_>],
) -> Vec<String> {
let mut all_dep_names: Vec<String> = Vec::new();
if let Some(pkg) = root_pkg {
all_dep_names.extend(pkg.all_dependency_names());
}
for (_, ws_pkg) in workspace_pkgs {
all_dep_names.extend(ws_pkg.all_dependency_names());
}
all_dep_names.sort_unstable();
all_dep_names.dedup();
all_dep_names
}
fn collect_all_script_names(
root_pkg: Option<&PackageJson>,
workspace_pkgs: &[LoadedWorkspacePackage<'_>],
) -> FxHashSet<String> {
let mut all_script_names: FxHashSet<String> = FxHashSet::default();
if let Some(pkg) = root_pkg
&& let Some(ref pkg_scripts) = pkg.scripts
{
all_script_names.extend(pkg_scripts.keys().cloned());
}
for (_, ws_pkg) in workspace_pkgs {
if let Some(ref ws_scripts) = ws_pkg.scripts {
all_script_names.extend(ws_scripts.keys().cloned());
}
}
all_script_names
}
fn collect_node_modules_roots<'a>(
config: &'a ResolvedConfig,
workspaces: &'a [fallow_config::WorkspaceInfo],
) -> Vec<&'a std::path::Path> {
let mut nm_roots: Vec<&std::path::Path> = Vec::new();
if config.root.join("node_modules").is_dir() {
nm_roots.push(&config.root);
}
for ws in workspaces {
if ws.root.join("node_modules").is_dir() {
nm_roots.push(&ws.root);
}
}
nm_roots
}
fn analyze_root_scripts(
config: &ResolvedConfig,
root_pkg: Option<&PackageJson>,
bin_map: &rustc_hash::FxHashMap<String, String>,
all_dep_set: &FxHashSet<String>,
plugin_result: &mut plugins::AggregatedPluginResult,
) {
let Some(pkg) = root_pkg else {
return;
};
let Some(ref pkg_scripts) = pkg.scripts else {
return;
};
let scripts_to_analyze = if config.production {
scripts::filter_production_scripts(pkg_scripts)
} else {
pkg_scripts.clone()
};
let script_names: FxHashSet<String> = pkg_scripts.keys().cloned().collect();
let script_analysis = scripts::analyze_scripts_with_dependency_context(
&scripts_to_analyze,
&config.root,
bin_map,
all_dep_set,
&script_names,
);
plugin_result.script_used_packages = script_analysis.used_packages;
for config_file in &script_analysis.config_files {
plugin_result
.discovered_always_used
.push((config_file.clone(), "scripts".to_string()));
}
for entry in &script_analysis.entry_files {
if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
plugin_result
.entry_patterns
.push((plugins::PathRule::new(pat), "scripts".to_string()));
}
}
}
type WsScriptOut = (
Vec<String>,
Vec<(String, String)>,
Vec<(plugins::PathRule, String)>,
);
fn analyze_workspace_scripts(
config: &ResolvedConfig,
workspace_pkgs: &[LoadedWorkspacePackage<'_>],
bin_map: &rustc_hash::FxHashMap<String, String>,
all_dep_set: &FxHashSet<String>,
plugin_result: &mut plugins::AggregatedPluginResult,
) {
let ws_results: Vec<WsScriptOut> = workspace_pkgs
.par_iter()
.map(|(ws, ws_pkg)| analyze_one_workspace_scripts(config, ws, ws_pkg, bin_map, all_dep_set))
.collect();
for (used_packages, discovered_always_used, entry_patterns) in ws_results {
plugin_result.script_used_packages.extend(used_packages);
plugin_result
.discovered_always_used
.extend(discovered_always_used);
plugin_result.entry_patterns.extend(entry_patterns);
}
}
fn analyze_one_workspace_scripts(
config: &ResolvedConfig,
ws: &fallow_config::WorkspaceInfo,
ws_pkg: &PackageJson,
bin_map: &rustc_hash::FxHashMap<String, String>,
all_dep_set: &FxHashSet<String>,
) -> WsScriptOut {
let mut used_packages = Vec::new();
let mut discovered_always_used: Vec<(String, String)> = Vec::new();
let mut entry_patterns: Vec<(plugins::PathRule, String)> = Vec::new();
let Some(ref ws_scripts) = ws_pkg.scripts else {
return (used_packages, discovered_always_used, entry_patterns);
};
let scripts_to_analyze = if config.production {
scripts::filter_production_scripts(ws_scripts)
} else {
ws_scripts.clone()
};
let script_names: FxHashSet<String> = ws_scripts.keys().cloned().collect();
let ws_analysis = scripts::analyze_scripts_with_dependency_context(
&scripts_to_analyze,
&ws.root,
bin_map,
all_dep_set,
&script_names,
);
used_packages.extend(ws_analysis.used_packages);
let ws_prefix = ws
.root
.strip_prefix(&config.root)
.unwrap_or(&ws.root)
.to_string_lossy();
for config_file in &ws_analysis.config_files {
discovered_always_used.push((format!("{ws_prefix}/{config_file}"), "scripts".to_string()));
}
for entry in &ws_analysis.entry_files {
if let Some(pat) = scripts::normalize_script_entry_pattern(&ws_prefix, entry) {
entry_patterns.push((plugins::PathRule::new(pat), "scripts".to_string()));
}
}
(used_packages, discovered_always_used, entry_patterns)
}
fn analyze_ci_scripts(
config: &ResolvedConfig,
bin_map: &rustc_hash::FxHashMap<String, String>,
all_dep_set: &FxHashSet<String>,
all_script_names: &FxHashSet<String>,
plugin_result: &mut plugins::AggregatedPluginResult,
) {
let ci_analysis =
scripts::ci::analyze_ci_files(&config.root, bin_map, all_dep_set, all_script_names);
plugin_result
.script_used_packages
.extend(ci_analysis.used_packages);
for entry in &ci_analysis.entry_files {
if let Some(pat) = scripts::normalize_script_entry_pattern("", entry) {
plugin_result
.entry_patterns
.push((plugins::PathRule::new(pat), "scripts".to_string()));
}
}
}
fn discover_all_entry_points(
config: &ResolvedConfig,
files: &[discover::DiscoveredFile],
workspaces: &[fallow_config::WorkspaceInfo],
root_pkg: Option<&PackageJson>,
workspace_pkgs: &[LoadedWorkspacePackage<'_>],
plugin_result: &plugins::AggregatedPluginResult,
) -> discover::CategorizedEntryPoints {
let mut entry_points = discover::CategorizedEntryPoints::default();
let root_discovery = discover::discover_entry_points_with_warnings_from_pkg(
config,
files,
root_pkg,
workspaces.is_empty(),
);
let workspace_pkg_by_root: rustc_hash::FxHashMap<std::path::PathBuf, &PackageJson> =
workspace_pkgs
.iter()
.map(|(ws, pkg)| (ws.root.clone(), pkg))
.collect();
let workspace_discovery: Vec<discover::EntryPointDiscovery> = workspaces
.par_iter()
.map(|ws| {
let pkg = workspace_pkg_by_root.get(&ws.root).copied();
discover::discover_workspace_entry_points_with_warnings_from_pkg(&ws.root, files, pkg)
})
.collect();
let mut skipped_entries = rustc_hash::FxHashMap::default();
entry_points.extend_runtime(root_discovery.entries);
for (path, count) in root_discovery.skipped_entries {
*skipped_entries.entry(path).or_insert(0) += count;
}
let mut ws_entries = Vec::new();
for workspace in workspace_discovery {
ws_entries.extend(workspace.entries);
for (path, count) in workspace.skipped_entries {
*skipped_entries.entry(path).or_insert(0) += count;
}
}
discover::warn_skipped_entry_summary(&skipped_entries);
entry_points.extend_runtime(ws_entries);
let plugin_entries = discover::discover_plugin_entry_point_sets(plugin_result, config, files);
entry_points.extend(plugin_entries);
let infra_entries = discover::discover_infrastructure_entry_points(&config.root);
entry_points.extend_runtime(infra_entries);
if !config.dynamically_loaded.is_empty() {
let dynamic_entries = discover::discover_dynamically_loaded_entry_points(config, files);
entry_points.extend_runtime(dynamic_entries);
}
entry_points.dedup()
}
fn summarize_entry_points(entry_points: &[discover::EntryPoint]) -> results::EntryPointSummary {
let mut counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
for ep in entry_points {
let category = match &ep.source {
discover::EntryPointSource::PackageJsonMain
| discover::EntryPointSource::PackageJsonModule
| discover::EntryPointSource::PackageJsonExports
| discover::EntryPointSource::PackageJsonBin
| discover::EntryPointSource::PackageJsonScript => "package.json",
discover::EntryPointSource::Plugin { .. } => "plugin",
discover::EntryPointSource::TestFile => "test file",
discover::EntryPointSource::DefaultIndex => "default index",
discover::EntryPointSource::ManualEntry => "manual entry",
discover::EntryPointSource::InfrastructureConfig => "config",
discover::EntryPointSource::DynamicallyLoaded => "dynamically loaded",
};
*counts.entry(category.to_string()).or_insert(0) += 1;
}
let mut by_source: Vec<(String, usize)> = counts.into_iter().collect();
by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
results::EntryPointSummary {
total: entry_points.len(),
by_source,
}
}
fn append_package_file_asset_patterns(
result: &mut plugins::AggregatedPluginResult,
prefix: &str,
pkg: &PackageJson,
) {
let prefix = prefix.trim_matches('/');
for pattern in package_assets::scaffold_template_asset_patterns(pkg) {
let pattern = if prefix.is_empty() {
pattern
} else {
format!("{prefix}/{pattern}")
};
result
.discovered_always_used
.push((pattern, package_assets::PACKAGE_FILES_SOURCE.to_string()));
}
}
fn append_workspace_package_file_asset_patterns(
result: &mut plugins::AggregatedPluginResult,
config: &ResolvedConfig,
workspace_pkgs: &[LoadedWorkspacePackage<'_>],
) {
for (ws, ws_pkg) in workspace_pkgs {
let ws_prefix = ws
.root
.strip_prefix(&config.root)
.unwrap_or(&ws.root)
.to_string_lossy()
.replace('\\', "/");
append_package_file_asset_patterns(result, &ws_prefix, ws_pkg);
}
}
fn run_plugins(
config: &ResolvedConfig,
files: &[discover::DiscoveredFile],
workspaces: &[fallow_config::WorkspaceInfo],
root_pkg: Option<&PackageJson>,
workspace_pkgs: &[LoadedWorkspacePackage<'_>],
config_candidates: &[std::path::PathBuf],
) -> Result<plugins::AggregatedPluginResult, FallowError> {
let registry = plugins::PluginRegistry::new(config.external_plugins.clone());
let file_paths: Vec<std::path::PathBuf> = files.iter().map(|f| f.path.clone()).collect();
let candidate_index = (!config.production).then(|| {
plugins::registry::ConfigCandidateIndex::build(
file_paths
.iter()
.map(std::path::PathBuf::as_path)
.chain(config_candidates.iter().map(std::path::PathBuf::as_path)),
)
});
let mut result = run_root_plugins(
®istry,
config,
root_pkg,
&file_paths,
candidate_index.as_ref(),
)?;
if workspaces.is_empty() {
gate_auto_import_entry_patterns(&mut result, config, workspaces);
return Ok(result);
}
append_workspace_package_file_asset_patterns(&mut result, config, workspace_pkgs);
let ws_results = run_workspace_plugins(
®istry,
config,
workspace_pkgs,
&file_paths,
&result.active_plugins,
candidate_index.as_ref(),
);
merge_workspace_plugin_results(&mut result, ws_results)?;
gate_auto_import_entry_patterns(&mut result, config, workspaces);
Ok(result)
}
type WorkspacePluginResult = Result<
(plugins::AggregatedPluginResult, String),
Vec<plugins::registry::PluginRegexValidationError>,
>;
fn run_root_plugins(
registry: &plugins::PluginRegistry,
config: &ResolvedConfig,
root_pkg: Option<&PackageJson>,
file_paths: &[std::path::PathBuf],
candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
) -> Result<plugins::AggregatedPluginResult, FallowError> {
let root_config_search_roots = collect_config_search_roots(&config.root, file_paths);
let root_config_search_root_refs: Vec<&Path> = root_config_search_roots
.iter()
.map(std::path::PathBuf::as_path)
.collect();
let mut result = if let Some(pkg) = root_pkg {
registry
.try_run_with_search_roots(
pkg,
&config.root,
file_paths,
&root_config_search_root_refs,
config.production,
candidate_index,
)
.map_err(|errors| {
FallowError::config(plugins::registry::format_plugin_regex_errors(&errors))
})?
} else {
plugins::AggregatedPluginResult::default()
};
if let Some(pkg) = root_pkg {
append_package_file_asset_patterns(&mut result, "", pkg);
}
Ok(result)
}
fn run_workspace_plugins(
registry: &plugins::PluginRegistry,
config: &ResolvedConfig,
workspace_pkgs: &[LoadedWorkspacePackage<'_>],
file_paths: &[std::path::PathBuf],
root_active_plugins: &[String],
candidate_index: Option<&plugins::registry::ConfigCandidateIndex>,
) -> Vec<WorkspacePluginResult> {
let root_active_plugins: rustc_hash::FxHashSet<&str> =
root_active_plugins.iter().map(String::as_str).collect();
let precompiled_matchers = registry.precompile_config_matchers();
let workspace_relative_files = bucket_files_by_workspace(workspace_pkgs, file_paths);
workspace_pkgs
.par_iter()
.zip(workspace_relative_files.par_iter())
.filter_map(|((ws, ws_pkg), relative_files)| {
let ws_result =
match registry.try_run_workspace_fast(&plugins::registry::WorkspacePluginRunInput {
pkg: ws_pkg,
root: &ws.root,
project_root: &config.root,
precompiled_config_matchers: &precompiled_matchers,
relative_files,
skip_config_plugins: &root_active_plugins,
production_mode: config.production,
candidate_index,
}) {
Ok(result) => result,
Err(errors) => return Some(Err(errors)),
};
if ws_result.active_plugins.is_empty() {
return None;
}
let ws_prefix = ws
.root
.strip_prefix(&config.root)
.unwrap_or(&ws.root)
.to_string_lossy()
.into_owned();
Some(Ok((ws_result, ws_prefix)))
})
.collect::<Vec<_>>()
}
fn merge_workspace_plugin_results(
result: &mut plugins::AggregatedPluginResult,
ws_results: Vec<WorkspacePluginResult>,
) -> Result<(), FallowError> {
let mut regex_errors = Vec::new();
for ws_result in ws_results {
match ws_result {
Ok((mut ws_result, ws_prefix)) => {
ws_result.apply_workspace_prefix(&ws_prefix);
ws_result.config_patterns.clear();
ws_result.script_used_packages.clear();
result.merge_into(ws_result);
}
Err(mut errors) => regex_errors.append(&mut errors),
}
}
if !regex_errors.is_empty() {
return Err(FallowError::config(
plugins::registry::format_plugin_regex_errors(®ex_errors),
));
}
Ok(())
}
fn gate_auto_import_entry_patterns(
result: &mut plugins::AggregatedPluginResult,
config: &ResolvedConfig,
workspaces: &[fallow_config::WorkspaceInfo],
) {
if !config.auto_imports {
return;
}
if !result.active_plugins.iter().any(|name| name == "nuxt") {
return;
}
let components_custom = plugins::nuxt::config_declares_components(&config.root)
|| workspaces
.iter()
.any(|ws| plugins::nuxt::config_declares_components(&ws.root));
let imports_custom = plugins::nuxt::config_declares_imports(&config.root)
|| workspaces
.iter()
.any(|ws| plugins::nuxt::config_declares_imports(&ws.root));
result.entry_patterns.retain(|(rule, plugin)| {
if plugin != "nuxt" {
return true;
}
if !components_custom && plugins::nuxt::is_component_entry_pattern(&rule.pattern) {
return false;
}
if !imports_custom && plugins::nuxt::is_script_auto_import_entry_pattern(&rule.pattern) {
return false;
}
true
});
}
fn bucket_files_by_workspace(
workspace_pkgs: &[LoadedWorkspacePackage<'_>],
file_paths: &[std::path::PathBuf],
) -> Vec<Vec<(std::path::PathBuf, String)>> {
use rayon::prelude::*;
let assignments: Vec<Option<(usize, std::path::PathBuf, String)>> = file_paths
.par_iter()
.map(|file_path| {
workspace_pkgs
.iter()
.enumerate()
.find_map(|(idx, (ws, _))| {
file_path.strip_prefix(&ws.root).ok().map(|relative| {
(
idx,
file_path.clone(),
relative.to_string_lossy().into_owned(),
)
})
})
})
.collect();
let mut buckets = vec![Vec::new(); workspace_pkgs.len()];
for (idx, file_path, relative) in assignments.into_iter().flatten() {
buckets[idx].push((file_path, relative));
}
buckets
}
fn collect_config_search_roots(
root: &Path,
file_paths: &[std::path::PathBuf],
) -> Vec<std::path::PathBuf> {
let mut roots: rustc_hash::FxHashSet<std::path::PathBuf> = rustc_hash::FxHashSet::default();
roots.insert(root.to_path_buf());
for file_path in file_paths {
let mut current = file_path.parent();
while let Some(dir) = current {
if !dir.starts_with(root) {
break;
}
roots.insert(dir.to_path_buf());
if dir == root {
break;
}
current = dir.parent();
}
}
let mut roots_vec: Vec<_> = roots.into_iter().collect();
roots_vec.sort();
roots_vec
}
pub(crate) fn config_for_project(
root: &Path,
config_path: Option<&Path>,
) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
let user_config = if let Some(path) = config_path {
Some((
fallow_config::FallowConfig::load(path)
.map_err(|e| FallowError::config(format!("{e:#}")))?,
path.to_path_buf(),
))
} else {
fallow_config::FallowConfig::find_and_load(root).map_err(FallowError::config)?
};
let config = match user_config {
Some((config, path)) => resolve_user_config(config, path, root)?,
None => (
fallow_config::FallowConfig::default().resolve(
root.to_path_buf(),
fallow_config::OutputFormat::Human,
num_cpus(),
false,
true,
None,
),
None,
),
};
Ok(config)
}
fn resolve_user_config(
mut config: fallow_config::FallowConfig,
path: std::path::PathBuf,
root: &Path,
) -> Result<(ResolvedConfig, Option<std::path::PathBuf>), FallowError> {
let dead_code_production = config
.production
.for_analysis(fallow_config::ProductionAnalysis::DeadCode);
config.production = dead_code_production.into();
config
.validate_resolved_boundaries(root)
.map_err(|errors| {
let joined = errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n - ");
FallowError::config(format!("invalid boundary configuration:\n - {joined}"))
})?;
let packs = fallow_config::load_rule_packs(root, &config.rule_packs).map_err(|errors| {
let joined = errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n - ");
FallowError::config(format!("invalid rule pack:\n - {joined}"))
})?;
let boundaries =
fallow_config::resolve_boundaries_for_rule_pack_validation(config.boundaries.clone(), root);
let zone_errors = fallow_config::validate_rule_pack_zone_references(
root,
&config.rule_packs,
&packs,
&boundaries,
);
if !zone_errors.is_empty() {
let joined = zone_errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n - ");
return Err(FallowError::config(format!(
"invalid rule pack:\n - {joined}"
)));
}
Ok((
config.resolve(
root.to_path_buf(),
fallow_config::OutputFormat::Human,
num_cpus(),
false,
true, None, ),
Some(path),
))
}
pub(crate) fn default_config(root: &Path) -> ResolvedConfig {
config_for_project(root, None).map_or_else(
|_| {
fallow_config::FallowConfig::default().resolve(
root.to_path_buf(),
fallow_config::OutputFormat::Human,
num_cpus(),
false,
true,
None,
)
},
|(config, _)| config,
)
}
fn num_cpus() -> usize {
std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
}
#[cfg(test)]
mod tests {
use super::{
AnalysisSession, bucket_files_by_workspace, collect_config_search_roots, default_config,
format_undeclared_workspace_warning, plugin_config_hash, resolver_options_hash,
warn_undeclared_workspaces,
};
use std::path::{Path, PathBuf};
use fallow_config::{
AutoImportKind, AutoImportRule, WorkspaceDiagnostic, WorkspaceDiagnosticKind,
};
fn plugin_result() -> crate::plugins::AggregatedPluginResult {
let mut result = crate::plugins::AggregatedPluginResult::default();
result.active_plugins.push("nuxt".to_string());
result
.path_aliases
.push(("@/".to_string(), "src/".to_string()));
result
}
#[test]
fn graph_cache_resolver_hash_includes_project_root() {
let dir_a = tempfile::tempdir().expect("create temp dir a");
let dir_b = tempfile::tempdir().expect("create temp dir b");
let config_a = session_config(dir_a.path());
let config_b = session_config(dir_b.path());
assert_ne!(
resolver_options_hash(&config_a),
resolver_options_hash(&config_b),
"shared cache dirs must not reuse graphs across project roots"
);
}
#[test]
fn graph_cache_resolver_hash_includes_resolve_conditions() {
let dir = tempfile::tempdir().expect("create temp dir");
let config_a = session_config(dir.path());
let mut config_b = session_config(dir.path());
config_b.resolve.conditions.push("react-server".to_string());
assert_ne!(
resolver_options_hash(&config_a),
resolver_options_hash(&config_b),
"resolve condition changes must invalidate the graph cache"
);
}
#[test]
fn graph_cache_plugin_hash_includes_auto_imports() {
let mut without_auto_import = plugin_result();
let mut with_auto_import = plugin_result();
with_auto_import.auto_imports.push(AutoImportRule {
name: "useCounter".to_string(),
source: PathBuf::from("/project/composables/useCounter.ts"),
kind: AutoImportKind::Named,
});
assert_ne!(
plugin_config_hash(&without_auto_import),
plugin_config_hash(&with_auto_import),
"auto-import edge changes must invalidate the graph cache"
);
without_auto_import.auto_imports.push(AutoImportRule {
name: "useCounter".to_string(),
source: PathBuf::from("/project/composables/useCounter.ts"),
kind: AutoImportKind::Default,
});
assert_ne!(
plugin_config_hash(&without_auto_import),
plugin_config_hash(&with_auto_import),
"auto-import kind changes must invalidate the graph cache"
);
}
#[test]
fn graph_cache_plugin_hash_includes_style_and_static_mappings() {
let base = plugin_result();
let mut with_scss = base.clone();
with_scss
.scss_include_paths
.push(PathBuf::from("/project/styles"));
assert_ne!(
plugin_config_hash(&base),
plugin_config_hash(&with_scss),
"SCSS include path changes must invalidate the graph cache"
);
let mut with_static_dir = base.clone();
with_static_dir
.static_dir_mappings
.push((PathBuf::from("/project/public"), "/".to_string()));
assert_ne!(
plugin_config_hash(&base),
plugin_config_hash(&with_static_dir),
"static directory mapping changes must invalidate the graph cache"
);
}
fn diag(root: &Path, relative: &str) -> WorkspaceDiagnostic {
WorkspaceDiagnostic::new(
root,
root.join(relative),
WorkspaceDiagnosticKind::UndeclaredWorkspace,
)
}
fn session_config(root: &Path) -> fallow_config::ResolvedConfig {
let mut config = default_config(root);
config.no_cache = true;
config.quiet = true;
config
}
fn write_session_fixture(root: &Path) {
let src = root.join("src");
std::fs::create_dir_all(&src).expect("create src");
std::fs::write(
root.join("package.json"),
r#"{"name":"session-fixture","type":"module"}"#,
)
.expect("write package json");
std::fs::write(
src.join("index.ts"),
"import { used } from './used';\nconsole.log(used);\n",
)
.expect("write index");
std::fs::write(src.join("used.ts"), "export const used = 1;\n").expect("write used");
}
#[test]
fn analysis_session_discovers_project_files() {
let dir = tempfile::tempdir().expect("create temp dir");
write_session_fixture(dir.path());
let config = session_config(dir.path());
let session = AnalysisSession::new(&config);
assert!(
session
.files()
.iter()
.any(|file| file.path.ends_with("src/index.ts")),
"session should own discovered project files"
);
assert_eq!(session.workspaces().len(), 0);
}
#[test]
fn analysis_session_parses_owned_modules() {
let dir = tempfile::tempdir().expect("create temp dir");
write_session_fixture(dir.path());
let config = session_config(dir.path());
let session = AnalysisSession::new(&config);
let parsed = session.parse_modules(false);
assert!(
parsed
.modules
.iter()
.any(|module| session.files()[module.file_id.0 as usize]
.path
.ends_with("src/index.ts")),
"session parsing should return modules keyed to session files"
);
}
#[test]
fn undeclared_workspace_warning_is_singular_for_one_path() {
let root = Path::new("/repo");
let warning = format_undeclared_workspace_warning(root, &[diag(root, "packages/api")])
.expect("warning should be rendered");
assert_eq!(
warning,
"1 directory with package.json is not declared as a workspace: packages/api. Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
);
}
#[test]
fn undeclared_workspace_warning_summarizes_many_paths() {
let root = PathBuf::from("/repo");
let diagnostics = [
"examples/a",
"examples/b",
"examples/c",
"examples/d",
"examples/e",
"examples/f",
]
.into_iter()
.map(|path| diag(&root, path))
.collect::<Vec<_>>();
let warning = format_undeclared_workspace_warning(&root, &diagnostics)
.expect("warning should be rendered");
assert_eq!(
warning,
"6 directories with package.json are not declared as workspaces: examples/a, examples/b, examples/c, examples/d, examples/e (and 1 more). Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
);
}
#[test]
fn collect_config_search_roots_includes_file_ancestors_once() {
let root = PathBuf::from("/repo");
let search_roots = collect_config_search_roots(
&root,
&[
root.join("apps/query/src/main.ts"),
root.join("packages/shared/lib/index.ts"),
],
);
assert_eq!(
search_roots,
vec![
root.clone(),
root.join("apps"),
root.join("apps/query"),
root.join("apps/query/src"),
root.join("packages"),
root.join("packages/shared"),
root.join("packages/shared/lib"),
]
);
}
#[test]
fn bucket_files_by_workspace_uses_workspace_relative_paths() {
let root = PathBuf::from("/repo");
let ui = fallow_config::WorkspaceInfo {
root: root.join("apps/ui"),
name: "ui".to_string(),
is_internal_dependency: false,
};
let api = fallow_config::WorkspaceInfo {
root: root.join("apps/api"),
name: "api".to_string(),
is_internal_dependency: false,
};
let workspace_pkgs = vec![
(
&ui,
fallow_config::PackageJson {
name: Some("ui".to_string()),
..Default::default()
},
),
(
&api,
fallow_config::PackageJson {
name: Some("api".to_string()),
..Default::default()
},
),
];
let files = vec![
root.join("apps/ui/vite.config.ts"),
root.join("apps/ui/src/main.ts"),
root.join("apps/api/src/server.ts"),
root.join("tools/build.ts"),
];
let buckets = bucket_files_by_workspace(&workspace_pkgs, &files);
assert_eq!(
buckets[0],
vec![
(
root.join("apps/ui/vite.config.ts"),
"vite.config.ts".to_string()
),
(root.join("apps/ui/src/main.ts"), "src/main.ts".to_string()),
]
);
assert_eq!(
buckets[1],
vec![(
root.join("apps/api/src/server.ts"),
"src/server.ts".to_string()
)]
);
}
#[test]
fn warn_undeclared_workspaces_suppresses_paths_already_flagged_as_malformed() {
let dir = tempfile::tempdir().expect("create temp dir");
let pkg_good = dir.path().join("packages").join("good");
let pkg_bad = dir.path().join("packages").join("bad");
std::fs::create_dir_all(&pkg_good).unwrap();
std::fs::create_dir_all(&pkg_bad).unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"workspaces": ["packages/*"]}"#,
)
.unwrap();
std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
let (workspaces, diagnostics) = fallow_config::discover_workspaces_with_diagnostics(
dir.path(),
&globset::GlobSet::empty(),
)
.expect("root package.json is valid");
assert_eq!(workspaces.len(), 1, "only the valid workspace discovers");
fallow_config::stash_workspace_diagnostics(dir.path(), diagnostics);
warn_undeclared_workspaces(dir.path(), &workspaces, &globset::GlobSet::empty(), false);
let diagnostics = fallow_config::workspace_diagnostics_for(dir.path());
let mut malformed = 0;
let mut undeclared_for_bad = 0;
for diag in &diagnostics {
if matches!(
diag.kind,
WorkspaceDiagnosticKind::MalformedPackageJson { .. }
) && diag.path.ends_with("bad")
{
malformed += 1;
}
if matches!(diag.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)
&& diag.path.ends_with("bad")
{
undeclared_for_bad += 1;
}
}
assert_eq!(
malformed, 1,
"expected one MalformedPackageJson for packages/bad: {diagnostics:?}"
);
assert_eq!(
undeclared_for_bad, 0,
"warn_undeclared_workspaces must NOT re-flag a path that already \
carries MalformedPackageJson; got duplicates: {diagnostics:?}"
);
}
}