use fallow_config::{ResolvedConfig, WorkspaceInfo};
use crate::{
discover::{DiscoveredFile, EntryPoint},
plugins::AggregatedPluginResult,
session::AnalysisSession,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ListInventoryError {
Plugins(String),
}
impl ListInventoryError {
#[must_use]
pub fn message(&self) -> &str {
match self {
Self::Plugins(message) => message,
}
}
}
#[derive(Debug, Clone)]
pub struct ListingInventory {
pub plugins: AggregatedPluginResult,
pub entry_points: Option<Vec<EntryPoint>>,
}
pub fn collect_listing_inventory(
session: &AnalysisSession,
with_entry_points: bool,
) -> Result<ListingInventory, ListInventoryError> {
let prelude = crate::core_backend::prepare_dead_code_backend_prelude(
session.config(),
session.discovery(),
)
.map_err(|err| ListInventoryError::Plugins(err.message().to_owned()))?;
let entry_points = with_entry_points.then(|| {
crate::core_backend::discover_dead_code_entry_points(&prelude)
.all()
.to_vec()
});
let plugins = AggregatedPluginResult::from(prelude.plugin_result());
prelude.finish();
Ok(ListingInventory {
plugins,
entry_points,
})
}
#[must_use]
pub fn collect_entry_points(
config: &ResolvedConfig,
discovered: &[DiscoveredFile],
workspaces: &[WorkspaceInfo],
plugin_result: Option<&AggregatedPluginResult>,
) -> Vec<EntryPoint> {
let mut entries = crate::discover::discover_entry_points(config, discovered, workspaces);
if let Some(plugin_result) = plugin_result {
entries.extend(crate::discover::discover_plugin_entry_points(
plugin_result,
config,
discovered,
));
}
entries
}
#[cfg(test)]
mod tests {
use std::path::Path;
use fallow_config::{FallowConfig, WorkspaceInfo};
use fallow_types::output_format::OutputFormat;
use super::*;
use crate::discover::{EntryPointSource, FileId};
#[test]
fn entry_points_include_root_and_workspace_entries() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
let config = FallowConfig::default().resolve(
root.to_path_buf(),
OutputFormat::Json,
1,
false,
true,
None,
);
let workspace = WorkspaceInfo {
root: root.join("packages/web"),
name: "web".to_owned(),
is_internal_dependency: false,
};
let discovered = vec![
DiscoveredFile {
id: FileId(0),
path: root.join("src/main.ts"),
size_bytes: 0,
},
DiscoveredFile {
id: FileId(1),
path: root.join("packages/web/src/index.ts"),
size_bytes: 0,
},
];
let entries = collect_entry_points(&config, &discovered, &[workspace], None);
assert!(
entries
.iter()
.any(|entry| entry.path.ends_with("src/main.ts"))
);
assert!(
entries
.iter()
.any(|entry| entry.path.ends_with("packages/web/src/index.ts"))
);
}
fn session_at(root: &Path) -> AnalysisSession {
let config = FallowConfig::default().resolve(
root.to_path_buf(),
OutputFormat::Json,
1,
true,
true,
None,
);
AnalysisSession::from_resolved_config(config).expect("session")
}
fn write(root: &Path, file: &str, source: &str) {
let path = root.join(file);
std::fs::create_dir_all(path.parent().expect("parent")).expect("dirs");
std::fs::write(path, source).expect("write");
}
#[test]
fn collected_entry_points_match_the_analysis_for_package_selections() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path();
write(
root,
"package.json",
r#"{
"name": "monorepo",
"private": true,
"workspaces": ["packages/*"],
"scripts": {
"start": "yarn workspace web node scripts/root-start.ts",
"gen": "pnpm -r exec tsx scripts/gen.ts",
"all": "yarn workspaces foreach -A exec node scripts/all.ts",
"serve": "pnpm -C packages/api run serve"
}
}"#,
);
write(
root,
"packages/web/package.json",
r#"{"name":"web","main":"src/index.ts"}"#,
);
write(
root,
"packages/api/package.json",
r#"{"name":"api","main":"src/index.ts","scripts":{"serve":"node src/server.ts"}}"#,
);
for file in [
"scripts/all.ts",
"packages/web/src/index.ts",
"packages/web/scripts/root-start.ts",
"packages/web/scripts/gen.ts",
"packages/api/src/index.ts",
"packages/api/src/server.ts",
] {
write(root, file, "export const x = 1;\n");
}
let session = session_at(root);
let inventory = collect_listing_inventory(&session, true).expect("inventory");
let relative = |entries: &[EntryPoint]| {
let mut paths: Vec<String> = entries
.iter()
.map(|entry| {
entry
.path
.strip_prefix(&session.config().root)
.expect("under root")
.to_string_lossy()
.replace('\\', "/")
})
.collect();
paths.sort();
paths.dedup();
paths
};
let analysis_entries = inventory.entry_points.expect("entry points");
let analysis = relative(&analysis_entries);
let analysis_packages: Vec<EntryPoint> = analysis_entries
.into_iter()
.filter(|entry| !matches!(entry.source, EntryPointSource::Plugin { .. }))
.collect();
let analysis_packages = relative(&analysis_packages);
let collected = relative(&collect_entry_points(
session.config(),
session.files(),
session.workspaces(),
None,
));
for selected in [
"packages/web/scripts/root-start.ts",
"packages/web/scripts/gen.ts",
"scripts/all.ts",
"packages/api/src/server.ts",
] {
assert!(
collected.iter().any(|path| path == selected),
"{selected} is missing from {collected:?}"
);
}
assert_eq!(collected, analysis_packages);
assert_eq!(
relative(&collect_entry_points(
session.config(),
session.files(),
session.workspaces(),
Some(&inventory.plugins),
)),
analysis
);
}
#[test]
fn active_plugins_ignores_missing_package_manifests() {
let temp = tempfile::tempdir().expect("tempdir");
let session = session_at(temp.path());
let inventory =
collect_listing_inventory(&session, true).expect("missing package should not fail");
assert!(inventory.plugins.active_plugins().is_empty());
let plugins_only =
collect_listing_inventory(&session, false).expect("missing package should not fail");
assert!(
plugins_only.entry_points.is_none(),
"a plugins-only listing skips entry-point discovery"
);
}
#[test]
fn entry_points_accept_plugin_result() {
let temp = tempfile::tempdir().expect("tempdir");
std::fs::write(
temp.path().join("package.json"),
r#"{"dependencies":{"next":"15.0.0"}}"#,
)
.expect("package manifest");
for file in ["src/app/dashboard/page.tsx", "src/helpers/format.ts"] {
let path = temp.path().join(file);
std::fs::create_dir_all(path.parent().expect("parent")).expect("dirs");
std::fs::write(path, "export const x = 1;\n").expect("source");
}
let session = session_at(temp.path());
let config = session.config();
let discovered = session.files();
let inventory = collect_listing_inventory(&session, true).expect("Next.js plugins load");
assert!(
inventory
.entry_points
.as_deref()
.unwrap_or_default()
.iter()
.any(|entry| entry.path.ends_with("src/app/dashboard/page.tsx")),
"the listing inventory carries the analysis entry points"
);
let plugin_result = inventory.plugins;
let entries = collect_entry_points(config, discovered, &[], None);
assert!(
entries
.iter()
.all(|entry| !matches!(entry.source, EntryPointSource::Plugin { .. }))
);
let entries = collect_entry_points(config, discovered, &[], Some(&plugin_result));
let plugin_entries: Vec<_> = entries
.iter()
.filter_map(|entry| match &entry.source {
EntryPointSource::Plugin { name } => Some((
entry.path.strip_prefix(&config.root).expect("under root"),
name.as_str(),
)),
_ => None,
})
.collect();
assert_eq!(
plugin_entries,
vec![(Path::new("src/app/dashboard/page.tsx"), "nextjs")]
);
}
}