Skip to main content

vivacity_core/
scope.rs

1//! Out-of-scope detector: decides, BEFORE touching the disk, whether vivacity
2//! can install this lock natively or must delegate to `composer install`
3//! (default fallback) / fail explicitly (when Composer is not available).
4//!
5//! Principle (plan r1/F3-F5): never a silently divergent vendor/. An unknown
6//! plugin, or one that changes the layout, is out of scope. Plugins proven
7//! harmless at boot (fixture qualification) are installed like ordinary
8//! libraries, with a warning.
9
10use crate::layout::Layout;
11use crate::lock::{DistKind, Lock, LockPackage};
12use serde_json::Value;
13use std::path::Path;
14
15/// Plugins emulated natively by vivacity (identical output, drift test).
16/// composer/installers (see `layout`) is, under conditions checked before
17/// any write. drupal/core-composer-scaffold is deliberately absent: its
18/// source is GPL-2.0-or-later and cannot be ported here (NOTICE.md).
19pub const EMULATED_PLUGINS: &[&str] = &["symfony/runtime", "composer/installers"];
20
21/// Plugins whose inaction is proven to have no effect on the vendor/ content
22/// needed at boot (fixtures qualified with `--no-plugins`). Installed as
23/// libraries, reported with a warning.
24pub const BENIGN_PLUGINS: &[&str] = &[
25    "symfony/flex",
26    "composer/package-versions-deprecated",
27    "php-http/discovery",
28    "dealerdirect/phpcodesniffer-composer-installer",
29    "phpstan/extension-installer",
30    "rector/extension-installer",
31    "pestphp/pest-plugin",
32    // Only listens to POST_CREATE_PROJECT_CMD / POST_INSTALL_CMD to print a
33    // message (MessagePlugin::getSubscribedEvents): no disk effect.
34    "drupal/core-project-message",
35    // Only listens to POST_UPDATE_CMD / POST_CREATE_PROJECT_CMD, and only acts
36    // in a `require` context (Plugin::getSubscribedEvents): inert at install.
37    "drupal/core-recipe-unpack",
38];
39
40/// Plugins known to change the install layout or the package contents:
41/// always out of scope.
42pub const LAYOUT_PLUGINS: &[&str] = &[
43    "cweagans/composer-patches",
44    "oomphinc/composer-installers-extender",
45    "mnsami/composer-custom-directory-installer",
46];
47
48#[derive(Debug, PartialEq, Eq)]
49pub enum ScopeIssue {
50    /// Plugin absent from the known lists: unpredictable behaviour.
51    UnknownPlugin(String),
52    /// Plugin known to change the layout (patches, installers-extender...).
53    LayoutPlugin(String),
54    /// Non-reproducible layout (composer/installers: version not ported,
55    /// framework with custom logic, refused target...).
56    Layout(String),
57    /// Package without a usable zip dist (source-only, exotic dist).
58    NoUsableDist(String),
59}
60
61impl std::fmt::Display for ScopeIssue {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            ScopeIssue::UnknownPlugin(p) => {
65                write!(f, "plugin {p} is not on vivacity's known-plugin list")
66            }
67            ScopeIssue::LayoutPlugin(p) => {
68                write!(f, "plugin {p} changes the install layout (not emulated)")
69            }
70            ScopeIssue::Layout(why) => write!(f, "{why}"),
71            ScopeIssue::NoUsableDist(p) => {
72                write!(f, "package {p} has no usable dist (no zip, no path source)")
73            }
74        }
75    }
76}
77
78#[derive(Debug, Default)]
79pub struct ScopeReport {
80    /// Blocking: at least one -> fallback (or error without Composer).
81    pub issues: Vec<ScopeIssue>,
82    /// Non-blocking: harmless plugins ignored, to report on stderr.
83    pub skipped_plugins: Vec<String>,
84    /// Resolved layout (None if a layout issue blocks).
85    pub layout: Option<Layout>,
86}
87
88impl ScopeReport {
89    pub fn is_native_ok(&self) -> bool {
90        self.issues.is_empty()
91    }
92}
93
94/// `plugins_enabled` = no `--no-plugins`: with the flag, Composer ignores
95/// every plugin, composer/installers included; everything goes into vendor/.
96pub fn analyze(
97    project_dir: &Path,
98    lock: &Lock,
99    root_manifest: &Value,
100    with_dev: bool,
101    plugins_enabled: bool,
102) -> ScopeReport {
103    let mut report = ScopeReport::default();
104
105    for p in lock.wanted_packages(with_dev) {
106        classify_package(project_dir, p, &mut report);
107    }
108    match Layout::resolve(project_dir, lock, root_manifest, with_dev, plugins_enabled) {
109        Ok(layout) => report.layout = Some(layout),
110        Err(issues) => report
111            .issues
112            .extend(issues.into_iter().map(ScopeIssue::Layout)),
113    }
114    report
115}
116
117/// Blocking plugin issues alone (unknown or layout-changing plugins in the
118/// lock), for commands that do not install but would still let Composer run
119/// plugin listeners — `dump-autoload` and its PRE_AUTOLOAD_DUMP.
120pub fn plugin_issues(lock: &Lock, with_dev: bool) -> Vec<ScopeIssue> {
121    let mut report = ScopeReport::default();
122    for p in lock.wanted_packages(with_dev) {
123        classify_plugin(p, &mut report);
124    }
125    report.issues
126}
127
128fn classify_package(project_dir: &Path, p: &LockPackage, report: &mut ScopeReport) {
129    classify_plugin(p, report);
130    if p.is_metapackage() {
131        return;
132    }
133    let usable = match p.dist_kind() {
134        DistKind::Zip => true,
135        // A `path` package is laid out natively (symlink or mirror) on
136        // Linux/macOS when its source directory is there; Windows
137        // (junctions) is left to Composer.
138        DistKind::Path => cfg!(unix) && p.dist_url().is_some_and(|u| project_dir.join(u).is_dir()),
139        DistKind::Other | DistKind::Missing => false,
140    };
141    if !usable {
142        report
143            .issues
144            .push(ScopeIssue::NoUsableDist(p.name().to_owned()));
145    }
146}
147
148fn classify_plugin(p: &LockPackage, report: &mut ScopeReport) {
149    if p.package_type() != "composer-plugin" {
150        return;
151    }
152    let name = p.name().to_owned();
153    if EMULATED_PLUGINS.contains(&name.as_str()) {
154        // Emulated natively: nothing to report.
155    } else if BENIGN_PLUGINS.contains(&name.as_str()) {
156        report.skipped_plugins.push(name);
157    } else if LAYOUT_PLUGINS.contains(&name.as_str()) {
158        report.issues.push(ScopeIssue::LayoutPlugin(name));
159    } else {
160        report.issues.push(ScopeIssue::UnknownPlugin(name));
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::lock::Lock;
168    use serde_json::json;
169
170    fn lock_with(packages: serde_json::Value) -> Lock {
171        Lock::parse(&json!({ "packages": packages, "packages-dev": [] }).to_string()).expect("lock")
172    }
173
174    fn zip_pkg(name: &str, r#type: &str) -> serde_json::Value {
175        json!({"name": name, "version": "1.0.0", "type": r#type,
176               "dist": {"type": "zip", "url": "https://x/y.zip", "reference": "r"}})
177    }
178
179    fn proj() -> std::path::PathBuf {
180        std::path::PathBuf::from("/nonexistent-vivacity-scope")
181    }
182
183    #[test]
184    fn plain_library_is_native() {
185        let lock = lock_with(json!([zip_pkg("a/b", "library")]));
186        let r = analyze(&proj(), &lock, &json!({}), true, true);
187        assert!(r.is_native_ok());
188        assert!(r.skipped_plugins.is_empty());
189        assert_eq!(r.layout.expect("layout").rel("a/b"), Some("vendor/a/b"));
190    }
191
192    #[test]
193    fn emulated_and_benign_plugins_stay_native() {
194        let lock = lock_with(json!([
195            zip_pkg("symfony/runtime", "composer-plugin"),
196            zip_pkg("symfony/flex", "composer-plugin"),
197        ]));
198        let r = analyze(&proj(), &lock, &json!({}), true, true);
199        assert!(r.is_native_ok());
200        assert_eq!(r.skipped_plugins, vec!["symfony/flex"]);
201    }
202
203    #[test]
204    fn unknown_or_layout_plugin_is_out_of_scope() {
205        let lock = lock_with(json!([
206            zip_pkg("acme/mystery-plugin", "composer-plugin"),
207            zip_pkg("cweagans/composer-patches", "composer-plugin"),
208        ]));
209        let r = analyze(&proj(), &lock, &json!({}), true, true);
210        assert_eq!(
211            r.issues,
212            vec![
213                ScopeIssue::UnknownPlugin("acme/mystery-plugin".into()),
214                ScopeIssue::LayoutPlugin("cweagans/composer-patches".into()),
215            ]
216        );
217    }
218
219    #[test]
220    fn installers_without_allow_plugins_and_sourceless_dist_are_out_of_scope() {
221        let lock = lock_with(json!([
222            {"name": "a/src-only", "version": "1.0.0", "type": "library",
223             "source": {"type": "git", "url": "https://g/x.git", "reference": "r"}},
224            {"name": "a/meta", "version": "1.0.0", "type": "metapackage"},
225            zip_pkg("composer/installers", "composer-plugin"),
226        ]));
227        // installer-paths alone is inert (as in Composer); the plugin without
228        // allow-plugins, however, blocks.
229        let manifest = json!({"extra": {"installer-paths": {"web/modules/{$name}": []}}});
230        let r = analyze(&proj(), &lock, &manifest, true, true);
231        assert_eq!(r.issues.len(), 2, "{:?}", r.issues);
232        assert_eq!(r.issues[0], ScopeIssue::NoUsableDist("a/src-only".into()));
233        assert!(matches!(&r.issues[1], ScopeIssue::Layout(m) if m.contains("allow-plugins")));
234    }
235
236    #[test]
237    fn path_package_is_native_when_its_source_exists() {
238        let tmp = tempfile::tempdir().expect("tmp");
239        std::fs::create_dir_all(tmp.path().join("packages/here")).expect("mkdir");
240        let lock = lock_with(json!([
241            {"name": "a/here", "version": "dev-main", "type": "library",
242             "dist": {"type": "path", "url": "packages/here", "reference": "r"}},
243            {"name": "a/gone", "version": "dev-main", "type": "library",
244             "dist": {"type": "path", "url": "packages/gone", "reference": "r"}},
245        ]));
246        let r = analyze(tmp.path(), &lock, &json!({}), true, true);
247        let expected = if cfg!(unix) {
248            vec![ScopeIssue::NoUsableDist("a/gone".into())]
249        } else {
250            vec![
251                ScopeIssue::NoUsableDist("a/here".into()),
252                ScopeIssue::NoUsableDist("a/gone".into()),
253            ]
254        };
255        assert_eq!(r.issues, expected);
256    }
257
258    #[test]
259    fn no_dev_skips_dev_packages() {
260        let lock = Lock::parse(
261            &json!({
262                "packages": [zip_pkg("a/b", "library")],
263                "packages-dev": [zip_pkg("acme/mystery-plugin", "composer-plugin")]
264            })
265            .to_string(),
266        )
267        .expect("lock");
268        assert!(analyze(&proj(), &lock, &json!({}), false, true).is_native_ok());
269        assert!(!analyze(&proj(), &lock, &json!({}), true, true).is_native_ok());
270    }
271}