Skip to main content

fallow_engine/
list_inventory.rs

1//! Engine-owned inventory helpers for list-style project metadata.
2
3use fallow_config::{ResolvedConfig, WorkspaceInfo};
4
5use crate::{
6    discover::{DiscoveredFile, EntryPoint},
7    plugins::AggregatedPluginResult,
8    session::AnalysisSession,
9};
10
11/// Error raised while assembling list inventory.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum ListInventoryError {
14    /// The plugin stage failed, for example on an invalid user-authored plugin
15    /// regex. Carries the message the analysis reports for the same failure.
16    Plugins(String),
17}
18
19impl ListInventoryError {
20    /// The user-facing message.
21    #[must_use]
22    pub fn message(&self) -> &str {
23        match self {
24            Self::Plugins(message) => message,
25        }
26    }
27}
28
29/// The plugins and entry points of a project, as the analysis sees them.
30#[derive(Debug, Clone)]
31pub struct ListingInventory {
32    /// The plugin stage's result: active plugins and plugin diagnostics.
33    pub plugins: AggregatedPluginResult,
34    /// Every entry point the analysis uses, deduplicated. `None` when the
35    /// caller did not ask for entry points, so their discovery did not run.
36    pub entry_points: Option<Vec<EntryPoint>>,
37}
38
39/// Run the analysis prelude (plugins and scripts) and its entry-point
40/// discovery over the session's whole discovery.
41///
42/// One implementation for the listing and the analysis: the workspace merge,
43/// the auto-import gate, the script-derived entries and the plugin diagnostics
44/// are the same, so `fallow list --entry-points` names the entry points the
45/// analysis uses, and the listing can report the `plugin-config-unreadable`
46/// and `plugin-effect-not-modeled` diagnostics the analysis reports (issue
47/// #2804). A path or changed-file scope narrows what a listing shows, never
48/// which plugins are active, so the caller filters the result afterwards.
49///
50/// `with_entry_points` false skips the entry-point discovery, for a listing
51/// of plugins only.
52///
53/// # Errors
54///
55/// Returns the plugin stage's error, such as an invalid plugin regex.
56pub fn collect_listing_inventory(
57    session: &AnalysisSession,
58    with_entry_points: bool,
59) -> Result<ListingInventory, ListInventoryError> {
60    let prelude = crate::core_backend::prepare_dead_code_backend_prelude(
61        session.config(),
62        session.discovery(),
63    )
64    .map_err(|err| ListInventoryError::Plugins(err.message().to_owned()))?;
65    let entry_points = with_entry_points.then(|| {
66        crate::core_backend::discover_dead_code_entry_points(&prelude)
67            .all()
68            .to_vec()
69    });
70    let plugins = AggregatedPluginResult::from(prelude.plugin_result());
71    prelude.finish();
72    Ok(ListingInventory {
73        plugins,
74        entry_points,
75    })
76}
77
78/// Collect root, workspace, and plugin entry points in one engine-owned pass.
79#[must_use]
80pub fn collect_entry_points(
81    config: &ResolvedConfig,
82    discovered: &[DiscoveredFile],
83    workspaces: &[WorkspaceInfo],
84    plugin_result: Option<&AggregatedPluginResult>,
85) -> Vec<EntryPoint> {
86    let mut entries = crate::discover::discover_entry_points(config, discovered, workspaces);
87    if let Some(plugin_result) = plugin_result {
88        entries.extend(crate::discover::discover_plugin_entry_points(
89            plugin_result,
90            config,
91            discovered,
92        ));
93    }
94    entries
95}
96
97#[cfg(test)]
98mod tests {
99    use std::path::Path;
100
101    use fallow_config::{FallowConfig, WorkspaceInfo};
102    use fallow_types::output_format::OutputFormat;
103
104    use super::*;
105    use crate::discover::{EntryPointSource, FileId};
106
107    #[test]
108    fn entry_points_include_root_and_workspace_entries() {
109        let temp = tempfile::tempdir().expect("tempdir");
110        let root = temp.path();
111        let config = FallowConfig::default().resolve(
112            root.to_path_buf(),
113            OutputFormat::Json,
114            1,
115            false,
116            true,
117            None,
118        );
119        let workspace = WorkspaceInfo {
120            root: root.join("packages/web"),
121            name: "web".to_owned(),
122            is_internal_dependency: false,
123        };
124        let discovered = vec![
125            DiscoveredFile {
126                id: FileId(0),
127                path: root.join("src/main.ts"),
128                size_bytes: 0,
129            },
130            DiscoveredFile {
131                id: FileId(1),
132                path: root.join("packages/web/src/index.ts"),
133                size_bytes: 0,
134            },
135        ];
136
137        let entries = collect_entry_points(&config, &discovered, &[workspace], None);
138
139        assert!(
140            entries
141                .iter()
142                .any(|entry| entry.path.ends_with("src/main.ts"))
143        );
144        assert!(
145            entries
146                .iter()
147                .any(|entry| entry.path.ends_with("packages/web/src/index.ts"))
148        );
149    }
150
151    fn session_at(root: &Path) -> AnalysisSession {
152        let config = FallowConfig::default().resolve(
153            root.to_path_buf(),
154            OutputFormat::Json,
155            1,
156            true,
157            true,
158            None,
159        );
160        AnalysisSession::from_resolved_config(config).expect("session")
161    }
162
163    fn write(root: &Path, file: &str, source: &str) {
164        let path = root.join(file);
165        std::fs::create_dir_all(path.parent().expect("parent")).expect("dirs");
166        std::fs::write(path, source).expect("write");
167    }
168
169    /// The type-aware entry points and the analysis entry points agree on the
170    /// files that a package selection runs: by name, by directory, in every
171    /// workspace package, and in the root workspace.
172    #[test]
173    fn collected_entry_points_match_the_analysis_for_package_selections() {
174        let temp = tempfile::tempdir().expect("tempdir");
175        let root = temp.path();
176        write(
177            root,
178            "package.json",
179            r#"{
180                "name": "monorepo",
181                "private": true,
182                "workspaces": ["packages/*"],
183                "scripts": {
184                    "start": "yarn workspace web node scripts/root-start.ts",
185                    "gen": "pnpm -r exec tsx scripts/gen.ts",
186                    "all": "yarn workspaces foreach -A exec node scripts/all.ts",
187                    "serve": "pnpm -C packages/api run serve"
188                }
189            }"#,
190        );
191        write(
192            root,
193            "packages/web/package.json",
194            r#"{"name":"web","main":"src/index.ts"}"#,
195        );
196        write(
197            root,
198            "packages/api/package.json",
199            r#"{"name":"api","main":"src/index.ts","scripts":{"serve":"node src/server.ts"}}"#,
200        );
201        for file in [
202            "scripts/all.ts",
203            "packages/web/src/index.ts",
204            "packages/web/scripts/root-start.ts",
205            "packages/web/scripts/gen.ts",
206            "packages/api/src/index.ts",
207            "packages/api/src/server.ts",
208        ] {
209            write(root, file, "export const x = 1;\n");
210        }
211        let session = session_at(root);
212        let inventory = collect_listing_inventory(&session, true).expect("inventory");
213        let relative = |entries: &[EntryPoint]| {
214            let mut paths: Vec<String> = entries
215                .iter()
216                .map(|entry| {
217                    entry
218                        .path
219                        .strip_prefix(&session.config().root)
220                        .expect("under root")
221                        .to_string_lossy()
222                        .replace('\\', "/")
223                })
224                .collect();
225            paths.sort();
226            paths.dedup();
227            paths
228        };
229        let analysis_entries = inventory.entry_points.expect("entry points");
230        let analysis = relative(&analysis_entries);
231        let analysis_packages: Vec<EntryPoint> = analysis_entries
232            .into_iter()
233            .filter(|entry| !matches!(entry.source, EntryPointSource::Plugin { .. }))
234            .collect();
235        let analysis_packages = relative(&analysis_packages);
236
237        // The type-aware path collects the package entries without plugins.
238        let collected = relative(&collect_entry_points(
239            session.config(),
240            session.files(),
241            session.workspaces(),
242            None,
243        ));
244
245        for selected in [
246            "packages/web/scripts/root-start.ts",
247            "packages/web/scripts/gen.ts",
248            "scripts/all.ts",
249            "packages/api/src/server.ts",
250        ] {
251            assert!(
252                collected.iter().any(|path| path == selected),
253                "{selected} is missing from {collected:?}"
254            );
255        }
256        assert_eq!(collected, analysis_packages);
257        assert_eq!(
258            relative(&collect_entry_points(
259                session.config(),
260                session.files(),
261                session.workspaces(),
262                Some(&inventory.plugins),
263            )),
264            analysis
265        );
266    }
267
268    #[test]
269    fn active_plugins_ignores_missing_package_manifests() {
270        let temp = tempfile::tempdir().expect("tempdir");
271        let session = session_at(temp.path());
272        let inventory =
273            collect_listing_inventory(&session, true).expect("missing package should not fail");
274
275        assert!(inventory.plugins.active_plugins().is_empty());
276        let plugins_only =
277            collect_listing_inventory(&session, false).expect("missing package should not fail");
278        assert!(
279            plugins_only.entry_points.is_none(),
280            "a plugins-only listing skips entry-point discovery"
281        );
282    }
283
284    #[test]
285    fn entry_points_accept_plugin_result() {
286        let temp = tempfile::tempdir().expect("tempdir");
287        std::fs::write(
288            temp.path().join("package.json"),
289            r#"{"dependencies":{"next":"15.0.0"}}"#,
290        )
291        .expect("package manifest");
292        for file in ["src/app/dashboard/page.tsx", "src/helpers/format.ts"] {
293            let path = temp.path().join(file);
294            std::fs::create_dir_all(path.parent().expect("parent")).expect("dirs");
295            std::fs::write(path, "export const x = 1;\n").expect("source");
296        }
297        let session = session_at(temp.path());
298        let config = session.config();
299        let discovered = session.files();
300        let inventory = collect_listing_inventory(&session, true).expect("Next.js plugins load");
301        assert!(
302            inventory
303                .entry_points
304                .as_deref()
305                .unwrap_or_default()
306                .iter()
307                .any(|entry| entry.path.ends_with("src/app/dashboard/page.tsx")),
308            "the listing inventory carries the analysis entry points"
309        );
310        let plugin_result = inventory.plugins;
311
312        let entries = collect_entry_points(config, discovered, &[], None);
313
314        assert!(
315            entries
316                .iter()
317                .all(|entry| !matches!(entry.source, EntryPointSource::Plugin { .. }))
318        );
319
320        let entries = collect_entry_points(config, discovered, &[], Some(&plugin_result));
321        let plugin_entries: Vec<_> = entries
322            .iter()
323            .filter_map(|entry| match &entry.source {
324                EntryPointSource::Plugin { name } => Some((
325                    entry.path.strip_prefix(&config.root).expect("under root"),
326                    name.as_str(),
327                )),
328                _ => None,
329            })
330            .collect();
331        assert_eq!(
332            plugin_entries,
333            vec![(Path::new("src/app/dashboard/page.tsx"), "nextjs")]
334        );
335    }
336}