fallow-engine 3.31.0

Typed analysis engine facade for fallow consumers
Documentation
//! Engine-owned inventory helpers for list-style project metadata.

use fallow_config::{ResolvedConfig, WorkspaceInfo};

use crate::{
    discover::{DiscoveredFile, EntryPoint},
    plugins::AggregatedPluginResult,
    session::AnalysisSession,
};

/// Error raised while assembling list inventory.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ListInventoryError {
    /// The plugin stage failed, for example on an invalid user-authored plugin
    /// regex. Carries the message the analysis reports for the same failure.
    Plugins(String),
}

impl ListInventoryError {
    /// The user-facing message.
    #[must_use]
    pub fn message(&self) -> &str {
        match self {
            Self::Plugins(message) => message,
        }
    }
}

/// The plugins and entry points of a project, as the analysis sees them.
#[derive(Debug, Clone)]
pub struct ListingInventory {
    /// The plugin stage's result: active plugins and plugin diagnostics.
    pub plugins: AggregatedPluginResult,
    /// Every entry point the analysis uses, deduplicated. `None` when the
    /// caller did not ask for entry points, so their discovery did not run.
    pub entry_points: Option<Vec<EntryPoint>>,
}

/// Run the analysis prelude (plugins and scripts) and its entry-point
/// discovery over the session's whole discovery.
///
/// One implementation for the listing and the analysis: the workspace merge,
/// the auto-import gate, the script-derived entries and the plugin diagnostics
/// are the same, so `fallow list --entry-points` names the entry points the
/// analysis uses, and the listing can report the `plugin-config-unreadable`
/// and `plugin-effect-not-modeled` diagnostics the analysis reports (issue
/// #2804). A path or changed-file scope narrows what a listing shows, never
/// which plugins are active, so the caller filters the result afterwards.
///
/// `with_entry_points` false skips the entry-point discovery, for a listing
/// of plugins only.
///
/// # Errors
///
/// Returns the plugin stage's error, such as an invalid plugin regex.
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,
    })
}

/// Collect root, workspace, and plugin entry points in one engine-owned pass.
#[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");
    }

    /// The type-aware entry points and the analysis entry points agree on the
    /// files that a package selection runs: by name, by directory, in every
    /// workspace package, and in the root workspace.
    #[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);

        // The type-aware path collects the package entries without plugins.
        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")]
        );
    }
}